Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43b90ccf0a | ||
|
|
bdeec6eff5 | ||
|
|
345650172b | ||
|
|
999dbe3ea3 | ||
|
|
c9d089534b | ||
|
|
573a2b4a07 | ||
|
|
9ef3547a00 | ||
|
|
2ab07c2343 | ||
|
|
342e732ef0 | ||
|
|
3205270b51 | ||
|
|
d8e31f2760 | ||
|
|
fdf9ea1b31 | ||
|
|
22cbfbc9bc | ||
|
|
839dd27925 |
@@ -180,20 +180,62 @@ pub enum TuiTheme {
|
||||
Light,
|
||||
/// Named ANSI colours, allowing the terminal to provide the colour set.
|
||||
Ansi,
|
||||
/// Solarized Dark.
|
||||
SolarizedDark,
|
||||
/// Solarized Light.
|
||||
SolarizedLight,
|
||||
/// Catppuccin Mocha.
|
||||
Catppuccin,
|
||||
/// GitHub Dark.
|
||||
GithubDark,
|
||||
/// GitHub Light.
|
||||
GithubLight,
|
||||
/// Tokyo Night.
|
||||
TokyoNight,
|
||||
/// Gruvbox Dark.
|
||||
Gruvbox,
|
||||
/// Nord.
|
||||
Nord,
|
||||
/// Dracula.
|
||||
Dracula,
|
||||
}
|
||||
|
||||
/// Every `[tui].theme` name and its variant, in documentation order.
|
||||
pub const TUI_THEMES: &[(&str, TuiTheme)] = &[
|
||||
("dark", TuiTheme::Dark),
|
||||
("light", TuiTheme::Light),
|
||||
("ansi", TuiTheme::Ansi),
|
||||
("solarized-dark", TuiTheme::SolarizedDark),
|
||||
("solarized-light", TuiTheme::SolarizedLight),
|
||||
("catppuccin", TuiTheme::Catppuccin),
|
||||
("github-dark", TuiTheme::GithubDark),
|
||||
("github-light", TuiTheme::GithubLight),
|
||||
("tokyo-night", TuiTheme::TokyoNight),
|
||||
("gruvbox", TuiTheme::Gruvbox),
|
||||
("nord", TuiTheme::Nord),
|
||||
("dracula", TuiTheme::Dracula),
|
||||
];
|
||||
|
||||
fn deserialize_theme<'de, D>(deserializer: D) -> Result<Option<TuiTheme>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = Option::<String>::deserialize(deserializer)?;
|
||||
raw.map(|name| match name.as_str() {
|
||||
"dark" => Ok(TuiTheme::Dark),
|
||||
"light" => Ok(TuiTheme::Light),
|
||||
"ansi" => Ok(TuiTheme::Ansi),
|
||||
_ => Err(D::Error::custom(format!(
|
||||
"invalid [tui].theme {name:?}; expected \"dark\", \"light\", or \"ansi\""
|
||||
))),
|
||||
raw.map(|name| {
|
||||
TUI_THEMES
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, theme)| *theme)
|
||||
.ok_or_else(|| {
|
||||
let known = TUI_THEMES
|
||||
.iter()
|
||||
.map(|(n, _)| format!("{n:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
D::Error::custom(format!(
|
||||
"invalid [tui].theme {name:?}; expected one of {known}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
@@ -630,23 +672,22 @@ bind_addr = "127.0.0.1:9090"
|
||||
#[test]
|
||||
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
|
||||
assert_eq!(TuiSection::default().theme, None);
|
||||
let cfg = file_with("[tui]\ntheme = \"light\"\n");
|
||||
assert_eq!(cfg.tui.theme, Some(TuiTheme::Light));
|
||||
let cfg = file_with("[tui]\ntheme = \"ansi\"\n");
|
||||
assert_eq!(cfg.tui.theme, Some(TuiTheme::Ansi));
|
||||
for (name, theme) in TUI_THEMES {
|
||||
let cfg = file_with(&format!("[tui]\ntheme = \"{name}\"\n"));
|
||||
assert_eq!(cfg.tui.theme, Some(*theme));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_theme_rejects_unknown_names_with_the_field_name() {
|
||||
let err =
|
||||
toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"solarized\"\n").unwrap_err();
|
||||
let err = toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"glorb\"\n").unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("[tui].theme"),
|
||||
"diagnostic should name the field: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("solarized"),
|
||||
message.contains("glorb"),
|
||||
"diagnostic should name the invalid value: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use axum::{Json, Router};
|
||||
use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||
ReportActivityRequest, Session, PROTOCOL_VERSION,
|
||||
RenameSessionRequest, ReportActivityRequest, Session, PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
@@ -47,7 +47,10 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
Router::new()
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.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::RECENT_DIRS, get(recent_dirs))
|
||||
.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
|
||||
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
||||
/// since been committed or pushed says so. Entries whose directory somebody
|
||||
|
||||
@@ -716,6 +716,38 @@ impl SessionManager {
|
||||
.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
|
||||
/// produce `ActivityState::Error`. The report outranks the activity pass's
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// #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]
|
||||
async fn delete_owned_session_kills_it() {
|
||||
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::{
|
||||
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.
|
||||
@@ -134,6 +135,17 @@ impl DaemonClient {
|
||||
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
|
||||
/// state. The daemon holds it above its own inference for a TTL, then
|
||||
/// resumes inference. Answers `204 No Content`.
|
||||
|
||||
@@ -495,6 +495,31 @@ pub struct NameBatch {
|
||||
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`)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -964,4 +989,42 @@ mod tests {
|
||||
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"})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,14 @@ pub enum Action {
|
||||
FocusSidebar,
|
||||
FocusPane(Direction),
|
||||
|
||||
// Session scrollback
|
||||
ScrollPanePageUp,
|
||||
ScrollPanePageDown,
|
||||
|
||||
// Sidebar highlight movement
|
||||
SidebarNext,
|
||||
SidebarPrev,
|
||||
ToggleSidebarGroup,
|
||||
|
||||
// Catalog actions on the highlighted sidebar row
|
||||
AttachHighlighted,
|
||||
@@ -43,16 +48,12 @@ pub enum Action {
|
||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||
FastForwardSpawn,
|
||||
KillHighlighted,
|
||||
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
||||
/// its current name.
|
||||
RenameHighlighted,
|
||||
/// Open the retained-worktrees view (#85).
|
||||
ShowWorktrees,
|
||||
|
||||
// Tabs
|
||||
NewTab,
|
||||
CloseTab,
|
||||
NextTab,
|
||||
PrevTab,
|
||||
GotoTab(u8),
|
||||
|
||||
// Chrome
|
||||
ToggleSidebar,
|
||||
ShowHelp,
|
||||
@@ -70,8 +71,40 @@ pub enum ActionParseError {
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`,
|
||||
/// or `"GotoTab(3)"`.
|
||||
/// Plain-language label for the in-app help menu. Configuration keeps the
|
||||
/// stable enum spelling; operators should not have to decode it.
|
||||
pub fn help_label(&self) -> String {
|
||||
match self {
|
||||
Action::Noop => "Do nothing".into(),
|
||||
Action::EnterInsert => "Send keys to session".into(),
|
||||
Action::LeaveInsert => "Return to sidebar".into(),
|
||||
Action::FocusSidebar => "Focus sidebar".into(),
|
||||
Action::FocusPane(direction) => {
|
||||
format!(
|
||||
"Focus session pane ({})",
|
||||
format!("{direction:?}").to_lowercase()
|
||||
)
|
||||
}
|
||||
Action::ScrollPanePageUp => "Scroll session up one page".into(),
|
||||
Action::ScrollPanePageDown => "Scroll session down one page".into(),
|
||||
Action::SidebarNext => "Select next sidebar item".into(),
|
||||
Action::SidebarPrev => "Select previous sidebar item".into(),
|
||||
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
|
||||
Action::AttachHighlighted => "Open selected item".into(),
|
||||
Action::ToggleAttachHighlighted => "Open selected item".into(),
|
||||
Action::NewDraftPane => "Create a session".into(),
|
||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||
Action::KillHighlighted => "Kill selected session".into(),
|
||||
Action::RenameHighlighted => "Rename selected session".into(),
|
||||
Action::ShowWorktrees => "Show retained worktrees".into(),
|
||||
Action::ToggleSidebar => "Show or hide sidebar".into(),
|
||||
Action::ShowHelp => "Show help".into(),
|
||||
Action::Quit => "Quit Maestro".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a canonical action string like `"EnterInsert"` or
|
||||
/// `"FocusPane(Left)"`.
|
||||
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
|
||||
let s = s.trim();
|
||||
match s {
|
||||
@@ -79,18 +112,18 @@ impl Action {
|
||||
"EnterInsert" => return Ok(Action::EnterInsert),
|
||||
"LeaveInsert" => return Ok(Action::LeaveInsert),
|
||||
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
||||
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
|
||||
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
|
||||
"SidebarNext" => return Ok(Action::SidebarNext),
|
||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
||||
"NewTab" => return Ok(Action::NewTab),
|
||||
"CloseTab" => return Ok(Action::CloseTab),
|
||||
"NextTab" => return Ok(Action::NextTab),
|
||||
"PrevTab" => return Ok(Action::PrevTab),
|
||||
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
|
||||
"ShowHelp" => return Ok(Action::ShowHelp),
|
||||
"Quit" => return Ok(Action::Quit),
|
||||
@@ -111,13 +144,6 @@ impl Action {
|
||||
})?;
|
||||
Ok(Action::FocusPane(dir))
|
||||
}
|
||||
"GotoTab" => {
|
||||
let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg {
|
||||
action: name.to_string(),
|
||||
detail: format!("expected u8 0..=255, got {arg:?}"),
|
||||
})?;
|
||||
Ok(Action::GotoTab(n))
|
||||
}
|
||||
_ => Err(ActionParseError::Unknown(name.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -137,7 +163,6 @@ impl fmt::Display for Action {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
|
||||
Action::GotoTab(n) => write!(f, "GotoTab({n})"),
|
||||
other => write!(f, "{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -154,6 +179,13 @@ mod tests {
|
||||
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_labels_are_plain_language() {
|
||||
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
|
||||
assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item");
|
||||
assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_focus_pane_directions() {
|
||||
assert_eq!(
|
||||
@@ -166,12 +198,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_goto_tab() {
|
||||
assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3));
|
||||
assert_eq!(Action::parse("GotoTab(0)").unwrap(), Action::GotoTab(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_action() {
|
||||
let err = Action::parse("Frobnicate").unwrap_err();
|
||||
@@ -186,22 +212,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_numeric_tab_arg() {
|
||||
let err = Action::parse("GotoTab(abc)").unwrap_err();
|
||||
assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "GotoTab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_overflow_tab_arg() {
|
||||
let err = Action::parse("GotoTab(9999)").unwrap_err();
|
||||
assert!(matches!(err, ActionParseError::BadArg { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unclosed_paren() {
|
||||
let err = Action::parse("GotoTab(3").unwrap_err();
|
||||
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
|
||||
let err = Action::parse("FocusPane(Left").unwrap_err();
|
||||
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
|
||||
}
|
||||
|
||||
/// The fast-forward pair sits next to the draft-pane key (#67).
|
||||
@@ -214,14 +228,28 @@ mod tests {
|
||||
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]
|
||||
fn display_round_trips() {
|
||||
for a in [
|
||||
Action::EnterInsert,
|
||||
Action::RenameHighlighted,
|
||||
Action::Quit,
|
||||
Action::NewDraftPane,
|
||||
Action::ScrollPanePageDown,
|
||||
Action::FocusPane(Direction::Right),
|
||||
Action::GotoTab(7),
|
||||
] {
|
||||
let s = a.to_string();
|
||||
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
|
||||
|
||||
+591
-49
@@ -30,15 +30,17 @@ use crate::catalog::CatalogStore;
|
||||
use crate::config::TuiConfig;
|
||||
use crate::draft::DraftInputResult;
|
||||
use crate::emulators::SessionEmulators;
|
||||
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
||||
use crate::input::{
|
||||
canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region,
|
||||
};
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::pane::{Pane, PaneStatus};
|
||||
use crate::render::{draw, RegionMap, RenderInputs};
|
||||
use crate::session_task::{SessionInput, SessionTask};
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
||||
use crate::tabs::TabBar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
||||
use crate::text_input::TextInput;
|
||||
use crate::worktrees::WorktreesView;
|
||||
use crate::TuiError;
|
||||
|
||||
@@ -52,6 +54,7 @@ enum OverlayDecision {
|
||||
|
||||
pub struct App {
|
||||
cfg: TuiConfig,
|
||||
home_dir: String,
|
||||
catalog: CatalogStore,
|
||||
emulators: SessionEmulators,
|
||||
state: AppState,
|
||||
@@ -73,6 +76,9 @@ pub struct App {
|
||||
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
|
||||
/// rather than in `AppState` so the overlay kind stays payload-free.
|
||||
worktrees: WorktreesView,
|
||||
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
||||
/// same reason the worktree entries are.
|
||||
rename_input: TextInput,
|
||||
region: RegionMap,
|
||||
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
||||
clicks: ClickTracker,
|
||||
@@ -121,6 +127,12 @@ pub enum Effect {
|
||||
/// Reclaim one retained worktree (#85) — removes its directory; the
|
||||
/// branch is never deleted.
|
||||
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 {
|
||||
@@ -137,6 +149,7 @@ impl App {
|
||||
.expect("build reqwest client");
|
||||
Self {
|
||||
cfg,
|
||||
home_dir: std::env::var("HOME").unwrap_or_default(),
|
||||
catalog,
|
||||
emulators: SessionEmulators::default(),
|
||||
state: AppState::new(),
|
||||
@@ -145,6 +158,7 @@ impl App {
|
||||
daemon,
|
||||
http,
|
||||
worktrees: WorktreesView::default(),
|
||||
rename_input: TextInput::default(),
|
||||
draft_tx,
|
||||
draft_rx,
|
||||
region: RegionMap::default(),
|
||||
@@ -158,6 +172,9 @@ impl App {
|
||||
mut self,
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
) -> Result<(), TuiError> {
|
||||
if let Ok(info) = self.daemon.daemon_info().await {
|
||||
self.home_dir = info.home_dir;
|
||||
}
|
||||
let mut events = EventStream::new();
|
||||
loop {
|
||||
self.redraw(terminal)?;
|
||||
@@ -197,6 +214,15 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if self.handle_sidebar_mouse(&key) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(effect) = self.handle_pane_scroll(&key) {
|
||||
self.perform(effect.into_iter().collect()).await;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
||||
match self.chord.feed(&key, &known) {
|
||||
ChordResult::Pending => Ok(false),
|
||||
@@ -224,6 +250,34 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks select rendered sidebar items directly. Double-clicks continue
|
||||
/// through the keymap so the configured attach binding remains intact.
|
||||
fn handle_sidebar_mouse(&mut self, key: &InputKey) -> bool {
|
||||
let InputKey::Mouse {
|
||||
kind: MouseKind::Click,
|
||||
region: Region::SidebarRow,
|
||||
row,
|
||||
..
|
||||
} = key
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(item) = self.region.sidebar_item_at(*row) else {
|
||||
return true;
|
||||
};
|
||||
self.state.focus = Focus::Sidebar;
|
||||
match item {
|
||||
SidebarItem::Group(key) => {
|
||||
self.state.sidebar_highlight = Some(SidebarItem::Group(key.clone()));
|
||||
self.toggle_sidebar_group(key);
|
||||
}
|
||||
SidebarItem::Session(id) => {
|
||||
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||
/// the daemon.
|
||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||
@@ -241,6 +295,7 @@ impl App {
|
||||
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
|
||||
Effect::LoadWorktrees => self.spawn_load_worktrees(),
|
||||
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
|
||||
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
||||
Effect::Forward { id, bytes } => {
|
||||
if let Some(handle) = self.emulators.get(id) {
|
||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||
@@ -263,6 +318,21 @@ impl App {
|
||||
}
|
||||
_ => (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 {
|
||||
InputKey::Char { c: 'j', .. } | InputKey::Down => {
|
||||
self.worktrees.move_highlight(1);
|
||||
@@ -322,32 +392,37 @@ impl App {
|
||||
match act {
|
||||
Action::Noop | Action::Quit => {}
|
||||
Action::EnterInsert => self.state.mode = Mode::Insert,
|
||||
Action::LeaveInsert => self.state.mode = Mode::Normal,
|
||||
Action::LeaveInsert => {
|
||||
self.state.mode = Mode::Normal;
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
|
||||
Action::FocusPane(_) => {
|
||||
// v1 has one pane per tab; geometric focus motion lands
|
||||
// either in the sidebar or back in the pane.
|
||||
self.state.focus = Focus::Pane;
|
||||
}
|
||||
Action::ScrollPanePageUp => self.scroll_active_pane(self.pane_page_rows() as isize),
|
||||
Action::ScrollPanePageDown => {
|
||||
self.scroll_active_pane(-(self.pane_page_rows() as isize))
|
||||
}
|
||||
Action::SidebarNext => {
|
||||
let order = visible_sidebar_order(&self.catalog.snapshot());
|
||||
let order = visible_sidebar_order(
|
||||
&self.catalog.snapshot(),
|
||||
&self.state.collapsed_sidebar_groups,
|
||||
);
|
||||
self.state.move_sidebar_highlight(&order, 1);
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::SidebarPrev => {
|
||||
let order = visible_sidebar_order(&self.catalog.snapshot());
|
||||
let order = visible_sidebar_order(
|
||||
&self.catalog.snapshot(),
|
||||
&self.state.collapsed_sidebar_groups,
|
||||
);
|
||||
self.state.move_sidebar_highlight(&order, -1);
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::NewTab => self.state.new_tab(),
|
||||
Action::CloseTab => {
|
||||
if let Some(sid) = self.state.close_tab() {
|
||||
self.release_session(sid);
|
||||
}
|
||||
}
|
||||
Action::NextTab => self.state.next_tab(),
|
||||
Action::PrevTab => self.state.prev_tab(),
|
||||
Action::GotoTab(n) => self.state.goto_tab(n),
|
||||
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
|
||||
Action::ToggleSidebar => self.state.toggle_sidebar(),
|
||||
Action::ShowHelp => {
|
||||
self.help = HelpOverlay {
|
||||
@@ -356,13 +431,24 @@ impl App {
|
||||
self.state.show_overlay(OverlayKind::Help);
|
||||
}
|
||||
Action::NewDraftPane => {
|
||||
if let Some(sid) = self.state.active_session() {
|
||||
self.release_session(sid);
|
||||
}
|
||||
self.state.open_draft_pane();
|
||||
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
|
||||
}
|
||||
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
||||
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
effects.push(Effect::Attach(sid));
|
||||
if let Some(SidebarItem::Group(key)) = self.state.sidebar_highlight.clone() {
|
||||
self.toggle_sidebar_group(key);
|
||||
} else if let Some(sid) = self.pick_sidebar_target() {
|
||||
self.state.sidebar_highlight = Some(SidebarItem::Session(sid));
|
||||
if self.state.active_session() != Some(sid) {
|
||||
if let Some(previous) = self.state.active_session() {
|
||||
self.release_session(previous);
|
||||
}
|
||||
effects.push(Effect::Attach(sid));
|
||||
}
|
||||
self.state.attach_to_focused(sid);
|
||||
}
|
||||
}
|
||||
@@ -371,6 +457,23 @@ impl App {
|
||||
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 => {
|
||||
self.state.show_overlay(OverlayKind::Worktrees);
|
||||
effects.push(Effect::LoadWorktrees);
|
||||
@@ -403,22 +506,127 @@ impl App {
|
||||
/// Return the session under the sidebar highlight, falling back to the
|
||||
/// first listed session so the user can attach without arrowing first.
|
||||
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
||||
if let Some(sid) = self.state.sidebar_highlight {
|
||||
if let Some(SidebarItem::Session(sid)) = self.state.sidebar_highlight {
|
||||
return Some(sid);
|
||||
}
|
||||
if self.state.sidebar_highlight.is_some() {
|
||||
return None;
|
||||
}
|
||||
let snap = self.catalog.snapshot();
|
||||
snap.sessions.first().map(|s| s.id)
|
||||
}
|
||||
|
||||
fn toggle_highlighted_sidebar_group(&mut self) {
|
||||
let key = match self.state.sidebar_highlight.clone() {
|
||||
Some(SidebarItem::Group(key)) => Some(key),
|
||||
Some(SidebarItem::Session(id)) => self
|
||||
.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.and_then(|s| maestro_protocol::display::session_tree_path(&s)),
|
||||
None => None,
|
||||
};
|
||||
if let Some(key) = key {
|
||||
self.toggle_sidebar_group(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_sidebar_group(&mut self, key: PathBuf) {
|
||||
let contains_highlight = match self.state.sidebar_highlight {
|
||||
Some(SidebarItem::Session(id)) => {
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.and_then(maestro_protocol::display::session_tree_path)
|
||||
.as_ref()
|
||||
== Some(&key)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.state.toggle_sidebar_group(key, contains_highlight);
|
||||
}
|
||||
|
||||
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
||||
/// attached to send it to.
|
||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||
let sid = self.state.active_session()?;
|
||||
self.emulators.get(sid)?;
|
||||
let handle = self.emulators.get(sid)?;
|
||||
let bytes = encode_key(key)?;
|
||||
handle
|
||||
.screen
|
||||
.lock()
|
||||
.expect("vt100 parser mutex")
|
||||
.set_scrollback(0);
|
||||
Some(Effect::Forward { id: sid, bytes })
|
||||
}
|
||||
|
||||
fn pane_page_rows(&self) -> usize {
|
||||
self.region
|
||||
.pane
|
||||
.map_or(1, |area| usize::from(area.height.saturating_sub(1).max(1)))
|
||||
}
|
||||
|
||||
/// Positive rows move into older history; negative rows return toward the
|
||||
/// live bottom. vt100 clamps both ends to the retained buffer.
|
||||
fn scroll_active_pane(&mut self, rows: isize) {
|
||||
let Some(sid) = self.state.active_session() else {
|
||||
return;
|
||||
};
|
||||
let Some(handle) = self.emulators.get(sid) else {
|
||||
return;
|
||||
};
|
||||
let mut parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||
let current = parser.screen().scrollback();
|
||||
let next = if rows >= 0 {
|
||||
current.saturating_add(rows as usize)
|
||||
} else {
|
||||
current.saturating_sub(rows.unsigned_abs())
|
||||
};
|
||||
parser.set_scrollback(next);
|
||||
}
|
||||
|
||||
/// Wheel input belongs to the terminal surface in every mode. Normal
|
||||
/// shells move through Maestro's retained history; full-screen programs
|
||||
/// that enabled terminal mouse reporting receive a real wheel event and
|
||||
/// scroll their own alternate-screen content.
|
||||
fn handle_pane_scroll(&mut self, key: &InputKey) -> Option<Option<Effect>> {
|
||||
let InputKey::Mouse {
|
||||
kind,
|
||||
region: Region::Pane,
|
||||
col,
|
||||
row,
|
||||
} = key
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let lines = match kind {
|
||||
MouseKind::ScrollUp => 3,
|
||||
MouseKind::ScrollDown => -3,
|
||||
_ => return None,
|
||||
};
|
||||
let sid = self.state.active_session()?;
|
||||
let handle = self.emulators.get(sid)?;
|
||||
let pane = self.region.pane?;
|
||||
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||
if parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None {
|
||||
let bytes = encode_mouse_wheel(
|
||||
*kind,
|
||||
col.saturating_sub(pane.x).saturating_add(1),
|
||||
row.saturating_sub(pane.y).saturating_add(1),
|
||||
parser.screen().mouse_protocol_encoding(),
|
||||
);
|
||||
drop(parser);
|
||||
return Some(Some(Effect::Forward { id: sid, bytes }));
|
||||
}
|
||||
drop(parser);
|
||||
self.scroll_active_pane(lines);
|
||||
Some(None)
|
||||
}
|
||||
|
||||
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||
/// effect when the user accepted a valid form; an invalid one names the
|
||||
/// missing field inside the form (#64) instead of posting.
|
||||
@@ -704,11 +912,6 @@ impl App {
|
||||
let resolved =
|
||||
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
||||
if let Some(req) = resolved.build_request() {
|
||||
// Never eat a pane already showing something: the spawn takes a
|
||||
// fresh tab, same as opening a draft over a session would.
|
||||
if self.state.active_tab().pane.is_some() {
|
||||
self.state.new_tab();
|
||||
}
|
||||
self.submit_spawn(req).await;
|
||||
return;
|
||||
}
|
||||
@@ -734,6 +937,9 @@ impl App {
|
||||
// Fill the pane in place with the new session (web parity,
|
||||
// #57) and attach to it right away.
|
||||
let sid = session.id;
|
||||
if let Some(previous) = self.state.active_session() {
|
||||
self.release_session(previous);
|
||||
}
|
||||
self.state.complete_spawn(sid);
|
||||
self.attach(sid);
|
||||
}
|
||||
@@ -766,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) —
|
||||
/// same off-loop pattern as the draft loaders.
|
||||
fn spawn_load_worktrees(&self) {
|
||||
@@ -811,13 +1025,9 @@ impl App {
|
||||
.collect();
|
||||
let sidebar = Sidebar {
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: self.state.sidebar_highlight,
|
||||
highlight: self.state.sidebar_highlight.clone(),
|
||||
attached,
|
||||
};
|
||||
let tab_bar = TabBar {
|
||||
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
|
||||
active_idx: self.state.active_tab_idx,
|
||||
};
|
||||
let active_sid = self.state.active_session();
|
||||
let pane = active_sid.and_then(|sid| {
|
||||
self.emulators.get(sid).map(|h| Pane {
|
||||
@@ -830,15 +1040,27 @@ impl App {
|
||||
Some(PaneRef::Draft(d)) => Some(d),
|
||||
_ => None,
|
||||
};
|
||||
let pane_title = match self.state.active_tab().pane.as_ref() {
|
||||
Some(PaneRef::Session(sid)) => snap
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == *sid)
|
||||
.map(crate::sidebar::name_for)
|
||||
.unwrap_or_else(|| "session".into()),
|
||||
Some(PaneRef::Draft(_)) => "new session".into(),
|
||||
None => "session".into(),
|
||||
};
|
||||
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
tab_bar: &tab_bar,
|
||||
home_dir: &self.home_dir,
|
||||
pane_title: &pane_title,
|
||||
pane: pane.as_ref(),
|
||||
state: &self.state,
|
||||
draft,
|
||||
help: &self.help,
|
||||
worktrees: Some(&self.worktrees),
|
||||
rename_input: &self.rename_input,
|
||||
};
|
||||
|
||||
let mut new_region = RegionMap::default();
|
||||
@@ -881,8 +1103,16 @@ impl App {
|
||||
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
|
||||
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
|
||||
/// visual sync with what the user sees.
|
||||
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
||||
snap.sessions.iter().map(|s| s.id).collect()
|
||||
fn visible_sidebar_order(
|
||||
snap: &crate::catalog::CatalogSnapshot,
|
||||
collapsed: &std::collections::HashSet<PathBuf>,
|
||||
) -> Vec<SidebarItem> {
|
||||
Sidebar {
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
}
|
||||
.rendered_items_with_collapsed(collapsed)
|
||||
}
|
||||
|
||||
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
||||
@@ -916,6 +1146,8 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
||||
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
||||
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||
InputKey::PageUp => Some(b"\x1b[5~".to_vec()),
|
||||
InputKey::PageDown => Some(b"\x1b[6~".to_vec()),
|
||||
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||
InputKey::Paste(text) => {
|
||||
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
||||
@@ -931,6 +1163,39 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_mouse_wheel(
|
||||
kind: MouseKind,
|
||||
col: u16,
|
||||
row: u16,
|
||||
encoding: vt100::MouseProtocolEncoding,
|
||||
) -> Vec<u8> {
|
||||
let button = match kind {
|
||||
MouseKind::ScrollUp => 64,
|
||||
MouseKind::ScrollDown => 65,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
match encoding {
|
||||
vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{col};{row}M").into_bytes(),
|
||||
vt100::MouseProtocolEncoding::Default => vec![
|
||||
0x1b,
|
||||
b'[',
|
||||
b'M',
|
||||
(button + 32) as u8,
|
||||
col.min(223).saturating_add(32) as u8,
|
||||
row.min(223).saturating_add(32) as u8,
|
||||
],
|
||||
vt100::MouseProtocolEncoding::Utf8 => {
|
||||
let mut out = b"\x1b[M".to_vec();
|
||||
for value in [button + 32, u32::from(col) + 32, u32::from(row) + 32] {
|
||||
let ch = char::from_u32(value).expect("mouse coordinate is a valid codepoint");
|
||||
let mut bytes = [0; 4];
|
||||
out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the lines shown by the help overlay from the live keymap, so the
|
||||
/// user always sees the bindings that are actually active (defaults + their
|
||||
/// own `[tui.keymap.*]` overrides).
|
||||
@@ -938,14 +1203,14 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
|
||||
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
|
||||
let mut out = vec![format!("{label} mode"), String::new()];
|
||||
for (input, action) in cfg.keymap.entries(mode) {
|
||||
out.push(format!(" {input:<24} {action}"));
|
||||
out.push(format!(" {input:<24} {}", action.help_label()));
|
||||
}
|
||||
out.push(String::new());
|
||||
out
|
||||
}
|
||||
let leader = cfg.leader.canonical();
|
||||
let mut lines = vec![
|
||||
format!("Leader: {leader} (Insert → Normal)"),
|
||||
format!("Leader: {leader} (return to sidebar)"),
|
||||
String::new(),
|
||||
];
|
||||
lines.extend(section(Mode::Normal, "Normal", cfg));
|
||||
@@ -1057,19 +1322,11 @@ mod tests {
|
||||
let mut app = make_app();
|
||||
assert_eq!(app.state.mode, Mode::Normal);
|
||||
app.dispatch(Action::EnterInsert);
|
||||
app.state.focus = Focus::Pane;
|
||||
assert_eq!(app.state.mode, Mode::Insert);
|
||||
app.dispatch(Action::LeaveInsert);
|
||||
assert_eq!(app.state.mode, Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tab_action_extends_tabs() {
|
||||
let mut app = make_app();
|
||||
app.dispatch(Action::NewTab);
|
||||
app.dispatch(Action::NewTab);
|
||||
assert_eq!(app.state.tabs.len(), 3);
|
||||
app.dispatch(Action::CloseTab);
|
||||
assert_eq!(app.state.tabs.len(), 2);
|
||||
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1079,6 +1336,18 @@ mod tests {
|
||||
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_uses_plain_language_instead_of_action_names() {
|
||||
let app = make_app();
|
||||
let help = render_help_lines(&app.cfg).join("\n");
|
||||
assert!(help.contains("Send keys to session"));
|
||||
assert!(help.contains("Create a session"));
|
||||
assert!(help.contains("Expand or collapse directory"));
|
||||
assert!(!help.contains("EnterInsert"));
|
||||
assert!(!help.contains("NewDraftPane"));
|
||||
assert!(!help.contains("ToggleSidebarGroup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
|
||||
let mut app = make_app();
|
||||
@@ -1092,21 +1361,19 @@ mod tests {
|
||||
assert!(app.state.overlay().is_none(), "no overlay involved");
|
||||
}
|
||||
|
||||
/// A tab already showing a session gets a new tab for the draft.
|
||||
#[test]
|
||||
fn new_draft_pane_over_a_session_opens_a_new_tab() {
|
||||
fn new_draft_pane_over_a_session_reuses_the_pane() {
|
||||
let sid = Uuid::from_u128(10);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
app.dispatch(Action::AttachHighlighted);
|
||||
|
||||
app.dispatch(Action::NewDraftPane);
|
||||
assert_eq!(app.state.tabs.len(), 2);
|
||||
assert_eq!(app.state.active_tab_idx, 1);
|
||||
assert_eq!(app.state.tabs.len(), 1);
|
||||
assert_eq!(app.state.active_tab_idx, 0);
|
||||
assert!(matches!(
|
||||
app.state.active_tab().pane,
|
||||
Some(PaneRef::Draft(_))
|
||||
));
|
||||
assert_eq!(app.state.tabs[0].session(), Some(sid));
|
||||
}
|
||||
|
||||
/// Seed the catalog with one session and highlight it.
|
||||
@@ -1134,9 +1401,32 @@ mod tests {
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
app.dispatch(Action::SidebarNext);
|
||||
app
|
||||
}
|
||||
|
||||
fn test_session(sid: Uuid, cwd: &str) -> maestro_protocol::Session {
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::{Session, SessionStatus};
|
||||
Session {
|
||||
id: sid,
|
||||
command: "bash".into(),
|
||||
cwd: cwd.into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
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 attach_highlighted_asks_for_an_attach() {
|
||||
let sid = Uuid::from_u128(11);
|
||||
@@ -1146,6 +1436,104 @@ mod tests {
|
||||
vec![Effect::Attach(sid)]
|
||||
);
|
||||
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_group_toggles_it_without_targeting_a_session() {
|
||||
use maestro_protocol::CatalogEvent;
|
||||
|
||||
let sid = Uuid::from_u128(14);
|
||||
let mut app = make_app();
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![test_session(sid, "/repo")],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/repo")))
|
||||
);
|
||||
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||
assert!(app
|
||||
.state
|
||||
.collapsed_sidebar_groups
|
||||
.contains(&PathBuf::from("/repo")));
|
||||
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||
assert!(!app
|
||||
.state
|
||||
.collapsed_sidebar_groups
|
||||
.contains(&PathBuf::from("/repo")));
|
||||
assert!(app.dispatch(Action::KillHighlighted).is_empty());
|
||||
assert!(app.state.overlay().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_the_group_containing_a_card_highlight_keeps_a_visible_group_highlight() {
|
||||
let sid = Uuid::from_u128(15);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||
assert!(app.dispatch(Action::ToggleSidebarGroup).is_empty());
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/")))
|
||||
);
|
||||
assert!(app
|
||||
.state
|
||||
.collapsed_sidebar_groups
|
||||
.contains(&PathBuf::from("/")));
|
||||
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_group_toggles_it_and_selects_the_group() {
|
||||
let sid = Uuid::from_u128(16);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10));
|
||||
app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))];
|
||||
let click = InputKey::Mouse {
|
||||
kind: MouseKind::Click,
|
||||
region: Region::SidebarRow,
|
||||
col: 3,
|
||||
row: 1,
|
||||
};
|
||||
assert!(app.handle_sidebar_mouse(&click));
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/")))
|
||||
);
|
||||
assert!(app
|
||||
.state
|
||||
.collapsed_sidebar_groups
|
||||
.contains(&PathBuf::from("/")));
|
||||
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sidebar_click_dismisses_a_toast_before_handling_the_click() {
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
|
||||
let sid = Uuid::from_u128(17);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
app.state.toast("notice");
|
||||
app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10));
|
||||
app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))];
|
||||
|
||||
let quit = app
|
||||
.handle_event(CtEvent::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 3,
|
||||
row: 1,
|
||||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!quit);
|
||||
assert!(app.state.overlay().is_none());
|
||||
assert!(app
|
||||
.state
|
||||
.collapsed_sidebar_groups
|
||||
.contains(&PathBuf::from("/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1602,6 +1990,49 @@ mod tests {
|
||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
|
||||
use crate::session_task::{SessionHandle, SessionState};
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
let sid = Uuid::from_u128(91);
|
||||
let mut parser = vt100::Parser::new(2, 20, 20);
|
||||
parser.process(b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
|
||||
let screen = Arc::new(Mutex::new(parser));
|
||||
let handle = SessionHandle {
|
||||
tx: mpsc::channel(1).0,
|
||||
screen: Arc::clone(&screen),
|
||||
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||
};
|
||||
let mut app = make_app();
|
||||
app.state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
app.emulators.insert(sid, handle);
|
||||
|
||||
app.scroll_active_pane(3);
|
||||
assert!(screen.lock().unwrap().screen().scrollback() > 0);
|
||||
assert!(app.forward_to_session(&InputKey::Enter).is_some());
|
||||
assert_eq!(screen.lock().unwrap().screen().scrollback(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_wheel_encoding_supports_sgr_terminals() {
|
||||
assert_eq!(
|
||||
encode_mouse_wheel(MouseKind::ScrollUp, 7, 4, vt100::MouseProtocolEncoding::Sgr,),
|
||||
b"\x1b[<64;7;4M"
|
||||
);
|
||||
assert_eq!(
|
||||
encode_mouse_wheel(
|
||||
MouseKind::ScrollDown,
|
||||
7,
|
||||
4,
|
||||
vt100::MouseProtocolEncoding::Sgr,
|
||||
),
|
||||
b"\x1b[<65;7;4M"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
||||
/// paste into an attached session vanished once bracketed paste was
|
||||
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
||||
@@ -1657,6 +2088,117 @@ mod tests {
|
||||
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) -------------------------------------------
|
||||
|
||||
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
|
||||
|
||||
@@ -40,11 +40,12 @@ pub enum TuiConfigError {
|
||||
const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
// Mode
|
||||
("i", "EnterInsert"),
|
||||
// Sidebar nav
|
||||
// Sidebar navigation
|
||||
("j", "SidebarNext"),
|
||||
("k", "SidebarPrev"),
|
||||
("down", "SidebarNext"),
|
||||
("up", "SidebarPrev"),
|
||||
("c", "ToggleSidebarGroup"),
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "NewDraftPane"),
|
||||
@@ -52,20 +53,19 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
// thing with every default resolved and no form.
|
||||
("S", "FastForwardSpawn"),
|
||||
("x", "KillHighlighted"),
|
||||
("r", "RenameHighlighted"),
|
||||
("w", "ShowWorktrees"),
|
||||
// Tabs
|
||||
("ctrl-t", "NewTab"),
|
||||
("g t", "NextTab"),
|
||||
("g T", "PrevTab"),
|
||||
// Focus
|
||||
("ctrl-w h", "FocusSidebar"),
|
||||
// Session scrollback
|
||||
("pageup", "ScrollPanePageUp"),
|
||||
("pagedown", "ScrollPanePageDown"),
|
||||
// Chrome
|
||||
("ctrl-w b", "ToggleSidebar"),
|
||||
("?", "ShowHelp"),
|
||||
("ctrl-w q", "Quit"),
|
||||
// Mouse
|
||||
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
|
||||
("mouse:click(tab)", "NextTab"),
|
||||
];
|
||||
|
||||
/// Default Insert-mode bindings. Empty by default — the only Insert binding
|
||||
@@ -82,6 +82,15 @@ impl TuiConfig {
|
||||
TuiTheme::Dark => Palette::DARK,
|
||||
TuiTheme::Light => Palette::LIGHT,
|
||||
TuiTheme::Ansi => Palette::ANSI,
|
||||
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
|
||||
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
|
||||
TuiTheme::Catppuccin => Palette::CATPPUCCIN,
|
||||
TuiTheme::GithubDark => Palette::GITHUB_DARK,
|
||||
TuiTheme::GithubLight => Palette::GITHUB_LIGHT,
|
||||
TuiTheme::TokyoNight => Palette::TOKYO_NIGHT,
|
||||
TuiTheme::Gruvbox => Palette::GRUVBOX,
|
||||
TuiTheme::Nord => Palette::NORD,
|
||||
TuiTheme::Dracula => Palette::DRACULA,
|
||||
};
|
||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
||||
let leader = InputKey::parse(leader_str)
|
||||
@@ -165,6 +174,15 @@ mod tests {
|
||||
(TuiTheme::Dark, Palette::DARK),
|
||||
(TuiTheme::Light, Palette::LIGHT),
|
||||
(TuiTheme::Ansi, Palette::ANSI),
|
||||
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
|
||||
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
|
||||
(TuiTheme::Catppuccin, Palette::CATPPUCCIN),
|
||||
(TuiTheme::GithubDark, Palette::GITHUB_DARK),
|
||||
(TuiTheme::GithubLight, Palette::GITHUB_LIGHT),
|
||||
(TuiTheme::TokyoNight, Palette::TOKYO_NIGHT),
|
||||
(TuiTheme::Gruvbox, Palette::GRUVBOX),
|
||||
(TuiTheme::Nord, Palette::NORD),
|
||||
(TuiTheme::Dracula, Palette::DRACULA),
|
||||
] {
|
||||
let section = TuiSection {
|
||||
theme: Some(theme),
|
||||
@@ -185,10 +203,6 @@ mod tests {
|
||||
cfg.keymap.resolve(Mode::Normal, "enter"),
|
||||
Some(Action::AttachHighlighted),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "g t"),
|
||||
Some(Action::NextTab),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||
Some(Action::NewDraftPane),
|
||||
|
||||
@@ -43,7 +43,7 @@ use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, W
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::input::InputKey;
|
||||
@@ -1078,9 +1078,7 @@ impl DraftPane {
|
||||
/// cursor placement the same way a session pane hides its cursor when
|
||||
/// unfocused.
|
||||
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
|
||||
let block = Block::default().title(" draft ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let inner = area;
|
||||
|
||||
let show_custom = self.is_custom();
|
||||
let show_raw = self.cwd_raw_visible;
|
||||
@@ -2117,7 +2115,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The terminal cursor must sit right after the typed text in the focused
|
||||
/// field (gutter is 5 chars, block border takes column 0).
|
||||
/// field (gutter is 5 chars and the pane has no border).
|
||||
#[test]
|
||||
fn render_places_the_cursor_in_the_focused_text_field() {
|
||||
let mut draft = DraftPane {
|
||||
@@ -2129,10 +2127,7 @@ mod tests {
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
|
||||
let pos = terminal.get_cursor_position().unwrap();
|
||||
assert_eq!(
|
||||
pos,
|
||||
ratatui::layout::Position::new(1 + GUTTER as u16 + 2, 2)
|
||||
);
|
||||
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,6 +36,8 @@ pub enum InputKey {
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Delete,
|
||||
/// Bracketed paste: crossterm delivers the whole payload as one event, so
|
||||
/// text fields can insert it as a single operation.
|
||||
@@ -63,7 +65,6 @@ pub enum MouseKind {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Region {
|
||||
SidebarRow,
|
||||
Tab,
|
||||
Pane,
|
||||
Modal,
|
||||
Unknown,
|
||||
@@ -136,6 +137,8 @@ impl InputKey {
|
||||
InputKey::Right => "right".into(),
|
||||
InputKey::Home => "home".into(),
|
||||
InputKey::End => "end".into(),
|
||||
InputKey::PageUp => "pageup".into(),
|
||||
InputKey::PageDown => "pagedown".into(),
|
||||
InputKey::Delete => "delete".into(),
|
||||
// Pastes are not bindable; the canonical form exists only to
|
||||
// satisfy exhaustiveness and never round-trips through `parse`.
|
||||
@@ -150,7 +153,6 @@ impl InputKey {
|
||||
};
|
||||
let r = match region {
|
||||
Region::SidebarRow => "sidebar-row",
|
||||
Region::Tab => "tab",
|
||||
Region::Pane => "pane",
|
||||
Region::Modal => "modal",
|
||||
Region::Unknown => "unknown",
|
||||
@@ -182,7 +184,6 @@ impl InputKey {
|
||||
};
|
||||
let region = match r {
|
||||
"sidebar-row" => Region::SidebarRow,
|
||||
"tab" => Region::Tab,
|
||||
"pane" => Region::Pane,
|
||||
"modal" => Region::Modal,
|
||||
"unknown" => Region::Unknown,
|
||||
@@ -207,6 +208,8 @@ impl InputKey {
|
||||
"right" => return Ok(InputKey::Right),
|
||||
"home" => return Ok(InputKey::Home),
|
||||
"end" => return Ok(InputKey::End),
|
||||
"pageup" => return Ok(InputKey::PageUp),
|
||||
"pagedown" => return Ok(InputKey::PageDown),
|
||||
"delete" => return Ok(InputKey::Delete),
|
||||
_ => {}
|
||||
}
|
||||
@@ -281,6 +284,8 @@ where
|
||||
KeyCode::Right => InputKey::Right,
|
||||
KeyCode::Home => InputKey::Home,
|
||||
KeyCode::End => InputKey::End,
|
||||
KeyCode::PageUp => InputKey::PageUp,
|
||||
KeyCode::PageDown => InputKey::PageDown,
|
||||
KeyCode::Delete => InputKey::Delete,
|
||||
_ => return None,
|
||||
})
|
||||
@@ -617,6 +622,8 @@ mod tests {
|
||||
"right",
|
||||
"home",
|
||||
"end",
|
||||
"pageup",
|
||||
"pagedown",
|
||||
"delete",
|
||||
] {
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
|
||||
@@ -107,8 +107,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolves_chord() {
|
||||
let k = build(&[(Mode::Normal, "g t", "NextTab")]);
|
||||
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
|
||||
let k = build(&[(Mode::Normal, "g h", "FocusSidebar")]);
|
||||
assert_eq!(k.resolve(Mode::Normal, "g h"), Some(Action::FocusSidebar));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -123,11 +123,11 @@ mod tests {
|
||||
#[test]
|
||||
fn known_bindings_sees_everything() {
|
||||
let k = build(&[
|
||||
(Mode::Normal, "g t", "NextTab"),
|
||||
(Mode::Normal, "g h", "FocusSidebar"),
|
||||
(Mode::Normal, "i", "EnterInsert"),
|
||||
]);
|
||||
let kb = k.known_bindings(Mode::Normal);
|
||||
assert!(kb.contains("g t"));
|
||||
assert!(kb.contains("g h"));
|
||||
assert!(kb.contains("i"));
|
||||
assert_eq!(kb.len(), 2);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ pub mod render;
|
||||
pub mod session_task;
|
||||
pub mod sidebar;
|
||||
pub mod state;
|
||||
pub mod tabs;
|
||||
pub mod terminal;
|
||||
pub mod text_input;
|
||||
pub mod worktrees;
|
||||
|
||||
@@ -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
|
||||
//! panes ([`crate::draft::DraftPane`]).
|
||||
@@ -7,10 +8,13 @@
|
||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::text_input::TextInput;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KillConfirm {
|
||||
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)]
|
||||
pub struct HelpOverlay {
|
||||
pub lines: Vec<String>,
|
||||
@@ -151,16 +199,32 @@ mod tests {
|
||||
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]
|
||||
fn help_overlay_lists_lines() {
|
||||
let help = HelpOverlay {
|
||||
lines: vec![
|
||||
"i EnterInsert".into(),
|
||||
"s NewDraftPane".into(),
|
||||
"i Send keys to session".into(),
|
||||
"s Create a session".into(),
|
||||
],
|
||||
};
|
||||
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
||||
assert!(rendered.contains("EnterInsert"));
|
||||
assert!(rendered.contains("NewDraftPane"));
|
||||
assert!(rendered.contains("Send keys to session"));
|
||||
assert!(rendered.contains("Create a session"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
//! keeps carrying the state either way (docs/SIDEBAR.md).
|
||||
//!
|
||||
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
|
||||
//! [`Palette::ANSI`] uses terminal-provided ANSI colours.
|
||||
//! [`Palette::ANSI`] uses terminal-provided ANSI colours. The remaining
|
||||
//! presets ([`Palette::presets`]) adapt well-known editor themes — Solarized,
|
||||
//! Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord, Dracula — with individual
|
||||
//! colours lifted or darkened where the originals fall short of the contrast
|
||||
//! floors asserted below.
|
||||
//!
|
||||
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
||||
//! pane and pass through `pane::translate_color` verbatim.
|
||||
@@ -109,6 +113,8 @@ pub struct Palette {
|
||||
/// paint this background too; otherwise its contrast depends on the
|
||||
/// operator's terminal theme.
|
||||
pub chrome_bg: Color,
|
||||
/// Subtle full-panel background while the sidebar owns focus.
|
||||
pub sidebar_focus_bg: Color,
|
||||
/// Panel border while that panel holds focus.
|
||||
pub border_focused: Color,
|
||||
/// Panel border while it does not.
|
||||
@@ -152,6 +158,7 @@ impl Palette {
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
sidebar_focus_bg: Color::Rgb(0x28, 0x30, 0x2a),
|
||||
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
|
||||
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
@@ -186,6 +193,7 @@ impl Palette {
|
||||
error: Color::Rgb(0xa5, 0x1d, 0x14),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
sidebar_focus_bg: Color::Rgb(0xee, 0xf5, 0xef),
|
||||
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
|
||||
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
@@ -220,6 +228,7 @@ impl Palette {
|
||||
error: Color::LightRed,
|
||||
},
|
||||
chrome_bg: Color::Black,
|
||||
sidebar_focus_bg: Color::DarkGray,
|
||||
border_focused: Color::LightGreen,
|
||||
border_unfocused: Color::DarkGray,
|
||||
focus_accent: Color::LightGreen,
|
||||
@@ -231,30 +240,353 @@ impl Palette {
|
||||
error_text: Color::LightRed,
|
||||
};
|
||||
|
||||
/// Solarized Dark — Ethan Schoonover's low-contrast classic,
|
||||
/// accents lifted where the originals fall short of WCAG AA.
|
||||
pub const SOLARIZED_DARK: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||
fg: Color::Rgb(0x93, 0xa1, 0xa1),
|
||||
muted: Color::Rgb(0x83, 0x94, 0x96),
|
||||
dim: Color::Rgb(0x65, 0x7b, 0x83),
|
||||
active: Color::Rgb(0x85, 0x99, 0x00),
|
||||
idle: Color::Rgb(0x83, 0x94, 0x96),
|
||||
awaiting: Color::Rgb(0xb5, 0x89, 0x00),
|
||||
error: Color::Rgb(0xe5, 0x68, 0x65),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x10, 0x53, 0x62),
|
||||
fg: Color::Rgb(0xb6, 0xc0, 0xc0),
|
||||
muted: Color::Rgb(0xb5, 0xc0, 0xc1),
|
||||
dim: Color::Rgb(0x8b, 0x9f, 0xa6),
|
||||
active: Color::Rgb(0xaf, 0xca, 0x00),
|
||||
idle: Color::Rgb(0xb5, 0xc0, 0xc1),
|
||||
awaiting: Color::Rgb(0xef, 0xb5, 0x00),
|
||||
error: Color::Rgb(0xf1, 0xad, 0xab),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||
sidebar_focus_bg: Color::Rgb(0x00, 0x1a, 0x21),
|
||||
border_focused: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||
border_unfocused: Color::Rgb(0x5e, 0x76, 0x7e),
|
||||
focus_accent: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||
group_row: Color::Rgb(0x93, 0xa1, 0xa1),
|
||||
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||
mode_insert_bg: Color::Rgb(0xb5, 0x89, 0x00),
|
||||
mode_insert_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||
error_text: Color::Rgb(0xe5, 0x68, 0x65),
|
||||
};
|
||||
|
||||
/// Solarized Light — the warm cream-paper peer of Solarized Dark.
|
||||
pub const SOLARIZED_LIGHT: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||
fg: Color::Rgb(0x58, 0x6e, 0x75),
|
||||
muted: Color::Rgb(0x5e, 0x73, 0x7a),
|
||||
dim: Color::Rgb(0x7e, 0x8f, 0x92),
|
||||
active: Color::Rgb(0x67, 0x76, 0x00),
|
||||
idle: Color::Rgb(0x5e, 0x73, 0x7a),
|
||||
awaiting: Color::Rgb(0x8d, 0x6a, 0x00),
|
||||
error: Color::Rgb(0xd7, 0x27, 0x24),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0xcb, 0xbd, 0x93),
|
||||
fg: Color::Rgb(0x3f, 0x4e, 0x53),
|
||||
muted: Color::Rgb(0x40, 0x4e, 0x53),
|
||||
dim: Color::Rgb(0x59, 0x67, 0x69),
|
||||
active: Color::Rgb(0x46, 0x50, 0x00),
|
||||
idle: Color::Rgb(0x40, 0x4e, 0x53),
|
||||
awaiting: Color::Rgb(0x60, 0x48, 0x00),
|
||||
error: Color::Rgb(0x94, 0x1b, 0x19),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||
sidebar_focus_bg: Color::Rgb(0xf7, 0xf4, 0xed),
|
||||
border_focused: Color::Rgb(0x29, 0x9b, 0x93),
|
||||
border_unfocused: Color::Rgb(0x7f, 0x8f, 0x8f),
|
||||
focus_accent: Color::Rgb(0x29, 0x9b, 0x93),
|
||||
group_row: Color::Rgb(0x58, 0x6e, 0x75),
|
||||
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
|
||||
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
||||
mode_insert_bg: Color::Rgb(0x8d, 0x6a, 0x00),
|
||||
mode_insert_fg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
||||
error_text: Color::Rgb(0xd7, 0x27, 0x24),
|
||||
};
|
||||
|
||||
/// Catppuccin Mocha — soothing pastels on a soft dark base.
|
||||
pub const CATPPUCCIN: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
|
||||
muted: Color::Rgb(0xa6, 0xad, 0xc8),
|
||||
dim: Color::Rgb(0x7f, 0x84, 0x9c),
|
||||
active: Color::Rgb(0xa6, 0xe3, 0xa1),
|
||||
idle: Color::Rgb(0xa6, 0xad, 0xc8),
|
||||
awaiting: Color::Rgb(0xf9, 0xe2, 0xaf),
|
||||
error: Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x45, 0x47, 0x5a),
|
||||
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
|
||||
muted: Color::Rgb(0xb2, 0xb8, 0xcf),
|
||||
dim: Color::Rgb(0x92, 0x97, 0xab),
|
||||
active: Color::Rgb(0xb5, 0xe8, 0xb1),
|
||||
idle: Color::Rgb(0xb2, 0xb8, 0xcf),
|
||||
awaiting: Color::Rgb(0xfa, 0xe9, 0xc2),
|
||||
error: Color::Rgb(0xf5, 0xa1, 0xb8),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||
sidebar_focus_bg: Color::Rgb(0x2a, 0x2a, 0x3c),
|
||||
border_focused: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||
border_unfocused: Color::Rgb(0x6c, 0x70, 0x86),
|
||||
focus_accent: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||
group_row: Color::Rgb(0xb4, 0xbe, 0xfe),
|
||||
mode_normal_bg: Color::Rgb(0xcb, 0xa6, 0xf7),
|
||||
mode_normal_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||
mode_insert_bg: Color::Rgb(0xf9, 0xe2, 0xaf),
|
||||
mode_insert_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
||||
error_text: Color::Rgb(0xf3, 0x8b, 0xa8),
|
||||
};
|
||||
|
||||
/// GitHub Dark — the github.com dark default.
|
||||
pub const GITHUB_DARK: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||
fg: Color::Rgb(0xe6, 0xed, 0xf3),
|
||||
muted: Color::Rgb(0x91, 0x98, 0xa1),
|
||||
dim: Color::Rgb(0x6e, 0x76, 0x81),
|
||||
active: Color::Rgb(0x3f, 0xb9, 0x50),
|
||||
idle: Color::Rgb(0x91, 0x98, 0xa1),
|
||||
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
|
||||
error: Color::Rgb(0xf8, 0x51, 0x49),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x1f, 0x3a, 0x5f),
|
||||
fg: Color::Rgb(0xe6, 0xed, 0xf3),
|
||||
muted: Color::Rgb(0x9f, 0xa5, 0xad),
|
||||
dim: Color::Rgb(0x7e, 0x86, 0x91),
|
||||
active: Color::Rgb(0x40, 0xbc, 0x51),
|
||||
idle: Color::Rgb(0x9f, 0xa5, 0xad),
|
||||
awaiting: Color::Rgb(0xd4, 0x9b, 0x22),
|
||||
error: Color::Rgb(0xfa, 0x81, 0x7b),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||
sidebar_focus_bg: Color::Rgb(0x16, 0x1b, 0x26),
|
||||
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
|
||||
border_unfocused: Color::Rgb(0x58, 0x64, 0x70),
|
||||
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
|
||||
group_row: Color::Rgb(0x91, 0x98, 0xa1),
|
||||
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
|
||||
mode_normal_fg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
|
||||
mode_insert_fg: Color::Rgb(0x0d, 0x11, 0x17),
|
||||
error_text: Color::Rgb(0xf8, 0x51, 0x49),
|
||||
};
|
||||
|
||||
/// GitHub Light — the github.com light default.
|
||||
pub const GITHUB_LIGHT: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
fg: Color::Rgb(0x1f, 0x23, 0x28),
|
||||
muted: Color::Rgb(0x59, 0x63, 0x6e),
|
||||
dim: Color::Rgb(0x81, 0x8b, 0x98),
|
||||
active: Color::Rgb(0x1a, 0x7f, 0x37),
|
||||
idle: Color::Rgb(0x59, 0x63, 0x6e),
|
||||
awaiting: Color::Rgb(0x9a, 0x67, 0x00),
|
||||
error: Color::Rgb(0xd1, 0x24, 0x2f),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0xa5, 0xcd, 0xf0),
|
||||
fg: Color::Rgb(0x1f, 0x23, 0x28),
|
||||
muted: Color::Rgb(0x4b, 0x53, 0x5d),
|
||||
dim: Color::Rgb(0x64, 0x6d, 0x7a),
|
||||
active: Color::Rgb(0x14, 0x5f, 0x29),
|
||||
idle: Color::Rgb(0x4b, 0x53, 0x5d),
|
||||
awaiting: Color::Rgb(0x71, 0x4b, 0x00),
|
||||
error: Color::Rgb(0xa1, 0x1c, 0x24),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
sidebar_focus_bg: Color::Rgb(0xf0, 0xf6, 0xfc),
|
||||
border_focused: Color::Rgb(0x09, 0x69, 0xda),
|
||||
border_unfocused: Color::Rgb(0x89, 0x93, 0x9d),
|
||||
focus_accent: Color::Rgb(0x09, 0x69, 0xda),
|
||||
group_row: Color::Rgb(0x59, 0x63, 0x6e),
|
||||
mode_normal_bg: Color::Rgb(0x09, 0x69, 0xda),
|
||||
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
mode_insert_bg: Color::Rgb(0x9a, 0x67, 0x00),
|
||||
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
error_text: Color::Rgb(0xd1, 0x24, 0x2f),
|
||||
};
|
||||
|
||||
/// Tokyo Night — neon-tinted blues of the VS Code / vim favourite.
|
||||
pub const TOKYO_NIGHT: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||
fg: Color::Rgb(0xc0, 0xca, 0xf5),
|
||||
muted: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||
dim: Color::Rgb(0x5d, 0x67, 0x95),
|
||||
active: Color::Rgb(0x9e, 0xce, 0x6a),
|
||||
idle: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||
awaiting: Color::Rgb(0xe0, 0xaf, 0x68),
|
||||
error: Color::Rgb(0xf7, 0x76, 0x8e),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x33, 0x46, 0x7c),
|
||||
fg: Color::Rgb(0xc0, 0xca, 0xf5),
|
||||
muted: Color::Rgb(0xb0, 0xb8, 0xd9),
|
||||
dim: Color::Rgb(0x8e, 0x96, 0xb8),
|
||||
active: Color::Rgb(0xa8, 0xd3, 0x79),
|
||||
idle: Color::Rgb(0xb0, 0xb8, 0xd9),
|
||||
awaiting: Color::Rgb(0xe1, 0xb1, 0x6b),
|
||||
error: Color::Rgb(0xf9, 0xa0, 0xb0),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||
sidebar_focus_bg: Color::Rgb(0x24, 0x28, 0x3b),
|
||||
border_focused: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||
border_unfocused: Color::Rgb(0x5d, 0x67, 0x95),
|
||||
focus_accent: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||
group_row: Color::Rgb(0xa9, 0xb1, 0xd6),
|
||||
mode_normal_bg: Color::Rgb(0x7a, 0xa2, 0xf7),
|
||||
mode_normal_fg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||
mode_insert_bg: Color::Rgb(0xe0, 0xaf, 0x68),
|
||||
mode_insert_fg: Color::Rgb(0x1a, 0x1b, 0x26),
|
||||
error_text: Color::Rgb(0xf7, 0x76, 0x8e),
|
||||
};
|
||||
|
||||
/// Gruvbox Dark — retro warm earth tones.
|
||||
pub const GRUVBOX: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x28, 0x28, 0x28),
|
||||
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
|
||||
muted: Color::Rgb(0xbd, 0xae, 0x93),
|
||||
dim: Color::Rgb(0x92, 0x83, 0x74),
|
||||
active: Color::Rgb(0xb8, 0xbb, 0x26),
|
||||
idle: Color::Rgb(0xbd, 0xae, 0x93),
|
||||
awaiting: Color::Rgb(0xfa, 0xbd, 0x2f),
|
||||
error: Color::Rgb(0xfb, 0x58, 0x44),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x50, 0x49, 0x45),
|
||||
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
|
||||
muted: Color::Rgb(0xc7, 0xbb, 0xa4),
|
||||
dim: Color::Rgb(0xa4, 0x98, 0x8b),
|
||||
active: Color::Rgb(0xbf, 0xc3, 0x28),
|
||||
idle: Color::Rgb(0xc7, 0xbb, 0xa4),
|
||||
awaiting: Color::Rgb(0xfa, 0xc3, 0x43),
|
||||
error: Color::Rgb(0xfd, 0xa5, 0x9a),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x28, 0x28, 0x28),
|
||||
sidebar_focus_bg: Color::Rgb(0x32, 0x30, 0x2f),
|
||||
border_focused: Color::Rgb(0xfe, 0x80, 0x19),
|
||||
border_unfocused: Color::Rgb(0x7e, 0x71, 0x67),
|
||||
focus_accent: Color::Rgb(0xfe, 0x80, 0x19),
|
||||
group_row: Color::Rgb(0xd5, 0xc4, 0xa1),
|
||||
mode_normal_bg: Color::Rgb(0xfe, 0x80, 0x19),
|
||||
mode_normal_fg: Color::Rgb(0x28, 0x28, 0x28),
|
||||
mode_insert_bg: Color::Rgb(0xfa, 0xbd, 0x2f),
|
||||
mode_insert_fg: Color::Rgb(0x28, 0x28, 0x28),
|
||||
error_text: Color::Rgb(0xfb, 0x58, 0x44),
|
||||
};
|
||||
|
||||
/// Nord — arctic, bluish and calm; the red lifted to stay legible.
|
||||
pub const NORD: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||
fg: Color::Rgb(0xec, 0xef, 0xf4),
|
||||
muted: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||
dim: Color::Rgb(0x9a, 0xa5, 0xb5),
|
||||
active: Color::Rgb(0xa3, 0xbe, 0x8c),
|
||||
idle: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||
awaiting: Color::Rgb(0xeb, 0xcb, 0x8b),
|
||||
error: Color::Rgb(0xd0, 0x8b, 0x91),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x4c, 0x56, 0x6a),
|
||||
fg: Color::Rgb(0xec, 0xef, 0xf4),
|
||||
muted: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||
dim: Color::Rgb(0x9f, 0xaa, 0xb9),
|
||||
active: Color::Rgb(0xc0, 0xd3, 0xb0),
|
||||
idle: Color::Rgb(0xe5, 0xe9, 0xf0),
|
||||
awaiting: Color::Rgb(0xee, 0xd3, 0x9c),
|
||||
error: Color::Rgb(0xe7, 0xc5, 0xc8),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||
sidebar_focus_bg: Color::Rgb(0x35, 0x3b, 0x49),
|
||||
border_focused: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||
border_unfocused: Color::Rgb(0x72, 0x80, 0x9a),
|
||||
focus_accent: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||
group_row: Color::Rgb(0xd8, 0xde, 0xe9),
|
||||
mode_normal_bg: Color::Rgb(0x88, 0xc0, 0xd0),
|
||||
mode_normal_fg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||
mode_insert_bg: Color::Rgb(0xeb, 0xcb, 0x8b),
|
||||
mode_insert_fg: Color::Rgb(0x2e, 0x34, 0x40),
|
||||
error_text: Color::Rgb(0xd0, 0x8b, 0x91),
|
||||
};
|
||||
|
||||
/// Dracula — high-saturation accents on a dark violet-grey base.
|
||||
pub const DRACULA: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
|
||||
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||
dim: Color::Rgb(0x65, 0x74, 0xa6),
|
||||
active: Color::Rgb(0x50, 0xfa, 0x7b),
|
||||
idle: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||
awaiting: Color::Rgb(0xf1, 0xfa, 0x8c),
|
||||
error: Color::Rgb(0xff, 0x59, 0x59),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x4a, 0x4e, 0x62),
|
||||
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
|
||||
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||
dim: Color::Rgb(0x94, 0x9f, 0xc1),
|
||||
active: Color::Rgb(0x64, 0xfb, 0x8a),
|
||||
idle: Color::Rgb(0xcc, 0xcf, 0xe1),
|
||||
awaiting: Color::Rgb(0xf3, 0xfb, 0xa0),
|
||||
error: Color::Rgb(0xff, 0xad, 0xad),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||
sidebar_focus_bg: Color::Rgb(0x31, 0x33, 0x42),
|
||||
border_focused: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||
border_unfocused: Color::Rgb(0x65, 0x74, 0xa6),
|
||||
focus_accent: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||
group_row: Color::Rgb(0xbf, 0xc3, 0xd9),
|
||||
mode_normal_bg: Color::Rgb(0xbd, 0x93, 0xf9),
|
||||
mode_normal_fg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||
mode_insert_bg: Color::Rgb(0xf1, 0xfa, 0x8c),
|
||||
mode_insert_fg: Color::Rgb(0x28, 0x2a, 0x36),
|
||||
error_text: Color::Rgb(0xff, 0x59, 0x59),
|
||||
};
|
||||
|
||||
/// The default preset, retained as a named compatibility constant.
|
||||
pub const DEFAULT: Palette = Self::DARK;
|
||||
|
||||
/// All preset names and their complete palettes.
|
||||
pub fn presets() -> [(&'static str, Palette); 3] {
|
||||
pub fn presets() -> [(&'static str, Palette); 12] {
|
||||
[
|
||||
("dark", Self::DARK),
|
||||
("light", Self::LIGHT),
|
||||
("ansi", Self::ANSI),
|
||||
("solarized-dark", Self::SOLARIZED_DARK),
|
||||
("solarized-light", Self::SOLARIZED_LIGHT),
|
||||
("catppuccin", Self::CATPPUCCIN),
|
||||
("github-dark", Self::GITHUB_DARK),
|
||||
("github-light", Self::GITHUB_LIGHT),
|
||||
("tokyo-night", Self::TOKYO_NIGHT),
|
||||
("gruvbox", Self::GRUVBOX),
|
||||
("nord", Self::NORD),
|
||||
("dracula", Self::DRACULA),
|
||||
]
|
||||
}
|
||||
|
||||
/// Resolve a config name to a palette.
|
||||
pub fn named(name: &str) -> Option<Palette> {
|
||||
match name {
|
||||
"dark" => Some(Self::DARK),
|
||||
"light" => Some(Self::LIGHT),
|
||||
"ansi" => Some(Self::ANSI),
|
||||
_ => None,
|
||||
}
|
||||
Self::presets()
|
||||
.into_iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// Every role, including the backgrounds it is painted against.
|
||||
pub fn roles(&self) -> [(&'static str, Color); 26] {
|
||||
pub fn roles(&self) -> [(&'static str, Color); 27] {
|
||||
[
|
||||
("card.bg", self.card.bg),
|
||||
("card.fg", self.card.fg),
|
||||
@@ -273,6 +605,7 @@ impl Palette {
|
||||
("selection.awaiting", self.selection.awaiting),
|
||||
("selection.error", self.selection.error),
|
||||
("chrome_bg", self.chrome_bg),
|
||||
("sidebar_focus_bg", self.sidebar_focus_bg),
|
||||
("border_focused", self.border_focused),
|
||||
("border_unfocused", self.border_unfocused),
|
||||
("focus_accent", self.focus_accent),
|
||||
@@ -392,11 +725,19 @@ mod tests {
|
||||
ActivityState::Error,
|
||||
];
|
||||
|
||||
/// Every preset except `ansi`, whose colours the terminal owns and which
|
||||
/// therefore has no fixed luminance to assert against.
|
||||
fn rgb_presets() -> impl Iterator<Item = (&'static str, Palette)> {
|
||||
Palette::presets()
|
||||
.into_iter()
|
||||
.filter(|(_, p)| relative_luminance(p.card.bg).is_some())
|
||||
}
|
||||
|
||||
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
|
||||
/// decoration and only has to clear the 3:1 non-text threshold.
|
||||
#[test]
|
||||
fn every_role_is_legible_on_its_own_surface() {
|
||||
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||
for (preset, p) in rgb_presets() {
|
||||
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
|
||||
for (role, color) in surface.roles() {
|
||||
let ratio = contrast_ratio(surface.bg, color)
|
||||
@@ -414,11 +755,9 @@ mod tests {
|
||||
#[test]
|
||||
fn every_preset_supplies_every_role() {
|
||||
for (name, p) in Palette::presets() {
|
||||
assert_eq!(p.roles().len(), 26, "{name} role set is incomplete");
|
||||
assert_eq!(p.roles().len(), 27, "{name} role set is incomplete");
|
||||
assert_eq!(Palette::named(name), Some(p));
|
||||
}
|
||||
assert_eq!(Palette::named("dark"), Some(Palette::DARK));
|
||||
assert_eq!(Palette::named("light"), Some(Palette::LIGHT));
|
||||
assert_eq!(Palette::named("ansi"), Some(Palette::ANSI));
|
||||
assert_eq!(Palette::named("unknown"), None);
|
||||
}
|
||||
|
||||
@@ -426,7 +765,7 @@ mod tests {
|
||||
/// merely a slightly different shade of the card.
|
||||
#[test]
|
||||
fn selection_background_is_distinguishable_from_the_card_background() {
|
||||
for (name, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||
for (name, p) in rgb_presets() {
|
||||
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
|
||||
assert!(
|
||||
ratio >= 1.6,
|
||||
@@ -456,7 +795,7 @@ mod tests {
|
||||
/// actually painted on; they must never rely on the terminal default.
|
||||
#[test]
|
||||
fn chrome_roles_are_rgb_values() {
|
||||
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||
for (preset, p) in rgb_presets() {
|
||||
for (name, color) in p.roles().into_iter().skip(16) {
|
||||
assert!(
|
||||
relative_luminance(color).is_some(),
|
||||
@@ -487,6 +826,17 @@ mod tests {
|
||||
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
||||
);
|
||||
}
|
||||
for (name, fg) in [
|
||||
("card.fg", p.card.fg),
|
||||
("card.muted", p.card.muted),
|
||||
("group_row", p.group_row),
|
||||
] {
|
||||
let ratio = contrast_ratio(fg, p.sidebar_focus_bg).unwrap();
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"{preset}.{name} contrast {ratio:.2} is below 4.5 on sidebar_focus_bg"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ impl Pane {
|
||||
|
||||
// Place the terminal cursor at vt100's cursor position when we own
|
||||
// focus and the cursor is on-screen. Terminals blink it on their own.
|
||||
if self.focused && !screen.hide_cursor() {
|
||||
if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() {
|
||||
let (cr, cc) = screen.cursor_position();
|
||||
if cr < rows && cc < cols {
|
||||
f.set_cursor_position((area.x + cc, area.y + cr));
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
//! Top-level draw orchestration. Composes sidebar / tab bar / pane / overlays
|
||||
//! Top-level draw orchestration. Composes sidebar / pane / overlays
|
||||
//! into one frame, and returns a [`RegionMap`] that the input router uses to
|
||||
//! hit-test mouse clicks.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::input::Region;
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
|
||||
use crate::palette;
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
use crate::tabs::TabBar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||
|
||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||
@@ -23,7 +22,7 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RegionMap {
|
||||
pub sidebar: Option<Rect>,
|
||||
pub tab_bar: Option<Rect>,
|
||||
pub sidebar_items: Vec<SidebarItem>,
|
||||
pub pane: Option<Rect>,
|
||||
pub modal: Option<Rect>,
|
||||
}
|
||||
@@ -35,11 +34,6 @@ impl RegionMap {
|
||||
return Region::Modal;
|
||||
}
|
||||
}
|
||||
if let Some(r) = self.tab_bar {
|
||||
if inside(r, col, row) {
|
||||
return Region::Tab;
|
||||
}
|
||||
}
|
||||
if let Some(r) = self.sidebar {
|
||||
if inside(r, col, row) {
|
||||
return Region::SidebarRow;
|
||||
@@ -52,6 +46,24 @@ impl RegionMap {
|
||||
}
|
||||
Region::Unknown
|
||||
}
|
||||
|
||||
/// Resolve a mouse row against the rendered sidebar sequence. The title
|
||||
/// occupies one inner row; groups occupy one row and visible cards two.
|
||||
pub fn sidebar_item_at(&self, row: u16) -> Option<SidebarItem> {
|
||||
let area = self.sidebar?;
|
||||
let mut cursor = area.y.saturating_add(1);
|
||||
for item in &self.sidebar_items {
|
||||
let height = match item {
|
||||
SidebarItem::Group(_) => 1,
|
||||
SidebarItem::Session(_) => 2,
|
||||
};
|
||||
if row >= cursor && row < cursor.saturating_add(height) {
|
||||
return Some(item.clone());
|
||||
}
|
||||
cursor = cursor.saturating_add(height);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(r: Rect, col: u16, row: u16) -> bool {
|
||||
@@ -63,14 +75,17 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
|
||||
#[derive(Debug)]
|
||||
pub struct RenderInputs<'a> {
|
||||
pub sidebar: &'a Sidebar,
|
||||
pub tab_bar: &'a TabBar,
|
||||
pub home_dir: &'a str,
|
||||
pub pane_title: &'a str,
|
||||
pub pane: Option<&'a Pane>,
|
||||
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
||||
/// Inline spawn form when the pane is a draft (issue #63).
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
pub help: &'a HelpOverlay,
|
||||
/// Retained-worktree entries for the worktrees view (#85).
|
||||
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
|
||||
@@ -102,43 +117,36 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
let right = cols[1];
|
||||
|
||||
if inputs.state.sidebar_visible {
|
||||
inputs
|
||||
.sidebar
|
||||
.render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
|
||||
inputs.sidebar.render_with_focus_and_collapsed_home(
|
||||
f,
|
||||
sidebar_area,
|
||||
inputs.state.focus == Focus::Sidebar,
|
||||
&inputs.state.collapsed_sidebar_groups,
|
||||
inputs.home_dir,
|
||||
);
|
||||
map.sidebar = Some(sidebar_area);
|
||||
map.sidebar_items = inputs
|
||||
.sidebar
|
||||
.rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups);
|
||||
}
|
||||
|
||||
// 1-row tab bar + 1-row horizontal rule (doubles as the pane's focus
|
||||
// indicator) + flex pane area below. Saves the 4 rows the old
|
||||
// `Length(3) + Borders::ALL` layout was eating before any content showed.
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||
.split(right);
|
||||
let tab_area = rows[0];
|
||||
let separator_area = rows[1];
|
||||
let pane_area = rows[2];
|
||||
|
||||
inputs.tab_bar.render(f, tab_area);
|
||||
map.tab_bar = Some(tab_area);
|
||||
let title_area = rows[0];
|
||||
let pane_area = rows[1];
|
||||
|
||||
let pane_focused = inputs.state.focus == Focus::Pane;
|
||||
f.render_widget(
|
||||
Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(focus_style(pane_focused)),
|
||||
separator_area,
|
||||
);
|
||||
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||
if let Some(p) = inputs.pane {
|
||||
p.render(f, pane_area);
|
||||
}
|
||||
if let Some(d) = inputs.draft {
|
||||
d.render(f, pane_area, pane_focused);
|
||||
}
|
||||
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||
// chrome; every remaining cell belongs to the emulator.
|
||||
map.pane = Some(pane_area);
|
||||
|
||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||
@@ -164,6 +172,23 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
kc.render(f, 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) => {
|
||||
inputs.help.render(f, area);
|
||||
map.modal = Some(area);
|
||||
@@ -194,10 +219,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
map
|
||||
}
|
||||
|
||||
/// Border style for a panel based on whether it currently has focus. Both
|
||||
/// forms are palette roles (`border_focused` / `border_unfocused`).
|
||||
fn focus_style(focused: bool) -> Style {
|
||||
palette::active().border_style(focused)
|
||||
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
||||
let palette = palette::active();
|
||||
let style = if focused {
|
||||
palette.mode_style(false)
|
||||
} else {
|
||||
palette.card.text_style()
|
||||
}
|
||||
.add_modifier(Modifier::BOLD);
|
||||
f.render_widget(Paragraph::new(format!(" {title}")).style(style), area);
|
||||
}
|
||||
|
||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||
@@ -214,7 +244,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
};
|
||||
let hint = match state.mode {
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||
Mode::Insert => "^a: back to normal",
|
||||
Mode::Insert => "^a: return to sidebar",
|
||||
};
|
||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||
let line = ratatui::text::Line::from(vec![
|
||||
@@ -238,22 +268,16 @@ mod tests {
|
||||
width: 10,
|
||||
height: 20,
|
||||
}),
|
||||
tab_bar: Some(Rect {
|
||||
sidebar_items: Vec::new(),
|
||||
pane: Some(Rect {
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 3,
|
||||
}),
|
||||
pane: Some(Rect {
|
||||
x: 10,
|
||||
y: 3,
|
||||
width: 30,
|
||||
height: 17,
|
||||
height: 20,
|
||||
}),
|
||||
modal: None,
|
||||
};
|
||||
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
|
||||
assert_eq!(map.region_at(15, 1), Region::Tab);
|
||||
assert_eq!(map.region_at(15, 5), Region::Pane);
|
||||
assert_eq!(map.region_at(50, 50), Region::Unknown);
|
||||
}
|
||||
@@ -267,17 +291,12 @@ mod tests {
|
||||
width: 10,
|
||||
height: 20,
|
||||
}),
|
||||
tab_bar: Some(Rect {
|
||||
sidebar_items: Vec::new(),
|
||||
pane: Some(Rect {
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 3,
|
||||
}),
|
||||
pane: Some(Rect {
|
||||
x: 10,
|
||||
y: 3,
|
||||
width: 30,
|
||||
height: 17,
|
||||
height: 20,
|
||||
}),
|
||||
modal: Some(Rect {
|
||||
x: 5,
|
||||
@@ -291,6 +310,40 @@ mod tests {
|
||||
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_mouse_rows_follow_group_and_two_line_card_heights() {
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
let group_a = SidebarItem::Group(PathBuf::from("/a"));
|
||||
let card_a = SidebarItem::Session(Uuid::from_u128(1));
|
||||
let group_b = SidebarItem::Group(PathBuf::from("/b"));
|
||||
let card_b = SidebarItem::Session(Uuid::from_u128(2));
|
||||
let map = RegionMap {
|
||||
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||
sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b],
|
||||
..RegionMap::default()
|
||||
};
|
||||
assert_eq!(map.sidebar_item_at(1), Some(group_a));
|
||||
assert_eq!(map.sidebar_item_at(2), Some(card_a.clone()));
|
||||
assert_eq!(map.sidebar_item_at(3), Some(card_a));
|
||||
assert_eq!(map.sidebar_item_at(4), Some(group_b));
|
||||
|
||||
let collapsed = RegionMap {
|
||||
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||
sidebar_items: vec![
|
||||
SidebarItem::Group(PathBuf::from("/a")),
|
||||
SidebarItem::Group(PathBuf::from("/b")),
|
||||
SidebarItem::Session(Uuid::from_u128(2)),
|
||||
],
|
||||
..RegionMap::default()
|
||||
};
|
||||
assert_eq!(
|
||||
collapsed.sidebar_item_at(2),
|
||||
Some(SidebarItem::Group(PathBuf::from("/b")))
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||
/// form where the terminal would otherwise be.
|
||||
#[test]
|
||||
@@ -310,19 +363,17 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
};
|
||||
let tab_bar = TabBar {
|
||||
tab_names: vec!["1".into()],
|
||||
active_idx: 0,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
tab_bar: &tab_bar,
|
||||
home_dir: "",
|
||||
pane_title: "new session",
|
||||
pane: None,
|
||||
draft: Some(draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
worktrees: None,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
};
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
@@ -341,7 +392,7 @@ mod tests {
|
||||
out.push('\n');
|
||||
}
|
||||
for s in [
|
||||
"draft",
|
||||
"new session",
|
||||
"cmd:",
|
||||
"dir:",
|
||||
"name:",
|
||||
@@ -351,4 +402,91 @@ mod tests {
|
||||
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_rectangles_are_stable_when_focus_moves() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::sidebar::Sidebar;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let draw_map = |focus| {
|
||||
let mut state = AppState::new();
|
||||
state.focus = focus;
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
worktrees: None,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let mut map = None;
|
||||
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
|
||||
map.unwrap()
|
||||
};
|
||||
|
||||
let sidebar_focus = draw_map(Focus::Sidebar);
|
||||
let pane_focus = draw_map(Focus::Pane);
|
||||
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
|
||||
assert_eq!(sidebar_focus.pane, pane_focus.pane);
|
||||
assert_eq!(sidebar_focus.pane, Some(Rect::new(28, 1, 52, 22)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_focus_uses_backgrounds_without_borders() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::sidebar::Sidebar;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let render = |focus| {
|
||||
let mut state = AppState::new();
|
||||
state.focus = focus;
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
worktrees: None,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw(f, inputs);
|
||||
})
|
||||
.unwrap();
|
||||
terminal.backend().buffer().clone()
|
||||
};
|
||||
|
||||
let sidebar_focused = render(Focus::Sidebar);
|
||||
let pane_focused = render(Focus::Pane);
|
||||
let p = palette::active();
|
||||
assert_eq!(sidebar_focused[(0, 0)].style().bg, Some(p.sidebar_focus_bg));
|
||||
assert_eq!(pane_focused[(0, 0)].style().bg, Some(p.chrome_bg));
|
||||
assert_eq!(sidebar_focused[(28, 0)].style().bg, Some(p.card.bg));
|
||||
assert_eq!(pane_focused[(28, 0)].style().bg, Some(p.mode_normal_bg));
|
||||
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
|
||||
assert!(title.contains("session"), "missing title: {title:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ use maestro_protocol::ClientControl;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Match the browser terminal's retained history without letting long-running
|
||||
/// sessions grow the client without bound.
|
||||
pub const SCROLLBACK_ROWS: usize = 5_000;
|
||||
|
||||
/// Input the dispatcher (or the resize tracker) pushes to a session task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionInput {
|
||||
@@ -103,7 +107,7 @@ impl SessionTask {
|
||||
/// up with `SessionInput::Resize` on every frame whose allocation changes.
|
||||
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
|
||||
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
|
||||
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
||||
|
||||
let parser_bg = parser.clone();
|
||||
|
||||
+423
-127
@@ -4,18 +4,21 @@
|
||||
//! Line 2: activity dot + duration in the state's colour, plus the
|
||||
//! adaptive detail, blocked-work, exited, and command fragments.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::palette;
|
||||
use crate::state::SidebarItem;
|
||||
|
||||
/// Time-in-state earns a duration fragment once the session has sat in its
|
||||
/// current activity state this long (docs/SIDEBAR.md).
|
||||
@@ -28,82 +31,213 @@ const META_LINE_AFTER_SECS: i64 = 30;
|
||||
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
|
||||
const INDENT: &str = " ";
|
||||
const META_INDENT: &str = " ";
|
||||
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
||||
const ACTIONS: &str = "⧉ ✕";
|
||||
|
||||
/// Every card sits on the palette's card surface so cards read as cards
|
||||
/// against the panel; the highlighted card swaps in the selection surface
|
||||
/// instead (`Modifier::REVERSED` made the card unreadable against a
|
||||
/// background).
|
||||
fn card_base(highlighted: bool) -> Style {
|
||||
palette::active().surface(highlighted).text_style()
|
||||
/// Cards use the selection surface only while the sidebar owns focus. An
|
||||
/// unfocused highlight remains an edge-marked cursor without claiming the
|
||||
/// keyboard via a filled selection background.
|
||||
fn card_base(highlighted: bool, focused: bool) -> Style {
|
||||
let palette = palette::active();
|
||||
let mut style = palette.surface(highlighted && focused).text_style();
|
||||
if focused && !highlighted {
|
||||
style = style.bg(palette.sidebar_focus_bg);
|
||||
}
|
||||
style
|
||||
}
|
||||
|
||||
/// Renderable sidebar state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sidebar {
|
||||
pub sessions: Vec<Session>,
|
||||
pub highlight: Option<Uuid>,
|
||||
pub highlight: Option<SidebarItem>,
|
||||
pub attached: HashSet<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SidebarGroup {
|
||||
pub key: PathBuf,
|
||||
pub sessions: Vec<Session>,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||
self.render_with_focus(f, area, false);
|
||||
}
|
||||
|
||||
/// Same as [`Sidebar::render`] but tints the right-edge separator in the
|
||||
/// focus colour when `focused`. Box borders eat too many rows/cols for
|
||||
/// the v1 layout — a single vertical rule keeps the panel boundary
|
||||
/// without stealing space.
|
||||
/// Render the panel without a border. Focus is the panel background, so
|
||||
/// it remains obvious without spending rows or columns on chrome.
|
||||
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
||||
let border_style = palette::active().border_style(focused);
|
||||
let block = Block::default()
|
||||
.borders(Borders::RIGHT)
|
||||
.border_style(border_style);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let width = inner.width as usize;
|
||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
||||
"sessions",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))];
|
||||
for s in &self.sessions {
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
lines.push(Line::from(self.name_row(s, width)));
|
||||
let meta = self.meta_row(s, width);
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new());
|
||||
}
|
||||
|
||||
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
|
||||
pub fn render_with_focus_and_collapsed(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
focused: bool,
|
||||
collapsed: &HashSet<PathBuf>,
|
||||
) {
|
||||
self.render_with_focus_and_collapsed_home(f, area, focused, collapsed, &home_dir());
|
||||
}
|
||||
|
||||
pub fn render_with_focus_and_collapsed_home(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
focused: bool,
|
||||
collapsed: &HashSet<PathBuf>,
|
||||
home: &str,
|
||||
) {
|
||||
let palette = palette::active();
|
||||
let panel_bg = if focused {
|
||||
palette.sidebar_focus_bg
|
||||
} else {
|
||||
palette.chrome_bg
|
||||
};
|
||||
f.render_widget(Block::default().style(Style::default().bg(panel_bg)), area);
|
||||
|
||||
let width = area.width as usize;
|
||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
||||
"sessions",
|
||||
Style::default()
|
||||
.bg(panel_bg)
|
||||
.fg(palette.card.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))];
|
||||
for group in self.groups(collapsed) {
|
||||
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
||||
if !group.collapsed {
|
||||
for s in &group.sessions {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
lines.push(Line::from(self.name_row(s, width, focused)));
|
||||
let meta = self.meta_row(s, width, focused);
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
}
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
/// Items in exactly the order and height they occupy on screen.
|
||||
pub fn rendered_items(&self) -> Vec<SidebarItem> {
|
||||
self.rendered_items_with_collapsed(&HashSet::new())
|
||||
}
|
||||
|
||||
pub fn rendered_items_with_collapsed(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarItem> {
|
||||
self.groups(collapsed)
|
||||
.into_iter()
|
||||
.flat_map(|group| {
|
||||
let mut items = vec![SidebarItem::Group(group.key)];
|
||||
if !group.collapsed {
|
||||
items.extend(
|
||||
group
|
||||
.sessions
|
||||
.into_iter()
|
||||
.map(|s| SidebarItem::Session(s.id)),
|
||||
);
|
||||
}
|
||||
items
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn groups(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarGroup> {
|
||||
let mut grouped: BTreeMap<PathBuf, Vec<Session>> = BTreeMap::new();
|
||||
for session in &self.sessions {
|
||||
if let Some(key) = session_tree_path(session) {
|
||||
grouped.entry(key).or_default().push(session.clone());
|
||||
}
|
||||
}
|
||||
grouped
|
||||
.into_iter()
|
||||
.map(|(key, mut sessions)| {
|
||||
sessions
|
||||
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
|
||||
SidebarGroup {
|
||||
collapsed: collapsed.contains(&key),
|
||||
key,
|
||||
sessions,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_row(
|
||||
&self,
|
||||
group: &SidebarGroup,
|
||||
width: usize,
|
||||
focused: bool,
|
||||
home: &str,
|
||||
) -> Vec<Span<'static>> {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone()));
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(highlighted && focused);
|
||||
let row_bg = if highlighted {
|
||||
surface.bg
|
||||
} else if focused {
|
||||
palette.sidebar_focus_bg
|
||||
} else {
|
||||
surface.bg
|
||||
};
|
||||
let base = Style::default().bg(row_bg).fg(if highlighted {
|
||||
surface.fg
|
||||
} else {
|
||||
palette.group_row
|
||||
});
|
||||
let edge = highlighted && !focused;
|
||||
let prefix = if edge { "▏ " } else { " " };
|
||||
let chevron = if group.collapsed { "▸ " } else { "▾ " };
|
||||
let path = abbreviate_dir(&group.key, home);
|
||||
let name = abbreviate_folder_name(&path);
|
||||
let count = format!(" ({})", group.sessions.len());
|
||||
let prefix = Span::styled(
|
||||
prefix,
|
||||
if edge {
|
||||
base.fg(palette.focus_accent)
|
||||
} else {
|
||||
base
|
||||
},
|
||||
);
|
||||
let chevron = Span::styled(chevron, base);
|
||||
let count = Span::styled(count, surface.muted_style().bg(row_bg));
|
||||
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
|
||||
let mut label = Vec::new();
|
||||
if let Some(stem) = name.stem {
|
||||
label.push(Span::styled(
|
||||
format!("{stem}/"),
|
||||
surface.dim_style().bg(row_bg),
|
||||
));
|
||||
}
|
||||
label.push(Span::styled(name.last, base));
|
||||
|
||||
let mut spans = vec![prefix, chevron];
|
||||
spans.extend(fit_to_width(label, label_budget));
|
||||
spans.push(count);
|
||||
pad_to_width(spans, width, base)
|
||||
}
|
||||
|
||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
(SessionStatus::Running, true) => "●",
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
// Line-1 actions are keyboard affordances shown on the highlighted
|
||||
// row only; they reserve their width so the name truncates with `…`
|
||||
// before it can ever reach them (docs/SIDEBAR.md).
|
||||
let actions_width = if highlighted {
|
||||
ACTIONS.chars().count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let usable = width.saturating_sub(actions_width);
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let prefix = INDENT.chars().count() + 2;
|
||||
// Keep one column of separation between a truncated name and the
|
||||
// actions so the ellipsis never touches them.
|
||||
let sep = if highlighted { 1 } else { 0 };
|
||||
let name_budget = usable.saturating_sub(prefix + sep);
|
||||
let name_budget = width.saturating_sub(prefix);
|
||||
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(INDENT, base),
|
||||
Span::styled(
|
||||
if edge { "▏ " } else { INDENT },
|
||||
if edge {
|
||||
base.fg(palette::active().focus_accent)
|
||||
} else {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled(format!("{glyph} "), base),
|
||||
];
|
||||
spans.extend(
|
||||
@@ -111,18 +245,11 @@ impl Sidebar {
|
||||
.into_iter()
|
||||
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
|
||||
);
|
||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
|
||||
spans.push(Span::styled(" ".repeat(fill), base));
|
||||
}
|
||||
if highlighted {
|
||||
spans.push(Span::styled(ACTIONS, base));
|
||||
}
|
||||
spans
|
||||
pad_to_width(spans, width, base)
|
||||
}
|
||||
|
||||
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
|
||||
fn meta_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
|
||||
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let now = Utc::now();
|
||||
let held = held_for(s, now);
|
||||
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
|
||||
@@ -130,12 +257,24 @@ impl Sidebar {
|
||||
// on line 1; the kill confirm names the specifics.
|
||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted)));
|
||||
let muted = palette::active().surface(highlighted).muted_style();
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let dot_style =
|
||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||
let muted = palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans = vec![
|
||||
Span::styled(META_INDENT, base),
|
||||
Span::styled(
|
||||
if edge { " ▏ " } else { META_INDENT },
|
||||
if edge {
|
||||
base.fg(palette::active().focus_accent)
|
||||
} else {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled("●", dot_style),
|
||||
];
|
||||
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
|
||||
@@ -179,8 +318,10 @@ impl Sidebar {
|
||||
/// state word is never spelled out — this colour carries it. The highlighted
|
||||
/// card takes its state colours from the selection surface: a colour picked
|
||||
/// for the dark card background is not legible on the selection background.
|
||||
fn activity_color(state: ActivityState, highlighted: bool) -> Color {
|
||||
palette::active().surface(highlighted).activity(state)
|
||||
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
|
||||
palette::active()
|
||||
.surface(highlighted && focused)
|
||||
.activity(state)
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||
@@ -240,14 +381,23 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
|
||||
}
|
||||
|
||||
/// `metadata.name` if present, else the command line.
|
||||
fn name_for(s: &Session) -> String {
|
||||
if let Some(serde_json::Value::String(name)) = s.metadata.get("name") {
|
||||
name.clone()
|
||||
} else {
|
||||
s.command.clone()
|
||||
pub(crate) fn name_for(s: &Session) -> String {
|
||||
custom_name_for(s).unwrap_or_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,
|
||||
}
|
||||
}
|
||||
|
||||
fn home_dir() -> String {
|
||||
std::env::var("HOME").unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -281,9 +431,24 @@ mod tests {
|
||||
}
|
||||
|
||||
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
|
||||
draw_with_focus(sidebar, w, h, true)
|
||||
}
|
||||
|
||||
fn draw_with_focus(sidebar: &Sidebar, w: u16, h: u16, focused: bool) -> Buffer {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
|
||||
terminal
|
||||
.draw(|f| sidebar.render_with_focus(f, f.area(), focused))
|
||||
.unwrap();
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
fn draw_collapsed(sidebar: &Sidebar, w: u16, h: u16, collapsed: &HashSet<PathBuf>) -> Buffer {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| sidebar.render_with_focus_and_collapsed(f, f.area(), true, collapsed))
|
||||
.unwrap();
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
@@ -309,7 +474,10 @@ mod tests {
|
||||
|
||||
/// Row text without the trailing space padding and right border.
|
||||
fn content(row: &str) -> &str {
|
||||
row.trim_end().trim_end_matches('│').trim_end()
|
||||
row.trim()
|
||||
.trim_start_matches('│')
|
||||
.trim_end_matches('│')
|
||||
.trim()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -330,6 +498,102 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_worktrees_under_their_base_repository() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.cwd = "/home/u/repos/worktrees/feature".into();
|
||||
s.worktree = Some(maestro_protocol::SessionWorktree {
|
||||
path: s.cwd.clone(),
|
||||
base_repo: "/home/u/repos/maestro".into(),
|
||||
});
|
||||
let groups = (Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
})
|
||||
.groups(&HashSet::new());
|
||||
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_render_groups_without_sessions() {
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
assert!(sidebar.groups(&HashSet::new()).is_empty());
|
||||
assert!(sidebar.rendered_items().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_a_group_hides_cards_but_keeps_the_count_and_item() {
|
||||
let sidebar = Sidebar {
|
||||
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let key = PathBuf::from("/tmp");
|
||||
let mut collapsed = HashSet::new();
|
||||
collapsed.insert(key.clone());
|
||||
let groups = sidebar.groups(&collapsed);
|
||||
assert_eq!(groups[0].sessions.len(), 2);
|
||||
assert!(groups[0].collapsed);
|
||||
assert_eq!(
|
||||
sidebar.rendered_items_with_collapsed(&collapsed),
|
||||
vec![SidebarItem::Group(key)]
|
||||
);
|
||||
let rendered = render_to_string(&draw_collapsed(&sidebar, 40, 6, &collapsed));
|
||||
assert!(rendered.contains("(2)"));
|
||||
assert!(!rendered.contains("claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_group_labels_yield_to_the_always_visible_count() {
|
||||
let mut session = session_named(1, "claude");
|
||||
session.cwd = "/very/long/project/path/extraordinarily-long-project-name".into();
|
||||
let sidebar = Sidebar {
|
||||
sessions: vec![session],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
|
||||
let collapsed = HashSet::from([key]);
|
||||
let rendered = render_to_string(&draw_collapsed(&sidebar, 28, 6, &collapsed));
|
||||
|
||||
assert!(rendered.contains('…'));
|
||||
assert!(
|
||||
rendered.contains("(1)"),
|
||||
"group count was truncated: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_display_helpers_abbreviate_group_labels() {
|
||||
let path = maestro_protocol::display::abbreviate_dir(
|
||||
std::path::Path::new("/home/u/git.naps.pt/naps62/maestro"),
|
||||
"/home/u",
|
||||
);
|
||||
let name = maestro_protocol::display::abbreviate_folder_name(&path);
|
||||
assert_eq!(path, "~/git.naps.pt/naps62/maestro");
|
||||
assert_eq!(name.stem.as_deref(), Some("~/g/n"));
|
||||
assert_eq!(name.last, "maestro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_cards_are_always_two_lines_after_one_group_row() {
|
||||
let sidebar = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sidebar, 40, 8));
|
||||
assert!(all[1].contains("/tmp"));
|
||||
assert!(all[2].contains("claude"));
|
||||
assert!(all[3].contains("bash"));
|
||||
assert!(!all[4].contains("bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_session_uses_filled_glyph() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
@@ -420,19 +684,19 @@ mod tests {
|
||||
assert_eq!(content(&all[0]), "sessions");
|
||||
assert!(row_containing(&all, "claude").contains('○'));
|
||||
assert!(
|
||||
all[2].contains('●'),
|
||||
all[3].contains('●'),
|
||||
"line 2 must carry the dot: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains("bash"),
|
||||
all[3].contains("bash"),
|
||||
"line 2 must carry the command: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
!all[2].contains("10s"),
|
||||
!all[3].contains("10s"),
|
||||
"fresh cards have no duration: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -511,7 +775,7 @@ mod tests {
|
||||
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
@@ -660,7 +924,7 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let all = rows(&draw(&sb, 42, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = content(&all[idx + 1]);
|
||||
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
||||
@@ -752,14 +1016,14 @@ mod tests {
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(
|
||||
all[1].contains("friendly name"),
|
||||
all[2].contains("friendly name"),
|
||||
"name must stay on line 1: {:?}",
|
||||
all[1]
|
||||
all[2]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains("bash"),
|
||||
all[3].contains("bash"),
|
||||
"command must be on line 2: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -771,16 +1035,16 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 10, 6));
|
||||
let all = rows(&draw(&sb, 9, 6));
|
||||
assert!(
|
||||
all[2].contains('●'),
|
||||
all[3].contains('●'),
|
||||
"dot must survive narrow cards: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains('…'),
|
||||
all[3].contains('…'),
|
||||
"command should truncate: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -798,8 +1062,8 @@ mod tests {
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 29, 6));
|
||||
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
|
||||
let meta = content(&all[2]);
|
||||
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||
let meta = content(&all[3]);
|
||||
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||
}
|
||||
|
||||
@@ -817,13 +1081,13 @@ mod tests {
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
|
||||
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||
assert!(
|
||||
all[2].contains("bash"),
|
||||
all[3].contains("bash"),
|
||||
"command must survive: {:?}",
|
||||
all[2]
|
||||
all[3]
|
||||
);
|
||||
assert!(all[2].contains('…'), "detail must truncate: {:?}", all[2]);
|
||||
assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]);
|
||||
}
|
||||
|
||||
/// Reversed video makes a card unreadable against a background; cards
|
||||
@@ -834,7 +1098,7 @@ mod tests {
|
||||
highlighted.activity_since = Utc::now() - Duration::seconds(120);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![highlighted],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
@@ -851,15 +1115,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The highlighted card gets the focus background; every other card gets
|
||||
/// a card background distinct from the panel's default.
|
||||
/// The highlighted card gets the selection background; the rest of the
|
||||
/// focused sidebar shares its subtle panel background.
|
||||
#[test]
|
||||
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
|
||||
let one = session_named(1, "claude");
|
||||
let two = session_named(2, "bash");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
@@ -869,24 +1133,61 @@ mod tests {
|
||||
|
||||
let p = palette::active();
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().bg,
|
||||
buf[(1, claude_y as u16)].style().bg,
|
||||
Some(p.selection.bg),
|
||||
"focused card must sit on the selection surface"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().fg,
|
||||
buf[(1, claude_y as u16)].style().fg,
|
||||
Some(p.selection.fg),
|
||||
"focused card text must use the selection foreground role"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, bash_y as u16)].style().bg,
|
||||
Some(p.card.bg),
|
||||
"unfocused cards must still sit on the card surface"
|
||||
buf[(1, bash_y as u16)].style().bg,
|
||||
Some(p.sidebar_focus_bg),
|
||||
"ordinary cards must share the focused panel surface"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(1, 0)].style().bg,
|
||||
Some(p.sidebar_focus_bg),
|
||||
"the focus background must cover the whole sidebar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlighted_card_is_hollow_when_sidebar_is_unfocused() {
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw_with_focus(&sb, 28, 8, false);
|
||||
let y = (0..buf.area.height)
|
||||
.find(|&y| (0..buf.area.width).any(|x| buf[(x, y)].symbol() == "c"))
|
||||
.expect("highlighted session row");
|
||||
assert_eq!(
|
||||
buf[(1, y)].style().bg,
|
||||
Some(palette::active().card.bg),
|
||||
"unfocused highlight must not paint the selection surface"
|
||||
);
|
||||
// The panel itself (header row) stays on the terminal default.
|
||||
assert!(
|
||||
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
|
||||
"panel must keep the terminal default background"
|
||||
(0..buf.area.width).any(|x| buf[(x, y)].symbol() == "▏"),
|
||||
"unfocused highlight must retain a visible edge marker"
|
||||
);
|
||||
let ordinary = draw_with_focus(
|
||||
&Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
},
|
||||
28,
|
||||
8,
|
||||
false,
|
||||
);
|
||||
assert!(
|
||||
!(0..ordinary.area.height)
|
||||
.any(|row| (0..ordinary.area.width).any(|x| ordinary[(x, row)].symbol() == "▏")),
|
||||
"ordinary unhighlighted cards must not carry the cursor marker"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -901,28 +1202,27 @@ mod tests {
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
||||
for x in 0..27 {
|
||||
for x in 1..27 {
|
||||
assert_eq!(
|
||||
buf[(x, y)].style().bg,
|
||||
Some(palette::active().card.bg),
|
||||
Some(palette::active().sidebar_focus_bg),
|
||||
"gap in card background at col {x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Line 1's actions live on the highlighted row's top-right corner.
|
||||
#[test]
|
||||
fn highlighted_row_anchors_the_actions_top_right() {
|
||||
fn highlighted_rows_do_not_show_action_glyphs() {
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = content(row_containing(&all, "claude"));
|
||||
assert!(
|
||||
line.ends_with("⧉ ✕"),
|
||||
"actions must sit at the right edge: {line:?}"
|
||||
!line.contains('⧉') && !line.contains('✕'),
|
||||
"keyboard-only actions should not consume card space: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -932,7 +1232,7 @@ mod tests {
|
||||
let two = session_named(2, "bash");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
@@ -943,27 +1243,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The name truncates with `…` before it can ever reach the reserved
|
||||
/// action width (docs/SIDEBAR.md — overlap is a defect).
|
||||
#[test]
|
||||
fn name_truncates_before_reaching_the_actions() {
|
||||
fn long_name_uses_the_full_card_width() {
|
||||
let mut s = session_named(1, "claude");
|
||||
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
|
||||
*name = "an-unreasonably-long-session-name".into();
|
||||
}
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = row_containing(&all, "an-unreasonably");
|
||||
let name_end = line.find('…').expect("name must truncate with …");
|
||||
let actions_start = line.find('⧉').expect("actions must still render");
|
||||
assert!(name_end < actions_start, "name overlaps actions: {line:?}");
|
||||
assert!(line.contains('…'), "name must truncate with …: {line:?}");
|
||||
assert!(
|
||||
line.split_at(actions_start).0.ends_with(' '),
|
||||
"no gap before the actions: {line:?}"
|
||||
!line.contains('⧉') && !line.contains('✕'),
|
||||
"actions leaked in: {line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+77
-182
@@ -1,13 +1,15 @@
|
||||
//! `AppState` — the top-level mutable state of the running TUI.
|
||||
//!
|
||||
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
||||
//! session or a draft form), an active tab index, focus split between sidebar
|
||||
//! and pane, the current input mode, and an optional overlay (kill confirm,
|
||||
//! v1 keeps it small on purpose: one pane containing a session or draft form,
|
||||
//! focus split between sidebar and pane, the current input mode, and an optional overlay (kill confirm,
|
||||
//! help, transient toast). The spawn form is no longer an overlay — an empty
|
||||
//! pane is a draft pane (issue #63).
|
||||
//!
|
||||
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::draft::DraftPane;
|
||||
@@ -19,8 +21,18 @@ pub enum Focus {
|
||||
Pane,
|
||||
}
|
||||
|
||||
/// One selectable item in the rendered sidebar sequence.
|
||||
///
|
||||
/// Group paths, unlike a session's position in the catalog, survive catalog
|
||||
/// redraws and make a highlighted collapsed group unambiguous.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum SidebarItem {
|
||||
Group(PathBuf),
|
||||
Session(Uuid),
|
||||
}
|
||||
|
||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
||||
// The draft variant dwarfs `Session(Uuid)`; the pane is moved rarely enough
|
||||
// that boxing every access costs more than the copy does.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -48,6 +60,9 @@ impl Tab {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OverlayKind {
|
||||
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,
|
||||
Toast(String),
|
||||
/// The retained-worktrees view (#85); its entries live on `App` because
|
||||
@@ -70,7 +85,10 @@ pub struct AppState {
|
||||
pub focus: Focus,
|
||||
pub mode: Mode,
|
||||
pub sidebar_visible: bool,
|
||||
pub sidebar_highlight: Option<Uuid>,
|
||||
pub sidebar_highlight: Option<SidebarItem>,
|
||||
/// Group paths whose cards are hidden. This is UI state rather than
|
||||
/// catalog state, so it survives every catalog redraw.
|
||||
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
||||
/// Private: every transition goes through [`AppState::show_overlay`],
|
||||
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
|
||||
/// from four call sites is what left a toast on screen with no way to
|
||||
@@ -96,6 +114,7 @@ impl AppState {
|
||||
mode: Mode::Normal,
|
||||
sidebar_visible: true,
|
||||
sidebar_highlight: None,
|
||||
collapsed_sidebar_groups: HashSet::new(),
|
||||
overlay: None,
|
||||
}
|
||||
}
|
||||
@@ -126,57 +145,6 @@ impl AppState {
|
||||
&mut self.tabs[self.active_tab_idx]
|
||||
}
|
||||
|
||||
/// Append a new tab and switch to it. Sidebar regains focus so the user
|
||||
/// can pick something to attach.
|
||||
pub fn new_tab(&mut self) {
|
||||
let name = format!("{}", self.tabs.len() + 1);
|
||||
self.tabs.push(Tab { name, pane: None });
|
||||
self.active_tab_idx = self.tabs.len() - 1;
|
||||
self.focus = Focus::Sidebar;
|
||||
}
|
||||
|
||||
/// Drop the active tab. No-op if it's the only one — there must always
|
||||
/// be at least one tab.
|
||||
///
|
||||
/// Returns the session that tab was showing, if any, so the caller can
|
||||
/// release its emulator. Without that the tab's WebSocket task and vt100
|
||||
/// parser outlive the tab forever. A draft pane holds no session and
|
||||
/// releases nothing.
|
||||
pub fn close_tab(&mut self) -> Option<Uuid> {
|
||||
if self.tabs.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
let closed = self.tabs.remove(self.active_tab_idx);
|
||||
if self.active_tab_idx >= self.tabs.len() {
|
||||
self.active_tab_idx = self.tabs.len() - 1;
|
||||
}
|
||||
let sid = closed.session()?;
|
||||
// Only release it if no other tab still shows the same session.
|
||||
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
|
||||
(!still_shown).then_some(sid)
|
||||
}
|
||||
|
||||
pub fn next_tab(&mut self) {
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
|
||||
}
|
||||
|
||||
pub fn prev_tab(&mut self) {
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
|
||||
}
|
||||
|
||||
pub fn goto_tab(&mut self, n: u8) {
|
||||
let idx = (n as usize)
|
||||
.saturating_sub(1)
|
||||
.min(self.tabs.len().saturating_sub(1));
|
||||
self.active_tab_idx = idx;
|
||||
}
|
||||
|
||||
/// Bind a session to the active tab, move focus into the pane, and
|
||||
/// drop straight into Insert mode so keystrokes go to the session
|
||||
/// without an extra `i` press. The leader returns to Normal.
|
||||
@@ -186,14 +154,10 @@ impl AppState {
|
||||
self.mode = Mode::Insert;
|
||||
}
|
||||
|
||||
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
|
||||
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
|
||||
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
|
||||
/// starts immediately, same flow as [`Self::attach_to_focused`].
|
||||
/// Replace the pane with a draft form. Focus lands in the pane, Insert
|
||||
/// mode on, so typing starts immediately, same flow as
|
||||
/// [`Self::attach_to_focused`].
|
||||
pub fn open_draft_pane(&mut self) {
|
||||
if self.active_tab().pane.is_some() {
|
||||
self.new_tab();
|
||||
}
|
||||
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
|
||||
self.focus = Focus::Pane;
|
||||
self.mode = Mode::Insert;
|
||||
@@ -213,19 +177,32 @@ impl AppState {
|
||||
}
|
||||
|
||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
||||
/// rendered item sequence. Wraps. No-op on empty.
|
||||
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
|
||||
if order.is_empty() {
|
||||
return;
|
||||
}
|
||||
let len = order.len() as i32;
|
||||
let cur = self
|
||||
.sidebar_highlight
|
||||
.and_then(|id| order.iter().position(|x| *x == id))
|
||||
.as_ref()
|
||||
.and_then(|id| order.iter().position(|x| x == id))
|
||||
.map(|i| i as i32)
|
||||
.unwrap_or(-1);
|
||||
let next = (cur + delta).rem_euclid(len) as usize;
|
||||
self.sidebar_highlight = Some(order[next]);
|
||||
self.sidebar_highlight = Some(order[next].clone());
|
||||
}
|
||||
|
||||
/// Toggle a group. When collapsing a group containing the current card,
|
||||
/// leave the highlight on the group row that remains visible.
|
||||
pub fn toggle_sidebar_group(&mut self, key: PathBuf, contains_highlight: bool) {
|
||||
if self.collapsed_sidebar_groups.remove(&key) {
|
||||
return;
|
||||
}
|
||||
self.collapsed_sidebar_groups.insert(key.clone());
|
||||
if contains_highlight {
|
||||
self.sidebar_highlight = Some(SidebarItem::Group(key));
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle sidebar visibility.
|
||||
@@ -252,88 +229,36 @@ mod tests {
|
||||
assert!(state.overlay.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tab_extends_and_focuses() {
|
||||
let mut state = AppState::new();
|
||||
state.new_tab();
|
||||
assert_eq!(state.tabs.len(), 2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
assert_eq!(state.focus, Focus::Sidebar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_keeps_at_least_one() {
|
||||
let mut state = AppState::new();
|
||||
state.close_tab();
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
state.new_tab();
|
||||
state.close_tab();
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_advances_when_last_is_closed() {
|
||||
let mut state = AppState::new();
|
||||
state.new_tab();
|
||||
state.new_tab();
|
||||
assert_eq!(state.active_tab_idx, 2);
|
||||
state.close_tab();
|
||||
assert_eq!(state.tabs.len(), 2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_prev_tab_wraps() {
|
||||
let mut state = AppState::new();
|
||||
state.new_tab();
|
||||
state.new_tab();
|
||||
state.active_tab_idx = 0;
|
||||
state.next_tab();
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
state.next_tab();
|
||||
assert_eq!(state.active_tab_idx, 2);
|
||||
state.next_tab();
|
||||
assert_eq!(state.active_tab_idx, 0);
|
||||
state.prev_tab();
|
||||
assert_eq!(state.active_tab_idx, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goto_tab_uses_one_based_clamp() {
|
||||
let mut state = AppState::new();
|
||||
state.new_tab();
|
||||
state.new_tab();
|
||||
state.goto_tab(1);
|
||||
assert_eq!(state.active_tab_idx, 0);
|
||||
state.goto_tab(2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
state.goto_tab(99);
|
||||
// Clamped to last available tab.
|
||||
assert_eq!(state.active_tab_idx, state.tabs.len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_sidebar_highlight_walks_order_and_wraps() {
|
||||
let mut state = AppState::new();
|
||||
let a = Uuid::from_u128(1);
|
||||
let b = Uuid::from_u128(2);
|
||||
let c = Uuid::from_u128(3);
|
||||
let order = vec![a, b, c];
|
||||
let order = vec![
|
||||
SidebarItem::Session(a),
|
||||
SidebarItem::Group("repo".into()),
|
||||
SidebarItem::Session(b),
|
||||
SidebarItem::Session(c),
|
||||
];
|
||||
|
||||
// No prior highlight + delta +1 lands on first item.
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(a));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(b));
|
||||
assert_eq!(
|
||||
state.sidebar_highlight,
|
||||
Some(SidebarItem::Group("repo".into()))
|
||||
);
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(c));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
// Wraps forward.
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(a));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
|
||||
// Wraps backward.
|
||||
state.move_sidebar_highlight(&order, -1);
|
||||
assert_eq!(state.sidebar_highlight, Some(c));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -343,6 +268,22 @@ mod tests {
|
||||
assert_eq!(state.sidebar_highlight, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_highlighted_group_moves_a_card_highlight_to_the_group() {
|
||||
let key = PathBuf::from("/repo");
|
||||
let mut state = AppState::new();
|
||||
state.sidebar_highlight = Some(SidebarItem::Session(Uuid::from_u128(9)));
|
||||
state.toggle_sidebar_group(key.clone(), true);
|
||||
assert_eq!(
|
||||
state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(key.clone()))
|
||||
);
|
||||
assert!(state.collapsed_sidebar_groups.contains(&key));
|
||||
state.toggle_sidebar_group(key.clone(), false);
|
||||
assert!(!state.collapsed_sidebar_groups.contains(&key));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Group(key)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_binds_focuses_pane_and_enters_insert_mode() {
|
||||
let mut state = AppState::new();
|
||||
@@ -367,38 +308,6 @@ mod tests {
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
}
|
||||
|
||||
/// The emulator leak: a closed tab's session must come back so the caller
|
||||
/// can abort its WebSocket task.
|
||||
#[test]
|
||||
fn close_tab_reports_the_session_it_dropped() {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(3);
|
||||
state.new_tab();
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
|
||||
assert_eq!(state.close_tab(), Some(sid));
|
||||
}
|
||||
|
||||
/// ...but not while another tab still shows it.
|
||||
#[test]
|
||||
fn close_tab_keeps_a_session_another_tab_still_shows() {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(4);
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
state.new_tab();
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
|
||||
assert_eq!(state.close_tab(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_is_a_no_op_on_the_last_tab() {
|
||||
let mut state = AppState::new();
|
||||
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
|
||||
assert_eq!(state.close_tab(), None);
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismiss_overlay_reports_whether_one_was_showing() {
|
||||
let mut state = AppState::new();
|
||||
@@ -420,18 +329,14 @@ mod tests {
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
}
|
||||
|
||||
/// A tab already showing a session gets a fresh tab for the draft; v1
|
||||
/// has no split tree, so a new tab is the "split".
|
||||
#[test]
|
||||
fn open_draft_over_a_session_opens_a_new_tab() {
|
||||
fn open_draft_over_a_session_reuses_the_pane() {
|
||||
let mut state = AppState::new();
|
||||
state.attach_to_focused(Uuid::from_u128(9));
|
||||
state.open_draft_pane();
|
||||
assert_eq!(state.tabs.len(), 2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
assert_eq!(state.active_tab_idx, 0);
|
||||
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
|
||||
// The session tab is untouched.
|
||||
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
|
||||
}
|
||||
|
||||
/// Web parity (#57): submitting fills the pane in place.
|
||||
@@ -445,14 +350,4 @@ mod tests {
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
assert_eq!(state.mode, Mode::Insert);
|
||||
}
|
||||
|
||||
/// A closed draft tab holds no session, so nothing is released.
|
||||
#[test]
|
||||
fn close_tab_reports_nothing_for_a_draft_pane() {
|
||||
let mut state = AppState::new();
|
||||
state.open_draft_pane();
|
||||
state.new_tab();
|
||||
state.active_tab_idx = 0;
|
||||
assert_eq!(state.close_tab(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
//! Tab-bar widget — top row of the main area.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Tabs;
|
||||
use ratatui::Frame;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TabBar {
|
||||
pub tab_names: Vec<String>,
|
||||
pub active_idx: usize,
|
||||
}
|
||||
|
||||
impl TabBar {
|
||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||
let titles: Vec<Line> = self
|
||||
.tab_names
|
||||
.iter()
|
||||
.map(|n| Line::from(n.as_str()))
|
||||
.collect();
|
||||
let tabs = Tabs::new(titles)
|
||||
.select(self.active_idx)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
f.render_widget(tabs, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn render_to_string(bar: &TabBar, w: u16, h: u16) -> String {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| bar.render(f, f.area())).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_three_tab_names() {
|
||||
let bar = TabBar {
|
||||
tab_names: vec!["1".into(), "2".into(), "3".into()],
|
||||
active_idx: 1,
|
||||
};
|
||||
let rendered = render_to_string(&bar, 40, 1);
|
||||
assert!(rendered.contains("1"));
|
||||
assert!(rendered.contains("2"));
|
||||
assert!(rendered.contains("3"));
|
||||
}
|
||||
}
|
||||
+22
-5
@@ -134,19 +134,20 @@ cover every v1 action — only override what you want to change:
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
theme = "dark" # dark (default), light, or ansi
|
||||
theme = "dark" # see the theme list below
|
||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||
|
||||
[tui.keymap.normal]
|
||||
"i" = "EnterInsert"
|
||||
"s" = "OpenSpawnModal"
|
||||
"s" = "NewDraftPane"
|
||||
"x" = "KillHighlighted"
|
||||
"r" = "RenameHighlighted"
|
||||
"?" = "ShowHelp"
|
||||
"ctrl-t" = "NewTab"
|
||||
"g t" = "NextTab"
|
||||
"ctrl-w h" = "FocusSidebar"
|
||||
"enter" = "AttachHighlighted"
|
||||
"pageup" = "ScrollPanePageUp"
|
||||
"pagedown" = "ScrollPanePageDown"
|
||||
|
||||
[tui.keymap.insert]
|
||||
# (leader is added automatically as LeaveInsert; only override here when you
|
||||
@@ -156,6 +157,22 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
||||
`theme` selects the complete TUI palette. `dark` is the default and follows
|
||||
Maestro's terminal-native dark product palette; `light` is its light peer;
|
||||
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
|
||||
The remaining presets adapt well-known editor themes, with individual colours
|
||||
lifted or darkened where the originals fall short of the WCAG AA contrast
|
||||
floors the palette tests assert:
|
||||
|
||||
| name | based on |
|
||||
|-------------------|------------------|
|
||||
| `solarized-dark` | Solarized Dark |
|
||||
| `solarized-light` | Solarized Light |
|
||||
| `catppuccin` | Catppuccin Mocha |
|
||||
| `github-dark` | GitHub Dark |
|
||||
| `github-light` | GitHub Light |
|
||||
| `tokyo-night` | Tokyo Night |
|
||||
| `gruvbox` | Gruvbox Dark |
|
||||
| `nord` | Nord |
|
||||
| `dracula` | Dracula |
|
||||
|
||||
An unknown name is rejected as an `[tui].theme` configuration error.
|
||||
|
||||
The full action vocabulary is the `Action` enum in
|
||||
|
||||
+13
-8
@@ -7,21 +7,26 @@ written here; if it needs to change, change it here first.
|
||||
|
||||
A card is always two lines: **line 1** and **line 2**.
|
||||
|
||||
## Line 1 — name + actions
|
||||
## Line 1 — name
|
||||
|
||||
- Left: the session name — `metadata.name` if present, else the command line
|
||||
(same fallback both clients already use).
|
||||
- Right: two actions anchored to the top-right corner: **spawn similar**
|
||||
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
|
||||
the highlighted row; in the web they are buttons. The glyphs are shared so
|
||||
muscle memory transfers.
|
||||
- Actions reserve their fixed width; the name truncates with `…` before it can
|
||||
ever reach them. **Overlap between the name and the actions is a defect** —
|
||||
that overlap is what started this milestone.
|
||||
- In the web client, **spawn similar** (`⧉`) and **kill** (`✕`) remain buttons
|
||||
anchored on the right. The TUI omits those glyphs because its actions are
|
||||
keyboard-driven and the icons do not identify their keys.
|
||||
- The name truncates with `…` at the available width. In the web client it
|
||||
must stop before the action buttons; in the TUI it may use the full row.
|
||||
|
||||
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
|
||||
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 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> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.session(id), {
|
||||
|
||||
+2
-1
@@ -123,7 +123,8 @@ export function createAttachConnection(
|
||||
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
||||
// scrolling *its* content (the only scroll that exists on the alt screen).
|
||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||||
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
|
||||
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||||
// gesture stream; only the terminal surface opts out of native page panning.
|
||||
{
|
||||
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||||
|
||||
+105
-14
@@ -1,9 +1,18 @@
|
||||
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 { CatalogStore } from "./events.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 {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
@@ -23,6 +32,11 @@ export function requestOpenSpawn(): void {
|
||||
|
||||
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
|
||||
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
||||
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 }));
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", async (ev) => {
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
// #83: the confirm restates the warning at the moment of decision.
|
||||
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}`);
|
||||
}
|
||||
void confirmAndKill(s, name);
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
openContextMenu(ev.clientX, ev.clientY, [
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, s) },
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent("catalog-spawn-similar", { detail: s })),
|
||||
},
|
||||
{ label: "Kill session", danger: true, onSelect: () => void confirmAndKill(s, name) },
|
||||
]);
|
||||
});
|
||||
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 {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "statusbar";
|
||||
@@ -402,6 +488,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
// Rebuilding the tree now would blow away the field being typed into.
|
||||
if (renamingId !== null) return;
|
||||
const state = store.snapshot();
|
||||
const sessions = [...state.sessions.values()];
|
||||
const tree = buildTree(sessions, homeDir, filter);
|
||||
@@ -458,6 +546,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
},
|
||||
dispose() {
|
||||
clearInterval(clock);
|
||||
// An unfinished rename would otherwise leave every later sidebar
|
||||
// refusing to refresh.
|
||||
renamingId = null;
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
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();
|
||||
});
|
||||
});
|
||||
+67
-3
@@ -814,6 +814,68 @@ textarea {
|
||||
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
|
||||
state word itself is never spelled out here; the colour carries it. */
|
||||
.card-line2 {
|
||||
@@ -1142,9 +1204,11 @@ textarea {
|
||||
.attach-body .xterm .xterm-viewport {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
/* We drive scrollback from touchmove (see attach.ts); claim vertical pans so
|
||||
the browser doesn't fight the handler or trigger gestures mid-scroll. */
|
||||
touch-action: pan-y;
|
||||
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
|
||||
the gesture to the browser, which may cancel the event stream before the
|
||||
terminal sees it; `none` keeps a drag inside the terminal under our
|
||||
control while the rest of the page retains native scrolling. */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
|
||||
|
||||
+20
-1
@@ -244,10 +244,29 @@ export interface ApiError {
|
||||
}
|
||||
|
||||
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 name = meta?.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
|
||||
|
||||
Reference in New Issue
Block a user