4076 lines
157 KiB
Rust
4076 lines
157 KiB
Rust
//! `App` — top-level event loop that stitches everything together.
|
|
//!
|
|
//! Per frame: `redraw()` reads from `CatalogStore` + `SessionEmulators` and
|
|
//! draws via [`crate::render::draw`], returning a `RegionMap` for the next
|
|
//! mouse hit-test. The event loop then races crossterm input, catalog
|
|
//! changes, and a 30Hz heartbeat.
|
|
//!
|
|
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
|
|
//! (`AttachHighlighted` spawning a `SessionTask`, a spawn-form submit firing
|
|
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
|
|
//! `dispatch` / the overlay routing.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use chrono::Utc;
|
|
use crossterm::event::{Event as CtEvent, EventStream};
|
|
use futures_util::StreamExt;
|
|
use maestro_protocol::{
|
|
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, SessionView,
|
|
StructuredApprovalDecision,
|
|
};
|
|
use ratatui::Terminal;
|
|
use uuid::Uuid;
|
|
|
|
use crate::action::Action;
|
|
use crate::catalog::CatalogStore;
|
|
use crate::command_bar::{BarOutcome, BarTarget, CommandBar, SessionActionId};
|
|
use crate::config::TuiConfig;
|
|
use crate::daemon_link::DaemonLink;
|
|
use crate::draft::{DraftInputResult, DraftPane};
|
|
use crate::emulators::SessionEmulators;
|
|
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, SidebarItem};
|
|
use crate::structured_pane::StructuredPane;
|
|
use crate::structured_task::{StructuredHandle, StructuredTask};
|
|
use crate::text_input::TextInput;
|
|
use crate::TuiError;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum OverlayDecision {
|
|
/// Overlay handled the event; do not run keymap dispatch.
|
|
Consumed,
|
|
/// No overlay open; the dispatcher should process the event normally.
|
|
Pass,
|
|
}
|
|
|
|
pub struct App {
|
|
cfg: TuiConfig,
|
|
home_dir: String,
|
|
catalog: CatalogStore,
|
|
emulators: SessionEmulators,
|
|
structured: HashMap<Uuid, StructuredHandle>,
|
|
state: AppState,
|
|
help: HelpOverlay,
|
|
chord: ChordState,
|
|
daemon: DaemonClient,
|
|
/// Liveness + release of the daemon (`docs/RESTART.md`): drives the status
|
|
/// bar's indicator, and locks the keyboard behind the restart overlay.
|
|
link: DaemonLink,
|
|
/// The spawn form behind [`OverlayKind::Spawn`], kept here rather than in
|
|
/// `AppState` so the overlay enum stays comparable — the same reason the
|
|
/// rename buffer lives here.
|
|
draft: Option<DraftPane>,
|
|
/// Results of draft data loads, delivered back to the event loop.
|
|
///
|
|
/// These are network calls — `/api/projects` on a cold cache walks all of
|
|
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
|
|
/// keys, no frames, attached panes stalled, for up to the request timeout
|
|
/// (#67 review). They run as detached tasks and post here instead.
|
|
draft_tx: tokio::sync::mpsc::UnboundedSender<DraftUpdate>,
|
|
draft_rx: tokio::sync::mpsc::UnboundedReceiver<DraftUpdate>,
|
|
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
|
|
/// `GET /api/names` (#64).
|
|
http: reqwest::Client,
|
|
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
|
|
/// same reason the worktree entries are.
|
|
rename_input: TextInput,
|
|
/// Query buffer and result list behind [`OverlayKind::CommandBar`] (#153),
|
|
/// kept here for the same reason the rename buffer is.
|
|
command_bar: Option<CommandBar>,
|
|
region: RegionMap,
|
|
last_sent_size: HashMap<Uuid, (u16, u16)>,
|
|
clicks: ClickTracker,
|
|
/// The session, if any, last reported to the daemon as `focused` — the
|
|
/// active tab's pane owning the keyboard, never a sidebar highlight.
|
|
/// [`Self::sync_focus`] diffs against this every loop tick so every path
|
|
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
|
|
/// mouse) is covered without each one remembering to report it.
|
|
focused_session: Option<Uuid>,
|
|
/// A sidebar highlight waiting for the pane's minimum gap before it
|
|
/// becomes a preview (`docs/PREVIEW.md`). Walking the list with `j` held
|
|
/// down must not open an attach per row it passes over.
|
|
pending_preview: Option<(Uuid, std::time::Instant)>,
|
|
/// When the pane last swapped what it previews. `None` until the first
|
|
/// one, which is why a highlight landing on a still sidebar shows at once.
|
|
last_preview_at: Option<std::time::Instant>,
|
|
/// Sessions attached only because they were previewed, oldest first. Kept
|
|
/// connected past the preview so stepping back onto a row is instant, and
|
|
/// bounded so a long walk doesn't leave a socket per session behind.
|
|
preview_lru: Vec<Uuid>,
|
|
/// Fingerprint of the last frame drawn. The loop compares a freshly
|
|
/// computed key against this and skips the repaint when they match — an
|
|
/// idle TUI used to emit a full diff 30 times a second forever, which over
|
|
/// a remote terminal is constant traffic for no change on screen.
|
|
last_frame: Option<FrameKey>,
|
|
/// When that frame went out, for the periodic refresh below.
|
|
last_drawn_at: std::time::Instant,
|
|
/// Bumped by every path that mutates `AppState` outside the catalog and
|
|
/// the attached pane: keys, mouse, draft results, link transitions. Those
|
|
/// mutations are too diffuse to fingerprint individually, so the loop
|
|
/// treats "an input was processed" as "assume the frame is stale".
|
|
epoch: u64,
|
|
}
|
|
|
|
/// Everything a frame is built from that is cheap to sample every tick.
|
|
/// Equal keys mean an identical frame, so the repaint can be skipped.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct FrameKey {
|
|
catalog: u64,
|
|
epoch: u64,
|
|
/// Active pane's session, its screen revision, and its link state.
|
|
pane: Option<(Uuid, u64, crate::session_task::SessionState)>,
|
|
area: ratatui::layout::Size,
|
|
}
|
|
|
|
/// Session cards carry a duration that advances on its own (`docs/SIDEBAR.md`),
|
|
/// so a frame is never allowed to stand for longer than this even when nothing
|
|
/// else changed.
|
|
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
|
|
|
|
/// Shortest gap between two pane swaps (`docs/PREVIEW.md`). The first
|
|
/// highlight to land shows at once; this only bounds how often the pane may
|
|
/// follow after that, so a fast walk down the list attaches to the row it
|
|
/// started on and the row it stopped on rather than every row between.
|
|
const PREVIEW_GAP: Duration = Duration::from_millis(120);
|
|
|
|
/// How many preview-only attaches to keep connected behind the current one.
|
|
const PREVIEW_BUDGET: usize = 3;
|
|
|
|
impl std::fmt::Debug for App {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("App")
|
|
.field("daemon", &self.daemon.base())
|
|
.field("state", &self.state)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
/// Something a keystroke decided to do that reaches outside `AppState`.
|
|
///
|
|
/// `dispatch` returns these instead of performing them, so every arm is
|
|
/// assertable without a daemon on the other end.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Effect {
|
|
Attach(Uuid),
|
|
Spawn(CreateSessionRequest),
|
|
Kill(Uuid),
|
|
Forward {
|
|
id: Uuid,
|
|
bytes: Vec<u8>,
|
|
},
|
|
StructuredPrompt {
|
|
id: Uuid,
|
|
prompt: String,
|
|
},
|
|
StructuredCancel {
|
|
id: Uuid,
|
|
},
|
|
StructuredApproval {
|
|
id: Uuid,
|
|
nonce: String,
|
|
decision: StructuredApprovalDecision,
|
|
},
|
|
/// Fetch presets / projects / names for a freshly opened form (#64).
|
|
LoadDraft,
|
|
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
|
/// spawn with no form; unresolvable defaults open a pre-filled form.
|
|
FastForward,
|
|
/// The same fast path pinned to one directory — the command bar's `~`
|
|
/// (#154). Nothing may override the directory the operator named.
|
|
FastForwardIn(PathBuf),
|
|
/// Fetch the command bar's `~` candidates: recent dirs and the project
|
|
/// list. `bar` guards a load landing in a bar that was reopened since.
|
|
LoadBarDirectories {
|
|
bar: u64,
|
|
},
|
|
/// The form's target changed: probe `/api/gitroot` so the worktree
|
|
/// section knows its base repo (#65). `seq` guards stale answers.
|
|
ProbeGitRoot {
|
|
seq: u32,
|
|
path: PathBuf,
|
|
},
|
|
/// Fetch the form target's branches from `/api/branches` (#65).
|
|
LoadBranches,
|
|
/// 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 {
|
|
pub fn new(
|
|
cfg: TuiConfig,
|
|
catalog: CatalogStore,
|
|
daemon: DaemonClient,
|
|
link: DaemonLink,
|
|
) -> Self {
|
|
let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let chord_timeout = cfg.chord_timeout;
|
|
let help = HelpOverlay {
|
|
lines: render_help_lines(&cfg),
|
|
};
|
|
let http = reqwest::Client::builder()
|
|
.connect_timeout(maestro_protocol::client::DEFAULT_CONNECT_TIMEOUT)
|
|
.timeout(maestro_protocol::client::DEFAULT_REQUEST_TIMEOUT)
|
|
.build()
|
|
.expect("build reqwest client");
|
|
Self {
|
|
cfg,
|
|
home_dir: std::env::var("HOME").unwrap_or_default(),
|
|
catalog,
|
|
emulators: SessionEmulators::default(),
|
|
structured: HashMap::new(),
|
|
state: AppState::new(),
|
|
draft: None,
|
|
help,
|
|
chord: ChordState::new(chord_timeout),
|
|
daemon,
|
|
link,
|
|
http,
|
|
rename_input: TextInput::default(),
|
|
command_bar: None,
|
|
draft_tx,
|
|
draft_rx,
|
|
region: RegionMap::default(),
|
|
last_sent_size: HashMap::new(),
|
|
clicks: ClickTracker::default(),
|
|
focused_session: None,
|
|
pending_preview: None,
|
|
last_preview_at: None,
|
|
preview_lru: Vec::new(),
|
|
last_frame: None,
|
|
last_drawn_at: std::time::Instant::now(),
|
|
epoch: 0,
|
|
}
|
|
}
|
|
|
|
/// Drive the loop until the user quits. Consumes self.
|
|
pub async fn run<B: ratatui::backend::Backend>(
|
|
mut self,
|
|
terminal: &mut Terminal<B>,
|
|
) -> Result<(), TuiError> {
|
|
if let Ok(info) = self.daemon.daemon_info().await {
|
|
self.home_dir = info.home_dir;
|
|
}
|
|
let mut events = EventStream::new();
|
|
loop {
|
|
self.sync_focus().await;
|
|
self.redraw_if_stale(terminal)?;
|
|
|
|
tokio::select! {
|
|
Some(Ok(ev)) = events.next() => {
|
|
if self.handle_event(ev).await? {
|
|
return Ok(());
|
|
}
|
|
}
|
|
Some(update) = self.draft_rx.recv() => {
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
self.apply_draft_update(update).await;
|
|
}
|
|
_ = self.catalog.changed() => {
|
|
// Picked up by the frame key's catalog version.
|
|
}
|
|
_ = self.link.changed() => {
|
|
// The status indicator and the restart overlay both read
|
|
// the link; neither is fingerprinted, so mark stale.
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
}
|
|
_ = tokio::time::sleep(Duration::from_millis(33)) => {
|
|
let now = std::time::Instant::now();
|
|
let mut effects = self.poll_due_probes_at(now);
|
|
effects.extend(self.poll_due_preview_at(now));
|
|
if !effects.is_empty() {
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
}
|
|
self.perform(effects).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true when the loop should exit.
|
|
async fn handle_event(&mut self, ev: CtEvent) -> Result<bool, TuiError> {
|
|
let map = self.region.clone();
|
|
let Some(key) = canonicalise(&ev, |c, r| map.region_at(c, r)) else {
|
|
return Ok(false);
|
|
};
|
|
let key = self.clicks.promote(key, std::time::Instant::now());
|
|
// Any key can move the cursor, open an overlay or edit a text buffer.
|
|
// Rather than fingerprint each of those, treat a processed input as
|
|
// proof the frame is stale.
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
|
|
// The daemon is gone: everything a key could do goes through it, so
|
|
// only the quit binding still resolves until it answers again.
|
|
if self.link.is_blocking() {
|
|
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
|
if let ChordResult::Resolved(canon) = self.chord.feed(&key, &known) {
|
|
if matches!(
|
|
self.cfg.keymap.resolve(self.state.mode, &canon),
|
|
Some(Action::Quit)
|
|
) {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
self.chord.take_replay();
|
|
return Ok(false);
|
|
}
|
|
|
|
// Overlay intercept first.
|
|
let (decision, effects) = self.handle_overlay_input(&key);
|
|
self.perform(effects).await;
|
|
if decision == OverlayDecision::Consumed {
|
|
return Ok(false);
|
|
}
|
|
|
|
if self.handle_sidebar_mouse(&key) {
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(effects) = self.handle_structured_key(&key) {
|
|
self.perform(effects).await;
|
|
return Ok(false);
|
|
}
|
|
|
|
if let Some(effect) = self.handle_pane_scroll(&key) {
|
|
self.perform(effect.into_iter().collect()).await;
|
|
return Ok(false);
|
|
}
|
|
|
|
// A key bumped out of an abandoned chord (#156) is replayed as its
|
|
// own keystroke, against the mode dispatch may have just changed.
|
|
let mut current_key = key;
|
|
let mut result = self.chord.feed(
|
|
¤t_key,
|
|
&self.cfg.keymap.known_bindings(self.state.mode),
|
|
);
|
|
loop {
|
|
let canon = match result {
|
|
ChordResult::Pending => return Ok(false),
|
|
ChordResult::Resolved(canon) => canon,
|
|
};
|
|
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
|
|
if matches!(act, Some(Action::Quit)) {
|
|
return Ok(true);
|
|
}
|
|
let pty_owns_keys =
|
|
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane);
|
|
match act {
|
|
// `Noop` suppresses a binding, and a key with no binding in
|
|
// Insert mode is the session's — so a key bound to `Noop`
|
|
// reaches the pty too. That is the escape hatch for taking
|
|
// a default binding back.
|
|
Some(Action::Noop) | None if pty_owns_keys => {
|
|
let effects = self.forward_to_session(¤t_key).into_iter().collect();
|
|
self.perform(effects).await;
|
|
}
|
|
Some(act) => {
|
|
let effects = self.dispatch(act);
|
|
self.perform(effects).await;
|
|
}
|
|
None => {}
|
|
}
|
|
match self.chord.take_replay() {
|
|
Some(replay_key) => {
|
|
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
|
result = self.chord.feed(&replay_key, &known);
|
|
current_key = replay_key;
|
|
}
|
|
None => return Ok(false),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|
|
self.refresh_preview();
|
|
true
|
|
}
|
|
|
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
|
/// the daemon.
|
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
|
for effect in effects {
|
|
match effect {
|
|
Effect::Attach(id) => self.attach(id),
|
|
Effect::Spawn(req) => self.submit_spawn(req).await,
|
|
Effect::Kill(id) => self.submit_kill(id).await,
|
|
Effect::LoadDraft => self.spawn_load_draft(),
|
|
Effect::FastForward => self.spawn_fast_forward(),
|
|
Effect::FastForwardIn(dir) => self.spawn_fast_forward_in(dir),
|
|
Effect::LoadBarDirectories { bar } => self.spawn_load_bar_directories(bar),
|
|
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
|
|
Effect::LoadBranches => self.spawn_load_branches(),
|
|
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;
|
|
}
|
|
}
|
|
Effect::StructuredPrompt { id, prompt } => {
|
|
if let Err(error) = self.daemon.send_structured_prompt(id, &prompt).await {
|
|
if let Some(handle) = self.structured.get(&id) {
|
|
handle
|
|
.state
|
|
.lock()
|
|
.expect("structured state mutex")
|
|
.prompt_failed(prompt, error.to_string());
|
|
}
|
|
}
|
|
}
|
|
Effect::StructuredCancel { id } => {
|
|
if let Err(error) = self.daemon.cancel_structured_turn(id).await {
|
|
if let Some(handle) = self.structured.get(&id) {
|
|
handle.state.lock().expect("structured state mutex").error =
|
|
Some(error.to_string());
|
|
}
|
|
}
|
|
}
|
|
Effect::StructuredApproval {
|
|
id,
|
|
nonce,
|
|
decision,
|
|
} => {
|
|
if let Err(error) = self
|
|
.daemon
|
|
.resolve_structured_approval(id, &nonce, decision)
|
|
.await
|
|
{
|
|
if let Some(handle) = self.structured.get(&id) {
|
|
handle
|
|
.state
|
|
.lock()
|
|
.expect("structured state mutex")
|
|
.approval_failed(&nonce, error.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
|
|
match self.state.overlay().cloned() {
|
|
// The form owns every key while it is up, Escape aside — the
|
|
// keymap must not see `s`, `j` or `x` as commands mid-typing.
|
|
Some(OverlayKind::Spawn) => match key {
|
|
InputKey::Esc => {
|
|
self.dismiss_spawn_modal();
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
other => (OverlayDecision::Consumed, self.handle_draft_input(other)),
|
|
},
|
|
Some(OverlayKind::KillConfirm(id)) => match key {
|
|
InputKey::Char { c: 'y', .. } => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Consumed, vec![Effect::Kill(id)])
|
|
}
|
|
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
_ => (OverlayDecision::Consumed, Vec::new()),
|
|
},
|
|
Some(OverlayKind::Rename(id)) => match key {
|
|
InputKey::Enter => {
|
|
let name = self.rename_input.value().to_string();
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Consumed, vec![Effect::Rename { id, name }])
|
|
}
|
|
InputKey::Esc => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
other => {
|
|
self.rename_input.handle_key(other);
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
},
|
|
// The bar owns every key while it is up: the keymap must not see
|
|
// `j`, `x` or `q` as commands mid-query.
|
|
Some(OverlayKind::CommandBar) => {
|
|
let outcome = match self.command_bar.as_mut() {
|
|
Some(bar) => bar.handle_key(key),
|
|
None => BarOutcome::Close,
|
|
};
|
|
match outcome {
|
|
BarOutcome::Stay => (OverlayDecision::Consumed, Vec::new()),
|
|
BarOutcome::Close => {
|
|
self.close_command_bar();
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
BarOutcome::Open(BarTarget::Session(sid)) => {
|
|
self.close_command_bar();
|
|
// The same open path the sidebar's own attach uses, so
|
|
// a bar attach can never leave the layout in a state a
|
|
// highlight-and-enter could not produce.
|
|
self.state.sidebar_highlight = Some(SidebarItem::Session(sid));
|
|
(
|
|
OverlayDecision::Consumed,
|
|
self.dispatch(Action::AttachHighlighted),
|
|
)
|
|
}
|
|
// The bar picks the action and hands off; it never
|
|
// reimplements one.
|
|
BarOutcome::Open(BarTarget::Command(action)) => {
|
|
self.close_command_bar();
|
|
(OverlayDecision::Consumed, self.dispatch(action))
|
|
}
|
|
BarOutcome::Open(BarTarget::Directory(dir)) => {
|
|
self.close_command_bar();
|
|
(OverlayDecision::Consumed, vec![Effect::FastForwardIn(dir)])
|
|
}
|
|
// The action page raises the flow this client already has
|
|
// — for kill that is `KillConfirm`, which the TUI puts in
|
|
// front of every kill (#160). The bar names the target;
|
|
// it never reimplements the flow.
|
|
BarOutcome::Open(BarTarget::SessionAction { session, action }) => {
|
|
self.close_command_bar();
|
|
let effects = match action {
|
|
SessionActionId::Kill => {
|
|
self.confirm_kill(session);
|
|
Vec::new()
|
|
}
|
|
SessionActionId::Rename => {
|
|
self.prompt_rename(session);
|
|
Vec::new()
|
|
}
|
|
SessionActionId::SpawnSimilar => self.open_spawn_similar(session),
|
|
};
|
|
(OverlayDecision::Consumed, effects)
|
|
}
|
|
}
|
|
}
|
|
Some(OverlayKind::Help) => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Consumed, Vec::new())
|
|
}
|
|
// A toast is informational: clear it, but let the keystroke go on
|
|
// and do its normal job rather than being swallowed.
|
|
Some(OverlayKind::Toast(_)) => {
|
|
self.state.dismiss_overlay();
|
|
(OverlayDecision::Pass, Vec::new())
|
|
}
|
|
None => (OverlayDecision::Pass, Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Fold a resolved action into `AppState` and report anything it wants done
|
|
/// outside it. Deliberately sync and side-effect-free.
|
|
fn dispatch(&mut self, act: Action) -> Vec<Effect> {
|
|
let mut effects = Vec::new();
|
|
match act {
|
|
Action::Noop | Action::Quit => {}
|
|
Action::EnterInsert => self.state.mode = Mode::Insert,
|
|
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 => self.step_sidebar_highlight(1),
|
|
Action::SidebarPrev => self.step_sidebar_highlight(-1),
|
|
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
|
|
Action::ToggleSidebar => self.state.toggle_sidebar(),
|
|
Action::ShowHelp => {
|
|
self.help = HelpOverlay {
|
|
lines: render_help_lines(&self.cfg),
|
|
};
|
|
self.state.show_overlay(OverlayKind::Help);
|
|
}
|
|
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
|
// Escape hatch (#156): the literal byte, not the bar.
|
|
Action::SendLiteralCtrlK => {
|
|
effects.extend(self.forward_literal_to_terminal(&InputKey::ctrl_char('k')));
|
|
}
|
|
// Escape hatch (#158): the leader's own byte, built from the
|
|
// configured leader rather than a hardcoded key.
|
|
Action::SendLiteralLeader => {
|
|
let leader = self.cfg.leader.clone();
|
|
effects.extend(self.forward_literal_to_terminal(&leader));
|
|
}
|
|
Action::OpenSpawnModal => {
|
|
self.open_spawn_modal();
|
|
effects.push(Effect::LoadDraft);
|
|
}
|
|
Action::FastForwardSpawn => effects.push(Effect::FastForward),
|
|
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
|
|
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);
|
|
}
|
|
// A previewed session is already attached: committing
|
|
// to it keeps that stream rather than reopening one.
|
|
if !self.is_attached(sid) {
|
|
effects.push(Effect::Attach(sid));
|
|
}
|
|
}
|
|
// It is the pane's own session now, not a loan from the
|
|
// preview budget.
|
|
self.preview_lru.retain(|s| *s != sid);
|
|
self.state.attach_to_focused(sid);
|
|
if self.session_view(sid) == SessionView::Structured {
|
|
self.state.mode = Mode::Normal;
|
|
}
|
|
}
|
|
}
|
|
Action::KillHighlighted => {
|
|
if let Some(sid) = self.pick_sidebar_target() {
|
|
self.confirm_kill(sid);
|
|
}
|
|
}
|
|
Action::RenameHighlighted => {
|
|
if let Some(sid) = self.pick_sidebar_target() {
|
|
self.prompt_rename(sid);
|
|
}
|
|
}
|
|
}
|
|
// Every action can move the highlight or the focus, and the preview is
|
|
// a function of both (`docs/PREVIEW.md`).
|
|
self.refresh_preview();
|
|
effects
|
|
}
|
|
|
|
/// Recompute what the pane should be previewing. A session highlighted in
|
|
/// the sidebar is previewed as soon as [`PREVIEW_GAP`] has passed since the
|
|
/// last swap — immediately, for a highlight landing on a still list.
|
|
/// Anything else — a group row, the pane holding the keyboard, or the
|
|
/// highlight already sitting on the attached session — clears at once.
|
|
fn refresh_preview(&mut self) {
|
|
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
|
|
// The bar is a view over the app, not a place in it: while it is
|
|
// up the sidebar highlight behind it previews nothing
|
|
// (`docs/COMMAND-BAR.md`, "Focus round-trip").
|
|
_ if self.state.overlay() == Some(&OverlayKind::CommandBar) => None,
|
|
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
|
|
_ => None,
|
|
};
|
|
match wanted {
|
|
Some(sid) if self.state.active_session() != Some(sid) => {
|
|
if self.state.preview != Some(sid) {
|
|
let now = std::time::Instant::now();
|
|
let due = match self.last_preview_at {
|
|
Some(last) if now < last + PREVIEW_GAP => last + PREVIEW_GAP,
|
|
_ => now,
|
|
};
|
|
self.pending_preview = Some((sid, due));
|
|
}
|
|
}
|
|
_ => {
|
|
self.pending_preview = None;
|
|
self.clear_preview();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The pane is free to follow: show the highlighted session, attaching it
|
|
/// if nothing already is.
|
|
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
|
let Some((sid, due)) = self.pending_preview else {
|
|
return Vec::new();
|
|
};
|
|
if now < due {
|
|
return Vec::new();
|
|
}
|
|
self.pending_preview = None;
|
|
if self.state.preview == Some(sid) {
|
|
return Vec::new();
|
|
}
|
|
self.last_preview_at = Some(now);
|
|
self.state.preview = Some(sid);
|
|
// Set outside the input path, so nothing else marks the frame stale.
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
let fresh = !self.is_attached(sid);
|
|
if fresh {
|
|
self.preview_lru.retain(|s| *s != sid);
|
|
self.preview_lru.push(sid);
|
|
}
|
|
self.trim_preview_budget();
|
|
if fresh {
|
|
vec![Effect::Attach(sid)]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
/// Put the command bar up over whatever is behind it (#153). Opening
|
|
/// while a session is previewed discards the preview first, like any
|
|
/// other real change (`docs/PREVIEW.md`); focus and mode are left alone,
|
|
/// so closing gives the keyboard back to whatever held it.
|
|
fn open_command_bar(&mut self) -> Vec<Effect> {
|
|
let snapshot = self.catalog.snapshot();
|
|
// The keymap for the mode the bar was opened from, which is the one
|
|
// the operator would be typing into behind it. Same source the help
|
|
// overlay reads, so the two can never disagree.
|
|
let keymap = self.cfg.keymap.entries(self.state.mode);
|
|
let bar = CommandBar::new(&snapshot.sessions, &keymap, &self.home_dir);
|
|
let id = bar.id();
|
|
self.command_bar = Some(bar);
|
|
self.pending_preview = None;
|
|
self.clear_preview();
|
|
self.state.show_overlay(OverlayKind::CommandBar);
|
|
// `~`'s candidates are a network round trip; the bar opens without
|
|
// them and fills in when they land.
|
|
vec![Effect::LoadBarDirectories { bar: id }]
|
|
}
|
|
|
|
/// Take the bar down, dropping its query. Every exit goes through here so
|
|
/// a stale buffer can never outlive the overlay.
|
|
fn close_command_bar(&mut self) {
|
|
self.command_bar = None;
|
|
self.state.dismiss_overlay();
|
|
}
|
|
|
|
fn clear_preview(&mut self) {
|
|
if self.state.preview.take().is_some() {
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
}
|
|
self.trim_preview_budget();
|
|
}
|
|
|
|
/// Drop preview-only attaches past the budget. What a tab holds, and what
|
|
/// is on screen right now, are never candidates.
|
|
fn trim_preview_budget(&mut self) {
|
|
while self.preview_lru.len() > PREVIEW_BUDGET {
|
|
let held: HashSet<Uuid> = self
|
|
.state
|
|
.tabs
|
|
.iter()
|
|
.filter_map(crate::state::Tab::session)
|
|
.chain(self.state.preview)
|
|
.collect();
|
|
let Some(pos) = self.preview_lru.iter().position(|sid| !held.contains(sid)) else {
|
|
break;
|
|
};
|
|
let victim = self.preview_lru.remove(pos);
|
|
self.release_session(victim);
|
|
}
|
|
}
|
|
|
|
/// Open a stream for `sid` if one isn't already running.
|
|
fn attach(&mut self, sid: Uuid) {
|
|
self.attach_with_view(sid, self.session_view(sid));
|
|
}
|
|
|
|
fn attach_with_view(&mut self, sid: Uuid, view: SessionView) {
|
|
if self.is_attached(sid) {
|
|
return;
|
|
}
|
|
match view {
|
|
SessionView::Terminal => match self.daemon.attach_url(sid) {
|
|
Ok(ws_url) => {
|
|
let handle = SessionTask::spawn(ws_url, 24, 80);
|
|
self.emulators.insert(sid, handle);
|
|
}
|
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
|
},
|
|
SessionView::Structured => match self.daemon.structured_url(sid) {
|
|
Ok(ws_url) => {
|
|
self.structured.insert(sid, StructuredTask::spawn(ws_url));
|
|
}
|
|
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Tear down the emulator for `sid`, aborting its WebSocket task and
|
|
/// dropping its vt100 parser.
|
|
fn release_session(&mut self, sid: Uuid) {
|
|
self.emulators.remove(sid);
|
|
if let Some(handle) = self.structured.remove(&sid) {
|
|
handle.end();
|
|
}
|
|
self.last_sent_size.remove(&sid);
|
|
}
|
|
|
|
fn session_view(&self, sid: Uuid) -> SessionView {
|
|
self.catalog
|
|
.snapshot()
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.id == sid)
|
|
.map(|session| session.view)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn is_attached(&self, sid: Uuid) -> bool {
|
|
self.emulators.get(sid).is_some() || self.structured.contains_key(&sid)
|
|
}
|
|
|
|
/// 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(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)
|
|
}
|
|
|
|
/// `j`/`k`: step the sidebar highlight one row. Group headers are skipped
|
|
/// — a directory is not something to focus, and the cards under it are one
|
|
/// row away anyway. A *collapsed* group is the exception: its header is the
|
|
/// only row it has, so skipping it would make the group unreachable.
|
|
fn step_sidebar_highlight(&mut self, delta: i32) {
|
|
let collapsed = self.state.collapsed_sidebar_groups.clone();
|
|
let order = visible_sidebar_order(&self.catalog.snapshot(), &collapsed);
|
|
self.state
|
|
.move_sidebar_highlight(&order, delta, |item| match item {
|
|
SidebarItem::Group(key) => collapsed.contains(key),
|
|
SidebarItem::Session(_) => true,
|
|
});
|
|
self.state.focus = Focus::Sidebar;
|
|
}
|
|
|
|
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()?;
|
|
if let Some(handle) = self.structured.get(&sid) {
|
|
let mut state = handle.state.lock().expect("structured state mutex");
|
|
if matches!(key, InputKey::Enter) {
|
|
return state
|
|
.begin_prompt()
|
|
.map(|prompt| Effect::StructuredPrompt { id: sid, prompt });
|
|
}
|
|
state.edit_composer(key);
|
|
return None;
|
|
}
|
|
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 forward_literal_to_terminal(&mut self, key: &InputKey) -> Option<Effect> {
|
|
let sid = self.state.active_session()?;
|
|
if self.session_view(sid) == SessionView::Structured {
|
|
return None;
|
|
}
|
|
self.forward_to_session(key)
|
|
}
|
|
|
|
fn handle_structured_key(&mut self, key: &InputKey) -> Option<Vec<Effect>> {
|
|
if self.state.focus != Focus::Pane || self.state.preview.is_some() {
|
|
return None;
|
|
}
|
|
let sid = self.state.active_session()?;
|
|
let handle = self.structured.get(&sid)?;
|
|
let mut state = handle.state.lock().expect("structured state mutex");
|
|
if self.state.mode == Mode::Insert {
|
|
if matches!(key, InputKey::Esc) && state.turn_active() {
|
|
return Some(vec![Effect::StructuredCancel { id: sid }]);
|
|
}
|
|
return None;
|
|
}
|
|
let effects = match key {
|
|
InputKey::Char {
|
|
c: 'j',
|
|
ctrl: false,
|
|
alt: false,
|
|
..
|
|
}
|
|
| InputKey::Down => {
|
|
state.select(1);
|
|
Vec::new()
|
|
}
|
|
InputKey::Char {
|
|
c: 'k',
|
|
ctrl: false,
|
|
alt: false,
|
|
..
|
|
}
|
|
| InputKey::Up => {
|
|
state.select(-1);
|
|
Vec::new()
|
|
}
|
|
InputKey::Enter => {
|
|
state.toggle_selected();
|
|
Vec::new()
|
|
}
|
|
InputKey::Esc => {
|
|
if state.turn_active() {
|
|
vec![Effect::StructuredCancel { id: sid }]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
InputKey::Char {
|
|
c: key @ ('a' | 'A' | 'd'),
|
|
ctrl: false,
|
|
alt: false,
|
|
..
|
|
} => state
|
|
.approval_decision(*key)
|
|
.map(|(nonce, decision)| Effect::StructuredApproval {
|
|
id: sid,
|
|
nonce,
|
|
decision,
|
|
})
|
|
.into_iter()
|
|
.collect(),
|
|
InputKey::PageUp => {
|
|
state.scroll_by(self.pane_page_rows() as isize);
|
|
Vec::new()
|
|
}
|
|
InputKey::PageDown => {
|
|
state.scroll_by(-(self.pane_page_rows() as isize));
|
|
Vec::new()
|
|
}
|
|
_ => return None,
|
|
};
|
|
Some(effects)
|
|
}
|
|
|
|
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;
|
|
};
|
|
if let Some(handle) = self.structured.get(&sid) {
|
|
handle
|
|
.state
|
|
.lock()
|
|
.expect("structured state mutex")
|
|
.scroll_by(rows);
|
|
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()?;
|
|
if let Some(handle) = self.structured.get(&sid) {
|
|
handle
|
|
.state
|
|
.lock()
|
|
.expect("structured state mutex")
|
|
.scroll_by(lines);
|
|
return Some(None);
|
|
}
|
|
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)
|
|
}
|
|
|
|
/// Raise the kill confirmation for one session (`docs/WORKTREES.md`).
|
|
/// Shared by the keymap's own kill and the command bar's action page
|
|
/// (#155), which picks the action and hands off rather than reimplementing
|
|
/// it; the blockers the dialog names are read at render time.
|
|
fn confirm_kill(&mut self, sid: Uuid) {
|
|
self.state.show_overlay(OverlayKind::KillConfirm(sid));
|
|
}
|
|
|
|
/// Raise the rename prompt for one session, pre-filled with its custom
|
|
/// name only: a session that has none starts empty rather than with its
|
|
/// command already typed in, which Enter would then pin as the name.
|
|
fn prompt_rename(&mut self, sid: Uuid) {
|
|
self.rename_input = TextInput::with_value(
|
|
self.catalog
|
|
.snapshot()
|
|
.sessions
|
|
.iter()
|
|
.find(|s| s.id == sid)
|
|
.and_then(crate::sidebar::custom_name_for)
|
|
.unwrap_or_default(),
|
|
);
|
|
self.state.show_overlay(OverlayKind::Rename(sid));
|
|
}
|
|
|
|
/// Put the spawn form up with a fresh draft behind it, preselecting the
|
|
/// project of the session the operator was last on — so `n` then Enter
|
|
/// spawns another session there, the way "spawn similar" would. A
|
|
/// worktree session seeds its *base repository*, not its checkout: the
|
|
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
|
|
/// rule as web/src/spawnSimilar.ts.
|
|
fn open_spawn_modal(&mut self) {
|
|
let mut draft = DraftPane::default();
|
|
draft.seed_cwd = self.focused_seed_cwd();
|
|
self.draft = Some(draft);
|
|
self.state.open_spawn_modal();
|
|
}
|
|
|
|
/// "Spawn similar" (#155): the spawn form seeded from one session's
|
|
/// directory and command, rather than from whatever the operator was last
|
|
/// looking at. Same base-repository rule as the plain seed, and the name
|
|
/// is left out on purpose — a clone is a new session and takes its own
|
|
/// generated handle, like `web/src/spawnSimilar.ts`.
|
|
fn open_spawn_similar(&mut self, sid: Uuid) -> Vec<Effect> {
|
|
let session = self
|
|
.catalog
|
|
.snapshot()
|
|
.sessions
|
|
.iter()
|
|
.find(|s| s.id == sid)
|
|
.cloned();
|
|
self.open_spawn_modal();
|
|
if let (Some(session), Some(draft)) = (session, self.draft.as_mut()) {
|
|
draft.seed_cwd = Some(match &session.worktree {
|
|
Some(wt) => wt.base_repo.clone(),
|
|
None => session.cwd.clone(),
|
|
});
|
|
draft.seed_command = (!session.command.is_empty()).then(|| session.command.clone());
|
|
}
|
|
vec![Effect::LoadDraft]
|
|
}
|
|
|
|
/// The project of the session the operator was last on — its base
|
|
/// repository for a worktree session, its cwd otherwise. Shared by the
|
|
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
|
/// both should land on "where I currently am", not just "whatever's
|
|
/// freshest".
|
|
fn focused_seed_cwd(&self) -> Option<PathBuf> {
|
|
self.state.active_session().and_then(|sid| {
|
|
self.catalog
|
|
.snapshot()
|
|
.sessions
|
|
.iter()
|
|
.find(|s| s.id == sid)
|
|
.map(|s| match &s.worktree {
|
|
Some(wt) => wt.base_repo.clone(),
|
|
None => s.cwd.clone(),
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Close the form and drop its half-filled state — reopening starts clean,
|
|
/// as the web modal does.
|
|
fn dismiss_spawn_modal(&mut self) {
|
|
self.state.dismiss_overlay();
|
|
self.draft = None;
|
|
}
|
|
|
|
/// Feed one keystroke into the spawn 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.
|
|
fn handle_draft_input(&mut self, key: &InputKey) -> Vec<Effect> {
|
|
let Some(draft) = self.draft.as_mut() else {
|
|
return Vec::new();
|
|
};
|
|
let mut effects = Vec::new();
|
|
if draft.on_input(key) == DraftInputResult::Submit {
|
|
match draft.build_request() {
|
|
Ok(req) => {
|
|
draft.error = None;
|
|
effects.push(Effect::Spawn(req));
|
|
}
|
|
Err(e) => draft.error = Some(e.to_string()),
|
|
}
|
|
}
|
|
if let Some((seq, path)) = draft.take_gitroot_probe(std::time::Instant::now()) {
|
|
effects.push(Effect::ProbeGitRoot { seq, path });
|
|
}
|
|
if draft.take_branch_load().is_some() {
|
|
effects.push(Effect::LoadBranches);
|
|
}
|
|
effects
|
|
}
|
|
|
|
/// Fire any gitroot probe whose debounce window has elapsed (#74). The
|
|
/// 30 Hz heartbeat calls this so a burst of typing issues its one probe
|
|
/// even after the last keystroke.
|
|
fn poll_due_probes_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
|
|
let Some(draft) = self.draft.as_mut() else {
|
|
return Vec::new();
|
|
};
|
|
draft
|
|
.take_gitroot_probe(now)
|
|
.map(|(seq, path)| Effect::ProbeGitRoot { seq, path })
|
|
.into_iter()
|
|
.collect()
|
|
}
|
|
|
|
/// Populate a freshly opened form: presets from `/api/info` (falling back
|
|
/// to the built-ins already in place), projects from `/api/projects`, and
|
|
/// a first name batch. Applies only if the same form is still up — a slow
|
|
/// response must never clobber the one that replaced it.
|
|
fn spawn_load_draft(&self) {
|
|
let Some(draft) = self.draft_id() else {
|
|
return;
|
|
};
|
|
let (daemon, http, base, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
|
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
|
let _ = tx.send(DraftUpdate::Data {
|
|
draft,
|
|
info: info.ok(),
|
|
projects: projects.ok(),
|
|
names,
|
|
});
|
|
});
|
|
}
|
|
|
|
fn spawn_probe_git_root(&self, seq: u32, path: PathBuf) {
|
|
let Some(draft) = self.draft_id() else {
|
|
return;
|
|
};
|
|
let (daemon, _, _, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let root = daemon
|
|
.git_root(&path.to_string_lossy())
|
|
.await
|
|
.ok()
|
|
.and_then(|g| g.root);
|
|
let _ = tx.send(DraftUpdate::GitRoot { draft, seq, root });
|
|
});
|
|
}
|
|
|
|
fn spawn_load_branches(&self) {
|
|
let Some(draft) = self.draft_id() else {
|
|
return;
|
|
};
|
|
let Some(cwd) = self.draft.as_ref().and_then(DraftPane::cwd) else {
|
|
return;
|
|
};
|
|
let (_, http, base, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let branches = fetch_branches(&http, &base, &cwd).await;
|
|
let _ = tx.send(DraftUpdate::Branches {
|
|
draft,
|
|
cwd,
|
|
branches,
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Identity of the form currently up, if any.
|
|
fn draft_id(&self) -> Option<u64> {
|
|
self.draft.as_ref().map(|d| d.id)
|
|
}
|
|
|
|
fn loader_handles(
|
|
&self,
|
|
) -> (
|
|
DaemonClient,
|
|
reqwest::Client,
|
|
String,
|
|
tokio::sync::mpsc::UnboundedSender<DraftUpdate>,
|
|
) {
|
|
(
|
|
self.daemon.clone(),
|
|
self.http.clone(),
|
|
self.daemon.base().to_string(),
|
|
self.draft_tx.clone(),
|
|
)
|
|
}
|
|
|
|
/// Fold a finished background load into the draft that asked for it.
|
|
async fn apply_draft_update(&mut self, update: DraftUpdate) {
|
|
if let DraftUpdate::FastForward {
|
|
info,
|
|
projects,
|
|
names,
|
|
focused_cwd,
|
|
pinned,
|
|
} = update
|
|
{
|
|
self.fast_forward_spawn(info, projects, names, focused_cwd, pinned)
|
|
.await;
|
|
return;
|
|
}
|
|
// Addressed to the bar, not to a form: a load that outlived its bar is
|
|
// dropped rather than filling in the next one.
|
|
if let DraftUpdate::BarDirectories { bar, paths } = update {
|
|
if let Some(open) = self.command_bar.as_mut().filter(|b| b.id() == bar) {
|
|
open.set_directories(&paths);
|
|
}
|
|
return;
|
|
}
|
|
let want = match &update {
|
|
DraftUpdate::Data { draft, .. }
|
|
| DraftUpdate::GitRoot { draft, .. }
|
|
| DraftUpdate::Branches { draft, .. } => *draft,
|
|
// Handled above; they target no particular draft.
|
|
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => unreachable!(),
|
|
};
|
|
// Match by identity: dismissing the form and opening another while a
|
|
// load is in flight must not let the old answer land in the new form.
|
|
let Some(draft) = self.draft.as_mut().filter(|d| d.id == want) else {
|
|
return;
|
|
};
|
|
let mut follow_up_branches = false;
|
|
match update {
|
|
DraftUpdate::Data {
|
|
info,
|
|
projects,
|
|
names,
|
|
..
|
|
} => {
|
|
if let Some(info) = info {
|
|
draft.home_dir.clone_from(&info.home_dir);
|
|
if !info.presets.is_empty() {
|
|
draft.set_presets(info.presets);
|
|
}
|
|
}
|
|
if let Some(projects) = projects {
|
|
draft.set_projects(projects, Utc::now());
|
|
}
|
|
if !names.is_empty() {
|
|
draft.seed_names(names);
|
|
}
|
|
}
|
|
DraftUpdate::GitRoot { seq, root, .. } => {
|
|
draft.resolve_gitroot(seq, root);
|
|
follow_up_branches = draft.take_branch_load().is_some();
|
|
}
|
|
DraftUpdate::Branches { cwd, branches, .. } => {
|
|
draft.set_branches(&cwd, branches);
|
|
}
|
|
DraftUpdate::FastForward { .. } | DraftUpdate::BarDirectories { .. } => {
|
|
unreachable!()
|
|
}
|
|
}
|
|
if follow_up_branches {
|
|
self.spawn_load_branches();
|
|
}
|
|
}
|
|
|
|
/// Fast-forward spawn (#67): one keystroke, every default resolved, no
|
|
/// form. The most recent project, its last command (or the first
|
|
/// non-custom preset), a freshly generated name, worktree on with a new
|
|
/// branch named after that name. Anything unresolvable — no projects
|
|
/// known yet, no command, no name — downgrades to the spawn form seeded
|
|
/// with whatever did resolve.
|
|
/// Load what the fast path needs without blocking the loop — the same
|
|
/// reason the draft loaders were moved off it (#67 review): a cold
|
|
/// `/api/projects` walks all of `$HOME`, and pressing the key must not
|
|
/// freeze keys, frames and attached panes until it returns.
|
|
fn spawn_fast_forward(&self) {
|
|
let focused_cwd = self.focused_seed_cwd();
|
|
let (daemon, http, base, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
|
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
|
let _ = tx.send(DraftUpdate::FastForward {
|
|
info: info.ok(),
|
|
projects: projects.ok(),
|
|
names,
|
|
focused_cwd,
|
|
pinned: None,
|
|
});
|
|
});
|
|
}
|
|
|
|
/// The same fast path, pinned to the directory the command bar's `~` row
|
|
/// named (#154). Off the loop for the same reason as [`Self::
|
|
/// spawn_fast_forward`]: a cold `/api/projects` walks all of `$HOME`.
|
|
fn spawn_fast_forward_in(&self, dir: PathBuf) {
|
|
let (daemon, http, base, tx) = self.loader_handles();
|
|
tokio::spawn(async move {
|
|
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
|
let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await;
|
|
let _ = tx.send(DraftUpdate::FastForward {
|
|
info: info.ok(),
|
|
projects: projects.ok(),
|
|
names,
|
|
focused_cwd: None,
|
|
pinned: Some(dir),
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Fetch `~`'s candidates: the spawn form's own two sources, recent dirs
|
|
/// (newest first) ahead of the project list. Either failing is a shorter
|
|
/// list, not an error state.
|
|
fn spawn_load_bar_directories(&self, bar: u64) {
|
|
let daemon = self.daemon.clone();
|
|
let tx = self.draft_tx.clone();
|
|
tokio::spawn(async move {
|
|
let (recents, projects) = tokio::join!(daemon.recent_dirs(), daemon.list_projects());
|
|
let mut paths: Vec<PathBuf> = recents
|
|
.map(|r| r.dirs.into_iter().map(|d| d.cwd).collect())
|
|
.unwrap_or_default();
|
|
paths.extend(projects.unwrap_or_default().into_iter().map(|p| p.path));
|
|
let _ = tx.send(DraftUpdate::BarDirectories { bar, paths });
|
|
});
|
|
}
|
|
|
|
async fn fast_forward_spawn(
|
|
&mut self,
|
|
info: Option<maestro_protocol::DaemonInfo>,
|
|
projects: Option<Vec<maestro_protocol::Project>>,
|
|
names: Vec<String>,
|
|
focused_cwd: Option<PathBuf>,
|
|
pinned: Option<PathBuf>,
|
|
) {
|
|
// Keep the home dir: without it the pre-filled draft renders
|
|
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
|
// home, so the two draft entry points disagreed (#67 review).
|
|
let home_dir = info
|
|
.as_ref()
|
|
.map(|i| i.home_dir.clone())
|
|
.unwrap_or_default();
|
|
let presets = match info {
|
|
Some(info) if !info.presets.is_empty() => info.presets,
|
|
_ => Vec::new(),
|
|
};
|
|
let discovered = projects.unwrap_or_default();
|
|
// A pinned directory is the operator's own pick, so it takes no
|
|
// fallback: a directory outside the project scan resolves no project,
|
|
// which is an unresolvable default and opens the form seeded with it.
|
|
let resolved = match pinned.as_deref() {
|
|
Some(dir) => {
|
|
crate::fast_forward::resolve_at(dir, &discovered, &presets, names.first().cloned())
|
|
}
|
|
None => crate::fast_forward::resolve(
|
|
&discovered,
|
|
&presets,
|
|
names.first().cloned(),
|
|
Utc::now(),
|
|
focused_cwd.as_deref(),
|
|
),
|
|
};
|
|
if let Some(req) = resolved.build_request() {
|
|
self.submit_spawn(req).await;
|
|
return;
|
|
}
|
|
// Unresolved defaults: the form, pre-filled with what did resolve.
|
|
self.open_spawn_modal();
|
|
let Some(draft) = self.draft.as_mut() else {
|
|
return;
|
|
};
|
|
draft.home_dir = home_dir;
|
|
// The fast path resolved its own project; its pick wins over the
|
|
// focused session's, so the seed never lands on top of it. A pinned
|
|
// directory is the exception: it is the seed, whether or not the scan
|
|
// knew it as a project.
|
|
draft.seed_cwd = pinned;
|
|
if !presets.is_empty() {
|
|
draft.set_presets(presets);
|
|
}
|
|
draft.set_projects(discovered, Utc::now());
|
|
resolved.apply_to_draft(draft);
|
|
// A missing generated name still arrives here; seeding is a no-op
|
|
// once apply_to_draft has set a touched name.
|
|
draft.seed_names(names);
|
|
}
|
|
|
|
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
|
|
match self.daemon.create_session(&req).await {
|
|
Ok(session) => {
|
|
// The form closes and the new session takes the pane, attached
|
|
// right away — the same landing the web modal gives it.
|
|
let sid = session.id;
|
|
if let Some(previous) = self.state.active_session() {
|
|
self.release_session(previous);
|
|
}
|
|
self.draft = None;
|
|
self.state.complete_spawn(sid);
|
|
if session.view == SessionView::Structured {
|
|
self.state.mode = Mode::Normal;
|
|
}
|
|
self.attach_with_view(sid, session.view);
|
|
}
|
|
Err(e) => {
|
|
// A failed POST belongs in the form it came from (#64), not a
|
|
// toast that dismisses on the next keystroke.
|
|
let message = e
|
|
.daemon_message()
|
|
.map(String::from)
|
|
.unwrap_or_else(|| e.to_string());
|
|
if let Some(draft) = self.draft.as_mut() {
|
|
draft.error = Some(format!("spawn failed: {message}"));
|
|
} else {
|
|
self.state.toast(format!("spawn failed: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn submit_kill(&mut self, sid: Uuid) {
|
|
match self.daemon.kill_session(sid, false).await {
|
|
Ok(()) => self.release_session(sid),
|
|
Err(e) => self.state.toast(format!("kill failed: {e}")),
|
|
}
|
|
}
|
|
|
|
/// 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}"));
|
|
}
|
|
}
|
|
|
|
/// Report a pane-focus change to the daemon (docs/SIDEBAR.md's
|
|
/// `AwaitingInput` auto-clear). Only `Focus::Pane` on the active tab's
|
|
/// session counts — the sidebar highlight is never a "focused pane".
|
|
/// Best-effort: a failed report only delays the glow clearing, so
|
|
/// nothing here surfaces to the user.
|
|
async fn sync_focus(&mut self) {
|
|
let wanted = matches!(self.state.focus, Focus::Pane)
|
|
.then(|| self.state.active_session())
|
|
.flatten();
|
|
if wanted == self.focused_session {
|
|
return;
|
|
}
|
|
if let Some(previous) = self.focused_session {
|
|
let _ = self.daemon.set_focus(previous, false).await;
|
|
}
|
|
if let Some(id) = wanted {
|
|
let _ = self.daemon.set_focus(id, true).await;
|
|
}
|
|
self.focused_session = wanted;
|
|
}
|
|
|
|
/// Sample everything a frame depends on. Cheap: two atomic loads, an
|
|
/// `RwLock` read of a `u64`, and the backend's size.
|
|
fn frame_key(&self, area: ratatui::layout::Size) -> FrameKey {
|
|
let pane = self.state.displayed_session().and_then(|sid| {
|
|
self.emulators
|
|
.get(sid)
|
|
.map(|h| (sid, h.revision(), h.state()))
|
|
.or_else(|| {
|
|
self.structured
|
|
.get(&sid)
|
|
.map(|h| (sid, h.revision(), h.connection()))
|
|
})
|
|
});
|
|
FrameKey {
|
|
catalog: self.catalog.version(),
|
|
epoch: self.epoch,
|
|
pane,
|
|
area,
|
|
}
|
|
}
|
|
|
|
/// Repaint only when the frame key moved, or the clock refresh is due.
|
|
fn redraw_if_stale<B: ratatui::backend::Backend>(
|
|
&mut self,
|
|
terminal: &mut Terminal<B>,
|
|
) -> Result<(), TuiError> {
|
|
// Polled rather than driven off `Event::Resize`: `Terminal::draw`'s own
|
|
// autoresize only runs on a draw, and a skipped draw must still notice
|
|
// the terminal changed shape.
|
|
let area = terminal
|
|
.size()
|
|
.map_err(|e| TuiError::Terminal(e.to_string()))?;
|
|
let key = self.frame_key(area);
|
|
if self.last_frame == Some(key) && self.last_drawn_at.elapsed() < CLOCK_REFRESH {
|
|
return Ok(());
|
|
}
|
|
self.redraw(terminal)?;
|
|
self.last_frame = Some(key);
|
|
self.last_drawn_at = std::time::Instant::now();
|
|
Ok(())
|
|
}
|
|
|
|
fn redraw<B: ratatui::backend::Backend>(
|
|
&mut self,
|
|
terminal: &mut Terminal<B>,
|
|
) -> Result<(), TuiError> {
|
|
let snap = self.catalog.snapshot();
|
|
let attached: HashSet<Uuid> = self
|
|
.state
|
|
.tabs
|
|
.iter()
|
|
.filter_map(crate::state::Tab::session)
|
|
.collect();
|
|
let sidebar = Sidebar {
|
|
sessions: snap.sessions.clone(),
|
|
highlight: self.state.sidebar_highlight.clone(),
|
|
attached,
|
|
// The active tab's session, not the displayed one: a preview is
|
|
// shown in the pane but never becomes what the operator picked,
|
|
// so it must not move this fill (`docs/PREVIEW.md`).
|
|
pane_session: self.state.active_session(),
|
|
};
|
|
let shown_sid = self.state.displayed_session();
|
|
let previewing = self.state.preview.is_some();
|
|
let pane = shown_sid.and_then(|sid| {
|
|
self.emulators.get(sid).map(|h| Pane {
|
|
screen: h.screen.clone(),
|
|
status: PaneStatus::from(h.state()),
|
|
// A preview never owns the keyboard, so it never draws a
|
|
// cursor either (`docs/PREVIEW.md`).
|
|
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
|
})
|
|
});
|
|
let structured_pane = shown_sid.and_then(|sid| {
|
|
let handle = self.structured.get(&sid)?;
|
|
let session = snap.sessions.iter().find(|session| session.id == sid)?;
|
|
Some(StructuredPane {
|
|
handle: handle.clone(),
|
|
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
|
mode: self.state.mode,
|
|
session_status: session.status,
|
|
activity_detail: session.activity_detail.clone(),
|
|
})
|
|
});
|
|
let pane_title = match shown_sid {
|
|
Some(sid) => {
|
|
let name = snap
|
|
.sessions
|
|
.iter()
|
|
.find(|session| session.id == sid)
|
|
.map(crate::sidebar::name_for)
|
|
.unwrap_or_else(|| "session".into());
|
|
if previewing {
|
|
format!("{name} · preview")
|
|
} else {
|
|
name
|
|
}
|
|
}
|
|
None => "session".into(),
|
|
};
|
|
|
|
let link_status = self.link.status();
|
|
let inputs = RenderInputs {
|
|
sidebar: &sidebar,
|
|
home_dir: &self.home_dir,
|
|
pane_title: &pane_title,
|
|
pane: pane.as_ref(),
|
|
structured_pane: structured_pane.as_ref(),
|
|
state: &self.state,
|
|
draft: self.draft.as_ref(),
|
|
help: &self.help,
|
|
rename_input: &self.rename_input,
|
|
command_bar: self.command_bar.as_ref(),
|
|
link: &link_status,
|
|
};
|
|
|
|
let mut new_region = RegionMap::default();
|
|
terminal
|
|
.draw(|f| {
|
|
new_region = draw(f, inputs);
|
|
})
|
|
.map_err(|e| TuiError::Terminal(e.to_string()))?;
|
|
self.region = new_region;
|
|
|
|
// Resize propagation: record the active pane's rect, recompute the
|
|
// per-dimension min for every attached session, send Resize when
|
|
// changed.
|
|
if let (Some(sid), Some(rect)) = (shown_sid, self.region.pane) {
|
|
self.emulators.resize_tracker_mut().set_pane_size(
|
|
sid,
|
|
"active",
|
|
(rect.width, rect.height),
|
|
);
|
|
}
|
|
let attached_sids: Vec<Uuid> = self.emulators.ids().collect();
|
|
for sid in attached_sids {
|
|
if let Some(size) = self.emulators.resize_tracker().session_size(sid) {
|
|
if self.last_sent_size.get(&sid).copied() != Some(size) {
|
|
if let Some(h) = self.emulators.get(sid) {
|
|
let _ = h.tx.try_send(SessionInput::Resize {
|
|
cols: size.0,
|
|
rows: size.1,
|
|
});
|
|
}
|
|
self.last_sent_size.insert(sid, size);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
collapsed: &std::collections::HashSet<PathBuf>,
|
|
) -> Vec<SidebarItem> {
|
|
Sidebar {
|
|
sessions: snap.sessions.clone(),
|
|
highlight: None,
|
|
attached: HashSet::new(),
|
|
pane_session: None,
|
|
}
|
|
.rendered_items_with_collapsed(collapsed)
|
|
}
|
|
|
|
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
|
/// with no byte representation (mouse events). A bracketed paste is forwarded
|
|
/// verbatim — the remote readline reassembles it, since the pane's PTY also
|
|
/// has bracketed paste enabled.
|
|
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
|
match key {
|
|
InputKey::Char { c, ctrl, alt, .. } => {
|
|
let mut out = Vec::with_capacity(8);
|
|
if *alt {
|
|
out.push(0x1b);
|
|
}
|
|
if *ctrl && c.is_ascii_alphabetic() {
|
|
out.push((c.to_ascii_uppercase() as u8) - b'@');
|
|
} else {
|
|
let mut b = [0u8; 4];
|
|
let s = c.encode_utf8(&mut b);
|
|
out.extend_from_slice(s.as_bytes());
|
|
}
|
|
Some(out)
|
|
}
|
|
InputKey::Enter => Some(b"\r".to_vec()),
|
|
InputKey::Tab => Some(b"\t".to_vec()),
|
|
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
|
|
InputKey::Backspace => Some(vec![0x7f]),
|
|
InputKey::Esc => Some(vec![0x1b]),
|
|
InputKey::Up => Some(b"\x1b[A".to_vec()),
|
|
InputKey::Down => Some(b"\x1b[B".to_vec()),
|
|
InputKey::Right => Some(b"\x1b[C".to_vec()),
|
|
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
|
|
// to an app that enabled bracketed paste, so a multi-line paste
|
|
// would execute line by line instead of arriving as one block.
|
|
let mut out = Vec::with_capacity(text.len() + 12);
|
|
out.extend_from_slice(b"\x1b[200~");
|
|
out.extend_from_slice(text.as_bytes());
|
|
out.extend_from_slice(b"\x1b[201~");
|
|
Some(out)
|
|
}
|
|
InputKey::Mouse { .. } => None,
|
|
}
|
|
}
|
|
|
|
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).
|
|
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.help_label()));
|
|
}
|
|
out.push(String::new());
|
|
out
|
|
}
|
|
let leader = cfg.leader.canonical();
|
|
let mut lines = vec![
|
|
format!("Leader: {leader} (return to sidebar)"),
|
|
String::new(),
|
|
];
|
|
lines.extend(section(Mode::Normal, "Normal", cfg));
|
|
lines.extend(section(Mode::Insert, "Insert", cfg));
|
|
lines.push(String::from("(any key dismisses this overlay)"));
|
|
lines
|
|
}
|
|
|
|
/// A completed background load, applied on the event loop.
|
|
#[derive(Debug)]
|
|
enum DraftUpdate {
|
|
Data {
|
|
draft: u64,
|
|
info: Option<maestro_protocol::DaemonInfo>,
|
|
projects: Option<Vec<maestro_protocol::Project>>,
|
|
names: Vec<String>,
|
|
},
|
|
GitRoot {
|
|
draft: u64,
|
|
seq: u32,
|
|
root: Option<PathBuf>,
|
|
},
|
|
Branches {
|
|
draft: u64,
|
|
cwd: PathBuf,
|
|
branches: Vec<BranchInfo>,
|
|
},
|
|
/// A fast-forward's data arrived; resolution and spawn happen on the loop.
|
|
FastForward {
|
|
info: Option<maestro_protocol::DaemonInfo>,
|
|
projects: Option<Vec<maestro_protocol::Project>>,
|
|
names: Vec<String>,
|
|
focused_cwd: Option<PathBuf>,
|
|
/// The command bar pinned the spawn to this directory (#154): nothing
|
|
/// — not the focused session, not the recency ranking — may move it.
|
|
pinned: Option<PathBuf>,
|
|
},
|
|
/// The command bar's `~` candidates arrived (#154).
|
|
BarDirectories { bar: u64, paths: Vec<PathBuf> },
|
|
}
|
|
|
|
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
|
|
async fn fetch_names(http: &reqwest::Client, base: &str, count: usize) -> Vec<String> {
|
|
let url = format!(
|
|
"{}{}",
|
|
maestro_protocol::routes::normalize_base(base),
|
|
routes::NAMES
|
|
);
|
|
match http
|
|
.get(url)
|
|
.query(&[(routes::COUNT_QUERY, count)])
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) => resp
|
|
.json::<NameBatch>()
|
|
.await
|
|
.map(|batch| batch.names)
|
|
.unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// `GET /api/branches?path=`. On failure the picker stays empty and "new
|
|
/// branch" mode still works.
|
|
async fn fetch_branches(
|
|
http: &reqwest::Client,
|
|
base: &str,
|
|
path: &std::path::Path,
|
|
) -> Vec<BranchInfo> {
|
|
let url = format!(
|
|
"{}{}",
|
|
maestro_protocol::routes::normalize_base(base),
|
|
routes::BRANCHES
|
|
);
|
|
match http
|
|
.get(url)
|
|
.query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())])
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) => resp.json::<Vec<BranchInfo>>().await.unwrap_or_default(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::action::Action;
|
|
use crate::draft::DraftField;
|
|
use crate::keymap::Mode;
|
|
use maestro_config::TuiSection;
|
|
|
|
fn make_app() -> App {
|
|
make_app_at("http://127.0.0.1:7070")
|
|
}
|
|
|
|
fn make_app_at(base: &str) -> App {
|
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
|
App::new(
|
|
cfg,
|
|
CatalogStore::default(),
|
|
DaemonClient::new(base),
|
|
DaemonLink::default(),
|
|
)
|
|
}
|
|
|
|
/// Counts how many times the loop actually repainted, by watching the
|
|
/// `TestBackend` buffer's generation via `draw` calls.
|
|
fn drew(app: &mut App, terminal: &mut Terminal<ratatui::backend::TestBackend>) -> bool {
|
|
let before = app.last_drawn_at;
|
|
app.redraw_if_stale(terminal).unwrap();
|
|
app.last_drawn_at != before
|
|
}
|
|
|
|
#[test]
|
|
fn unchanged_frame_is_not_repainted() {
|
|
let mut app = make_app();
|
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
|
|
|
assert!(drew(&mut app, &mut terminal), "first frame must paint");
|
|
assert!(
|
|
!drew(&mut app, &mut terminal),
|
|
"nothing changed; the repaint should be skipped"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_processed_key_makes_the_frame_stale() {
|
|
let mut app = make_app();
|
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
|
drew(&mut app, &mut terminal);
|
|
|
|
app.epoch = app.epoch.wrapping_add(1);
|
|
assert!(drew(&mut app, &mut terminal));
|
|
}
|
|
|
|
#[test]
|
|
fn a_catalog_event_makes_the_frame_stale() {
|
|
let mut app = make_app();
|
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
|
drew(&mut app, &mut terminal);
|
|
|
|
app.catalog
|
|
.apply_event(maestro_protocol::CatalogEvent::Snapshot {
|
|
sessions: Vec::new(),
|
|
});
|
|
assert!(drew(&mut app, &mut terminal));
|
|
}
|
|
|
|
#[test]
|
|
fn a_resize_makes_the_frame_stale() {
|
|
let mut app = make_app();
|
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
|
drew(&mut app, &mut terminal);
|
|
|
|
terminal.backend_mut().resize(120, 40);
|
|
assert!(drew(&mut app, &mut terminal));
|
|
}
|
|
|
|
#[test]
|
|
fn pane_output_makes_the_frame_stale() {
|
|
use crate::session_task::{SessionHandle, SessionState};
|
|
use std::sync::atomic::{AtomicU64, AtomicU8};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
let mut app = make_app();
|
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
|
|
|
let sid = Uuid::from_u128(7);
|
|
let revision = Arc::new(AtomicU64::new(0));
|
|
app.emulators.insert(
|
|
sid,
|
|
SessionHandle {
|
|
tx: tokio::sync::mpsc::channel(1).0,
|
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
|
revision: revision.clone(),
|
|
},
|
|
);
|
|
app.state.attach_to_focused(sid);
|
|
drew(&mut app, &mut terminal);
|
|
assert!(!drew(&mut app, &mut terminal), "idle pane must not repaint");
|
|
|
|
revision.fetch_add(1, std::sync::atomic::Ordering::Release);
|
|
assert!(drew(&mut app, &mut terminal), "new PTY bytes must repaint");
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_clock_repaints_even_with_no_events() {
|
|
let mut app = make_app();
|
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
|
drew(&mut app, &mut terminal);
|
|
assert!(!drew(&mut app, &mut terminal));
|
|
|
|
// Session cards show an advancing duration, so a frame may not stand
|
|
// for longer than CLOCK_REFRESH regardless of the key.
|
|
app.last_drawn_at = std::time::Instant::now() - CLOCK_REFRESH;
|
|
assert!(drew(&mut app, &mut terminal));
|
|
}
|
|
|
|
#[test]
|
|
fn enter_insert_then_leave() {
|
|
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);
|
|
assert_eq!(app.state.focus, Focus::Sidebar);
|
|
}
|
|
|
|
#[test]
|
|
fn show_help_opens_overlay() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::ShowHelp);
|
|
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("OpenSpawnModal"));
|
|
assert!(!help.contains("ToggleSidebarGroup"));
|
|
}
|
|
|
|
#[test]
|
|
fn open_spawn_modal_puts_the_form_up_and_asks_for_its_data() {
|
|
let mut app = make_app();
|
|
let effects = app.dispatch(Action::OpenSpawnModal);
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
|
assert!(app.draft.is_some());
|
|
assert_eq!(effects, vec![Effect::LoadDraft]);
|
|
}
|
|
|
|
/// The modal covers the attached session rather than evicting it: Escape
|
|
/// gives back exactly what was there, still attached.
|
|
#[test]
|
|
fn spawning_over_a_session_leaves_it_attached_behind_the_modal() {
|
|
let sid = Uuid::from_u128(10);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
app.dispatch(Action::AttachHighlighted);
|
|
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
assert_eq!(app.state.active_session(), Some(sid));
|
|
|
|
app.handle_overlay_input(&InputKey::Esc);
|
|
assert!(app.state.overlay().is_none());
|
|
assert!(app.draft.is_none());
|
|
assert_eq!(app.state.active_session(), Some(sid));
|
|
}
|
|
|
|
/// While the form is up the keymap must not see its keystrokes: `x` is a
|
|
/// kill binding in Normal mode, and typing it into a name would fire it.
|
|
#[test]
|
|
fn the_modal_swallows_keys_the_keymap_would_otherwise_claim() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
|
c: 'x',
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
});
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert!(effects.is_empty());
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
|
}
|
|
|
|
/// Seed the catalog with `ids` as sessions in one directory.
|
|
fn app_with_sessions(ids: &[Uuid]) -> App {
|
|
use chrono::Utc;
|
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
|
let app = make_app();
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: ids
|
|
.iter()
|
|
.map(|id| Session {
|
|
view: Default::default(),
|
|
id: *id,
|
|
command: "bash".into(),
|
|
cwd: std::path::PathBuf::from("/"),
|
|
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: chrono::Utc::now(),
|
|
current_cwd: None,
|
|
blocked_work: None,
|
|
last_focused_at: None,
|
|
acp_session_id: None,
|
|
})
|
|
.collect(),
|
|
});
|
|
app
|
|
}
|
|
|
|
fn fake_handle() -> crate::session_task::SessionHandle {
|
|
use crate::session_task::{SessionHandle, SessionState};
|
|
use std::sync::atomic::{AtomicU64, AtomicU8};
|
|
use std::sync::{Arc, Mutex};
|
|
SessionHandle {
|
|
tx: tokio::sync::mpsc::channel(1).0,
|
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
|
revision: Arc::new(AtomicU64::new(0)),
|
|
}
|
|
}
|
|
|
|
fn after_gap() -> std::time::Instant {
|
|
std::time::Instant::now() + PREVIEW_GAP + Duration::from_millis(1)
|
|
}
|
|
|
|
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
|
|
/// tab taking it on.
|
|
#[test]
|
|
fn highlighting_a_session_previews_it_without_waiting() {
|
|
let a = Uuid::from_u128(1);
|
|
let mut app = app_with_sessions(&[a]);
|
|
app.dispatch(Action::SidebarNext);
|
|
|
|
// The list was still, so the pane follows on the next poll — no gap to
|
|
// wait out first (docs/PREVIEW.md).
|
|
assert_eq!(
|
|
app.poll_due_preview_at(std::time::Instant::now()),
|
|
vec![Effect::Attach(a)]
|
|
);
|
|
assert_eq!(app.state.preview, Some(a));
|
|
assert_eq!(app.state.displayed_session(), Some(a));
|
|
// The tab is untouched — the preview is a view, not a layout change.
|
|
assert_eq!(app.state.active_tab().session(), None);
|
|
}
|
|
|
|
/// The gap only bounds the *second* swap, so a burst costs two mounts.
|
|
#[test]
|
|
fn a_second_move_inside_the_gap_waits_for_it() {
|
|
let a = Uuid::from_u128(1);
|
|
let b = Uuid::from_u128(2);
|
|
let mut app = app_with_sessions(&[a, b]);
|
|
|
|
app.dispatch(Action::SidebarNext);
|
|
let now = std::time::Instant::now();
|
|
assert_eq!(app.poll_due_preview_at(now), vec![Effect::Attach(a)]);
|
|
|
|
app.dispatch(Action::SidebarNext);
|
|
assert_eq!(app.poll_due_preview_at(now), vec![]);
|
|
assert_eq!(app.state.preview, Some(a));
|
|
|
|
assert_eq!(
|
|
app.poll_due_preview_at(after_gap()),
|
|
vec![Effect::Attach(b)]
|
|
);
|
|
assert_eq!(app.state.preview, Some(b));
|
|
}
|
|
|
|
/// A fast walk down the list attaches to where it stopped, not to every
|
|
/// row it crossed.
|
|
#[test]
|
|
fn walking_past_rows_only_previews_the_one_it_lands_on() {
|
|
let a = Uuid::from_u128(1);
|
|
let b = Uuid::from_u128(2);
|
|
let mut app = app_with_sessions(&[a, b]);
|
|
app.dispatch(Action::SidebarNext);
|
|
app.dispatch(Action::SidebarNext);
|
|
|
|
let effects = app.poll_due_preview_at(after_gap());
|
|
assert_eq!(effects, vec![Effect::Attach(b)]);
|
|
assert_eq!(app.state.preview, Some(b));
|
|
}
|
|
|
|
/// The pane's own session is not something to preview.
|
|
#[test]
|
|
fn highlighting_the_attached_session_clears_the_preview() {
|
|
let a = Uuid::from_u128(1);
|
|
let b = Uuid::from_u128(2);
|
|
let mut app = app_with_sessions(&[a, b]);
|
|
app.state.attach_to_focused(a);
|
|
app.state.focus = Focus::Sidebar;
|
|
|
|
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
|
app.refresh_preview();
|
|
app.poll_due_preview_at(after_gap());
|
|
assert_eq!(app.state.preview, Some(b));
|
|
|
|
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
|
|
app.refresh_preview();
|
|
assert_eq!(app.state.preview, None);
|
|
assert_eq!(app.state.displayed_session(), Some(a));
|
|
}
|
|
|
|
/// Moving off every session — onto a group row, or into the pane — puts
|
|
/// back what the tab was showing.
|
|
#[test]
|
|
fn leaving_the_session_rows_ends_the_preview() {
|
|
let a = Uuid::from_u128(1);
|
|
let b = Uuid::from_u128(2);
|
|
let mut app = app_with_sessions(&[a, b]);
|
|
app.state.attach_to_focused(a);
|
|
app.state.focus = Focus::Sidebar;
|
|
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
|
|
app.refresh_preview();
|
|
app.poll_due_preview_at(after_gap());
|
|
|
|
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
|
|
assert_eq!(app.state.preview, None);
|
|
assert_eq!(app.state.displayed_session(), Some(a));
|
|
}
|
|
|
|
/// Committing keeps the stream the preview already opened.
|
|
#[test]
|
|
fn attaching_to_a_previewed_session_reuses_its_stream() {
|
|
let a = Uuid::from_u128(1);
|
|
let mut app = app_with_sessions(&[a]);
|
|
app.dispatch(Action::SidebarNext);
|
|
app.poll_due_preview_at(after_gap());
|
|
// Stand in for the attach the effect would have opened.
|
|
app.emulators.insert(a, fake_handle());
|
|
|
|
let effects = app.dispatch(Action::AttachHighlighted);
|
|
assert_eq!(effects, vec![]);
|
|
assert_eq!(app.state.active_tab().session(), Some(a));
|
|
assert_eq!(app.state.preview, None);
|
|
// No longer on loan from the preview budget.
|
|
assert!(!app.preview_lru.contains(&a));
|
|
}
|
|
|
|
/// Preview-only attaches are kept for a step back, but bounded.
|
|
#[test]
|
|
fn preview_attaches_are_dropped_past_the_budget() {
|
|
let ids: Vec<Uuid> = (1..=(PREVIEW_BUDGET as u128 + 2))
|
|
.map(Uuid::from_u128)
|
|
.collect();
|
|
let mut app = app_with_sessions(&ids);
|
|
// Each row is previewed in its own gap window, so all of them land.
|
|
let mut clock = std::time::Instant::now();
|
|
for id in &ids {
|
|
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
|
|
app.refresh_preview();
|
|
clock += PREVIEW_GAP + Duration::from_millis(1);
|
|
app.poll_due_preview_at(clock);
|
|
app.emulators.insert(*id, fake_handle());
|
|
}
|
|
|
|
assert_eq!(app.preview_lru.len(), PREVIEW_BUDGET);
|
|
// The oldest rows previewed are gone; the newest are still warm.
|
|
assert!(app.emulators.get(ids[0]).is_none());
|
|
assert!(app.emulators.get(*ids.last().unwrap()).is_some());
|
|
}
|
|
|
|
/// Seed the catalog with one session and highlight it.
|
|
fn app_with_highlighted_session(sid: Uuid) -> App {
|
|
use chrono::Utc;
|
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
|
let mut app = make_app();
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![Session {
|
|
view: Default::default(),
|
|
id: sid,
|
|
command: "bash".into(),
|
|
cwd: std::path::PathBuf::from("/"),
|
|
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: chrono::Utc::now(),
|
|
current_cwd: None,
|
|
blocked_work: None,
|
|
last_focused_at: None,
|
|
acp_session_id: None,
|
|
}],
|
|
});
|
|
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 {
|
|
view: Default::default(),
|
|
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,
|
|
last_focused_at: None,
|
|
acp_session_id: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn attach_highlighted_asks_for_an_attach() {
|
|
let sid = Uuid::from_u128(11);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
assert_eq!(
|
|
app.dispatch(Action::AttachHighlighted),
|
|
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")],
|
|
});
|
|
// `j` never lands on an expanded group, so reach it the way a click
|
|
// would.
|
|
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 sidebar_navigation_skips_expanded_group_headers() {
|
|
use maestro_protocol::CatalogEvent;
|
|
|
|
let a = Uuid::from_u128(20);
|
|
let b = Uuid::from_u128(21);
|
|
let mut app = make_app();
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![test_session(a, "/one"), test_session(b, "/two")],
|
|
});
|
|
|
|
// Two groups, one card each: `j` visits the cards and nothing else.
|
|
app.dispatch(Action::SidebarNext);
|
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
|
app.dispatch(Action::SidebarNext);
|
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
|
app.dispatch(Action::SidebarNext);
|
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
|
app.dispatch(Action::SidebarPrev);
|
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
|
}
|
|
|
|
/// A collapsed group hides its cards, so its header is the only row left
|
|
/// to land on — skipping it would strand the group.
|
|
#[test]
|
|
fn sidebar_navigation_stops_on_a_collapsed_group() {
|
|
use maestro_protocol::CatalogEvent;
|
|
|
|
let a = Uuid::from_u128(22);
|
|
let b = Uuid::from_u128(23);
|
|
let mut app = make_app();
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![test_session(a, "/one"), test_session(b, "/two")],
|
|
});
|
|
app.dispatch(Action::SidebarNext);
|
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
|
// `c` on a card collapses the group that holds it.
|
|
app.dispatch(Action::ToggleSidebarGroup);
|
|
assert_eq!(
|
|
app.state.sidebar_highlight,
|
|
Some(SidebarItem::Group(PathBuf::from("/one")))
|
|
);
|
|
|
|
app.dispatch(Action::SidebarNext);
|
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
|
app.dispatch(Action::SidebarNext);
|
|
assert_eq!(
|
|
app.state.sidebar_highlight,
|
|
Some(SidebarItem::Group(PathBuf::from("/one")))
|
|
);
|
|
}
|
|
|
|
#[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]
|
|
fn confirming_a_kill_asks_for_the_kill() {
|
|
let sid = Uuid::from_u128(12);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
app.dispatch(Action::KillHighlighted);
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
|
c: 'y',
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
});
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert_eq!(effects, vec![Effect::Kill(sid)]);
|
|
assert!(app.state.overlay().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn declining_a_kill_asks_for_nothing() {
|
|
let sid = Uuid::from_u128(13);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
app.dispatch(Action::KillHighlighted);
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert!(effects.is_empty());
|
|
assert!(app.state.overlay().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn opening_the_spawn_modal_schedules_a_data_load() {
|
|
let mut app = make_app();
|
|
assert_eq!(
|
|
app.dispatch(Action::OpenSpawnModal),
|
|
vec![Effect::LoadDraft]
|
|
);
|
|
}
|
|
|
|
/// The fast-forward keystroke itself is side-effect-free until its
|
|
/// daemon fetches come back.
|
|
#[test]
|
|
fn fast_forward_spawn_dispatches_the_fast_forward_effect() {
|
|
let mut app = make_app();
|
|
assert_eq!(
|
|
app.dispatch(Action::FastForwardSpawn),
|
|
vec![Effect::FastForward]
|
|
);
|
|
}
|
|
|
|
/// Every default resolves: the POST goes straight out — most recent
|
|
/// project, its last command, generated name, worktree branch named
|
|
/// after it (#67).
|
|
#[tokio::test]
|
|
async fn fast_forward_spawns_immediately_when_every_default_resolves() {
|
|
use axum::routing::{get, post};
|
|
use axum::Json;
|
|
use serde_json::json;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
async fn info() -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
|
|
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
|
}))
|
|
}
|
|
async fn projects() -> Json<serde_json::Value> {
|
|
Json(json!([
|
|
{"path": "/repos/old", "name": "old",
|
|
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "zsh"},
|
|
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
|
|
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"}
|
|
]))
|
|
}
|
|
async fn names() -> Json<serde_json::Value> {
|
|
Json(json!({"names": ["crimson-otter"]}))
|
|
}
|
|
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
|
|
let seen = posted.clone();
|
|
async fn spawn_session(
|
|
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> Json<serde_json::Value> {
|
|
seen.lock().unwrap().push(body);
|
|
Json(json!({
|
|
"id": uuid::Uuid::from_u128(77).to_string(),
|
|
"cwd": "/repos/fresh", "command": "aider",
|
|
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
|
|
}))
|
|
}
|
|
|
|
let app_router = axum::Router::new()
|
|
.route(maestro_protocol::routes::INFO, get(info))
|
|
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
|
.route(maestro_protocol::routes::NAMES, get(names))
|
|
.route(
|
|
maestro_protocol::routes::SESSIONS,
|
|
post(spawn_session).with_state(seen),
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
|
|
|
let mut app = make_app_at(&base);
|
|
let effects = app.dispatch(Action::FastForwardSpawn);
|
|
// The fast path loads off the event loop now; the run loop drains the
|
|
// channel, so do that by hand.
|
|
app.perform(effects).await;
|
|
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
|
.await
|
|
.expect("load completed")
|
|
.expect("channel open");
|
|
app.apply_draft_update(update).await;
|
|
|
|
let posts = posted.lock().unwrap();
|
|
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
|
|
let req = &posts[0];
|
|
assert_eq!(req["cwd"], json!("/repos/fresh"));
|
|
assert_eq!(req["command"], json!("aider"));
|
|
// #131: the fast path names no session; the daemon does.
|
|
assert_eq!(req["metadata"], json!({}));
|
|
assert_eq!(req["worktree"]["base_repo"], json!("/repos/fresh"));
|
|
assert_eq!(req["worktree"]["branch"], json!("crimson-otter"));
|
|
assert_eq!(req["worktree"]["create_branch"], json!(true));
|
|
}
|
|
|
|
/// The command bar's `~` pins the spawn: the directory the operator named
|
|
/// wins over the recency ranking that plain fast-forward would follow.
|
|
#[tokio::test]
|
|
async fn a_pinned_directory_spawns_there_not_in_the_freshest_project() {
|
|
use axum::routing::{get, post};
|
|
use axum::Json;
|
|
use serde_json::json;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
async fn info() -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
|
|
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
|
}))
|
|
}
|
|
async fn projects() -> Json<serde_json::Value> {
|
|
Json(json!([
|
|
{"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh",
|
|
"last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"},
|
|
{"path": "/repos/pinned", "name": "pinned", "base_repo": "/repos/pinned",
|
|
"last_activity": (chrono::Utc::now() - chrono::TimeDelta::days(3)).to_rfc3339(),
|
|
"last_command": "zsh"}
|
|
]))
|
|
}
|
|
async fn names() -> Json<serde_json::Value> {
|
|
Json(json!({"names": ["crimson-otter"]}))
|
|
}
|
|
let posted: Arc<Mutex<Vec<serde_json::Value>>> = Arc::default();
|
|
let seen = posted.clone();
|
|
async fn spawn_session(
|
|
axum::extract::State(seen): axum::extract::State<Arc<Mutex<Vec<serde_json::Value>>>>,
|
|
Json(body): Json<serde_json::Value>,
|
|
) -> Json<serde_json::Value> {
|
|
seen.lock().unwrap().push(body);
|
|
Json(json!({
|
|
"id": uuid::Uuid::from_u128(78).to_string(),
|
|
"cwd": "/repos/pinned", "command": "zsh",
|
|
"status": "Running", "created_at": chrono::Utc::now().to_rfc3339()
|
|
}))
|
|
}
|
|
|
|
let app_router = axum::Router::new()
|
|
.route(maestro_protocol::routes::INFO, get(info))
|
|
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
|
.route(maestro_protocol::routes::NAMES, get(names))
|
|
.route(
|
|
maestro_protocol::routes::SESSIONS,
|
|
post(spawn_session).with_state(seen),
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
|
|
|
let mut app = make_app_at(&base);
|
|
app.perform(vec![Effect::FastForwardIn(PathBuf::from("/repos/pinned"))])
|
|
.await;
|
|
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
|
.await
|
|
.expect("load completed")
|
|
.expect("channel open");
|
|
app.apply_draft_update(update).await;
|
|
|
|
let posts = posted.lock().unwrap();
|
|
assert_eq!(posts.len(), 1, "one immediate POST, no form shown");
|
|
assert_eq!(posts[0]["cwd"], json!("/repos/pinned"));
|
|
assert_eq!(posts[0]["command"], json!("zsh"));
|
|
}
|
|
|
|
/// A directory the project scan does not know resolves no project, which
|
|
/// is an unresolvable default: the form opens seeded with it rather than
|
|
/// the spawn silently landing somewhere else.
|
|
#[tokio::test]
|
|
async fn a_pinned_directory_outside_the_scan_opens_the_prefilled_form() {
|
|
let mut app = make_app();
|
|
app.fast_forward_spawn(
|
|
None,
|
|
Some(vec![maestro_protocol::Project {
|
|
path: PathBuf::from("/repos/fresh"),
|
|
name: "fresh".into(),
|
|
is_worktree: false,
|
|
base_repo: None,
|
|
last_activity: Some(Utc::now()),
|
|
last_command: Some("aider".into()),
|
|
}]),
|
|
vec!["crimson-otter".into()],
|
|
None,
|
|
Some(PathBuf::from("/elsewhere")),
|
|
)
|
|
.await;
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
|
let draft = app.draft.as_ref().expect("form is up");
|
|
// The seed is dropped by the picker when no project matches it, so
|
|
// the operator picks by hand — but nothing spawned in `/repos/fresh`.
|
|
assert_ne!(draft.picked_cwd, Some(PathBuf::from("/repos/fresh")));
|
|
}
|
|
|
|
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
|
|
/// switching which session the pane shows, and leaving the pane back to
|
|
/// the sidebar. A sidebar highlight alone must report nothing.
|
|
#[tokio::test]
|
|
async fn sync_focus_reports_pane_focus_changes_to_the_daemon() {
|
|
use axum::extract::Path;
|
|
use axum::routing::post;
|
|
use axum::Json;
|
|
use serde_json::{json, Value};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
type PostedFocus = Arc<Mutex<Vec<(Uuid, Value)>>>;
|
|
|
|
let posted: PostedFocus = Arc::default();
|
|
let seen = posted.clone();
|
|
async fn set_focus(
|
|
axum::extract::State(seen): axum::extract::State<PostedFocus>,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<Value>,
|
|
) -> axum::http::StatusCode {
|
|
seen.lock().unwrap().push((id, body));
|
|
axum::http::StatusCode::NO_CONTENT
|
|
}
|
|
|
|
let app_router = axum::Router::new().route(
|
|
maestro_protocol::routes::patterns::SESSION_FOCUS,
|
|
post(set_focus).with_state(seen),
|
|
);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
|
|
|
let sid_a = Uuid::from_u128(1);
|
|
let sid_b = Uuid::from_u128(2);
|
|
let mut app = make_app_at(&base);
|
|
|
|
// A sidebar highlight alone is not a focused pane.
|
|
app.state.sidebar_highlight = Some(SidebarItem::Session(sid_a));
|
|
app.sync_focus().await;
|
|
assert!(posted.lock().unwrap().is_empty());
|
|
|
|
// Attaching moves focus into the pane.
|
|
app.state.attach_to_focused(sid_a);
|
|
app.sync_focus().await;
|
|
|
|
// Switching which session the pane shows unfocuses the old one first.
|
|
app.state.active_tab_mut().pane = Some(sid_b);
|
|
app.sync_focus().await;
|
|
|
|
// Leaving the pane for the sidebar unfocuses the current session.
|
|
app.state.focus = Focus::Sidebar;
|
|
app.sync_focus().await;
|
|
|
|
assert_eq!(
|
|
*posted.lock().unwrap(),
|
|
vec![
|
|
(sid_a, json!({"focused": true})),
|
|
(sid_a, json!({"focused": false})),
|
|
(sid_b, json!({"focused": true})),
|
|
(sid_b, json!({"focused": false})),
|
|
]
|
|
);
|
|
}
|
|
|
|
/// No projects known yet: nothing can resolve, so the keystroke opens a
|
|
/// spawn form instead of guessing (#67).
|
|
#[tokio::test]
|
|
async fn unresolvable_defaults_open_a_prefilled_draft_instead() {
|
|
use axum::routing::get;
|
|
use axum::Json;
|
|
use serde_json::json;
|
|
|
|
async fn info() -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"hostname": "box", "version": "0.1.0", "home_dir": "/home/u",
|
|
"presets": [{"id": "claude", "label": "Claude", "command": "claude"}]
|
|
}))
|
|
}
|
|
async fn projects() -> Json<serde_json::Value> {
|
|
Json(json!([]))
|
|
}
|
|
async fn names() -> Json<serde_json::Value> {
|
|
Json(json!({"names": ["amber-falcon"]}))
|
|
}
|
|
|
|
let app_router = axum::Router::new()
|
|
.route(maestro_protocol::routes::INFO, get(info))
|
|
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
|
.route(maestro_protocol::routes::NAMES, get(names));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
|
|
|
let mut app = make_app_at(&base);
|
|
let effects = app.dispatch(Action::FastForwardSpawn);
|
|
// The fast path loads off the event loop now; the run loop drains the
|
|
// channel, so do that by hand.
|
|
app.perform(effects).await;
|
|
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
|
.await
|
|
.expect("load completed")
|
|
.expect("channel open");
|
|
app.apply_draft_update(update).await;
|
|
|
|
assert_eq!(
|
|
app.state.overlay(),
|
|
Some(&OverlayKind::Spawn),
|
|
"the fallback is the spawn modal"
|
|
);
|
|
let draft = app.draft.as_ref().expect("a form behind the modal");
|
|
// Whatever did resolve (nothing project-side) is seeded. The name
|
|
// field stays empty (#131); the handle seeds the branch instead.
|
|
assert_eq!(draft.name.value(), "");
|
|
assert_eq!(draft.branch.value(), "amber-falcon");
|
|
assert_eq!(draft.command(), "claude");
|
|
}
|
|
|
|
/// Opening the form preselects the project of the session in the pane, so
|
|
/// `n` then Enter spawns another session there.
|
|
#[test]
|
|
fn opening_the_spawn_modal_seeds_the_focused_sessions_project() {
|
|
use maestro_protocol::{CatalogEvent, SessionWorktree};
|
|
|
|
let sid = Uuid::from_u128(21);
|
|
let mut app = make_app();
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![test_session(sid, "/repos/maestro")],
|
|
});
|
|
app.state.attach_to_focused(sid);
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
assert_eq!(
|
|
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
|
|
Some(std::path::Path::new("/repos/maestro"))
|
|
);
|
|
|
|
// A worktree session seeds its base repository: the daemon never
|
|
// adopts an existing worktree (docs/WORKTREES.md).
|
|
let mut wt_session = test_session(sid, "/repos/maestro/worktrees/flugelhorn");
|
|
wt_session.worktree = Some(SessionWorktree {
|
|
base_repo: "/repos/maestro".into(),
|
|
path: "/repos/maestro/worktrees/flugelhorn".into(),
|
|
});
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![wt_session],
|
|
});
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
assert_eq!(
|
|
app.draft.as_ref().unwrap().seed_cwd.as_deref(),
|
|
Some(std::path::Path::new("/repos/maestro"))
|
|
);
|
|
}
|
|
|
|
/// A valid form flows through: pick a project (set directly — the picker
|
|
/// itself is covered in `draft.rs`), select the custom tile, type a
|
|
/// command, then submit from the Spawn row.
|
|
#[test]
|
|
fn typing_into_the_form_then_enter_carries_the_typed_fields() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
let draft = app.draft.as_mut().unwrap();
|
|
|
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/work"));
|
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
|
draft.selected_preset = draft.presets.len() - 1; // custom
|
|
app.handle_draft_input(&InputKey::Tab); // dir picker → preset
|
|
app.handle_draft_input(&InputKey::Tab); // → custom command
|
|
for c in "claude".chars() {
|
|
app.handle_draft_input(&InputKey::char(c));
|
|
}
|
|
app.handle_draft_input(&InputKey::Tab); // → name
|
|
for c in "crimson-otter".chars() {
|
|
app.handle_draft_input(&InputKey::char(c));
|
|
}
|
|
app.handle_draft_input(&InputKey::Tab); // → advanced disclosure (collapsed, #93)
|
|
app.handle_draft_input(&InputKey::Tab); // → submit
|
|
|
|
match app.handle_draft_input(&InputKey::Enter).as_slice() {
|
|
[Effect::Spawn(req)] => {
|
|
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
|
|
assert_eq!(req.command, "claude");
|
|
assert_eq!(
|
|
req.metadata.get("name").and_then(|v| v.as_str()),
|
|
Some("crimson-otter"),
|
|
"the generated name rides along as metadata.name"
|
|
);
|
|
}
|
|
other => panic!("expected a single Spawn effect, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Editing the raw path schedules a `/api/gitroot` probe only once the
|
|
/// keystroke burst settles (#74): nothing fires inside the debounce
|
|
/// window; the heartbeat picks the due probe up afterwards.
|
|
#[test]
|
|
fn typing_a_raw_path_schedules_a_debounced_gitroot_probe() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
let draft = app.draft.as_mut().unwrap();
|
|
draft.cwd_raw_visible = true;
|
|
draft.focused_field = DraftField::CwdRaw;
|
|
|
|
// A burst of keystrokes fires nothing.
|
|
for c in "/opt/tooling".chars() {
|
|
assert!(app.handle_draft_input(&InputKey::char(c)).is_empty());
|
|
assert!(app.poll_due_probes_at(std::time::Instant::now()).is_empty());
|
|
}
|
|
|
|
// Once the quiet period passes, exactly one probe is due.
|
|
let due = std::time::Instant::now()
|
|
+ std::time::Duration::from_millis(crate::draft::GITROOT_PROBE_DEBOUNCE_MS) * 10;
|
|
match app.poll_due_probes_at(due).as_slice() {
|
|
[Effect::ProbeGitRoot { path, .. }] => {
|
|
assert_eq!(path, &std::path::PathBuf::from("/opt/tooling"));
|
|
}
|
|
other => panic!("expected one ProbeGitRoot effect, got {other:?}"),
|
|
}
|
|
assert!(
|
|
app.poll_due_probes_at(due).is_empty(),
|
|
"the probe is consumed once"
|
|
);
|
|
}
|
|
|
|
/// Switching the worktree branch source to Existing schedules one
|
|
/// `/api/branches` load for the current target (#65).
|
|
#[test]
|
|
fn switching_to_existing_branch_mode_schedules_a_branch_load() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
let draft = app.draft.as_mut().unwrap();
|
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
|
draft.base_repo = Some(std::path::PathBuf::from("/srv/repo"));
|
|
draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo"));
|
|
draft.focused_field = DraftField::BranchMode;
|
|
|
|
let effects = app.handle_draft_input(&InputKey::Right);
|
|
assert!(
|
|
matches!(effects.as_slice(), [Effect::LoadBranches]),
|
|
"{effects:?}"
|
|
);
|
|
}
|
|
|
|
/// Submitting with no directory names the missing field inside the form
|
|
/// and posts nothing (#64).
|
|
#[test]
|
|
fn submitting_an_empty_form_reports_the_missing_field_instead_of_posting() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
// Walk to Submit without picking anything.
|
|
for _ in 0..4 {
|
|
app.handle_draft_input(&InputKey::Tab);
|
|
}
|
|
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
|
assert_eq!(
|
|
app.draft.as_ref().unwrap().error.as_deref(),
|
|
Some("Pick a working directory."),
|
|
"the refusal shows in the form"
|
|
);
|
|
|
|
// With a directory but an empty custom command, it names the command.
|
|
let draft = app.draft.as_mut().unwrap();
|
|
draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work"));
|
|
draft.selected_preset = draft.presets.len() - 1; // custom, empty text
|
|
assert!(app.handle_draft_input(&InputKey::Enter).is_empty());
|
|
let draft = app.draft.as_ref().unwrap();
|
|
assert_eq!(
|
|
draft.error.as_deref(),
|
|
Some("Pick a command (or fill the custom command).")
|
|
);
|
|
}
|
|
|
|
/// A failed POST lands back in the form as an error line (#64), not a
|
|
/// toast that the next keystroke dismisses.
|
|
#[tokio::test]
|
|
async fn a_failed_spawn_shows_the_daemons_error_in_the_form() {
|
|
use std::time::Duration;
|
|
let mut app = App::new(
|
|
TuiConfig::load(&TuiSection::default()).unwrap(),
|
|
CatalogStore::default(),
|
|
DaemonClient::with_timeouts(
|
|
"http://127.0.0.1:9",
|
|
Duration::from_millis(200),
|
|
Duration::from_millis(500),
|
|
),
|
|
DaemonLink::default(),
|
|
);
|
|
app.dispatch(Action::OpenSpawnModal);
|
|
let req = CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: std::path::PathBuf::from("/srv/work"),
|
|
command: "bash".into(),
|
|
env: Default::default(),
|
|
metadata: Default::default(),
|
|
worktree: None,
|
|
};
|
|
app.submit_spawn(req).await;
|
|
|
|
let draft = app.draft.as_ref().expect("the form stays up on failure");
|
|
let error = draft
|
|
.error
|
|
.as_deref()
|
|
.expect("spawn failure must reach the form");
|
|
assert!(error.starts_with("spawn failed"), "{error}");
|
|
assert_eq!(
|
|
app.state.overlay(),
|
|
Some(&OverlayKind::Spawn),
|
|
"the form stays up so the error is readable next to the fields"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_response_selects_transport_before_catalog_event() {
|
|
use axum::routing::post;
|
|
use axum::Json;
|
|
|
|
async fn spawn_session(
|
|
Json(req): Json<CreateSessionRequest>,
|
|
) -> Json<maestro_protocol::Session> {
|
|
let id = match req.view {
|
|
SessionView::Terminal => Uuid::from_u128(187),
|
|
SessionView::Structured => Uuid::from_u128(188),
|
|
};
|
|
let mut session = test_session(id, req.cwd.to_str().unwrap());
|
|
session.view = req.view;
|
|
session.command = req.command;
|
|
Json(session)
|
|
}
|
|
|
|
let router =
|
|
axum::Router::new().route(maestro_protocol::routes::SESSIONS, post(spawn_session));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
|
|
|
|
let mut app = make_app_at(&base);
|
|
let request = |view| CreateSessionRequest {
|
|
view,
|
|
cwd: std::path::PathBuf::from("/srv/work"),
|
|
command: "claude".into(),
|
|
env: Default::default(),
|
|
metadata: Default::default(),
|
|
worktree: None,
|
|
};
|
|
|
|
let structured_id = Uuid::from_u128(188);
|
|
app.submit_spawn(request(SessionView::Structured)).await;
|
|
assert!(app.catalog.snapshot().sessions.is_empty());
|
|
assert!(app.structured.contains_key(&structured_id));
|
|
assert!(app.emulators.get(structured_id).is_none());
|
|
|
|
let terminal_id = Uuid::from_u128(187);
|
|
app.submit_spawn(request(SessionView::Terminal)).await;
|
|
assert!(app.catalog.snapshot().sessions.is_empty());
|
|
assert!(app.emulators.get(terminal_id).is_some());
|
|
assert!(!app.structured.contains_key(&terminal_id));
|
|
}
|
|
|
|
/// The full data load against a live daemon stub: presets replace the
|
|
/// built-ins, projects land sorted, and the first generated name seeds
|
|
/// the field.
|
|
#[tokio::test]
|
|
async fn load_draft_data_fills_presets_projects_and_the_branch_handle() {
|
|
use axum::routing::get;
|
|
use axum::Json;
|
|
use serde_json::json;
|
|
|
|
async fn info() -> Json<serde_json::Value> {
|
|
Json(json!({
|
|
"hostname": "box",
|
|
"version": "0.1.0",
|
|
"home_dir": "/home/u",
|
|
"presets": [
|
|
{"id": "aider", "label": "Aider", "command": "aider"},
|
|
{"id": "custom", "label": "Custom…", "command": ""}
|
|
]
|
|
}))
|
|
}
|
|
async fn projects() -> Json<serde_json::Value> {
|
|
Json(json!([
|
|
{"path": "/repos/maestro", "name": "maestro",
|
|
"last_activity": chrono::Utc::now().to_rfc3339(),
|
|
"last_command": "claude"}
|
|
]))
|
|
}
|
|
async fn names() -> Json<serde_json::Value> {
|
|
Json(json!({"names": ["crimson-otter", "amber-falcon"]}))
|
|
}
|
|
|
|
let app_router = axum::Router::new()
|
|
.route(maestro_protocol::routes::INFO, get(info))
|
|
.route(maestro_protocol::routes::PROJECTS, get(projects))
|
|
.route(maestro_protocol::routes::NAMES, get(names));
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
|
|
|
|
let mut app = make_app_at(&base);
|
|
let effects = app.dispatch(Action::OpenSpawnModal);
|
|
assert!(matches!(effects.as_slice(), [Effect::LoadDraft]));
|
|
// `perform` only spawns the load — it must not block the event loop on
|
|
// the network (#67 review). The result arrives on the draft channel,
|
|
// which the run loop drains; do that by hand here.
|
|
app.perform(effects).await;
|
|
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
|
|
.await
|
|
.expect("load completed")
|
|
.expect("channel open");
|
|
app.apply_draft_update(update).await;
|
|
|
|
let draft = app.draft.as_ref().unwrap();
|
|
assert_eq!(draft.command(), "aider");
|
|
assert_eq!(draft.groups.len(), 1);
|
|
assert_eq!(draft.groups[0].main.name, "maestro");
|
|
assert_eq!(draft.home_dir, "/home/u");
|
|
assert_eq!(
|
|
draft.name.value(),
|
|
"",
|
|
"the operator names it, or nobody does"
|
|
);
|
|
assert_eq!(draft.branch.value(), "crimson-otter");
|
|
}
|
|
|
|
/// Keys fed while the form is not up must be ignored, not panic.
|
|
#[test]
|
|
fn draft_input_is_a_no_op_without_a_form() {
|
|
let mut app = make_app();
|
|
assert!(app.handle_draft_input(&InputKey::char('x')).is_empty());
|
|
}
|
|
|
|
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
|
|
/// failure message stayed on screen until another overlay replaced it.
|
|
#[test]
|
|
fn a_toast_is_dismissed_by_the_next_key_without_eating_it() {
|
|
let mut app = make_app();
|
|
app.state.toast("spawn failed: nope");
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Char {
|
|
c: 'j',
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
});
|
|
assert_eq!(
|
|
decision,
|
|
OverlayDecision::Pass,
|
|
"the keystroke should still reach the keymap"
|
|
);
|
|
assert!(effects.is_empty());
|
|
assert!(app.state.overlay().is_none(), "the toast must be gone");
|
|
}
|
|
|
|
#[test]
|
|
fn forwarding_is_a_no_op_without_an_attached_pane() {
|
|
let mut app = make_app();
|
|
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
|
}
|
|
|
|
/// Drive one key event through an app in Insert mode with a session
|
|
/// attached, and report the bytes that reached that session.
|
|
async fn bytes_sent_for(section: &TuiSection, ev: CtEvent) -> Option<Vec<u8>> {
|
|
use crate::session_task::{SessionHandle, SessionState};
|
|
use std::sync::atomic::{AtomicU64, AtomicU8};
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::sync::mpsc;
|
|
|
|
let sid = Uuid::from_u128(41);
|
|
let mut app = App::new(
|
|
TuiConfig::load(section).unwrap(),
|
|
CatalogStore::default(),
|
|
DaemonClient::new("http://127.0.0.1:7070"),
|
|
DaemonLink::default(),
|
|
);
|
|
let (tx, mut rx) = mpsc::channel(4);
|
|
app.emulators.insert(
|
|
sid,
|
|
SessionHandle {
|
|
tx,
|
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
|
revision: Arc::new(AtomicU64::new(0)),
|
|
},
|
|
);
|
|
app.state.attach_to_focused(sid);
|
|
app.state.mode = Mode::Insert;
|
|
app.state.focus = Focus::Pane;
|
|
app.handle_event(ev).await.unwrap();
|
|
match rx.try_recv() {
|
|
Ok(SessionInput::Bytes(bytes)) => Some(bytes),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn key_event(code: crossterm::event::KeyCode, mods: crossterm::event::KeyModifiers) -> CtEvent {
|
|
CtEvent::Key(crossterm::event::KeyEvent::new(code, mods))
|
|
}
|
|
|
|
fn ctrl_key_event(c: char) -> CtEvent {
|
|
key_event(
|
|
crossterm::event::KeyCode::Char(c),
|
|
crossterm::event::KeyModifiers::CONTROL,
|
|
)
|
|
}
|
|
|
|
/// Drive a sequence of key events through an app in Insert mode with a
|
|
/// session attached, and report the app afterwards plus every chunk of
|
|
/// bytes that reached that session, in order.
|
|
async fn drive_insert_sequence(
|
|
section: &TuiSection,
|
|
events: &[CtEvent],
|
|
) -> (App, Vec<Vec<u8>>) {
|
|
use crate::session_task::{SessionHandle, SessionState};
|
|
use std::sync::atomic::{AtomicU64, AtomicU8};
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::sync::mpsc;
|
|
|
|
let sid = Uuid::from_u128(41);
|
|
let mut app = App::new(
|
|
TuiConfig::load(section).unwrap(),
|
|
CatalogStore::default(),
|
|
DaemonClient::new("http://127.0.0.1:7070"),
|
|
DaemonLink::default(),
|
|
);
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
app.emulators.insert(
|
|
sid,
|
|
SessionHandle {
|
|
tx,
|
|
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
|
|
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
|
revision: Arc::new(AtomicU64::new(0)),
|
|
},
|
|
);
|
|
app.state.attach_to_focused(sid);
|
|
app.state.mode = Mode::Insert;
|
|
app.state.focus = Focus::Pane;
|
|
for ev in events {
|
|
app.handle_event(ev.clone()).await.unwrap();
|
|
}
|
|
let mut sent = Vec::new();
|
|
while let Ok(SessionInput::Bytes(bytes)) = rx.try_recv() {
|
|
sent.push(bytes);
|
|
}
|
|
(app, sent)
|
|
}
|
|
|
|
/// Escape belongs to the session: full-screen apps (vim, Claude Code) read
|
|
/// it constantly, so no Insert-mode default may swallow it. The leader is
|
|
/// the way back to the sidebar.
|
|
#[tokio::test]
|
|
async fn escape_reaches_the_attached_session() {
|
|
let sent = bytes_sent_for(
|
|
&TuiSection::default(),
|
|
key_event(
|
|
crossterm::event::KeyCode::Esc,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(sent, Some(vec![0x1b]));
|
|
}
|
|
|
|
/// `Noop` suppresses a binding, and in Insert mode a key with no binding is
|
|
/// the session's — so binding a key to `Noop` hands it to the pty.
|
|
///
|
|
/// Uses `ctrl-x`, not the leader: the leader is also a chord prefix
|
|
/// (#156), so a single fed keystroke on it resolves as `Pending`, not
|
|
/// `Resolved` — a different mechanism, covered separately below.
|
|
#[tokio::test]
|
|
async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
|
|
let mut section = TuiSection::default();
|
|
section.keymap_insert.insert("ctrl-x".into(), "Noop".into());
|
|
let sent = bytes_sent_for(
|
|
§ion,
|
|
key_event(
|
|
crossterm::event::KeyCode::Char('x'),
|
|
crossterm::event::KeyModifiers::CONTROL,
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(sent, Some(vec![0x18]));
|
|
}
|
|
|
|
/// Leader then `ctrl-k` sends the literal byte and never opens the bar
|
|
/// (#156) — the default leader, `ctrl-a`.
|
|
#[tokio::test]
|
|
async fn leader_then_ctrl_k_sends_the_literal_byte_and_does_not_open_the_bar() {
|
|
let (app, sent) = drive_insert_sequence(
|
|
&TuiSection::default(),
|
|
&[ctrl_key_event('a'), ctrl_key_event('k')],
|
|
)
|
|
.await;
|
|
assert_eq!(sent, vec![vec![0x0b]]);
|
|
assert_eq!(app.state.overlay(), None);
|
|
assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left");
|
|
}
|
|
|
|
/// The escape hatch is built from the configured leader, not a hardcoded
|
|
/// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#156).
|
|
#[tokio::test]
|
|
async fn a_rebound_leader_still_reaches_the_literal_ctrl_k_escape() {
|
|
let section = TuiSection {
|
|
leader: Some("ctrl-b".into()),
|
|
..Default::default()
|
|
};
|
|
let (app, sent) =
|
|
drive_insert_sequence(§ion, &[ctrl_key_event('b'), ctrl_key_event('k')]).await;
|
|
assert_eq!(sent, vec![vec![0x0b]]);
|
|
assert_eq!(app.state.overlay(), None);
|
|
}
|
|
|
|
/// Leader alone, followed by an ordinary key, still leaves Insert mode —
|
|
/// the escape hatch chord doesn't cost the leader its existing meaning
|
|
/// (#156).
|
|
#[tokio::test]
|
|
async fn leader_alone_still_leaves_insert_when_followed_by_another_key() {
|
|
let (app, sent) = drive_insert_sequence(
|
|
&TuiSection::default(),
|
|
&[
|
|
ctrl_key_event('a'),
|
|
key_event(
|
|
crossterm::event::KeyCode::Char('j'),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
),
|
|
],
|
|
)
|
|
.await;
|
|
assert!(
|
|
sent.is_empty(),
|
|
"no bytes for the leader chord itself: {sent:?}"
|
|
);
|
|
assert_eq!(app.state.mode, Mode::Normal, "leader still leaves Insert");
|
|
}
|
|
|
|
/// Leader pressed twice sends one literal leader byte, tmux-style, and
|
|
/// clears the pending chord rather than leaving Insert (#158) — the
|
|
/// default leader, `ctrl-a`.
|
|
#[tokio::test]
|
|
async fn leader_twice_sends_the_literal_leader_byte_and_stays_in_insert() {
|
|
let (app, sent) = drive_insert_sequence(
|
|
&TuiSection::default(),
|
|
&[ctrl_key_event('a'), ctrl_key_event('a')],
|
|
)
|
|
.await;
|
|
assert_eq!(sent, vec![vec![0x01]]);
|
|
assert_eq!(app.state.mode, Mode::Insert, "still in Insert, not left");
|
|
}
|
|
|
|
/// The escape hatch is built from the configured leader, not a hardcoded
|
|
/// `ctrl-a` — rebinding `[tui].leader` rebinds it too (#158).
|
|
#[tokio::test]
|
|
async fn a_rebound_leader_still_reaches_the_literal_leader_escape() {
|
|
let section = TuiSection {
|
|
leader: Some("ctrl-b".into()),
|
|
..Default::default()
|
|
};
|
|
let (app, sent) =
|
|
drive_insert_sequence(§ion, &[ctrl_key_event('b'), ctrl_key_event('b')]).await;
|
|
assert_eq!(sent, vec![vec![0x02]]);
|
|
assert_eq!(app.state.mode, Mode::Insert);
|
|
}
|
|
|
|
/// After the literal-leader escape fires, the chord is gone — a further
|
|
/// leader press starts a fresh chord rather than resolving as a
|
|
/// three-deep sequence (#158).
|
|
#[tokio::test]
|
|
async fn leader_twice_then_ctrl_k_is_a_fresh_chord() {
|
|
let (_app, sent) = drive_insert_sequence(
|
|
&TuiSection::default(),
|
|
&[
|
|
ctrl_key_event('a'),
|
|
ctrl_key_event('a'),
|
|
ctrl_key_event('a'),
|
|
ctrl_key_event('k'),
|
|
],
|
|
)
|
|
.await;
|
|
assert_eq!(sent, vec![vec![0x01], vec![0x0b]]);
|
|
}
|
|
|
|
#[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)),
|
|
revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
};
|
|
let mut app = make_app();
|
|
app.state.active_tab_mut().pane = Some(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
|
|
/// remote shell reads the payload as typed keys and runs a multi-line
|
|
/// paste line by line.
|
|
#[test]
|
|
fn a_bracketed_paste_reaches_the_session_with_its_markers() {
|
|
assert_eq!(
|
|
encode_key(&InputKey::Paste("cd /tmp/foo\nls".into())),
|
|
Some(b"\x1b[200~cd /tmp/foo\nls\x1b[201~".to_vec())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_events_have_no_wire_encoding() {
|
|
use crate::input::{MouseKind, Region};
|
|
assert!(encode_key(&InputKey::Mouse {
|
|
kind: MouseKind::Click,
|
|
region: Region::Pane,
|
|
col: 3,
|
|
row: 4,
|
|
})
|
|
.is_none());
|
|
}
|
|
|
|
// -- command bar (#153) -------------------------------------------------
|
|
|
|
fn seed_two_sessions(app: &mut App) -> (Uuid, Uuid) {
|
|
use chrono::Utc;
|
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
|
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
|
|
view: Default::default(),
|
|
id: Uuid::from_u128(n),
|
|
command: "bash".into(),
|
|
cwd: std::path::PathBuf::from("/"),
|
|
env: Default::default(),
|
|
metadata: HashMap::from([(
|
|
"name".to_string(),
|
|
serde_json::Value::String(name.to_string()),
|
|
)]),
|
|
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,
|
|
last_focused_at: focused,
|
|
acp_session_id: None,
|
|
};
|
|
let older = Utc::now() - chrono::Duration::seconds(60);
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![
|
|
make(1, "alpha", Some(older)),
|
|
make(2, "bravo", Some(Utc::now())),
|
|
],
|
|
});
|
|
(Uuid::from_u128(1), Uuid::from_u128(2))
|
|
}
|
|
|
|
/// `ctrl-k` is an ordinary keymap entry, bound in both modes so the bar
|
|
/// opens from the sidebar and from inside a pane (`docs/COMMAND-BAR.md`).
|
|
#[test]
|
|
fn ctrl_k_is_bound_to_the_command_bar_in_both_modes() {
|
|
let app = make_app();
|
|
assert_eq!(
|
|
app.cfg.keymap.resolve(Mode::Normal, "ctrl-k"),
|
|
Some(Action::OpenCommandBar)
|
|
);
|
|
assert_eq!(
|
|
app.cfg.keymap.resolve(Mode::Insert, "ctrl-k"),
|
|
Some(Action::OpenCommandBar)
|
|
);
|
|
}
|
|
|
|
/// The help overlay builds its lines from the live keymap, so the bar
|
|
/// appears there with no second list to maintain.
|
|
#[test]
|
|
fn the_help_overlay_lists_the_command_bar() {
|
|
let app = make_app();
|
|
let lines = render_help_lines(&app.cfg);
|
|
assert!(
|
|
lines
|
|
.iter()
|
|
.any(|l| l.contains("ctrl-k") && l.contains("Go to a session")),
|
|
"{lines:#?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opening_the_bar_lists_sessions_most_recently_focused_first() {
|
|
let mut app = make_app();
|
|
let (alpha, bravo) = seed_two_sessions(&mut app);
|
|
app.dispatch(Action::OpenCommandBar);
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
|
let bar = app.command_bar.as_ref().unwrap();
|
|
assert_eq!(bar.query(), "");
|
|
assert_eq!(
|
|
bar.rows()
|
|
.iter()
|
|
.map(|r| match r.target {
|
|
BarTarget::Session(id) => id,
|
|
ref other => panic!("expected a session row, got {other:?}"),
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![bravo, alpha]
|
|
);
|
|
}
|
|
|
|
/// `~`'s candidates are a network round trip, so opening asks for them
|
|
/// rather than blocking the keystroke on one.
|
|
#[test]
|
|
fn opening_the_bar_asks_for_its_directories() {
|
|
let mut app = make_app();
|
|
let effects = app.dispatch(Action::OpenCommandBar);
|
|
let bar = app.command_bar.as_ref().unwrap().id();
|
|
assert_eq!(effects, vec![Effect::LoadBarDirectories { bar }]);
|
|
}
|
|
|
|
fn type_into_bar(app: &mut App, text: &str) {
|
|
for c in text.chars() {
|
|
app.handle_overlay_input(&InputKey::Char {
|
|
c,
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// `>` lists the live keymap for the current mode, matched on the label
|
|
/// alone, and Enter hands the action to the ordinary dispatcher.
|
|
#[test]
|
|
fn enter_on_a_command_row_dispatches_the_action() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenCommandBar);
|
|
type_into_bar(&mut app, ">defaults");
|
|
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
|
|
assert_eq!(row.name, "Create session with defaults");
|
|
assert_eq!(
|
|
row.target,
|
|
BarTarget::Command(Action::FastForwardSpawn),
|
|
"chords: {:?}",
|
|
row.chords
|
|
);
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert_eq!(effects, vec![Effect::FastForward]);
|
|
assert!(app.state.overlay().is_none());
|
|
assert!(app.command_bar.is_none());
|
|
}
|
|
|
|
/// The row shows the chord bound to it, and both chords when the map binds
|
|
/// two — but the chord is never what matched.
|
|
#[test]
|
|
fn a_command_row_carries_its_chords() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenCommandBar);
|
|
type_into_bar(&mut app, ">open selected");
|
|
let row = app.command_bar.as_ref().unwrap().selected().unwrap();
|
|
assert_eq!(row.name, "Open selected item");
|
|
assert!(
|
|
row.chords.iter().any(|c| c == "enter"),
|
|
"chords: {:?}",
|
|
row.chords
|
|
);
|
|
assert!(
|
|
row.chords.len() > 1,
|
|
"the default map binds more than one chord to it: {:?}",
|
|
row.chords
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enter_on_a_directory_row_fast_forward_spawns_there() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenCommandBar);
|
|
app.command_bar
|
|
.as_mut()
|
|
.unwrap()
|
|
.set_directories(&[PathBuf::from("/repos/api")]);
|
|
type_into_bar(&mut app, "~api");
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert_eq!(
|
|
effects,
|
|
vec![Effect::FastForwardIn(PathBuf::from("/repos/api"))]
|
|
);
|
|
assert!(app.state.overlay().is_none());
|
|
}
|
|
|
|
/// A load that outlived its bar fills in nothing: the reopened bar has its
|
|
/// own identity.
|
|
#[tokio::test]
|
|
async fn a_stale_directory_load_is_dropped() {
|
|
let mut app = make_app();
|
|
app.dispatch(Action::OpenCommandBar);
|
|
let stale = app.command_bar.as_ref().unwrap().id();
|
|
app.handle_overlay_input(&InputKey::Esc);
|
|
app.dispatch(Action::OpenCommandBar);
|
|
|
|
app.apply_draft_update(DraftUpdate::BarDirectories {
|
|
bar: stale,
|
|
paths: vec![PathBuf::from("/repos/api")],
|
|
})
|
|
.await;
|
|
type_into_bar(&mut app, "~");
|
|
assert!(app.command_bar.as_ref().unwrap().rows().is_empty());
|
|
}
|
|
|
|
/// Opening from Insert mode leaves the keyboard where it was, so closing
|
|
/// gives it back — the bar is a view over the app, not a place in it.
|
|
#[test]
|
|
fn opening_from_insert_mode_leaves_focus_and_mode_alone() {
|
|
let mut app = make_app();
|
|
seed_two_sessions(&mut app);
|
|
app.state.attach_to_focused(Uuid::from_u128(1));
|
|
assert_eq!(
|
|
(app.state.focus, app.state.mode),
|
|
(Focus::Pane, Mode::Insert)
|
|
);
|
|
|
|
app.dispatch(Action::OpenCommandBar);
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
|
assert_eq!(
|
|
(app.state.focus, app.state.mode),
|
|
(Focus::Pane, Mode::Insert)
|
|
);
|
|
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Esc);
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert!(effects.is_empty());
|
|
assert!(app.state.overlay().is_none());
|
|
assert!(app.command_bar.is_none());
|
|
assert_eq!(
|
|
(app.state.focus, app.state.mode),
|
|
(Focus::Pane, Mode::Insert)
|
|
);
|
|
}
|
|
|
|
/// Enter goes through the same open path the sidebar's attach uses.
|
|
#[test]
|
|
fn enter_attaches_the_highlighted_session() {
|
|
let mut app = make_app();
|
|
let (_alpha, bravo) = seed_two_sessions(&mut app);
|
|
app.dispatch(Action::OpenCommandBar);
|
|
let (decision, effects) = app.handle_overlay_input(&InputKey::Enter);
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert_eq!(effects, vec![Effect::Attach(bravo)]);
|
|
assert!(app.state.overlay().is_none());
|
|
assert!(app.command_bar.is_none());
|
|
assert_eq!(app.state.active_session(), Some(bravo));
|
|
}
|
|
|
|
/// Down moves the highlight, so Enter opens the second row instead.
|
|
#[test]
|
|
fn the_highlight_moves_before_enter_attaches() {
|
|
let mut app = make_app();
|
|
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
|
app.dispatch(Action::OpenCommandBar);
|
|
app.handle_overlay_input(&InputKey::Down);
|
|
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
|
|
assert_eq!(effects, vec![Effect::Attach(alpha)]);
|
|
assert_eq!(app.state.active_session(), Some(alpha));
|
|
}
|
|
|
|
/// Typing goes to the query, not to the keymap: `q` must not quit and `x`
|
|
/// must not raise a kill confirm while the bar is up.
|
|
#[test]
|
|
fn typing_reaches_the_query_rather_than_the_keymap() {
|
|
let mut app = make_app();
|
|
seed_two_sessions(&mut app);
|
|
app.dispatch(Action::OpenCommandBar);
|
|
for c in "bra".chars() {
|
|
let (decision, _) = app.handle_overlay_input(&InputKey::Char {
|
|
c,
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
});
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
}
|
|
let bar = app.command_bar.as_ref().unwrap();
|
|
assert_eq!(bar.query(), "bra");
|
|
assert_eq!(bar.rows().len(), 1);
|
|
}
|
|
|
|
/// The bar is not previewed into (`docs/PREVIEW.md`): opening discards a
|
|
/// preview and the highlight behind it raises no new one.
|
|
#[test]
|
|
fn opening_the_bar_discards_a_preview() {
|
|
let mut app = make_app();
|
|
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
|
app.state.sidebar_highlight = Some(SidebarItem::Session(alpha));
|
|
app.state.preview = Some(alpha);
|
|
|
|
app.dispatch(Action::OpenCommandBar);
|
|
assert_eq!(app.state.preview, None);
|
|
assert_eq!(app.pending_preview, None);
|
|
}
|
|
|
|
#[test]
|
|
fn kill_highlighted_opens_confirm() {
|
|
use chrono::Utc;
|
|
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
|
|
let mut app = make_app();
|
|
let sid = Uuid::from_u128(7);
|
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
|
sessions: vec![Session {
|
|
view: Default::default(),
|
|
id: sid,
|
|
command: "bash".into(),
|
|
cwd: std::path::PathBuf::from("/"),
|
|
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: chrono::Utc::now(),
|
|
activity_since: chrono::Utc::now(),
|
|
|
|
current_cwd: None,
|
|
blocked_work: None,
|
|
last_focused_at: None,
|
|
acp_session_id: None,
|
|
}],
|
|
});
|
|
app.dispatch(Action::KillHighlighted);
|
|
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 {
|
|
view: Default::default(),
|
|
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,
|
|
last_focused_at: None,
|
|
acp_session_id: 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()
|
|
}]
|
|
);
|
|
}
|
|
|
|
// ---- the session action page (#155) ----
|
|
|
|
/// Drill into the second row's session, then pick an action by name.
|
|
fn drill_and_pick(app: &mut App, action: &str) -> (OverlayDecision, Vec<Effect>) {
|
|
app.dispatch(Action::OpenCommandBar);
|
|
app.handle_overlay_input(&InputKey::Down);
|
|
app.handle_overlay_input(&InputKey::Tab);
|
|
type_into_bar(app, action);
|
|
app.handle_overlay_input(&InputKey::Enter)
|
|
}
|
|
|
|
/// The bar picks the action and hands off: kill goes through the confirm
|
|
/// this client puts in front of every kill, not straight to the daemon.
|
|
#[test]
|
|
fn the_action_page_raises_the_existing_kill_confirm() {
|
|
let mut app = make_app();
|
|
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
|
let (decision, effects) = drill_and_pick(&mut app, "kill");
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert!(effects.is_empty(), "the confirm is the whole effect");
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::KillConfirm(alpha)));
|
|
assert!(app.command_bar.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn the_action_page_raises_the_existing_rename_prompt_prefilled() {
|
|
let mut app = make_app();
|
|
let (alpha, _bravo) = seed_two_sessions(&mut app);
|
|
let (decision, effects) = drill_and_pick(&mut app, "rename");
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert!(effects.is_empty());
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Rename(alpha)));
|
|
assert_eq!(app.rename_input.value(), "alpha");
|
|
assert!(app.command_bar.is_none());
|
|
}
|
|
|
|
/// Spawn similar opens the spawn form seeded from that session's directory
|
|
/// and command — not from whatever the operator was last looking at.
|
|
#[test]
|
|
fn the_action_page_raises_the_spawn_form_seeded_from_the_session() {
|
|
let mut app = make_app();
|
|
seed_two_sessions(&mut app);
|
|
let (decision, effects) = drill_and_pick(&mut app, "spawn");
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
assert_eq!(effects, vec![Effect::LoadDraft]);
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
|
|
let draft = app.draft.as_ref().unwrap();
|
|
assert_eq!(draft.seed_cwd, Some(std::path::PathBuf::from("/")));
|
|
assert_eq!(draft.seed_command.as_deref(), Some("bash"));
|
|
assert!(app.command_bar.is_none());
|
|
}
|
|
|
|
/// Backing out is free: the result-list query survives the round trip.
|
|
#[test]
|
|
fn escape_on_the_action_page_returns_to_the_result_list() {
|
|
let mut app = make_app();
|
|
seed_two_sessions(&mut app);
|
|
app.dispatch(Action::OpenCommandBar);
|
|
type_into_bar(&mut app, "alp");
|
|
app.handle_overlay_input(&InputKey::Tab);
|
|
assert!(app.command_bar.as_ref().unwrap().on_action_page());
|
|
|
|
let (decision, _) = app.handle_overlay_input(&InputKey::Esc);
|
|
assert_eq!(decision, OverlayDecision::Consumed);
|
|
let bar = app.command_bar.as_ref().expect("escape backs out, not out");
|
|
assert!(!bar.on_action_page());
|
|
assert_eq!(bar.query(), "alp");
|
|
assert_eq!(app.state.overlay(), Some(&OverlayKind::CommandBar));
|
|
|
|
// A second escape is the way out again.
|
|
app.handle_overlay_input(&InputKey::Esc);
|
|
assert!(app.command_bar.is_none());
|
|
assert!(app.state.overlay().is_none());
|
|
}
|
|
|
|
fn structured_app(sid: Uuid) -> App {
|
|
let mut app = app_with_highlighted_session(sid);
|
|
let mut session = app.catalog.snapshot().sessions[0].clone();
|
|
session.view = SessionView::Structured;
|
|
app.catalog
|
|
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
|
app.structured.insert(
|
|
sid,
|
|
StructuredHandle::test_handle(crate::structured_task::StructuredState::default()),
|
|
);
|
|
app.state.attach_to_focused(sid);
|
|
app
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_view_selects_the_structured_connection_and_cleanup_stops_it() {
|
|
let sid = Uuid::from_u128(141);
|
|
let mut app = app_with_highlighted_session(sid);
|
|
let mut session = app.catalog.snapshot().sessions[0].clone();
|
|
session.view = SessionView::Structured;
|
|
app.catalog
|
|
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
|
|
|
app.attach(sid);
|
|
assert!(app.structured.contains_key(&sid));
|
|
assert!(app.emulators.get(sid).is_none());
|
|
let handle = app.structured.get(&sid).unwrap().clone();
|
|
app.release_session(sid);
|
|
assert_eq!(
|
|
handle.connection(),
|
|
crate::session_task::SessionState::Ended
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn structured_normal_mode_routes_navigation_expansion_and_approval_by_row() {
|
|
use maestro_protocol::{ReducedState, TranscriptRow, TranscriptRowKind};
|
|
use serde_json::json;
|
|
|
|
let sid = Uuid::from_u128(141);
|
|
let mut app = structured_app(sid);
|
|
app.dispatch(Action::AttachHighlighted);
|
|
assert_eq!(app.state.focus, Focus::Pane);
|
|
assert_eq!(app.state.mode, Mode::Normal);
|
|
let handle = app.structured.get(&sid).unwrap().clone();
|
|
{
|
|
let mut state = handle.state.lock().unwrap();
|
|
state.rows = vec![
|
|
TranscriptRow {
|
|
id: "text".into(),
|
|
kind: TranscriptRowKind::AgentText,
|
|
data: json!({"content":{"type":"text","text":"hello"}}),
|
|
},
|
|
TranscriptRow {
|
|
id: "approval".into(),
|
|
kind: TranscriptRowKind::Approval,
|
|
data: json!({"options":[{"kind":"allow_once"}]}),
|
|
},
|
|
];
|
|
state.selected = Some("text".into());
|
|
state.reduced = ReducedState {
|
|
pending_approval: Some(json!({"nonce":"n"})),
|
|
..ReducedState::default()
|
|
};
|
|
}
|
|
|
|
assert_eq!(
|
|
app.handle_structured_key(&InputKey::char('j')),
|
|
Some(vec![])
|
|
);
|
|
assert_eq!(
|
|
app.handle_structured_key(&InputKey::char('a')),
|
|
Some(vec![Effect::StructuredApproval {
|
|
id: sid,
|
|
nonce: "n".into(),
|
|
decision: StructuredApprovalDecision::AllowOnce,
|
|
}])
|
|
);
|
|
assert_eq!(
|
|
handle.state.lock().unwrap().selected.as_deref(),
|
|
Some("approval")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn structured_insert_mode_edits_submits_and_cancels_without_pty_bytes() {
|
|
let sid = Uuid::from_u128(141);
|
|
let mut app = structured_app(sid);
|
|
assert!(app.emulators.get(sid).is_none());
|
|
app.forward_to_session(&InputKey::char('h'));
|
|
app.forward_to_session(&InputKey::char('i'));
|
|
assert_eq!(
|
|
app.forward_to_session(&InputKey::Enter),
|
|
Some(Effect::StructuredPrompt {
|
|
id: sid,
|
|
prompt: "hi".into(),
|
|
})
|
|
);
|
|
assert_eq!(
|
|
app.handle_structured_key(&InputKey::Esc),
|
|
Some(vec![Effect::StructuredCancel { id: sid }])
|
|
);
|
|
assert_eq!(
|
|
app.forward_literal_to_terminal(&app.cfg.leader.clone()),
|
|
None
|
|
);
|
|
}
|
|
}
|