pub mod render;
pub mod state;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use std::time::{Duration, Instant};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
pub use state::{
BuildScope, InfraChannelEntry, InfraGraphData, InfraHealthStatus, InfraLogLine,
InfraLogService, InfraServiceHealth, InfraViewState, LogSource, ServiceStatus,
};
const TICK: Duration = Duration::from_millis(30);
#[derive(Debug)]
pub struct LogEntry {
pub source: LogSource,
pub line: String,
}
#[derive(Debug)]
pub enum TuiEvent {
Log(LogEntry),
Status {
source: LogSource,
status: ServiceStatus,
detail: Option<String>,
},
InfraHealth(Vec<state::InfraServiceHealth>),
InfraLog(state::InfraLogLine),
InfraGraph(state::InfraGraphData),
InfraDb(state::InfraDbSummary),
InfraSnapshot(state::InfraSnapshotInfo),
AppStatus {
name: String,
status: ServiceStatus,
detail: Option<String>,
},
AppPath {
name: String,
path: std::path::PathBuf,
},
DaemonEnvFile {
instance: String,
path: std::path::PathBuf,
},
}
pub type LogTx = mpsc::SyncSender<TuiEvent>;
pub type LogRx = mpsc::Receiver<TuiEvent>;
#[derive(Clone)]
pub struct DevSignals {
pub restart_requested: Arc<AtomicBool>,
pub build_scope_requested: Arc<std::sync::atomic::AtomicU8>,
pub quit_requested: Arc<AtomicBool>,
pub shutdown_complete: Arc<AtomicBool>,
}
impl DevSignals {
fn new() -> Self {
Self {
restart_requested: Arc::new(AtomicBool::new(false)),
build_scope_requested: Arc::new(std::sync::atomic::AtomicU8::new(0)),
quit_requested: Arc::new(AtomicBool::new(false)),
shutdown_complete: Arc::new(AtomicBool::new(false)),
}
}
pub fn take_restart(&self) -> bool {
self.restart_requested.swap(false, Ordering::SeqCst)
}
pub fn take_build_scope(&self) -> Option<state::BuildScope> {
let v = self.build_scope_requested.swap(0, Ordering::SeqCst);
state::BuildScope::from_u8(v)
}
pub fn should_quit(&self) -> bool {
self.quit_requested.load(Ordering::SeqCst)
}
pub fn mark_shutdown_complete(&self) {
self.shutdown_complete.store(true, Ordering::SeqCst);
}
}
pub fn setup() -> (LogTx, LogRx, DevSignals) {
let (tx, rx) = mpsc::sync_channel(512);
(tx, rx, DevSignals::new())
}
pub fn is_tty() -> bool {
use std::io::IsTerminal;
std::io::stdout().is_terminal()
}
pub fn sys_log(tx: Option<&LogTx>, line: impl Into<String>) {
match tx {
Some(t) => {
let _ = t.send(TuiEvent::Log(LogEntry {
source: LogSource::System,
line: line.into(),
}));
}
None => eprintln!("{}", line.into()),
}
}
pub fn update_status(
tx: Option<&LogTx>,
source: LogSource,
status: ServiceStatus,
detail: Option<String>,
) {
if let Some(t) = tx {
let _ = t.send(TuiEvent::Status {
source,
status,
detail,
});
}
}
pub fn update_app_status(
tx: Option<&LogTx>,
name: impl Into<String>,
status: ServiceStatus,
detail: Option<String>,
) {
if let Some(t) = tx {
let _ = t.send(TuiEvent::AppStatus {
name: name.into(),
status,
detail,
});
}
}
pub fn update_app_path(
tx: Option<&LogTx>,
name: impl Into<String>,
path: std::path::PathBuf,
) {
if let Some(t) = tx {
let _ = t.send(TuiEvent::AppPath {
name: name.into(),
path,
});
}
}
pub fn update_daemon_env_file(
tx: Option<&LogTx>,
instance: impl Into<String>,
path: std::path::PathBuf,
) {
if let Some(t) = tx {
let _ = t.send(TuiEvent::DaemonEnvFile {
instance: instance.into(),
path,
});
}
}
pub fn run(rx: LogRx, mut app: state::AppState, signals: DevSignals) -> io::Result<()> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = render_loop(&mut terminal, rx, &mut app, &signals);
execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen,
)?;
terminal::disable_raw_mode()?;
terminal.show_cursor()?;
result
}
fn render_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
rx: LogRx,
app: &mut state::AppState,
signals: &DevSignals,
) -> io::Result<()> {
use state::ShutdownPhase;
let mut last_tick = Instant::now();
loop {
loop {
match rx.try_recv() {
Ok(TuiEvent::Log(entry)) => app.push_log(entry),
Ok(TuiEvent::Status {
source,
status,
detail,
}) => app.update_service(source, status, detail),
Ok(TuiEvent::InfraHealth(s)) => {
if let Some(ref mut iv) = app.infra {
iv.services = s;
iv.last_refresh = Some(std::time::Instant::now());
}
}
Ok(TuiEvent::InfraLog(e)) => {
if let Some(ref mut iv) = app.infra {
iv.push_log(e);
}
}
Ok(TuiEvent::InfraGraph(g)) => {
if let Some(ref mut iv) = app.infra {
iv.graph = Some(g);
iv.graph_loading = false;
}
}
Ok(TuiEvent::InfraDb(d)) => {
if let Some(ref mut iv) = app.infra {
iv.db = Some(d);
iv.db_loading = false;
}
}
Ok(TuiEvent::InfraSnapshot(s)) => {
if let Some(ref mut iv) = app.infra {
iv.snapshot = Some(s);
iv.snapshot_loading = false;
}
}
Ok(TuiEvent::AppStatus { name, status, detail }) => {
app.set_app_status(&name, status, detail);
}
Ok(TuiEvent::AppPath { name, path }) => {
app.set_app_path(&name, path);
}
Ok(TuiEvent::DaemonEnvFile { instance, path }) => {
app.set_daemon_env_file(&instance, path);
}
Err(_) => break,
}
}
if signals.shutdown_complete.load(Ordering::SeqCst) {
app.shutdown_phase = ShutdownPhase::Done;
terminal.draw(|f| render::draw(f, app))?;
return Ok(());
}
terminal.draw(|f| render::draw(f, app))?;
if app.shutdown_phase == ShutdownPhase::ShuttingDown {
if last_tick.elapsed() >= TICK {
last_tick = Instant::now();
}
continue;
}
let remaining = TICK.saturating_sub(last_tick.elapsed());
if event::poll(remaining)? {
match event::read()? {
Event::Key(key) => {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
begin_shutdown(app, signals);
} else if app.search_input_active {
handle_search_input(key, app);
} else {
handle_normal(key, app, signals);
}
}
Event::Mouse(mouse_event) => {
handle_mouse(mouse_event, app);
}
_ => {}
}
}
if app
.copy_flash
.is_some_and(|t| t.elapsed() > std::time::Duration::from_secs(2))
{
app.copy_flash = None;
}
if last_tick.elapsed() >= TICK {
last_tick = Instant::now();
}
}
}
fn begin_shutdown(app: &mut state::AppState, signals: &DevSignals) {
use state::ShutdownPhase;
if app.shutdown_phase != ShutdownPhase::Running {
return;
}
app.shutdown_phase = ShutdownPhase::ShuttingDown;
app.active_pane = LogSource::System;
app.scroll_bottom();
app.clear_search();
signals.quit_requested.store(true, Ordering::SeqCst);
}
fn handle_search_input(key: event::KeyEvent, app: &mut state::AppState) {
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Enter | KeyCode::Esc => app.exit_search_input(),
KeyCode::Backspace => app.search_backspace(),
KeyCode::Up => {
if shift { app.page_up() } else { app.scroll_up() }
}
KeyCode::Down => {
if shift { app.page_down() } else { app.scroll_down() }
}
KeyCode::Char(c) => app.search_push(c),
_ => {}
}
}
fn handle_normal(key: event::KeyEvent, app: &mut state::AppState, signals: &DevSignals) {
if key.code == KeyCode::Char('I') {
app.toggle_view();
return;
}
match app.view {
state::TuiView::Infra => handle_infra_keys(key, app),
state::TuiView::Dev => handle_dev_keys(key, app, signals),
}
}
fn handle_infra_keys(key: event::KeyEvent, app: &mut state::AppState) {
if app.infra.is_none() {
return;
}
match key.code {
KeyCode::Esc => {
app.view = state::TuiView::Dev;
}
KeyCode::Char('1') => {
if let Some(iv) = &mut app.infra {
iv.set_tab(state::InfraTab::Status);
}
}
KeyCode::Char('2') => {
if let Some(iv) = &mut app.infra {
iv.set_tab(state::InfraTab::Graph);
}
}
KeyCode::Char('3') => {
if let Some(iv) = &mut app.infra {
iv.set_tab(state::InfraTab::Snapshot);
}
}
KeyCode::Char('4') => {
if let Some(iv) = &mut app.infra {
iv.set_tab(state::InfraTab::Db);
}
}
KeyCode::Char('5') => {
if let Some(iv) = &mut app.infra {
iv.set_tab(state::InfraTab::Logs);
}
}
KeyCode::Tab => {
if let Some(iv) = &mut app.infra {
iv.cycle_tab(1);
}
}
KeyCode::BackTab => {
if let Some(iv) = &mut app.infra {
iv.cycle_tab(-1);
}
}
KeyCode::Up => {
if let Some(iv) = &mut app.infra {
iv.scroll_up();
}
}
KeyCode::Down => {
if let Some(iv) = &mut app.infra {
iv.scroll_down();
}
}
KeyCode::Char('g') => {
if let Some(iv) = &mut app.infra {
iv.scroll_top();
}
}
KeyCode::Char('G') => {
if let Some(iv) = &mut app.infra {
iv.scroll_bottom();
}
}
KeyCode::Char('a') | KeyCode::Char('r') | KeyCode::Char('p') => {
if let Some(iv) = &mut app.infra {
if iv.active_tab == state::InfraTab::Logs {
iv.log_filter = match key.code {
KeyCode::Char('a') => state::InfraLogFilter::All,
KeyCode::Char('r') => state::InfraLogFilter::Rgs,
_ => state::InfraLogFilter::Postgres,
};
iv.scroll_bottom();
}
}
}
_ => {}
}
}
fn handle_dev_keys(key: event::KeyEvent, app: &mut state::AppState, signals: &DevSignals) {
if app.build_dialog.is_some() {
handle_build_dialog_keys(key, app, signals);
return;
}
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Char('q') => begin_shutdown(app, signals),
KeyCode::Char('/') => app.enter_search(),
KeyCode::Esc => app.clear_search(),
KeyCode::Char('r') => {
signals.restart_requested.store(true, Ordering::SeqCst);
}
KeyCode::Char('b') => {
app.build_dialog = Some(state::BuildDialog::new());
}
KeyCode::Tab => app.cycle_focus(1),
KeyCode::BackTab => app.cycle_focus(-1),
KeyCode::Char(c @ '1'..='9') => {
let idx = (c as u8 - b'1') as usize;
if let Some(source) = state::nth_visible_service(app, idx) {
app.active_pane = source;
app.focused_pane = state::FocusedPane::Services;
}
}
KeyCode::Left if app.focused_pane == state::FocusedPane::Services => {
app.focus_next_service(-1);
}
KeyCode::Right if app.focused_pane == state::FocusedPane::Services => {
app.focus_next_service(1);
}
KeyCode::Up => {
if shift {
app.page_up()
} else {
match app.focused_pane {
state::FocusedPane::Services => app.focus_next_service(-1),
state::FocusedPane::Apps => app.focus_next_app(-1),
state::FocusedPane::Nodes => app.focus_next_node(-1),
state::FocusedPane::Logs => app.scroll_up(),
}
}
}
KeyCode::Down => {
if shift {
app.page_down()
} else {
match app.focused_pane {
state::FocusedPane::Services => app.focus_next_service(1),
state::FocusedPane::Apps => app.focus_next_app(1),
state::FocusedPane::Nodes => app.focus_next_node(1),
state::FocusedPane::Logs => app.scroll_down(),
}
}
}
KeyCode::Char('g') => app.scroll_top(),
KeyCode::Char('G') => app.scroll_bottom(),
KeyCode::Char('c') => app.clear_active_pane(),
KeyCode::Char('i') => app.cycle_instance_filter(),
_ => {}
}
}
fn handle_build_dialog_keys(
key: event::KeyEvent,
app: &mut state::AppState,
signals: &DevSignals,
) {
let Some(dialog) = app.build_dialog.as_mut() else {
return;
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('b') => {
app.build_dialog = None;
}
KeyCode::Up | KeyCode::Char('k') => dialog.move_up(),
KeyCode::Down | KeyCode::Char('j') => dialog.move_down(),
KeyCode::Enter | KeyCode::Char(' ') => {
let scope = dialog.current();
signals
.build_scope_requested
.store(scope as u8, Ordering::SeqCst);
app.build_dialog = None;
}
KeyCode::Char(c) => {
let scope = match c {
'A' => Some(state::BuildScope::All),
's' | 'S' => Some(state::BuildScope::System),
'a' => Some(state::BuildScope::Apps),
'u' | 'U' => Some(state::BuildScope::Ui),
_ => None,
};
if let Some(scope) = scope {
signals
.build_scope_requested
.store(scope as u8, Ordering::SeqCst);
app.build_dialog = None;
}
}
_ => {}
}
}
fn copy_to_clipboard(text: &str) -> bool {
use std::io::Write as _;
#[cfg(target_os = "macos")]
{
if let Ok(mut child) = std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
return child.wait().map(|s| s.success()).unwrap_or(false);
}
false
}
#[cfg(target_os = "linux")]
{
for (cmd, args) in [
("wl-copy", vec![] as Vec<&str>),
("xclip", vec!["-selection", "clipboard"]),
] {
if let Ok(mut child) = std::process::Command::new(cmd)
.args(&args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
}
if child.wait().map(|s| s.success()).unwrap_or(false) {
return true;
}
}
}
false
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = text;
false
}
}
fn handle_mouse(mouse: crossterm::event::MouseEvent, app: &mut state::AppState) {
use crossterm::event::{MouseButton, MouseEventKind};
use state::Selection;
let col = mouse.column;
let row = mouse.row;
let layout = app.layout;
let in_log_panel = col >= layout.log_scroll.x;
match mouse.kind {
MouseEventKind::ScrollUp if in_log_panel => {
app.scroll_up();
return;
}
MouseEventKind::ScrollDown if in_log_panel => {
app.scroll_down();
return;
}
_ => {}
}
let sl = layout.service_list;
if col >= sl.x && col < sl.x + sl.width && row >= sl.y && row < sl.y + sl.height {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
let hide_app = !app.app_list.is_empty();
let visible: Vec<(LogSource, &str)> = app
.active_services()
.iter()
.filter(|(s, _)| !(hide_app && *s == LogSource::App))
.map(|(s, v)| (*s, v.label))
.collect();
let mut x = sl.x + 1;
let click_x = col;
let click_row = row;
if click_row == sl.y + 1 {
for (i, (source, label)) in visible.iter().enumerate() {
let label_text = format!("{}:{}", i + 1, label);
let tab_width = (1 + label_text.chars().count() + 1 + 1 + 1) as u16;
if click_x >= x && click_x < x + tab_width {
app.active_pane = *source;
app.focused_pane = state::FocusedPane::Services;
app.auto_scroll = true;
app.clear_search();
app.selection = None;
return;
}
x += tab_width;
}
}
app.focused_pane = state::FocusedPane::Services;
}
return;
}
let nl = layout.node_list;
if nl.width > 0
&& col >= nl.x
&& col < nl.x + nl.width
&& row >= nl.y
&& row < nl.y + nl.height
{
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && row > nl.y {
let row_in_items = (row - nl.y - 1) as usize;
let idx = row_in_items / 3;
if idx < app.node_list.len() {
app.node_filter = Some(idx);
app.focused_pane = state::FocusedPane::Nodes;
app.auto_scroll = true;
app.clear_search();
app.selection = None;
}
}
return;
}
let al = layout.app_list;
if al.width > 0
&& col >= al.x
&& col < al.x + al.width
&& row >= al.y
&& row < al.y + al.height
{
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && row > al.y {
let row_in_items = (row - al.y - 1) as usize;
let idx = row_in_items / 3;
if let Some(entry) = app.app_list.get(idx) {
let name = entry.name.clone();
if let Some(inst_idx) = app.instance_names.iter().position(|n| n == &name) {
app.clear_search();
app.selection = None;
app.instance_filter = Some(inst_idx);
app.active_pane = LogSource::App;
app.focused_pane = state::FocusedPane::Apps;
app.auto_scroll = true;
}
}
}
return;
}
let ls = layout.log_scroll;
if col < ls.x || col >= ls.x + ls.width || row < ls.y || row >= ls.y + ls.height {
return;
}
if !app.search_query.is_empty() {
return;
}
let row_in_area = (row - ls.y) as usize;
let total = app.log_lines(app.active_pane).len();
let line_idx = app.log_scroll_start + row_in_area;
if line_idx >= total {
return;
}
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
app.focused_pane = state::FocusedPane::Logs;
app.selection = Some(Selection { anchor: line_idx, head: line_idx });
app.auto_scroll = false;
if app.scroll_pos == 0 {
app.scroll_pos = app.log_scroll_start;
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(sel) = app.selection.as_mut() {
sel.head = line_idx;
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(sel) = app.selection {
let text = app.get_selected_text();
if !text.is_empty() && copy_to_clipboard(&text) {
app.copy_flash = Some(std::time::Instant::now());
}
let _ = sel;
}
}
_ => {}
}
}