Removes the retained-worktree store end to end: the daemon's state-dir store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the TUI overlay behind `w`, and the web `#/worktrees` view and header link. The kill guard stays. A forced kill and a dirty natural exit now leave the worktree on disk untouched instead of parking it in a store, so removing one is an ordinary `git worktree remove`. A pristine tree is still removed on exit. DELETE /api/sessions/:id always answers 204. The header's debug button also goes; Ctrl+Shift+D and `?hud` still toggle the HUD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
139 lines
4.8 KiB
Rust
139 lines
4.8 KiB
Rust
//! `maestro tui` — terminal UI client over the local daemon's `/api/*` surface.
|
|
|
|
#![deny(missing_debug_implementations)]
|
|
|
|
pub mod action;
|
|
pub mod app;
|
|
pub mod catalog;
|
|
pub mod cli;
|
|
pub mod config;
|
|
pub mod draft;
|
|
pub mod emulators;
|
|
pub mod fast_forward;
|
|
pub mod input;
|
|
pub mod keymap;
|
|
pub mod overlays;
|
|
pub mod palette;
|
|
pub mod pane;
|
|
pub mod projects;
|
|
pub mod render;
|
|
pub mod session_task;
|
|
pub mod sidebar;
|
|
pub mod state;
|
|
pub mod terminal;
|
|
pub mod text_input;
|
|
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use maestro_protocol::DaemonClient;
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::Terminal;
|
|
|
|
use crate::app::App;
|
|
use crate::catalog::CatalogStore;
|
|
use crate::config::TuiConfig;
|
|
|
|
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
|
|
/// `TuiArgs` plus any global flags.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RunConfig {
|
|
/// HTTP base URL of the local daemon, e.g. `http://127.0.0.1:7070`.
|
|
/// No trailing slash.
|
|
pub daemon_url: String,
|
|
/// Explicit `--config <path>` override from the umbrella binary's global
|
|
/// flag. When `None`, [`maestro_config::default_config_path`] picks the
|
|
/// default (`$MAESTRO_CONFIG` or `~/.config/maestro/config.toml`).
|
|
pub config_path: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum TuiError {
|
|
#[error("daemon unreachable at {0}; check `systemctl --user status maestro-daemon`")]
|
|
DaemonUnreachable(String),
|
|
#[error("config: {0}")]
|
|
Config(String),
|
|
#[error("catalog: {0}")]
|
|
Catalog(String),
|
|
#[error("terminal: {0}")]
|
|
Terminal(String),
|
|
}
|
|
|
|
/// Entry point. Spawns the catalog WS loop, installs the panic hook, enters
|
|
/// the TUI alt screen, drives [`App::run`] until the user quits.
|
|
pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
|
terminal::install_panic_hook();
|
|
|
|
let base_url = cfg.daemon_url.trim_end_matches('/').to_string();
|
|
|
|
// Health check — fail fast if no daemon is reachable.
|
|
health_check(&DaemonClient::with_timeouts(
|
|
&base_url,
|
|
HEALTH_TIMEOUT,
|
|
HEALTH_TIMEOUT,
|
|
))
|
|
.await?;
|
|
|
|
let daemon = DaemonClient::with_timeouts(&base_url, REQUEST_TIMEOUT, REQUEST_TIMEOUT);
|
|
|
|
// Load runtime config: explicit `--config <path>` wins, then
|
|
// `$MAESTRO_CONFIG`, then the platform default. Missing file is fine —
|
|
// we fall back to shipped defaults.
|
|
let cfg_path = cfg.config_path.clone().map(Ok).unwrap_or_else(|| {
|
|
maestro_config::default_config_path().map_err(|e| TuiError::Config(e.to_string()))
|
|
});
|
|
let cfg_file = match cfg_path {
|
|
Ok(path) => maestro_config::MaestroConfigFile::load(&path)
|
|
.map_err(|e| TuiError::Config(e.to_string()))?,
|
|
Err(_) => maestro_config::MaestroConfigFile::default(),
|
|
};
|
|
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
|
|
palette::set_active(tui_cfg.palette);
|
|
|
|
let catalog = CatalogStore::default();
|
|
let ws_url = daemon
|
|
.events_url()
|
|
.map_err(|e| TuiError::Config(e.to_string()))?;
|
|
let bg_catalog = catalog.clone();
|
|
tokio::spawn(async move { bg_catalog.run_events_loop(ws_url).await });
|
|
|
|
// Wait briefly for the bootstrap Snapshot to land. If it doesn't, we
|
|
// still enter the UI — the sidebar will populate when events flow.
|
|
let _ = tokio::time::timeout(Duration::from_secs(2), catalog.wait_for_initial_snapshot()).await;
|
|
|
|
let _guard = terminal::TerminalGuard::enter().map_err(|e| match e {
|
|
terminal::TerminalError::Io(io) => TuiError::Terminal(io.to_string()),
|
|
})?;
|
|
let backend = CrosstermBackend::new(std::io::stdout());
|
|
let mut terminal = Terminal::new(backend).map_err(|e| TuiError::Terminal(e.to_string()))?;
|
|
|
|
let app = App::new(tui_cfg, catalog, daemon);
|
|
app.run(&mut terminal).await
|
|
}
|
|
|
|
/// Startup probe budget, applied to connect *and* the whole request: the user is
|
|
/// staring at a terminal, so an unreachable daemon must fail in under a second.
|
|
const HEALTH_TIMEOUT: Duration = Duration::from_millis(500);
|
|
|
|
/// Budget for the TUI's own calls once it is up. Tighter than the client
|
|
/// default — a spawn or kill that stalls should surface as a toast, not hang
|
|
/// the interface.
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
async fn health_check(daemon: &DaemonClient) -> Result<(), TuiError> {
|
|
for _ in 0..3 {
|
|
if let Ok(info) = daemon.daemon_info().await {
|
|
if !maestro_protocol::is_compatible(&info.version) {
|
|
tracing::warn!(
|
|
daemon = %info.version,
|
|
client = %maestro_protocol::PROTOCOL_VERSION,
|
|
"daemon protocol version differs from this client's",
|
|
);
|
|
}
|
|
return Ok(());
|
|
}
|
|
tokio::time::sleep(HEALTH_TIMEOUT).await;
|
|
}
|
|
Err(TuiError::DaemonUnreachable(daemon.base().to_string()))
|
|
}
|