Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43b90ccf0a | ||
|
|
bdeec6eff5 | ||
|
|
345650172b |
@@ -20,7 +20,7 @@ use axum::{Json, Router};
|
|||||||
use maestro_config::SpawnPreset;
|
use maestro_config::SpawnPreset;
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
ReportActivityRequest, Session, PROTOCOL_VERSION,
|
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -47,7 +47,10 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||||
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
||||||
.route(routes::patterns::SESSION, delete(kill_session))
|
.route(
|
||||||
|
routes::patterns::SESSION,
|
||||||
|
delete(kill_session).patch(rename_session),
|
||||||
|
)
|
||||||
.route(routes::DIRS, get(list_dir))
|
.route(routes::DIRS, get(list_dir))
|
||||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||||
.route(routes::PROJECTS, get(projects))
|
.route(routes::PROJECTS, get(projects))
|
||||||
@@ -213,6 +216,28 @@ async fn kill_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/sessions/:id` (#125): rename a session. The name is trimmed
|
||||||
|
/// and capped server-side; a blank one clears `metadata.name` so the session
|
||||||
|
/// falls back to its command. Answers with the updated session, which also
|
||||||
|
/// goes out as `SessionUpdated` to every events subscriber.
|
||||||
|
async fn rename_session(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<RenameSessionRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s.manager.rename(id, &body.name).await {
|
||||||
|
Ok(session) => (StatusCode::OK, Json(session)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(
|
||||||
|
session_error_status(&info.code),
|
||||||
|
info.code.as_str(),
|
||||||
|
info.message.as_str(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
||||||
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
||||||
/// since been committed or pushed says so. Entries whose directory somebody
|
/// since been committed or pushed says so. Entries whose directory somebody
|
||||||
|
|||||||
@@ -716,6 +716,38 @@ impl SessionManager {
|
|||||||
.map_err(SessionError::Tmux)
|
.map_err(SessionError::Tmux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a session (#125): set `metadata.name`, or clear it when the
|
||||||
|
/// requested name is blank so the session falls back to its command.
|
||||||
|
///
|
||||||
|
/// The new name is persisted to the sidecar before the event goes out — a
|
||||||
|
/// rename the operator can see but that a daemon restart would undo is
|
||||||
|
/// worse than one that failed outright.
|
||||||
|
pub async fn rename(&self, id: Uuid, name: &str) -> Result<Session, SessionError> {
|
||||||
|
// The sidecar write happens under the sessions lock so what is on disk
|
||||||
|
// and what is in memory can never disagree about the newest rename.
|
||||||
|
let session = {
|
||||||
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
|
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||||
|
let mut renamed = rec.session.clone();
|
||||||
|
match maestro_protocol::normalize_session_name(name) {
|
||||||
|
Some(name) => renamed
|
||||||
|
.metadata
|
||||||
|
.insert("name".into(), serde_json::Value::String(name)),
|
||||||
|
None => renamed.metadata.remove("name"),
|
||||||
|
};
|
||||||
|
self.inner
|
||||||
|
.sidecar
|
||||||
|
.write(&renamed)
|
||||||
|
.map_err(SessionError::Sidecar)?;
|
||||||
|
rec.session = renamed.clone();
|
||||||
|
renamed
|
||||||
|
};
|
||||||
|
let _ = self.inner.events.send(CatalogEvent::SessionUpdated {
|
||||||
|
session: session.clone(),
|
||||||
|
});
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
/// Accept an agent-reported activity state (#105), the only path that can
|
/// Accept an agent-reported activity state (#105), the only path that can
|
||||||
/// produce `ActivityState::Error`. The report outranks the activity pass's
|
/// produce `ActivityState::Error`. The report outranks the activity pass's
|
||||||
/// own inference until its TTL lapses — so an agent that dies mid-run
|
/// own inference until its TTL lapses — so an agent that dies mid-run
|
||||||
|
|||||||
@@ -323,6 +323,48 @@ async fn delete_unknown_session_returns_404() {
|
|||||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #125: the rename endpoint answers with the updated session, and the name
|
||||||
|
/// it stored is the trimmed one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_session_renames_it() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let session = h
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "sleep 60".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.patch(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||||
|
.json(&serde_json::json!({ "name": " crimson-otter " }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
||||||
|
let updated: Session = resp.json().await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
updated.metadata.get("name").and_then(|v| v.as_str()),
|
||||||
|
Some("crimson-otter")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_unknown_session_returns_404() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.patch(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4())))
|
||||||
|
.json(&serde_json::json!({ "name": "nope" }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delete_owned_session_kills_it() {
|
async fn delete_owned_session_kills_it() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Renaming a live session (#125): `SessionManager::rename` semantics —
|
||||||
|
//! what lands in `metadata.name`, what reaches the sidecar, and what goes out
|
||||||
|
//! on the event stream.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use common::TmuxFixture;
|
||||||
|
use maestro_daemon::sidecar::SidecarStore;
|
||||||
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
|
use maestro_daemon::SessionManager;
|
||||||
|
use maestro_protocol::{CatalogEvent, CreateSessionRequest, MAX_SESSION_NAME_LEN};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
manager: SessionManager,
|
||||||
|
sidecar: SidecarStore,
|
||||||
|
_tmux: TmuxFixture,
|
||||||
|
_state_dir: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
let tmux = TmuxFixture::new();
|
||||||
|
let state_dir = TempDir::new().unwrap();
|
||||||
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||||
|
let manager = SessionManager::new(
|
||||||
|
Arc::new(tmux.tmux.clone()),
|
||||||
|
sidecar.clone(),
|
||||||
|
WorktreeManager::new(None),
|
||||||
|
state_dir.path().join("fifos"),
|
||||||
|
);
|
||||||
|
Fixture {
|
||||||
|
manager,
|
||||||
|
sidecar,
|
||||||
|
_tmux: tmux,
|
||||||
|
_state_dir: state_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
||||||
|
let metadata = match name {
|
||||||
|
Some(n) => std::collections::HashMap::from([(
|
||||||
|
"name".to_string(),
|
||||||
|
serde_json::Value::String(n.to_string()),
|
||||||
|
)]),
|
||||||
|
None => Default::default(),
|
||||||
|
};
|
||||||
|
manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: "/tmp".into(),
|
||||||
|
command: "sleep 60".into(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata,
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_of(session: &maestro_protocol::Session) -> Option<&str> {
|
||||||
|
session.metadata.get("name").and_then(|v| v.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_on_unknown_session_is_not_found() {
|
||||||
|
let fx = fixture();
|
||||||
|
let err = fx
|
||||||
|
.manager
|
||||||
|
.rename(Uuid::new_v4(), "whatever")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_sets_the_name_in_memory_and_on_disk() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
||||||
|
|
||||||
|
let renamed = fx.manager.rename(id, " crimson-otter ").await.unwrap();
|
||||||
|
assert_eq!(name_of(&renamed), Some("crimson-otter"));
|
||||||
|
|
||||||
|
let snapshot = fx.manager.snapshot().await;
|
||||||
|
let live = snapshot.iter().find(|s| s.id == id).unwrap();
|
||||||
|
assert_eq!(name_of(live), Some("crimson-otter"));
|
||||||
|
|
||||||
|
// A rename a daemon restart would undo is worse than one that failed.
|
||||||
|
let persisted = fx.sidecar.read(id).unwrap().unwrap();
|
||||||
|
assert_eq!(name_of(&persisted), Some("crimson-otter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_blank_rename_clears_the_name() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
||||||
|
|
||||||
|
let renamed = fx.manager.rename(id, " ").await.unwrap();
|
||||||
|
assert_eq!(name_of(&renamed), None);
|
||||||
|
// Line 1 falls back to the command, per docs/SIDEBAR.md.
|
||||||
|
assert_eq!(renamed.command, "sleep 60");
|
||||||
|
assert_eq!(name_of(&fx.sidecar.read(id).unwrap().unwrap()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_caps_a_long_name() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn_named(&fx.manager, None).await;
|
||||||
|
let long = "x".repeat(MAX_SESSION_NAME_LEN + 20);
|
||||||
|
let renamed = fx.manager.rename(id, &long).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
name_of(&renamed).unwrap().chars().count(),
|
||||||
|
MAX_SESSION_NAME_LEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rename_broadcasts_a_session_update() {
|
||||||
|
let fx = fixture();
|
||||||
|
let id = spawn_named(&fx.manager, Some("amber-falcon")).await;
|
||||||
|
let mut events = fx.manager.events();
|
||||||
|
|
||||||
|
fx.manager.rename(id, "crimson-otter").await.unwrap();
|
||||||
|
|
||||||
|
let event = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
|
||||||
|
.await
|
||||||
|
.expect("an update within the timeout")
|
||||||
|
.expect("the event channel stays open");
|
||||||
|
match event {
|
||||||
|
CatalogEvent::SessionUpdated { session } => {
|
||||||
|
assert_eq!(session.id, id);
|
||||||
|
assert_eq!(name_of(&session), Some("crimson-otter"));
|
||||||
|
}
|
||||||
|
other => panic!("expected SessionUpdated, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ use uuid::Uuid;
|
|||||||
use crate::routes::{self, UrlError};
|
use crate::routes::{self, UrlError};
|
||||||
use crate::{
|
use crate::{
|
||||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||||
KillOutcome, Project, RecentDirs, ReportActivityRequest, RetainedWorktree, Session,
|
KillOutcome, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest,
|
||||||
|
RetainedWorktree, Session,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Time budget for establishing a TCP connection to the daemon.
|
/// Time budget for establishing a TCP connection to the daemon.
|
||||||
@@ -134,6 +135,17 @@ impl DaemonClient {
|
|||||||
json(resp).await
|
json(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name
|
||||||
|
/// clears the custom one, so the session falls back to its command. The
|
||||||
|
/// daemon trims and caps the name and answers with the updated session.
|
||||||
|
pub async fn rename_session(&self, id: Uuid, name: &str) -> Result<Session, ClientError> {
|
||||||
|
let req = RenameSessionRequest { name: name.into() };
|
||||||
|
let resp = self
|
||||||
|
.send(Method::PATCH, &routes::session(id), &[], Some(&req))
|
||||||
|
.await?;
|
||||||
|
json(check(resp).await?).await
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
||||||
/// state. The daemon holds it above its own inference for a TTL, then
|
/// state. The daemon holds it above its own inference for a TTL, then
|
||||||
/// resumes inference. Answers `204 No Content`.
|
/// resumes inference. Answers `204 No Content`.
|
||||||
|
|||||||
@@ -495,6 +495,31 @@ pub struct NameBatch {
|
|||||||
pub names: Vec<String>,
|
pub names: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Longest session name the daemon stores (#125), in characters. A name is a
|
||||||
|
/// sidebar card's line 1; past this it only truncates on every client anyway.
|
||||||
|
pub const MAX_SESSION_NAME_LEN: usize = 64;
|
||||||
|
|
||||||
|
/// HTTP `PATCH /api/sessions/:id` body (#125): rename a live session.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RenameSessionRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trim a requested session name and cap it at [`MAX_SESSION_NAME_LEN`]
|
||||||
|
/// characters. `None` when nothing is left — that clears `metadata.name` and
|
||||||
|
/// the card falls back to the command, the same rule the spawn form uses when
|
||||||
|
/// its name field is blank.
|
||||||
|
///
|
||||||
|
/// Shared so the daemon and every Rust client agree on what a name becomes;
|
||||||
|
/// `web/src/types.ts` carries the same function for the browser.
|
||||||
|
pub fn normalize_session_name(name: &str) -> Option<String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(trimmed.chars().take(MAX_SESSION_NAME_LEN).collect())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Catalog events (`WS /api/events`)
|
// Catalog events (`WS /api/events`)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -964,4 +989,42 @@ mod tests {
|
|||||||
assert_eq!(c, round_trip(&c));
|
assert_eq!(c, round_trip(&c));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #125: a rename request is trimmed and capped; a blank one clears the
|
||||||
|
/// name so the card falls back to the command.
|
||||||
|
#[test]
|
||||||
|
fn session_names_are_trimmed_capped_and_blankable() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_session_name(" amber-falcon "),
|
||||||
|
Some("amber-falcon".into())
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_session_name(""), None);
|
||||||
|
assert_eq!(normalize_session_name(" \t "), None);
|
||||||
|
let long = "x".repeat(MAX_SESSION_NAME_LEN + 10);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_session_name(&long).unwrap().chars().count(),
|
||||||
|
MAX_SESSION_NAME_LEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cap counts characters, not bytes — a multi-byte name must not be
|
||||||
|
/// sliced mid-codepoint.
|
||||||
|
#[test]
|
||||||
|
fn session_name_cap_counts_characters() {
|
||||||
|
let name = "é".repeat(MAX_SESSION_NAME_LEN + 5);
|
||||||
|
let capped = normalize_session_name(&name).unwrap();
|
||||||
|
assert_eq!(capped.chars().count(), MAX_SESSION_NAME_LEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_request_round_trips() {
|
||||||
|
let req = RenameSessionRequest {
|
||||||
|
name: "amber-falcon".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(req, round_trip(&req));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&req).unwrap(),
|
||||||
|
serde_json::json!({"name": "amber-falcon"})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ pub enum Action {
|
|||||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||||
FastForwardSpawn,
|
FastForwardSpawn,
|
||||||
KillHighlighted,
|
KillHighlighted,
|
||||||
|
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
||||||
|
/// its current name.
|
||||||
|
RenameHighlighted,
|
||||||
/// Open the retained-worktrees view (#85).
|
/// Open the retained-worktrees view (#85).
|
||||||
ShowWorktrees,
|
ShowWorktrees,
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ impl Action {
|
|||||||
Action::NewDraftPane => "Create a session".into(),
|
Action::NewDraftPane => "Create a session".into(),
|
||||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||||
Action::KillHighlighted => "Kill selected session".into(),
|
Action::KillHighlighted => "Kill selected session".into(),
|
||||||
|
Action::RenameHighlighted => "Rename selected session".into(),
|
||||||
Action::ShowWorktrees => "Show retained worktrees".into(),
|
Action::ShowWorktrees => "Show retained worktrees".into(),
|
||||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||||
Action::ShowHelp => "Show help".into(),
|
Action::ShowHelp => "Show help".into(),
|
||||||
@@ -118,6 +122,7 @@ impl Action {
|
|||||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||||
|
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||||
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
||||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||||
@@ -223,10 +228,24 @@ mod tests {
|
|||||||
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_action_round_trips() {
|
||||||
|
assert_eq!(
|
||||||
|
Action::parse("RenameHighlighted").unwrap(),
|
||||||
|
Action::RenameHighlighted
|
||||||
|
);
|
||||||
|
assert_eq!(Action::RenameHighlighted.to_string(), "RenameHighlighted");
|
||||||
|
assert_eq!(
|
||||||
|
Action::RenameHighlighted.help_label(),
|
||||||
|
"Rename selected session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn display_round_trips() {
|
fn display_round_trips() {
|
||||||
for a in [
|
for a in [
|
||||||
Action::EnterInsert,
|
Action::EnterInsert,
|
||||||
|
Action::RenameHighlighted,
|
||||||
Action::Quit,
|
Action::Quit,
|
||||||
Action::NewDraftPane,
|
Action::NewDraftPane,
|
||||||
Action::ScrollPanePageDown,
|
Action::ScrollPanePageDown,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ use crate::render::{draw, RegionMap, RenderInputs};
|
|||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
||||||
|
use crate::text_input::TextInput;
|
||||||
use crate::worktrees::WorktreesView;
|
use crate::worktrees::WorktreesView;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
|
|
||||||
@@ -75,6 +76,9 @@ pub struct App {
|
|||||||
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
|
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
|
||||||
/// rather than in `AppState` so the overlay kind stays payload-free.
|
/// rather than in `AppState` so the overlay kind stays payload-free.
|
||||||
worktrees: WorktreesView,
|
worktrees: WorktreesView,
|
||||||
|
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
||||||
|
/// same reason the worktree entries are.
|
||||||
|
rename_input: TextInput,
|
||||||
region: RegionMap,
|
region: RegionMap,
|
||||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||||
clicks: ClickTracker,
|
clicks: ClickTracker,
|
||||||
@@ -123,6 +127,12 @@ pub enum Effect {
|
|||||||
/// Reclaim one retained worktree (#85) — removes its directory; the
|
/// Reclaim one retained worktree (#85) — removes its directory; the
|
||||||
/// branch is never deleted.
|
/// branch is never deleted.
|
||||||
ReclaimWorktree(Uuid),
|
ReclaimWorktree(Uuid),
|
||||||
|
/// Rename a session (#125). An empty name clears the custom one, so the
|
||||||
|
/// card falls back to the command.
|
||||||
|
Rename {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -148,6 +158,7 @@ impl App {
|
|||||||
daemon,
|
daemon,
|
||||||
http,
|
http,
|
||||||
worktrees: WorktreesView::default(),
|
worktrees: WorktreesView::default(),
|
||||||
|
rename_input: TextInput::default(),
|
||||||
draft_tx,
|
draft_tx,
|
||||||
draft_rx,
|
draft_rx,
|
||||||
region: RegionMap::default(),
|
region: RegionMap::default(),
|
||||||
@@ -284,6 +295,7 @@ impl App {
|
|||||||
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
||||||
Effect::LoadWorktrees => self.spawn_load_worktrees(),
|
Effect::LoadWorktrees => self.spawn_load_worktrees(),
|
||||||
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
|
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
|
||||||
|
Effect::Rename { id, name } => self.submit_rename(id, &name).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;
|
||||||
@@ -306,6 +318,21 @@ impl App {
|
|||||||
}
|
}
|
||||||
_ => (OverlayDecision::Consumed, Vec::new()),
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
||||||
},
|
},
|
||||||
|
Some(OverlayKind::Rename(id)) => match key {
|
||||||
|
InputKey::Enter => {
|
||||||
|
let name = self.rename_input.value().to_string();
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, vec![Effect::Rename { id, name }])
|
||||||
|
}
|
||||||
|
InputKey::Esc => {
|
||||||
|
self.state.dismiss_overlay();
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
self.rename_input.handle_key(other);
|
||||||
|
(OverlayDecision::Consumed, Vec::new())
|
||||||
|
}
|
||||||
|
},
|
||||||
Some(OverlayKind::Worktrees) => match key {
|
Some(OverlayKind::Worktrees) => match key {
|
||||||
InputKey::Char { c: 'j', .. } | InputKey::Down => {
|
InputKey::Char { c: 'j', .. } | InputKey::Down => {
|
||||||
self.worktrees.move_highlight(1);
|
self.worktrees.move_highlight(1);
|
||||||
@@ -430,6 +457,23 @@ impl App {
|
|||||||
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Action::RenameHighlighted => {
|
||||||
|
if let Some(sid) = self.pick_sidebar_target() {
|
||||||
|
// Pre-fill with the 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.
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Action::ShowWorktrees => {
|
Action::ShowWorktrees => {
|
||||||
self.state.show_overlay(OverlayKind::Worktrees);
|
self.state.show_overlay(OverlayKind::Worktrees);
|
||||||
effects.push(Effect::LoadWorktrees);
|
effects.push(Effect::LoadWorktrees);
|
||||||
@@ -928,6 +972,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a session (#125). The catalog updates from the daemon's
|
||||||
|
/// `SessionUpdated` broadcast, so nothing is patched locally here.
|
||||||
|
async fn submit_rename(&mut self, sid: Uuid, name: &str) {
|
||||||
|
if let Err(e) = self.daemon.rename_session(sid, name).await {
|
||||||
|
self.state.toast(format!("rename failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the retained-worktree store without blocking the loop (#85) —
|
/// Fetch the retained-worktree store without blocking the loop (#85) —
|
||||||
/// same off-loop pattern as the draft loaders.
|
/// same off-loop pattern as the draft loaders.
|
||||||
fn spawn_load_worktrees(&self) {
|
fn spawn_load_worktrees(&self) {
|
||||||
@@ -1008,6 +1060,7 @@ impl App {
|
|||||||
draft,
|
draft,
|
||||||
help: &self.help,
|
help: &self.help,
|
||||||
worktrees: Some(&self.worktrees),
|
worktrees: Some(&self.worktrees),
|
||||||
|
rename_input: &self.rename_input,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_region = RegionMap::default();
|
let mut new_region = RegionMap::default();
|
||||||
@@ -2035,6 +2088,117 @@ mod tests {
|
|||||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- rename (#125) ------------------------------------------------------
|
||||||
|
|
||||||
|
fn seed_session(app: &mut App, sid: Uuid, name: Option<&str>) {
|
||||||
|
use chrono::Utc;
|
||||||
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
||||||
|
let metadata = match name {
|
||||||
|
Some(n) => {
|
||||||
|
HashMap::from([("name".to_string(), serde_json::Value::String(n.to_string()))])
|
||||||
|
}
|
||||||
|
None => Default::default(),
|
||||||
|
};
|
||||||
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
|
sessions: vec![Session {
|
||||||
|
id: sid,
|
||||||
|
command: "bash".into(),
|
||||||
|
cwd: std::path::PathBuf::from("/"),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata,
|
||||||
|
worktree: None,
|
||||||
|
status: SessionStatus::Running,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
foreground: None,
|
||||||
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_highlighted_opens_a_prompt_prefilled_with_the_current_name() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
seed_session(&mut app, sid, Some("amber-falcon"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
assert!(matches!(app.state.overlay(), Some(OverlayKind::Rename(s)) if *s == sid));
|
||||||
|
assert_eq!(app.rename_input.value(), "amber-falcon");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session with no name of its own opens an empty field: pre-filling
|
||||||
|
/// with the command would let Enter pin the command as the name.
|
||||||
|
#[test]
|
||||||
|
fn rename_prompt_starts_empty_for_an_unnamed_session() {
|
||||||
|
let mut app = make_app();
|
||||||
|
seed_session(&mut app, Uuid::from_u128(7), None);
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
assert_eq!(app.rename_input.value(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_then_enter_submits_the_edited_name() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
seed_session(&mut app, sid, Some("amber"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
for c in "-fox".chars() {
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
||||||
|
c,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
});
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert_eq!(
|
||||||
|
effects,
|
||||||
|
vec![Effect::Rename {
|
||||||
|
id: sid,
|
||||||
|
name: "amber-fox".into()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_cancels_the_rename_without_an_effect() {
|
||||||
|
let mut app = make_app();
|
||||||
|
seed_session(&mut app, Uuid::from_u128(7), Some("amber"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
||||||
|
assert_eq!(decision, OverlayDecision::Consumed);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clearing the field and submitting is how the name is dropped, so the
|
||||||
|
/// card falls back to the command.
|
||||||
|
#[test]
|
||||||
|
fn an_emptied_field_submits_a_blank_name() {
|
||||||
|
let mut app = make_app();
|
||||||
|
let sid = Uuid::from_u128(7);
|
||||||
|
seed_session(&mut app, sid, Some("ab"));
|
||||||
|
app.dispatch(Action::RenameHighlighted);
|
||||||
|
app.handle_overlay_input(&InputKey::Backspace);
|
||||||
|
app.handle_overlay_input(&InputKey::Backspace);
|
||||||
|
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
||||||
|
assert_eq!(
|
||||||
|
effects,
|
||||||
|
vec![Effect::Rename {
|
||||||
|
id: sid,
|
||||||
|
name: String::new()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -- retained worktrees (#85) -------------------------------------------
|
// -- retained worktrees (#85) -------------------------------------------
|
||||||
|
|
||||||
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
|
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
// thing with every default resolved and no form.
|
// thing with every default resolved and no form.
|
||||||
("S", "FastForwardSpawn"),
|
("S", "FastForwardSpawn"),
|
||||||
("x", "KillHighlighted"),
|
("x", "KillHighlighted"),
|
||||||
|
("r", "RenameHighlighted"),
|
||||||
("w", "ShowWorktrees"),
|
("w", "ShowWorktrees"),
|
||||||
// Focus
|
// Focus
|
||||||
("ctrl-w h", "FocusSidebar"),
|
("ctrl-w h", "FocusSidebar"),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
|
//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay,
|
||||||
|
//! transient toast.
|
||||||
//!
|
//!
|
||||||
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
|
||||||
//! panes ([`crate::draft::DraftPane`]).
|
//! panes ([`crate::draft::DraftPane`]).
|
||||||
@@ -7,10 +8,13 @@
|
|||||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||||
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
|
use crate::text_input::TextInput;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct KillConfirm {
|
pub struct KillConfirm {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -39,6 +43,50 @@ impl KillConfirm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename prompt for the highlighted session (#125).
|
||||||
|
///
|
||||||
|
/// Borrows its text buffer rather than owning it: the buffer lives on `App`
|
||||||
|
/// alongside the worktrees view's entries, so [`crate::state::OverlayKind`]
|
||||||
|
/// stays a plain comparable enum.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RenamePrompt<'a> {
|
||||||
|
/// What the card shows today — the session's name, or the command it
|
||||||
|
/// falls back to when it has none.
|
||||||
|
pub current: &'a str,
|
||||||
|
pub input: &'a TextInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenamePrompt<'_> {
|
||||||
|
/// Draws the prompt and returns where the terminal cursor belongs.
|
||||||
|
pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> {
|
||||||
|
let area = centered(parent, 64, 7);
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
let block = Block::default().title(" rename ").borders(Borders::ALL);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
f.render_widget(block, area);
|
||||||
|
if inner.height < 4 || inner.width == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let row = |offset: u16| Rect {
|
||||||
|
x: inner.x,
|
||||||
|
y: inner.y + offset,
|
||||||
|
width: inner.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(format!("Rename {}", self.current))),
|
||||||
|
row(0),
|
||||||
|
);
|
||||||
|
let field = row(1);
|
||||||
|
let col = self.input.render(field, f.buffer_mut(), Style::default());
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from("[enter] save [esc] cancel")),
|
||||||
|
row(3),
|
||||||
|
);
|
||||||
|
Some((col, field.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HelpOverlay {
|
pub struct HelpOverlay {
|
||||||
pub lines: Vec<String>,
|
pub lines: Vec<String>,
|
||||||
@@ -151,6 +199,22 @@ mod tests {
|
|||||||
assert!(rendered.contains("refused"), "{rendered}");
|
assert!(rendered.contains("refused"), "{rendered}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_prompt_shows_the_current_name_and_the_edit_buffer() {
|
||||||
|
let input = TextInput::with_value("crimson-otter");
|
||||||
|
let prompt = RenamePrompt {
|
||||||
|
current: "amber-falcon",
|
||||||
|
input: &input,
|
||||||
|
};
|
||||||
|
let rendered = render_to_string(70, 12, |f| {
|
||||||
|
prompt.render(f, f.area());
|
||||||
|
});
|
||||||
|
assert!(rendered.contains("Rename amber-falcon"), "{rendered}");
|
||||||
|
assert!(rendered.contains("crimson-otter"), "{rendered}");
|
||||||
|
assert!(rendered.contains("[enter] save"), "{rendered}");
|
||||||
|
assert!(rendered.contains("[esc] cancel"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_overlay_lists_lines() {
|
fn help_overlay_lists_lines() {
|
||||||
let help = HelpOverlay {
|
let help = HelpOverlay {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use ratatui::Frame;
|
|||||||
|
|
||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
|
||||||
use crate::palette;
|
use crate::palette;
|
||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
@@ -84,6 +84,8 @@ pub struct RenderInputs<'a> {
|
|||||||
pub help: &'a HelpOverlay,
|
pub help: &'a HelpOverlay,
|
||||||
/// Retained-worktree entries for the worktrees view (#85).
|
/// Retained-worktree entries for the worktrees view (#85).
|
||||||
pub worktrees: Option<&'a WorktreesView>,
|
pub worktrees: Option<&'a WorktreesView>,
|
||||||
|
/// Edit buffer behind the rename prompt (#125).
|
||||||
|
pub rename_input: &'a crate::text_input::TextInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
||||||
@@ -170,6 +172,23 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
kc.render(f, area);
|
kc.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
}
|
}
|
||||||
|
Some(OverlayKind::Rename(id)) => {
|
||||||
|
let current = inputs
|
||||||
|
.sidebar
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| &s.id == id)
|
||||||
|
.map(crate::sidebar::name_for)
|
||||||
|
.unwrap_or_else(|| id.to_string());
|
||||||
|
let prompt = RenamePrompt {
|
||||||
|
current: ¤t,
|
||||||
|
input: inputs.rename_input,
|
||||||
|
};
|
||||||
|
if let Some((x, y)) = prompt.render(f, area) {
|
||||||
|
f.set_cursor_position((x, y));
|
||||||
|
}
|
||||||
|
map.modal = Some(area);
|
||||||
|
}
|
||||||
Some(OverlayKind::Help) => {
|
Some(OverlayKind::Help) => {
|
||||||
inputs.help.render(f, area);
|
inputs.help.render(f, area);
|
||||||
map.modal = Some(area);
|
map.modal = Some(area);
|
||||||
@@ -354,6 +373,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
worktrees: None,
|
||||||
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
@@ -407,6 +427,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
worktrees: None,
|
||||||
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
@@ -446,6 +467,7 @@ mod tests {
|
|||||||
state: &state,
|
state: &state,
|
||||||
help: &help,
|
help: &help,
|
||||||
worktrees: None,
|
worktrees: None,
|
||||||
|
rename_input: &crate::text_input::TextInput::default(),
|
||||||
};
|
};
|
||||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
|||||||
@@ -382,10 +382,15 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
|
|||||||
|
|
||||||
/// `metadata.name` if present, else the command line.
|
/// `metadata.name` if present, else the command line.
|
||||||
pub(crate) fn name_for(s: &Session) -> String {
|
pub(crate) fn name_for(s: &Session) -> String {
|
||||||
if let Some(serde_json::Value::String(name)) = s.metadata.get("name") {
|
custom_name_for(s).unwrap_or_else(|| s.command.clone())
|
||||||
name.clone()
|
}
|
||||||
} else {
|
|
||||||
s.command.clone()
|
/// The session's own name, without the command fallback (#125): `None` for a
|
||||||
|
/// session that was never given one.
|
||||||
|
pub(crate) fn custom_name_for(s: &Session) -> Option<String> {
|
||||||
|
match s.metadata.get("name") {
|
||||||
|
Some(serde_json::Value::String(name)) => Some(name.clone()),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ impl Tab {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum OverlayKind {
|
pub enum OverlayKind {
|
||||||
KillConfirm(Uuid),
|
KillConfirm(Uuid),
|
||||||
|
/// Rename prompt for this session (#125). The edit buffer lives on `App`,
|
||||||
|
/// like the worktrees view's entries, so this stays comparable.
|
||||||
|
Rename(Uuid),
|
||||||
Help,
|
Help,
|
||||||
Toast(String),
|
Toast(String),
|
||||||
/// The retained-worktrees view (#85); its entries live on `App` because
|
/// The retained-worktrees view (#85); its entries live on `App` because
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
|||||||
"i" = "EnterInsert"
|
"i" = "EnterInsert"
|
||||||
"s" = "NewDraftPane"
|
"s" = "NewDraftPane"
|
||||||
"x" = "KillHighlighted"
|
"x" = "KillHighlighted"
|
||||||
|
"r" = "RenameHighlighted"
|
||||||
"?" = "ShowHelp"
|
"?" = "ShowHelp"
|
||||||
"ctrl-w h" = "FocusSidebar"
|
"ctrl-w h" = "FocusSidebar"
|
||||||
"enter" = "AttachHighlighted"
|
"enter" = "AttachHighlighted"
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ A card is always two lines: **line 1** and **line 2**.
|
|||||||
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
|
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
|
||||||
badge move off this row into line 2.
|
badge move off this row into line 2.
|
||||||
|
|
||||||
|
The name is editable in place (#125). In the web client, right-clicking a card
|
||||||
|
opens a context menu whose **rename** entry swaps the name for a text field —
|
||||||
|
Enter commits, Escape cancels. The TUI opens the same edit as a prompt, bound
|
||||||
|
to `r` in the sidebar by default. Both send `PATCH /api/sessions/:id`; both
|
||||||
|
start from the session's own name, never from the command it falls back to,
|
||||||
|
and an empty field clears the name so the fallback resumes.
|
||||||
|
|
||||||
## Line 2 — meta line (adaptive)
|
## Line 2 — meta line (adaptive)
|
||||||
|
|
||||||
Line 2 always renders — never an empty placeholder row either, since its
|
Line 2 always renders — never an empty placeholder row either, since its
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ export async function createSession(req: CreateSessionRequest): Promise<Session>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rename a session (#125). An empty name clears the custom one, so the card
|
||||||
|
* falls back to the command. The daemon trims and caps what it stores and
|
||||||
|
* broadcasts the update, so callers need not patch the store themselves. */
|
||||||
|
export async function renameSession(id: Uuid, name: string): Promise<Session> {
|
||||||
|
return jsonOrError(
|
||||||
|
await fetch(routes.session(id), {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function killSession(id: Uuid): Promise<void> {
|
export async function killSession(id: Uuid): Promise<void> {
|
||||||
await jsonOrError(
|
await jsonOrError(
|
||||||
await fetch(routes.session(id), {
|
await fetch(routes.session(id), {
|
||||||
|
|||||||
+105
-14
@@ -1,9 +1,18 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { getDaemonInfo, killSession } from "./api.js";
|
import { getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||||
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
import {
|
||||||
|
abbreviateFolderName,
|
||||||
|
customSessionName,
|
||||||
|
formatInTheWay,
|
||||||
|
MAX_SESSION_NAME_LEN,
|
||||||
|
normalizeSessionName,
|
||||||
|
sessionBlockers,
|
||||||
|
sessionName,
|
||||||
|
} from "./types.js";
|
||||||
import {
|
import {
|
||||||
activityColorSlug,
|
activityColorSlug,
|
||||||
activityDurationLabel,
|
activityDurationLabel,
|
||||||
@@ -23,6 +32,11 @@ export function requestOpenSpawn(): void {
|
|||||||
|
|
||||||
const collapsed = new Set<string>();
|
const collapsed = new Set<string>();
|
||||||
|
|
||||||
|
/** Session whose card is currently being renamed inline (#125). The tree
|
||||||
|
* re-renders on every catalog delta, and an activity update arriving mid-edit
|
||||||
|
* would otherwise replace the field the user is typing into. */
|
||||||
|
let renamingId: string | null = null;
|
||||||
|
|
||||||
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
|
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
|
||||||
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
||||||
const STATE_WORDS: Record<string, string> = {
|
const STATE_WORDS: Record<string, string> = {
|
||||||
@@ -124,24 +138,96 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
|
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s }));
|
||||||
});
|
});
|
||||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||||
killBtn.addEventListener("click", async (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
// #83: the confirm restates the warning at the moment of decision.
|
void confirmAndKill(s, name);
|
||||||
const blockers = sessionBlockers(s);
|
});
|
||||||
const question = blockers
|
row.addEventListener("contextmenu", (ev) => {
|
||||||
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
ev.preventDefault();
|
||||||
: `Kill session "${name}"?`;
|
ev.stopPropagation();
|
||||||
if (!confirm(question)) return;
|
openContextMenu(ev.clientX, ev.clientY, [
|
||||||
try {
|
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
||||||
await killSession(s.id);
|
{
|
||||||
} catch (e) {
|
label: "Spawn similar",
|
||||||
alert(`Failed to kill: ${(e as Error).message}`);
|
onSelect: () =>
|
||||||
}
|
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
|
||||||
|
},
|
||||||
|
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #83: the confirm restates the warning at the moment of decision. */
|
||||||
|
async function confirmAndKill(s: Session, name: string): Promise<void> {
|
||||||
|
const blockers = sessionBlockers(s);
|
||||||
|
const question = blockers
|
||||||
|
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
||||||
|
: `Kill session "${name}"?`;
|
||||||
|
if (!confirm(question)) return;
|
||||||
|
try {
|
||||||
|
await killSession(s.id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Failed to kill: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap the card's name for a text field (#125). Enter commits, Escape and
|
||||||
|
* blur cancel. An emptied field clears the name, so the card falls back to
|
||||||
|
* the command — the same rule the spawn form's blank name field follows.
|
||||||
|
*/
|
||||||
|
function beginRename(row: HTMLElement, s: Session): void {
|
||||||
|
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
|
||||||
|
if (!nameEl) return;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.className = "leaf-name leaf-rename";
|
||||||
|
input.value = customSessionName(s) ?? "";
|
||||||
|
input.maxLength = MAX_SESSION_NAME_LEN;
|
||||||
|
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
|
||||||
|
// The card is an <a>, and the tree starts a drag on pointerdown: without
|
||||||
|
// these, clicking into the field navigates away or begins dragging the row.
|
||||||
|
for (const type of ["click", "pointerdown", "dblclick"]) {
|
||||||
|
input.addEventListener(type, (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renamingId = s.id;
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return false;
|
||||||
|
settled = true;
|
||||||
|
renamingId = null;
|
||||||
|
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
ev.preventDefault();
|
||||||
|
// Normalised here so what is sent is what the daemon will store.
|
||||||
|
const name = normalizeSessionName(input.value);
|
||||||
|
if (!finish()) return;
|
||||||
|
void renameSession(s.id, name).catch((e: Error) => {
|
||||||
|
alert(`Failed to rename: ${e.message}`);
|
||||||
|
});
|
||||||
|
} else if (ev.key === "Escape") {
|
||||||
|
ev.preventDefault();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener("blur", () => finish());
|
||||||
|
|
||||||
|
nameEl.replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
function renderStatusbar(sessions: Session[]): HTMLElement {
|
function renderStatusbar(sessions: Session[]): HTMLElement {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "statusbar";
|
wrap.className = "statusbar";
|
||||||
@@ -402,6 +488,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
|
// Rebuilding the tree now would blow away the field being typed into.
|
||||||
|
if (renamingId !== null) return;
|
||||||
const state = store.snapshot();
|
const state = store.snapshot();
|
||||||
const sessions = [...state.sessions.values()];
|
const sessions = [...state.sessions.values()];
|
||||||
const tree = buildTree(sessions, homeDir, filter);
|
const tree = buildTree(sessions, homeDir, filter);
|
||||||
@@ -458,6 +546,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
clearInterval(clock);
|
clearInterval(clock);
|
||||||
|
// An unfinished rename would otherwise leave every later sidebar
|
||||||
|
// refusing to refresh.
|
||||||
|
renamingId = null;
|
||||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { closeContextMenu, openContextMenu } from "./contextMenu.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeContextMenu();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
function labels(): string[] {
|
||||||
|
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("context menu (#125)", () => {
|
||||||
|
it("renders one item per entry, in order", () => {
|
||||||
|
openContextMenu(10, 20, [
|
||||||
|
{ label: "Rename…", onSelect: () => {} },
|
||||||
|
{ label: "Kill session", onSelect: () => {}, danger: true },
|
||||||
|
]);
|
||||||
|
expect(labels()).toEqual(["Rename…", "Kill session"]);
|
||||||
|
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe(
|
||||||
|
"Kill session",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the selected entry and closes", () => {
|
||||||
|
const chosen = vi.fn();
|
||||||
|
openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]);
|
||||||
|
(document.querySelector(".context-menu-item") as HTMLButtonElement).click();
|
||||||
|
expect(chosen).toHaveBeenCalledOnce();
|
||||||
|
expect(document.querySelector(".context-menu")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening a second menu replaces the first", () => {
|
||||||
|
openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]);
|
||||||
|
openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]);
|
||||||
|
expect(document.querySelectorAll(".context-menu")).toHaveLength(1);
|
||||||
|
expect(labels()).toEqual(["second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape and on a press outside it", () => {
|
||||||
|
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||||
|
expect(document.querySelector(".context-menu")).toBeNull();
|
||||||
|
|
||||||
|
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
|
||||||
|
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||||
|
expect(document.querySelector(".context-menu")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays open for a press inside itself", () => {
|
||||||
|
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
|
||||||
|
const menu = document.querySelector(".context-menu") as HTMLElement;
|
||||||
|
menu.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||||
|
expect(document.querySelector(".context-menu")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/** Custom right-click menu (#125).
|
||||||
|
*
|
||||||
|
* One menu exists at a time: opening a second closes the first. The menu is
|
||||||
|
* appended to `<body>` rather than to the row it belongs to, so an overflowing
|
||||||
|
* sidebar cannot clip it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
/** Renders the entry in the destructive style. */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
|
/** Close the menu that is currently up, if any. */
|
||||||
|
export function closeContextMenu(): void {
|
||||||
|
open?.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a menu at viewport coordinates `x`/`y`. The menu closes on selection,
|
||||||
|
* on Escape, on a pointer press anywhere outside it, and on scroll or resize —
|
||||||
|
* a menu anchored to coordinates goes stale the moment the page moves under it.
|
||||||
|
*/
|
||||||
|
export function openContextMenu(x: number, y: number, items: ContextMenuItem[]): HTMLElement {
|
||||||
|
closeContextMenu();
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "context-menu";
|
||||||
|
el.setAttribute("role", "menu");
|
||||||
|
for (const item of items) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = item.danger ? "context-menu-item is-danger" : "context-menu-item";
|
||||||
|
btn.setAttribute("role", "menuitem");
|
||||||
|
btn.textContent = item.label;
|
||||||
|
btn.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeContextMenu();
|
||||||
|
item.onSelect();
|
||||||
|
});
|
||||||
|
el.appendChild(btn);
|
||||||
|
}
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
// Nudge back inside the viewport when the click was near an edge. happy-dom
|
||||||
|
// reports 0 for every measurement, which lands the menu at the click point —
|
||||||
|
// the same place it would sit with room to spare.
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const maxX = window.innerWidth - rect.width;
|
||||||
|
const maxY = window.innerHeight - rect.height;
|
||||||
|
el.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
|
||||||
|
el.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
|
||||||
|
|
||||||
|
const onPointerDown = (ev: Event) => {
|
||||||
|
if (!el.contains(ev.target as Node)) closeContextMenu();
|
||||||
|
};
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === "Escape") closeContextMenu();
|
||||||
|
};
|
||||||
|
const dispose = () => {
|
||||||
|
if (open?.el !== el) return;
|
||||||
|
open = null;
|
||||||
|
el.remove();
|
||||||
|
window.removeEventListener("pointerdown", onPointerDown, true);
|
||||||
|
window.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
window.removeEventListener("scroll", closeContextMenu, true);
|
||||||
|
window.removeEventListener("resize", closeContextMenu);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", onPointerDown, true);
|
||||||
|
window.addEventListener("keydown", onKeyDown, true);
|
||||||
|
window.addEventListener("scroll", closeContextMenu, true);
|
||||||
|
window.addEventListener("resize", closeContextMenu);
|
||||||
|
|
||||||
|
open = { el, dispose };
|
||||||
|
(el.firstElementChild as HTMLElement | null)?.focus();
|
||||||
|
return el;
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { renderSidebar } from "./catalog.js";
|
||||||
|
import { closeContextMenu } from "./contextMenu.js";
|
||||||
|
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
|
import { normalizeSessionName } from "./types.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
value: { getItem: () => null, setItem: () => {} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeContextMenu();
|
||||||
|
mounted?.dispose();
|
||||||
|
mounted = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount a sidebar with one session; the handle is disposed after each test. */
|
||||||
|
function mount(s: Session): HTMLElement {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() => Promise.reject(new Error("offline"))),
|
||||||
|
);
|
||||||
|
const handle = renderSidebar(storeWith([s]));
|
||||||
|
mounted = handle;
|
||||||
|
return handle.el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardIn(el: HTMLElement, s: Session): HTMLElement {
|
||||||
|
return el.querySelector<HTMLElement>(`[data-session-id="${s.id}"]`) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mount a sidebar with one session and right-click its card. */
|
||||||
|
function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } {
|
||||||
|
const el = mount(s);
|
||||||
|
const row = cardIn(el, s);
|
||||||
|
row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true }));
|
||||||
|
return { el, row };
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuItem(label: string): HTMLButtonElement {
|
||||||
|
const found = [...document.querySelectorAll<HTMLButtonElement>(".context-menu-item")].find(
|
||||||
|
(b) => b.textContent === label,
|
||||||
|
);
|
||||||
|
if (!found) throw new Error(`no "${label}" entry in the context menu`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session name normalisation (#125)", () => {
|
||||||
|
it("trims, and treats a blank name as a clear", () => {
|
||||||
|
expect(normalizeSessionName(" amber-falcon ")).toBe("amber-falcon");
|
||||||
|
expect(normalizeSessionName(" ")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at the daemon's limit, counting code points", () => {
|
||||||
|
expect(normalizeSessionName("x".repeat(80))).toHaveLength(64);
|
||||||
|
expect([...normalizeSessionName("é".repeat(80))]).toHaveLength(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sidebar card context menu (#125)", () => {
|
||||||
|
it("suppresses the browser menu and offers rename, spawn similar and kill", () => {
|
||||||
|
const s = session();
|
||||||
|
const row = cardIn(mount(s), s);
|
||||||
|
const ev = new Event("contextmenu", { bubbles: true, cancelable: true });
|
||||||
|
row.dispatchEvent(ev);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([
|
||||||
|
"Rename…",
|
||||||
|
"Spawn similar",
|
||||||
|
"Kill session",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawn similar dispatches the same event the ⧉ button does", () => {
|
||||||
|
const s = session();
|
||||||
|
openCardMenu(s);
|
||||||
|
const seen = vi.fn();
|
||||||
|
document.addEventListener("catalog-spawn-similar", seen);
|
||||||
|
menuItem("Spawn similar").click();
|
||||||
|
document.removeEventListener("catalog-spawn-similar", seen);
|
||||||
|
expect(seen).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inline rename (#125)", () => {
|
||||||
|
it("pre-fills the field with the session's own name", () => {
|
||||||
|
const { row } = openCardMenu(session());
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
|
||||||
|
expect(input?.value).toBe("amber-falcon");
|
||||||
|
expect(row.querySelector("span.leaf-name")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts empty for a session that has no name of its own", () => {
|
||||||
|
const { row } = openCardMenu(session({ metadata: {} }));
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
expect(row.querySelector<HTMLInputElement>("input.leaf-rename")?.value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the tree still while the field is open", () => {
|
||||||
|
const s = session();
|
||||||
|
const { el, row } = openCardMenu(s);
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
|
||||||
|
// A catalog delta mid-edit must not rebuild the row out from under it.
|
||||||
|
document.dispatchEvent(new CustomEvent("catalog-rerender"));
|
||||||
|
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter PATCHes the session with the typed name", () => {
|
||||||
|
const s = session();
|
||||||
|
const { row } = openCardMenu(s);
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
|
||||||
|
input.value = "crimson-otter";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(() =>
|
||||||
|
Promise.resolve(new Response(JSON.stringify(s), { status: 200 })),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
|
expect(url).toBe(`/api/sessions/${s.id}`);
|
||||||
|
expect(init.method).toBe("PATCH");
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({ name: "crimson-otter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape cancels without a request and restores the name", () => {
|
||||||
|
const s = session();
|
||||||
|
const { el, row } = openCardMenu(s);
|
||||||
|
menuItem("Rename…").click();
|
||||||
|
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
|
||||||
|
input.value = "discarded";
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(() => Promise.reject(new Error("offline")));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
// The row is rebuilt from the store, so the old name comes back.
|
||||||
|
expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon");
|
||||||
|
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -814,6 +814,68 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline rename field (#125): sits exactly where the name it replaces did, so
|
||||||
|
the card does not jump when the edit starts. */
|
||||||
|
input.leaf-rename {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--accent-ring);
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
padding: 0 var(--sp-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.leaf-rename:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right-click menu on a session card (#125). Positioned from the click point,
|
||||||
|
so it is fixed to the viewport rather than to the scrolling sidebar. */
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 168px;
|
||||||
|
padding: var(--sp-1);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-3);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-2);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-13);
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--sp-1) var(--sp-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover,
|
||||||
|
.context-menu-item:focus-visible {
|
||||||
|
background: var(--surface-3);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.is-danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item.is-danger:hover,
|
||||||
|
.context-menu-item.is-danger:focus-visible {
|
||||||
|
background: var(--danger-tint);
|
||||||
|
}
|
||||||
|
|
||||||
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
||||||
state word itself is never spelled out here; the colour carries it. */
|
state word itself is never spelled out here; the colour carries it. */
|
||||||
.card-line2 {
|
.card-line2 {
|
||||||
|
|||||||
+20
-1
@@ -244,10 +244,29 @@ export interface ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sessionName(s: Session): string {
|
export function sessionName(s: Session): string {
|
||||||
|
return customSessionName(s) ?? s.command;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's own name, without the command fallback (#125): `undefined`
|
||||||
|
* for a session that was never given one. */
|
||||||
|
export function customSessionName(s: Session): string | undefined {
|
||||||
const meta = s.metadata as Record<string, unknown>;
|
const meta = s.metadata as Record<string, unknown>;
|
||||||
const name = meta?.name;
|
const name = meta?.name;
|
||||||
if (typeof name === "string" && name.length > 0) return name;
|
if (typeof name === "string" && name.length > 0) return name;
|
||||||
return s.command;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest session name the daemon stores (#125), in characters. Mirrors
|
||||||
|
* `maestro_protocol::MAX_SESSION_NAME_LEN`. */
|
||||||
|
export const MAX_SESSION_NAME_LEN = 64;
|
||||||
|
|
||||||
|
/** Trim a requested session name and cap it at {@link MAX_SESSION_NAME_LEN}
|
||||||
|
* characters. Empty means "clear the name", so the card falls back to the
|
||||||
|
* command. Mirrors `maestro_protocol::normalize_session_name` — the daemon
|
||||||
|
* applies the same rule, this only keeps the UI honest about what it will
|
||||||
|
* get back. Uses the spread so the cap counts code points, not UTF-16 units. */
|
||||||
|
export function normalizeSessionName(name: string): string {
|
||||||
|
return [...name.trim()].slice(0, MAX_SESSION_NAME_LEN).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
||||||
|
|||||||
Reference in New Issue
Block a user