use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs},
Frame, Terminal,
};
use sqlx::PgPool;
use std::time::Duration;
use crate::tui::error_waterfall;
use crate::tui::tabs::{
AuthInspectorTab, CodeGenTab, DbTab, FeaturesTab, MigrationsTab, PerfTab, QueryProfilerTab,
QueueTab, RoutesTab, SchemaVisualizerTab,
};
#[cfg(feature = "tui-studio")]
use rok_testing::studio::{StudioAction, StudioTab};
pub fn copy_to_clipboard(text: &str) {
let result = if cfg!(target_os = "windows") {
std::process::Command::new("clip")
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(text.as_bytes())?;
child.wait()?;
Ok(())
})
} else if cfg!(target_os = "macos") {
std::process::Command::new("pbcopy")
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(text.as_bytes())?;
child.wait()?;
Ok(())
})
} else {
std::process::Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(text.as_bytes())?;
child.wait()?;
Ok(())
})
.or_else(|_| {
std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(text.as_bytes())?;
child.wait()?;
Ok(())
})
})
};
if result.is_err() {
let _ = std::fs::write(std::env::temp_dir().join("rok-tui-clipboard.txt"), text);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppAction {
None,
Quit,
Reload,
LoadRows,
Copy,
ToggleCommandPalette,
ProfileQuery,
MigrateUp,
MigrateDown,
MigrateAll,
AuthVerify,
AuthLookupUser,
AuthCheckBlacklist,
#[cfg(feature = "tui-studio")]
SendRequest,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandPaletteEntry {
pub key: String,
pub description: String,
pub action: AppAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tab {
#[default]
Db,
Migrations,
Features,
Queue,
Perf,
QueryProfiler,
Schema,
AuthInspector,
Routes,
CodeGen,
#[cfg(feature = "tui-studio")]
Studio,
}
impl Tab {
fn all() -> &'static [Tab] {
#[cfg(not(feature = "tui-studio"))]
{
&[
Tab::Db,
Tab::Migrations,
Tab::Features,
Tab::Queue,
Tab::Perf,
Tab::QueryProfiler,
Tab::Schema,
Tab::AuthInspector,
Tab::Routes,
Tab::CodeGen,
]
}
#[cfg(feature = "tui-studio")]
{
&[
Tab::Db,
Tab::Migrations,
Tab::Features,
Tab::Queue,
Tab::Perf,
Tab::QueryProfiler,
Tab::Schema,
Tab::AuthInspector,
Tab::Routes,
Tab::CodeGen,
Tab::Studio,
]
}
}
fn index(self) -> usize {
match self {
Tab::Db => 0,
Tab::Migrations => 1,
Tab::Features => 2,
Tab::Queue => 3,
Tab::Perf => 4,
Tab::QueryProfiler => 5,
Tab::Schema => 6,
Tab::AuthInspector => 7,
Tab::Routes => 8,
Tab::CodeGen => 9,
#[cfg(feature = "tui-studio")]
Tab::Studio => 10,
}
}
fn label(self) -> &'static str {
match self {
Tab::Db => " DB Browser ",
Tab::Migrations => " Migrations ",
Tab::Features => " Features ",
Tab::Queue => " Queue ",
Tab::Perf => " Performance ",
Tab::QueryProfiler => " Profiler ",
Tab::Schema => " Schema ",
Tab::AuthInspector => " Auth ",
Tab::Routes => " Routes ",
Tab::CodeGen => " Code Gen ",
#[cfg(feature = "tui-studio")]
Tab::Studio => " Studio ",
}
}
fn next(self) -> Self {
#[cfg(not(feature = "tui-studio"))]
{
match self {
Tab::Db => Tab::Migrations,
Tab::Migrations => Tab::Features,
Tab::Features => Tab::Queue,
Tab::Queue => Tab::Perf,
Tab::Perf => Tab::QueryProfiler,
Tab::QueryProfiler => Tab::Schema,
Tab::Schema => Tab::AuthInspector,
Tab::AuthInspector => Tab::Routes,
Tab::Routes => Tab::CodeGen,
Tab::CodeGen => Tab::Db,
}
}
#[cfg(feature = "tui-studio")]
{
match self {
Tab::Db => Tab::Migrations,
Tab::Migrations => Tab::Features,
Tab::Features => Tab::Queue,
Tab::Queue => Tab::Perf,
Tab::Perf => Tab::QueryProfiler,
Tab::QueryProfiler => Tab::Schema,
Tab::Schema => Tab::AuthInspector,
Tab::AuthInspector => Tab::Routes,
Tab::Routes => Tab::CodeGen,
Tab::CodeGen => Tab::Studio,
Tab::Studio => Tab::Db,
}
}
}
fn prev(self) -> Self {
#[cfg(not(feature = "tui-studio"))]
{
match self {
Tab::Db => Tab::CodeGen,
Tab::Migrations => Tab::Db,
Tab::Features => Tab::Migrations,
Tab::Queue => Tab::Features,
Tab::Perf => Tab::Queue,
Tab::QueryProfiler => Tab::Perf,
Tab::Schema => Tab::QueryProfiler,
Tab::AuthInspector => Tab::Schema,
Tab::Routes => Tab::AuthInspector,
Tab::CodeGen => Tab::Routes,
}
}
#[cfg(feature = "tui-studio")]
{
match self {
Tab::Db => Tab::Studio,
Tab::Migrations => Tab::Db,
Tab::Features => Tab::Migrations,
Tab::Queue => Tab::Features,
Tab::Perf => Tab::Queue,
Tab::QueryProfiler => Tab::Perf,
Tab::Schema => Tab::QueryProfiler,
Tab::AuthInspector => Tab::Schema,
Tab::Routes => Tab::AuthInspector,
Tab::CodeGen => Tab::Routes,
Tab::Studio => Tab::CodeGen,
}
}
}
}
pub struct App {
pub pool: PgPool,
pub current_tab: Tab,
pub db_tab: DbTab,
pub migrations_tab: MigrationsTab,
pub features_tab: FeaturesTab,
pub queue_tab: QueueTab,
pub perf_tab: PerfTab,
pub profiler_tab: QueryProfilerTab,
pub schema_tab: SchemaVisualizerTab,
pub auth_tab: AuthInspectorTab,
pub routes_tab: RoutesTab,
pub codegen_tab: CodeGenTab,
#[cfg(feature = "tui-studio")]
pub studio_tab: StudioTab,
pub command_palette_open: bool,
pub command_palette_state: ListState,
pub command_palette_filter: String,
pub status_message: String,
pub status_timer: u8,
}
impl App {
pub fn new(pool: PgPool) -> Self {
let mut palette_state = ListState::default();
palette_state.select(Some(0));
Self {
pool,
current_tab: Tab::default(),
db_tab: DbTab::default(),
migrations_tab: MigrationsTab::default(),
features_tab: FeaturesTab::default(),
queue_tab: QueueTab::default(),
perf_tab: PerfTab::default(),
profiler_tab: QueryProfilerTab::default(),
schema_tab: SchemaVisualizerTab::default(),
auth_tab: AuthInspectorTab::default(),
routes_tab: RoutesTab::default(),
codegen_tab: CodeGenTab::default(),
#[cfg(feature = "tui-studio")]
studio_tab: StudioTab::default(),
command_palette_open: false,
command_palette_state: palette_state,
command_palette_filter: String::new(),
status_message: String::new(),
status_timer: 0,
}
}
pub async fn load_current(&mut self) {
let pool = self.pool.clone();
match self.current_tab {
Tab::Db => self.db_tab.load(&pool).await,
Tab::Migrations => self.migrations_tab.load(&pool).await,
Tab::Features => self.features_tab.load(&pool).await,
Tab::Queue => self.queue_tab.load(&pool).await,
Tab::Perf => self.perf_tab.load(&pool).await,
Tab::QueryProfiler => self.profiler_tab.load(&pool).await,
Tab::Schema => self.schema_tab.load(&pool).await,
Tab::AuthInspector => self.auth_tab.load(&pool).await,
Tab::Routes => self.routes_tab.load(&pool).await,
Tab::CodeGen => self.codegen_tab.load(&pool).await,
#[cfg(feature = "tui-studio")]
Tab::Studio => self.studio_tab.load().await,
}
}
pub async fn load_rows(&mut self) {
let pool = self.pool.clone();
self.db_tab.load_rows(&pool).await;
}
pub fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
if self.command_palette_open {
self.render_command_palette(frame, area);
return;
}
let error_count = error_waterfall::waterfall_count();
let waterfall_h: u16 = if error_count > 0 {
(error_count.min(5) as u16) + 1
} else {
0
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(waterfall_h),
Constraint::Length(1),
])
.split(area);
self.render_tabs(frame, chunks[0]);
self.render_body(frame, chunks[1]);
if waterfall_h > 0 {
error_waterfall::render_waterfall(frame, chunks[2]);
}
self.render_status_bar(frame, chunks[3]);
}
fn render_command_palette(&self, frame: &mut Frame, area: Rect) {
let entries = self.get_palette_entries();
let filtered: Vec<&CommandPaletteEntry> = if self.command_palette_filter.is_empty() {
entries.iter().collect()
} else {
let f = self.command_palette_filter.to_lowercase();
entries
.iter()
.filter(|e| {
e.key.to_lowercase().contains(&f) || e.description.to_lowercase().contains(&f)
})
.collect()
};
let items: Vec<ListItem> = filtered
.iter()
.map(|e| {
ListItem::new(Line::from(vec![
Span::styled(format!(" {:15}", e.key), Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled(&e.description, Style::default().fg(Color::White)),
]))
})
.collect();
let input = Paragraph::new(if self.command_palette_filter.is_empty() {
" Type to filter commands..."
} else {
&self.command_palette_filter
})
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.borders(Borders::ALL)
.title(" Command Palette (Ctrl+P) "),
);
let list = List::new(items)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.block(Block::default().borders(Borders::ALL).title(" Commands "));
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.margin(2)
.split(area);
frame.render_widget(input, chunks[0]);
frame.render_stateful_widget(list, chunks[1], &mut self.command_palette_state.clone());
}
fn get_palette_entries(&self) -> Vec<CommandPaletteEntry> {
vec![
CommandPaletteEntry {
key: "⌘Q".into(),
description: "Quit".into(),
action: AppAction::Quit,
},
CommandPaletteEntry {
key: "F5".into(),
description: "Refresh current tab".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "Ctrl+C".into(),
description: "Copy selection to clipboard".into(),
action: AppAction::Copy,
},
CommandPaletteEntry {
key: "Tab".into(),
description: fmt_tab_desc(self.current_tab, "Switch to next panel"),
action: AppAction::None,
},
CommandPaletteEntry {
key: "←→".into(),
description: "Switch tabs".into(),
action: AppAction::None,
},
CommandPaletteEntry {
key: "F1".into(),
description: "Go to DB Browser".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F2".into(),
description: "Go to Migrations".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F3".into(),
description: "Go to Features".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F4".into(),
description: "Go to Queue".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F5".into(),
description: "Go to Performance".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F6".into(),
description: "Go to Profiler".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F7".into(),
description: "Go to Schema".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F8".into(),
description: "Go to Auth Inspector".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F9".into(),
description: "Go to Routes".into(),
action: AppAction::Reload,
},
CommandPaletteEntry {
key: "F10".into(),
description: "Go to Code Gen".into(),
action: AppAction::Reload,
},
]
}
fn render_tabs(&self, frame: &mut Frame, area: Rect) {
let titles: Vec<Line> = Tab::all().iter().map(|t| Line::from(t.label())).collect();
let tabs = Tabs::new(titles)
.select(self.current_tab.index())
.block(Block::default().borders(Borders::ALL).title(" rok-tui "))
.highlight_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(tabs, area);
}
fn render_body(&mut self, frame: &mut Frame, area: Rect) {
match self.current_tab {
Tab::Db => self.db_tab.render(frame, area),
Tab::Migrations => self.migrations_tab.render(frame, area),
Tab::Features => self.features_tab.render(frame, area),
Tab::Queue => self.queue_tab.render(frame, area),
Tab::Perf => self.perf_tab.render(frame, area),
Tab::QueryProfiler => self.profiler_tab.render(frame, area),
Tab::Schema => self.schema_tab.render(frame, area),
Tab::AuthInspector => self.auth_tab.render(frame, area),
Tab::Routes => self.routes_tab.render(frame, area),
Tab::CodeGen => self.codegen_tab.render(frame, area),
#[cfg(feature = "tui-studio")]
Tab::Studio => self.studio_tab.render(frame, area),
}
}
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
let tabs_hint = "F1-F10";
let message = if !self.status_message.is_empty() {
format!(" {}", self.status_message)
} else {
String::new()
};
let help = Paragraph::new(Line::from(vec![
Span::styled(format!(" {tabs_hint}"), Style::default().fg(Color::Cyan)),
Span::raw("/"),
Span::styled("←→", Style::default().fg(Color::Cyan)),
Span::raw(" Tab switch "),
Span::styled("F5", Style::default().fg(Color::Cyan)),
Span::raw(" Refresh "),
Span::styled("Ctrl+P", Style::default().fg(Color::Cyan)),
Span::raw(" Palette "),
Span::styled("Ctrl+C", Style::default().fg(Color::Cyan)),
Span::raw(" Copy "),
Span::styled("q", Style::default().fg(Color::Red)),
Span::raw(" Quit"),
Span::styled(message, Style::default().fg(Color::Green)),
]))
.block(Block::default());
frame.render_widget(help, area);
}
pub fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) -> AppAction {
if self.command_palette_open {
match key.code {
KeyCode::Esc => {
self.command_palette_open = false;
self.command_palette_filter.clear();
AppAction::None
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.command_palette_filter.push(c);
AppAction::None
}
KeyCode::Backspace => {
self.command_palette_filter.pop();
AppAction::None
}
KeyCode::Up => {
let i = self
.command_palette_state
.selected()
.unwrap_or(0)
.saturating_sub(1);
self.command_palette_state.select(Some(i));
AppAction::None
}
KeyCode::Down => {
let i = self.command_palette_state.selected().unwrap_or(0) + 1;
self.command_palette_state.select(Some(i));
AppAction::None
}
KeyCode::Enter => {
self.command_palette_open = false;
self.command_palette_filter.clear();
let entries = self.get_palette_entries();
let filtered: Vec<&CommandPaletteEntry> =
if self.command_palette_filter.is_empty() {
entries.iter().collect()
} else {
let f = self.command_palette_filter.to_lowercase();
entries
.iter()
.filter(|e| {
e.key.to_lowercase().contains(&f)
|| e.description.to_lowercase().contains(&f)
})
.collect()
};
if let Some(selected) = self.command_palette_state.selected() {
if selected < filtered.len() {
return filtered[selected].action;
}
}
AppAction::None
}
_ => AppAction::None,
}
} else {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q')
if key.modifiers.contains(KeyModifiers::CONTROL)
|| self.current_tab != Tab::Db =>
{
AppAction::Quit
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
AppAction::ToggleCommandPalette
}
KeyCode::Left | KeyCode::BackTab => {
self.current_tab = self.current_tab.prev();
AppAction::Reload
}
KeyCode::Right => {
self.current_tab = self.current_tab.next();
AppAction::Reload
}
KeyCode::F(1) => {
self.current_tab = Tab::Db;
AppAction::Reload
}
KeyCode::F(2) => {
self.current_tab = Tab::Migrations;
AppAction::Reload
}
KeyCode::F(3) => {
self.current_tab = Tab::Features;
AppAction::Reload
}
KeyCode::F(4) => {
self.current_tab = Tab::Queue;
AppAction::Reload
}
KeyCode::F(5) => {
self.current_tab = Tab::Perf;
AppAction::Reload
}
KeyCode::F(6) => {
self.current_tab = Tab::QueryProfiler;
AppAction::Reload
}
KeyCode::F(7) => {
self.current_tab = Tab::Schema;
AppAction::Reload
}
KeyCode::F(8) => {
self.current_tab = Tab::AuthInspector;
AppAction::Reload
}
KeyCode::F(9) => {
self.current_tab = Tab::Routes;
AppAction::Reload
}
KeyCode::F(10) => {
self.current_tab = Tab::CodeGen;
AppAction::Reload
}
_ => AppAction::None,
}
}
}
}
fn fmt_tab_desc(current: Tab, action: &str) -> String {
format!("{} — {}", action, current.label().trim())
}
pub async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
mut app: App,
) -> anyhow::Result<()> {
app.load_current().await;
loop {
terminal.draw(|f| app.render(f))?;
if !event::poll(Duration::from_millis(200))? {
continue;
}
if let Event::Key(key) = event::read()? {
let global_action = app.handle_global_key(key);
let action = if global_action != AppAction::None {
global_action
} else {
match app.current_tab {
Tab::Db => app.db_tab.handle_key(key),
Tab::Migrations => app.migrations_tab.handle_key(key),
Tab::Features => app.features_tab.handle_key(key),
Tab::Queue => app.queue_tab.handle_key(key),
Tab::Perf => app.perf_tab.handle_key(key),
Tab::QueryProfiler => app.profiler_tab.handle_key(key),
Tab::Schema => app.schema_tab.handle_key(key),
Tab::AuthInspector => app.auth_tab.handle_key(key),
Tab::Routes => app.routes_tab.handle_key(key),
Tab::CodeGen => app.codegen_tab.handle_key(key),
#[cfg(feature = "tui-studio")]
Tab::Studio => match app.studio_tab.handle_key(key) {
StudioAction::None => AppAction::None,
StudioAction::Reload => AppAction::Reload,
StudioAction::SendRequest => AppAction::SendRequest,
},
}
};
match action {
AppAction::Quit => break,
AppAction::Reload => {
app.command_palette_open = false;
app.load_current().await;
}
AppAction::LoadRows => {
app.command_palette_open = false;
app.load_rows().await;
}
AppAction::Copy => {
let text = app.db_tab.selected_row_text();
if !text.is_empty() {
copy_to_clipboard(&text);
app.status_message = "✓ Copied to clipboard".into();
app.status_timer = 5;
}
}
AppAction::ToggleCommandPalette => {
app.command_palette_open = !app.command_palette_open;
if app.command_palette_open {
app.command_palette_filter.clear();
app.command_palette_state.select(Some(0));
}
}
AppAction::None => {}
AppAction::AuthVerify => {
app.auth_tab.verify_signature(&app.pool).await;
}
AppAction::AuthLookupUser => {
app.auth_tab.lookup_user(&app.pool).await;
}
AppAction::AuthCheckBlacklist => {
app.auth_tab.check_blacklist(&app.pool).await;
}
AppAction::ProfileQuery => {
app.profiler_tab.explain_selected(&app.pool).await;
app.load_current().await;
}
AppAction::MigrateUp => {
app.migrations_tab.apply_up(&app.pool).await;
app.load_current().await;
}
AppAction::MigrateDown => {
app.migrations_tab.rollback_down(&app.pool).await;
app.load_current().await;
}
AppAction::MigrateAll => {
app.migrations_tab.apply_all(&app.pool).await;
app.load_current().await;
}
#[cfg(feature = "tui-studio")]
AppAction::SendRequest => app.studio_tab.send_request_now().await,
}
}
if app.status_timer > 0 {
app.status_timer -= 1;
if app.status_timer == 0 {
app.status_message.clear();
}
}
}
Ok(())
}