pub(crate) mod layout;
pub(crate) mod new_session;
pub(crate) mod new_session_entry;
pub(crate) mod new_session_order;
pub(crate) mod render;
pub(crate) mod state;
#[cfg(test)]
#[path = "session_tui/tests.rs"]
mod tests;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use trusty_mpm::client::ManagedSessionSummary;
use trusty_mpm::tui::terminal::{self, TerminalGuard};
use super::picker_delete::{DeleteReport, route_delete};
use super::session_picker::{PickerScope, fetch_live_sessions};
use state::{Action, Input, Severity, TuiState};
pub(crate) fn map_key(key: KeyEvent) -> Option<Input> {
if key.kind == KeyEventKind::Release {
return None;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c' | 'd') if ctrl => Some(Input::Cancel),
KeyCode::Char(_) if ctrl => None,
KeyCode::Char(c) => Some(Input::Char(c)),
KeyCode::Enter => Some(Input::Enter),
KeyCode::Backspace => Some(Input::Backspace),
KeyCode::Esc => Some(Input::Escape),
KeyCode::Up => Some(Input::Up),
KeyCode::Down => Some(Input::Down),
KeyCode::Home => Some(Input::Home),
KeyCode::End => Some(Input::End),
KeyCode::PageUp => Some(Input::PageUp),
KeyCode::PageDown => Some(Input::PageDown),
_ => None,
}
}
pub(crate) async fn run_session_tui(
client: &reqwest::Client,
url: &str,
scope: &mut PickerScope,
sessions: Vec<ManagedSessionSummary>,
self_session_id: Option<String>,
self_tmux_name: Option<String>,
) -> anyhow::Result<()> {
let mut sessions = sessions;
let use_color = super::session_picker_render::table_use_color(
std::io::IsTerminal::is_terminal(&std::io::stdout()),
);
let mut state = TuiState::new(self_session_id, self_tmux_name).with_color(use_color);
let mut terminal = terminal::enter()?;
let _guard = TerminalGuard;
loop {
let menu = super::session_picker_order::prepare_menu(sessions, scope);
sessions = menu.sessions;
state.sync(&sessions);
scope.selected_id = sessions.get(state.selected()).map(|s| s.id.clone());
terminal.draw(|frame| render::render(frame, &sessions, &mut state))?;
let Event::Key(key) = event::read()? else {
continue;
};
let Some(input) = map_key(key) else { continue };
match state.apply(input, &sessions) {
Action::Ignore | Action::Redraw => continue,
Action::Quit => break,
Action::Refresh => {
state.set_message("refreshed", Severity::Info);
}
Action::NewSession => match new_session::fetch_targets(client, url, &sessions).await {
Ok(targets) => {
let cursor = sessions.get(state.selected());
state.open_new_session(
new_session::NewSessionFlow::new(targets).preselected_for(cursor),
);
continue;
}
Err(e) => state.set_message(
format!("could not list registered projects: {e}"),
Severity::Error,
),
},
Action::Create(request) => {
let label = request.label.clone();
terminal::suspend(&mut terminal)?;
match new_session::perform(client, url, request).await {
Ok(outcome) if outcome.ends_interactive_loop() => return Ok(()),
Ok(_) => {
terminal::resume(&mut terminal)?;
state.set_message(format!("new session in {label}"), Severity::Info);
}
Err(e) => {
terminal::resume(&mut terminal)?;
state.set_message(format!("new session failed: {e}"), Severity::Error);
}
}
}
Action::Open(index) => {
terminal::suspend(&mut terminal)?;
let outcome =
super::guided_resume::resume_guided_session(client, url, &sessions[index])
.await;
match outcome {
Ok(outcome) if outcome.ends_interactive_loop() => return Ok(()),
Ok(_) => terminal::resume(&mut terminal)?,
Err(e) => {
terminal::resume(&mut terminal)?;
state.set_message(format!("open failed: {e}"), Severity::Error);
}
}
}
Action::Delete {
index,
force,
stop_first,
} => {
let session = &sessions[index];
let report = route_delete(client, url, &session.id, force, stop_first).await;
let (text, severity) = delete_outcome(&session.name, report);
state.set_message(text, severity);
}
Action::Rename { index, name } => {
let session = &sessions[index];
match super::rename::do_rename_request(
client,
url,
&session.name,
&session.id,
name,
)
.await
{
Ok(msg) => state.set_message(msg, Severity::Info),
Err(e) => state.set_message(format!("rename failed: {e}"), Severity::Error),
}
}
}
match fetch_live_sessions(client, url, scope.source_id.as_deref(), false).await {
Ok(fetched) => sessions = fetched,
Err(e) => state.set_message(format!("refresh failed: {e}"), Severity::Error),
}
}
Ok(())
}
pub(crate) fn delete_outcome(
name: &str,
report: anyhow::Result<DeleteReport>,
) -> (String, Severity) {
match report {
Ok(DeleteReport::Deleted {
name,
prior_state,
local: true,
}) => (
format!("deleted '{name}' [was {prior_state}] from the project store"),
Severity::Info,
),
Ok(DeleteReport::Deleted {
name, prior_state, ..
}) => (
format!(
"'{name}' [was {prior_state}] marked --deleted-- \
(still listed; `tm sessions prune --state deleted` removes it)"
),
Severity::Info,
),
Ok(DeleteReport::Refused(msg)) => {
(format!("delete refused: {}", msg.trim()), Severity::Error)
}
Ok(DeleteReport::StopFailed(msg)) => (
format!("'{name}' NOT deleted — {}", msg.trim()),
Severity::Error,
),
Ok(DeleteReport::NotFound) => {
(format!("'{name}' not found — already gone"), Severity::Info)
}
Err(e) => (format!("delete failed: {e}"), Severity::Error),
}
}