pub mod message;
pub mod model;
pub mod update;
pub mod view;
use crate::client::Client;
use crate::error::Result;
use message::{Message, Tab};
use model::{query::EditorMode, Model};
use ratatui::crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::time::Duration;
pub async fn run_tui(client: Client) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let size = terminal.size()?;
if size.width < 80 || size.height < 24 {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
return Err(crate::error::KqlPanopticonError::Other(format!(
"Terminal too small. Minimum size: 80x24, current: {}x{}",
size.width, size.height
)));
}
let mut model = Model::new(client.clone());
let (init_tx, mut init_rx) = tokio::sync::mpsc::unbounded_channel::<message::Message>();
let init_client = client;
let tx = init_tx.clone();
tokio::spawn(async move {
let _ = tx.send(message::Message::SessionsRefresh);
let _ = tx.send(message::Message::PacksRefresh);
let _ = tx.send(message::Message::InvestigationsRefresh);
match init_client.force_validate_auth().await {
Ok(_) => {
let _ = tx.send(message::Message::AuthCompleted);
match init_client.list_workspaces().await {
Ok(workspaces) => {
let _ = tx.send(message::Message::WorkspacesLoaded(workspaces));
let _ = tx.send(message::Message::InitCompleted);
}
Err(e) => {
let _ = tx.send(message::Message::ShowError(format!(
"Failed to load workspaces: {}",
e
)));
let _ = tx.send(message::Message::InitCompleted);
}
}
}
Err(e) => {
let _ = tx.send(message::Message::AuthFailed(e.to_string()));
}
}
});
let result = run_app(&mut terminal, &mut model, &mut init_rx).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
model: &mut Model,
init_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Message>,
) -> Result<()> {
loop {
model.process_job_updates();
while let Ok(msg) = init_rx.try_recv() {
if matches!(msg, Message::SessionsRefresh) {
match crate::session::Session::list_all() {
Ok(sessions) => {
model.sessions.refresh_from_disk(sessions);
}
Err(e) => {
log::error!("Failed to refresh sessions during init: {}", e);
}
}
continue;
}
let new_messages = update::update(model, msg);
for new_msg in new_messages {
let _ = update::update(model, new_msg);
}
}
if model.init_state == model::InitState::Initializing {
model.spinner_frame = model.spinner_frame.wrapping_add(1);
}
terminal.draw(|f| view::ui(f, model))?;
if event::poll(Duration::from_millis(50))? {
match event::read()? {
Event::Key(key) => {
let message = handle_key_event(key.code, key.modifiers, model);
let mut messages_to_process = vec![message];
while let Some(msg) = messages_to_process.pop() {
if matches!(msg, Message::Quit) {
return Ok(());
}
if matches!(msg, Message::WorkspacesRefresh) {
match model.client.list_workspaces().await {
Ok(workspaces) => {
messages_to_process.push(Message::WorkspacesLoaded(workspaces));
}
Err(e) => {
messages_to_process.push(Message::ShowError(format!(
"Failed to refresh workspaces: {}",
e
)));
}
}
continue;
}
if matches!(msg, Message::SessionsRefresh) {
match crate::session::Session::list_all() {
Ok(sessions) => {
model.sessions.refresh_from_disk(sessions);
}
Err(e) => {
messages_to_process.push(Message::ShowError(format!(
"Failed to refresh sessions: {}",
e
)));
}
}
continue;
}
let new_messages = update::update(model, msg);
messages_to_process.extend(new_messages);
}
}
Event::Resize(_width, _height) => {
}
_ => {
}
}
}
}
}
fn handle_key_event(key: KeyCode, modifiers: KeyModifiers, model: &Model) -> Message {
if let Some(popup) = &model.popup {
return handle_popup_key(key, popup, model);
}
let in_query_edit_mode = model.current_tab == Tab::Query
&& (model.query.mode == EditorMode::Insert || model.query.mode == EditorMode::Visual);
let in_investigation_input = model.investigations.is_collecting_inputs();
if !in_query_edit_mode && !in_investigation_input {
match key {
KeyCode::Char('q') => return Message::Quit,
KeyCode::Char('r') => {
if model.current_tab == Tab::Workspaces {
return Message::WorkspacesRefresh;
} else if model.current_tab == Tab::Sessions {
return Message::SessionsRefresh;
} else if model.current_tab == Tab::Investigations {
return Message::InvestigationsRefresh;
}
}
KeyCode::Char('1') => return Message::SwitchTab(Tab::Query),
KeyCode::Char('2') => return Message::SwitchTab(Tab::Packs),
KeyCode::Char('3') => return Message::SwitchTab(Tab::Investigations),
KeyCode::Char('4') => return Message::SwitchTab(Tab::Workspaces),
KeyCode::Char('5') => return Message::SwitchTab(Tab::Settings),
KeyCode::Char('6') => return Message::SwitchTab(Tab::Jobs),
KeyCode::Char('7') => return Message::SwitchTab(Tab::Sessions),
_ => {}
}
}
if key == KeyCode::Tab {
return Message::SwitchTab(model.current_tab.next());
}
if key == KeyCode::BackTab {
return Message::SwitchTab(model.current_tab.previous());
}
if modifiers.contains(KeyModifiers::CONTROL)
&& key == KeyCode::Char('j')
&& model.current_tab == Tab::Query
{
return Message::QueryStartExecution;
}
match model.current_tab {
Tab::Settings => handle_settings_key(key),
Tab::Workspaces => handle_workspaces_key(key),
Tab::Query => handle_query_key(key, modifiers, model),
Tab::Jobs => handle_jobs_key(key),
Tab::Sessions => handle_sessions_key(key, modifiers),
Tab::Packs => handle_packs_key(key),
Tab::Investigations => handle_investigations_key(key, modifiers, model),
}
}
fn handle_popup_key(key: KeyCode, popup: &model::Popup, model: &Model) -> Message {
match popup {
model::Popup::Error(_) | model::Popup::Success(_) => {
if matches!(key, KeyCode::Esc | KeyCode::Enter) {
Message::ClosePopup
} else {
Message::NoOp
}
}
model::Popup::SettingsEdit => match key {
KeyCode::Esc => Message::SettingsCancel,
KeyCode::Enter => Message::SettingsSave,
KeyCode::Backspace => Message::SettingsInputBackspace,
KeyCode::Char(c) => Message::SettingsInputChar(c),
_ => Message::NoOp,
},
model::Popup::JobNameInput => match key {
KeyCode::Esc => Message::ClosePopup,
KeyCode::Enter => {
if let Some(ref job_name) = model.query.job_name_input {
if !job_name.trim().is_empty() {
return Message::ExecuteQuery(job_name.clone());
}
}
Message::ClosePopup
}
KeyCode::Backspace => Message::JobNameInputBackspace,
KeyCode::Char(c) => Message::JobNameInputChar(c),
_ => Message::NoOp,
},
model::Popup::SessionNameInput => match key {
KeyCode::Esc => Message::ClosePopup,
KeyCode::Enter => {
if let Some(ref name) = model.sessions.name_input {
if !name.trim().is_empty() {
return Message::SessionsSave(None);
}
}
Message::ClosePopup
}
KeyCode::Backspace => Message::SessionNameInputBackspace,
KeyCode::Char(c) => Message::SessionNameInputChar(c),
_ => Message::NoOp,
},
model::Popup::JobDetails(job_idx) => {
match key {
KeyCode::Esc | KeyCode::Enter => Message::ClosePopup,
KeyCode::Char('r') => {
if let Some(job) = model.jobs.jobs.get(*job_idx) {
use crate::tui::model::jobs::JobStatus;
let can_retry =
matches!(job.status, JobStatus::Failed | JobStatus::Completed)
&& job.retry_context.is_some();
if !can_retry {
return Message::ShowError(
"Job cannot be retried (missing context)".to_string(),
);
}
if let Some(error) = &job.error {
if !error.is_retryable() {
return Message::ShowError(
"Cannot retry: query syntax error - fix query first"
.to_string(),
);
}
}
return Message::JobsRetry;
}
Message::NoOp
}
_ => Message::NoOp,
}
}
}
}
fn handle_settings_key(key: KeyCode) -> Message {
match key {
KeyCode::Up => Message::SettingsPrevious,
KeyCode::Down => Message::SettingsNext,
KeyCode::Enter | KeyCode::Char(' ') => Message::SettingsStartEdit,
_ => Message::NoOp,
}
}
fn handle_workspaces_key(key: KeyCode) -> Message {
match key {
KeyCode::Up => Message::WorkspacesPrevious,
KeyCode::Down => Message::WorkspacesNext,
KeyCode::Char(' ') => Message::WorkspacesToggle,
KeyCode::Char('a') => Message::WorkspacesSelectAll,
KeyCode::Char('n') => Message::WorkspacesSelectNone,
_ => Message::NoOp,
}
}
fn handle_query_key(key: KeyCode, modifiers: KeyModifiers, model: &Model) -> Message {
if model.query.load_panel.is_some() {
match key {
KeyCode::Esc => return Message::QueryLoadPanelCancel,
KeyCode::Enter => return Message::QueryLoadPanelConfirm,
KeyCode::Up => return Message::QueryLoadPanelNavigate(-1),
KeyCode::Down => return Message::QueryLoadPanelNavigate(1),
KeyCode::Tab => return Message::QueryLoadPanelCycleSort,
KeyCode::Char('i') => return Message::QueryLoadPanelInvertSort,
_ => return Message::NoOp,
}
}
match model.query.mode {
EditorMode::Normal => {
match key {
KeyCode::Char('i') => Message::QueryEnterInsertMode,
KeyCode::Char('v') => Message::QueryEnterVisualMode, KeyCode::Char('a') => Message::QueryAppend, KeyCode::Char('A') => Message::QueryAppendEnd, KeyCode::Char('o') => Message::QueryOpenBelow, KeyCode::Char('O') => Message::QueryOpenAbove, KeyCode::Char('x') => Message::QueryDeleteChar, KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => {
Message::QueryDeleteLine
} KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => {
Message::QueryUndo
}
KeyCode::Char('r') if modifiers.contains(KeyModifiers::CONTROL) => {
Message::QueryRedo
}
KeyCode::Char('c') => Message::QueryClear, KeyCode::Char('l') => Message::QueryOpenLoadPanel, KeyCode::Char('[') => Message::QueryPrevPackQuery, KeyCode::Char(']') => Message::QueryNextPackQuery, KeyCode::Char('h') | KeyCode::Left => Message::QueryMoveCursor(KeyCode::Left),
KeyCode::Char('j') | KeyCode::Down => Message::QueryMoveCursor(KeyCode::Down),
KeyCode::Char('k') | KeyCode::Up => Message::QueryMoveCursor(KeyCode::Up),
KeyCode::Right => Message::QueryMoveCursor(KeyCode::Right),
KeyCode::Char('0') => Message::QueryMoveCursor(KeyCode::Home),
KeyCode::Char('$') => Message::QueryMoveCursor(KeyCode::End),
KeyCode::Char('g') => Message::QueryMoveTop,
KeyCode::Char('G') => Message::QueryMoveBottom,
_ => Message::NoOp,
}
}
EditorMode::Insert => {
match key {
KeyCode::Esc => Message::QueryExitInsertMode,
_ => Message::QueryInput(ratatui::crossterm::event::KeyEvent::new(key, modifiers)),
}
}
EditorMode::Visual => {
match key {
KeyCode::Esc => Message::QueryExitVisualMode,
KeyCode::Char('y') => Message::QueryYank, KeyCode::Char('d') | KeyCode::Char('x') => Message::QueryDeleteSelection, KeyCode::Char('h') | KeyCode::Left => Message::QueryMoveCursor(KeyCode::Left),
KeyCode::Char('j') | KeyCode::Down => Message::QueryMoveCursor(KeyCode::Down),
KeyCode::Char('k') | KeyCode::Up => Message::QueryMoveCursor(KeyCode::Up),
KeyCode::Char('l') | KeyCode::Right => Message::QueryMoveCursor(KeyCode::Right),
KeyCode::Char('0') => Message::QueryMoveCursor(KeyCode::Home),
KeyCode::Char('$') => Message::QueryMoveCursor(KeyCode::End),
KeyCode::Char('g') => Message::QueryMoveTop,
KeyCode::Char('G') => Message::QueryMoveBottom,
_ => Message::NoOp,
}
}
}
}
fn handle_jobs_key(key: KeyCode) -> Message {
match key {
KeyCode::Up => Message::JobsPrevious,
KeyCode::Down => Message::JobsNext,
KeyCode::Enter => Message::JobsViewDetails,
KeyCode::Char('c') => Message::JobsClearCompleted,
KeyCode::Char('r') => Message::JobsRetry,
_ => Message::NoOp,
}
}
fn handle_sessions_key(key: KeyCode, modifiers: KeyModifiers) -> Message {
match key {
KeyCode::Up => Message::SessionsPrevious,
KeyCode::Down => Message::SessionsNext,
KeyCode::Char('n') => Message::SessionsStartNew,
KeyCode::Char('s') => {
if modifiers.contains(KeyModifiers::SHIFT) {
Message::SessionsStartNew
} else {
Message::SessionsSave(None)
}
}
KeyCode::Char('l') => Message::SessionsLoad,
KeyCode::Char('d') => Message::SessionsDelete,
KeyCode::Char('p') => Message::SessionExportAsPack,
_ => Message::NoOp,
}
}
fn handle_packs_key(key: KeyCode) -> Message {
match key {
KeyCode::Up => Message::PacksPrevious,
KeyCode::Down => Message::PacksNext,
KeyCode::Char('r') => Message::PacksRefresh,
KeyCode::Enter => Message::PacksLoadQuery,
KeyCode::Char('e') => Message::PacksExecute,
KeyCode::Char('s') => Message::PacksSave,
_ => Message::NoOp,
}
}
fn handle_investigations_key(key: KeyCode, _modifiers: KeyModifiers, model: &Model) -> Message {
if model.investigations.is_collecting_inputs() {
match key {
KeyCode::Esc => Message::InvestigationsInputCancel,
KeyCode::Enter => Message::InvestigationsInputConfirm,
KeyCode::Tab | KeyCode::Down => Message::InvestigationsInputNext,
KeyCode::BackTab | KeyCode::Up => Message::InvestigationsInputPrev,
KeyCode::Backspace => Message::InvestigationsInputBackspace,
KeyCode::Char(c) => Message::InvestigationsInputChar(c),
_ => Message::NoOp,
}
} else {
match key {
KeyCode::Up => Message::InvestigationsPrevious,
KeyCode::Down => Message::InvestigationsNext,
KeyCode::Char('r') => Message::InvestigationsRefresh,
KeyCode::Enter => Message::InvestigationsLoadDetails,
KeyCode::Char('e') => Message::InvestigationsStartExecution,
_ => Message::NoOp,
}
}
}