feat(usable): package gui-host validation snapshot
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use std::env;
|
||||
use std::io::{self, stdout};
|
||||
use std::io::{self, stdin, stdout, IsTerminal};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::thread;
|
||||
@@ -29,12 +29,27 @@ use ratatui::{
|
||||
};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
if let Some(message) = tty_requirement_message(stdin().is_terminal(), stdout().is_terminal()) {
|
||||
eprintln!("{message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut terminal = setup_terminal()?;
|
||||
let run_result = run_app(&mut terminal);
|
||||
restore_terminal(&mut terminal)?;
|
||||
run_result
|
||||
}
|
||||
|
||||
fn tty_requirement_message(stdin_is_tty: bool, stdout_is_tty: bool) -> Option<String> {
|
||||
if stdin_is_tty && stdout_is_tty {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"dbtool-tui requires an interactive TTY on stdin and stdout.\nDetected stdin TTY: {stdin_is_tty}\nDetected stdout TTY: {stdout_is_tty}\nUse `scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui` for repeatable smoke validation.\n`--help` is not a supported TUI smoke path."
|
||||
))
|
||||
}
|
||||
|
||||
fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<std::io::Stdout>>> {
|
||||
enable_raw_mode()?;
|
||||
let mut output = stdout();
|
||||
@@ -43,9 +58,7 @@ fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<std::io::Stdout>>> {
|
||||
Terminal::new(backend)
|
||||
}
|
||||
|
||||
fn restore_terminal(
|
||||
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||
) -> io::Result<()> {
|
||||
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>) -> io::Result<()> {
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()
|
||||
@@ -291,7 +304,10 @@ struct BrowserSchema {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum BrowserRow {
|
||||
Schema(usize),
|
||||
Object { schema_index: usize, object_index: usize },
|
||||
Object {
|
||||
schema_index: usize,
|
||||
object_index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -406,6 +422,7 @@ struct ConnectionRecord {
|
||||
environment_label: &'static str,
|
||||
database_label: &'static str,
|
||||
mode_label: &'static str,
|
||||
query_qualifier: &'static str,
|
||||
validation_label: String,
|
||||
status: ConnectionHealth,
|
||||
browser_schemas: &'static [BrowserSchema],
|
||||
@@ -434,16 +451,12 @@ impl ConnectionRecord {
|
||||
rows.push(BrowserRow::Schema(schema_index));
|
||||
|
||||
if self.expanded_schemas[schema_index] && schema.state == SchemaState::Ready {
|
||||
rows.extend(
|
||||
schema
|
||||
.objects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(object_index, _)| BrowserRow::Object {
|
||||
schema_index,
|
||||
object_index,
|
||||
}),
|
||||
);
|
||||
rows.extend(schema.objects.iter().enumerate().map(|(object_index, _)| {
|
||||
BrowserRow::Object {
|
||||
schema_index,
|
||||
object_index,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,8 +493,11 @@ impl ConnectionRecord {
|
||||
}
|
||||
|
||||
fn move_browser_selection(&mut self, delta: isize) -> String {
|
||||
self.selected_browser_row =
|
||||
cycle_index(self.selected_browser_row, self.visible_browser_rows().len(), delta);
|
||||
self.selected_browser_row = cycle_index(
|
||||
self.selected_browser_row,
|
||||
self.visible_browser_rows().len(),
|
||||
delta,
|
||||
);
|
||||
|
||||
match self.selected_browser_row() {
|
||||
BrowserRow::Schema(index) => {
|
||||
@@ -619,6 +635,7 @@ impl Default for App {
|
||||
environment_label: "Local file",
|
||||
database_label: "main",
|
||||
mode_label: "Evidence review",
|
||||
query_qualifier: "main",
|
||||
validation_label: String::from("Live local demo"),
|
||||
status: ConnectionHealth::Ready,
|
||||
browser_schemas: &SQLITE_SCHEMAS,
|
||||
@@ -634,75 +651,44 @@ impl Default for App {
|
||||
ConnectionRecord {
|
||||
profile: postgres_profile.clone(),
|
||||
summary: ConnectionSummary::from(&postgres_profile),
|
||||
environment_label: "Production",
|
||||
database_label: "analytics",
|
||||
mode_label: "Read-only audit",
|
||||
validation_label: String::from("Unavailable in local runner"),
|
||||
status: ConnectionHealth::Failed,
|
||||
environment_label: "Docker demo",
|
||||
database_label: "dbtool_demo",
|
||||
mode_label: "Network live",
|
||||
query_qualifier: "qa_demo",
|
||||
validation_label: String::from("Ready for activation"),
|
||||
status: ConnectionHealth::Ready,
|
||||
browser_schemas: &POSTGRES_SCHEMAS,
|
||||
expanded_schemas: vec![true, false, false, false],
|
||||
selected_browser_row: 1,
|
||||
details: &[
|
||||
"Production profile stays visible for layout review.",
|
||||
"This runner has no live network credentials.",
|
||||
"Use sqlite-local for end-to-end verification.",
|
||||
"Docker-backed PostgreSQL demo uses host.docker.internal:55432.",
|
||||
"Activate this target to load live schema, query, and export state.",
|
||||
"Query drafts switch to qa_demo-qualified SQL for this connection.",
|
||||
],
|
||||
failure_message: Some(String::from(
|
||||
"Live Postgres access is not available in this local runner. Switch to sqlite-local for a working end-to-end path.",
|
||||
)),
|
||||
failure_message: None,
|
||||
},
|
||||
ConnectionRecord {
|
||||
profile: mysql_profile.clone(),
|
||||
summary: ConnectionSummary::from(&mysql_profile),
|
||||
environment_label: "Staging",
|
||||
environment_label: "Docker demo",
|
||||
database_label: "qa_demo",
|
||||
mode_label: "Write enabled",
|
||||
validation_label: String::from("Unavailable in local runner"),
|
||||
status: ConnectionHealth::Failed,
|
||||
mode_label: "Network live",
|
||||
query_qualifier: "qa_demo",
|
||||
validation_label: String::from("Ready for activation"),
|
||||
status: ConnectionHealth::Ready,
|
||||
browser_schemas: &MYSQL_SCHEMAS,
|
||||
expanded_schemas: vec![true, false],
|
||||
selected_browser_row: 1,
|
||||
details: &[
|
||||
"Staging profile stays visible for multi-target review.",
|
||||
"Network targets stay in a readable failed state here.",
|
||||
"Use sqlite-local for the live demo path.",
|
||||
"Docker-backed MySQL demo uses host.docker.internal:53306.",
|
||||
"Activate this target to load live schema, query, and export state.",
|
||||
"Query drafts switch to qa_demo-qualified SQL for this connection.",
|
||||
],
|
||||
failure_message: Some(String::from(
|
||||
"Live MySQL access is not available in this local runner. Use sqlite-local for end-to-end query and export verification.",
|
||||
)),
|
||||
failure_message: None,
|
||||
},
|
||||
];
|
||||
|
||||
let query_drafts = vec![
|
||||
QueryDraft {
|
||||
name: "account_ticket_summary.sql",
|
||||
sql: String::from(
|
||||
"select\n a.id as account_id,\n a.display_name,\n count(t.id) as ticket_count,\n sum(case when t.status = 'open' then 1 else 0 end) as open_ticket_count\nfrom accounts a\nleft join tickets t on t.account_id = a.id\ngroup by a.id, a.display_name\norder by a.id;",
|
||||
),
|
||||
detail: "Success state keeps execution detail adjacent to the real result table.",
|
||||
},
|
||||
QueryDraft {
|
||||
name: "ticket_export_preview.sql",
|
||||
sql: String::from(
|
||||
"select\n t.id as ticket_id,\n a.email,\n t.title,\n t.status,\n t.amount_cents,\n t.created_at\nfrom tickets t\njoin accounts a on a.id = t.account_id\norder by t.id;",
|
||||
),
|
||||
detail: "Use [ and ] in Results focus to page across wide real result columns.",
|
||||
},
|
||||
QueryDraft {
|
||||
name: "empty_recent_tickets.sql",
|
||||
sql: String::from(
|
||||
"select\n id,\n status,\n created_at\nfrom tickets\nwhere created_at > '2026-04-01T00:00:00Z'\n and status = 'open'\norder by created_at desc;",
|
||||
),
|
||||
detail: "Empty results stay distinct from query failures and remain exportable.",
|
||||
},
|
||||
QueryDraft {
|
||||
name: "bad_syntax.sql",
|
||||
sql: String::from(
|
||||
"select id, status\nform tickets\nwhere status = 'open';",
|
||||
),
|
||||
detail: "Error summary stays visible in the workspace with the offending query buffer.",
|
||||
},
|
||||
];
|
||||
let query_drafts = build_query_drafts("main");
|
||||
|
||||
Self {
|
||||
should_quit: false,
|
||||
@@ -803,7 +789,8 @@ impl App {
|
||||
KeyCode::Char('j') => self.start_export(ExportFormat::Json),
|
||||
KeyCode::Char('i') if self.focus == FocusArea::Editor => {
|
||||
self.editor_mode = EditorMode::Insert;
|
||||
self.activity_message = String::from("Editor insert mode enabled. Esc exits insert mode.");
|
||||
self.activity_message =
|
||||
String::from("Editor insert mode enabled. Esc exits insert mode.");
|
||||
}
|
||||
KeyCode::Char('[') => self.handle_bracket_key(-1),
|
||||
KeyCode::Char(']') => self.handle_bracket_key(1),
|
||||
@@ -811,7 +798,9 @@ impl App {
|
||||
if self.focus == FocusArea::Connections {
|
||||
self.activate_selected_connection();
|
||||
} else if self.focus == FocusArea::Schema {
|
||||
let message = self.active_connection_record_mut().activate_browser_selection();
|
||||
let message = self
|
||||
.active_connection_record_mut()
|
||||
.activate_browser_selection();
|
||||
self.activity_message = message;
|
||||
self.update_surface_state();
|
||||
} else if self.focus == FocusArea::Editor {
|
||||
@@ -836,9 +825,12 @@ impl App {
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.current_query_draft_mut().sql.push('\n');
|
||||
self.activity_message = String::from("Inserted a newline. Press Esc, then Enter to run.");
|
||||
self.activity_message =
|
||||
String::from("Inserted a newline. Press Esc, then Enter to run.");
|
||||
}
|
||||
KeyCode::Char(ch) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => {
|
||||
KeyCode::Char(ch)
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||||
{
|
||||
self.current_query_draft_mut().sql.push(ch);
|
||||
}
|
||||
_ => {}
|
||||
@@ -884,7 +876,8 @@ impl App {
|
||||
fn handle_bracket_key(&mut self, delta: isize) {
|
||||
match self.focus {
|
||||
FocusArea::Editor => {
|
||||
self.selected_query = cycle_index(self.selected_query, self.query_drafts.len(), delta);
|
||||
self.selected_query =
|
||||
cycle_index(self.selected_query, self.query_drafts.len(), delta);
|
||||
self.clear_result_context();
|
||||
self.update_surface_state();
|
||||
self.activity_message = format!(
|
||||
@@ -925,12 +918,19 @@ impl App {
|
||||
FocusArea::Connections => {
|
||||
self.selected_connection =
|
||||
cycle_index(self.selected_connection, self.connections.len(), delta);
|
||||
let selected_name = self.selected_connection_record().summary.profile_name.clone();
|
||||
self.activity_message =
|
||||
format!("Selected connection moved to {selected_name}. Press Enter to activate it.");
|
||||
let selected_name = self
|
||||
.selected_connection_record()
|
||||
.summary
|
||||
.profile_name
|
||||
.clone();
|
||||
self.activity_message = format!(
|
||||
"Selected connection moved to {selected_name}. Press Enter to activate it."
|
||||
);
|
||||
}
|
||||
FocusArea::Schema => {
|
||||
let message = self.active_connection_record_mut().move_browser_selection(delta);
|
||||
let message = self
|
||||
.active_connection_record_mut()
|
||||
.move_browser_selection(delta);
|
||||
self.activity_message = message;
|
||||
self.update_surface_state();
|
||||
}
|
||||
@@ -938,7 +938,8 @@ impl App {
|
||||
let row_count = self.current_result_rows().len();
|
||||
|
||||
if row_count == 0 {
|
||||
self.activity_message = String::from("Current result set has no rows to navigate.");
|
||||
self.activity_message =
|
||||
String::from("Current result set has no rows to navigate.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1182,6 +1183,8 @@ impl App {
|
||||
self.selected_connection = connection_index;
|
||||
self.view = WorkspaceView::Workspace;
|
||||
self.focus = FocusArea::Editor;
|
||||
self.query_drafts = build_query_drafts(self.connections[connection_index].query_qualifier);
|
||||
self.selected_query = self.selected_query.min(self.query_drafts.len().saturating_sub(1));
|
||||
self.clear_result_context();
|
||||
|
||||
let run_id = self.next_run_id;
|
||||
@@ -1232,9 +1235,9 @@ impl App {
|
||||
let draft = self.current_query_draft();
|
||||
|
||||
match self.query_run_state {
|
||||
QueryRunState::Idle => String::from(
|
||||
"Enter runs the draft. Press i to edit. After success, x/j exports.",
|
||||
),
|
||||
QueryRunState::Idle => {
|
||||
String::from("Enter runs the draft. Press i to edit. After success, x/j exports.")
|
||||
}
|
||||
QueryRunState::Running => format!(
|
||||
"Executing {} against {}…",
|
||||
draft.name,
|
||||
@@ -1274,12 +1277,8 @@ impl App {
|
||||
|
||||
fn export_execution_copy(&self) -> String {
|
||||
match self.export_run_state {
|
||||
ExportRunState::Idle => {
|
||||
String::from("Run a row-returning query before exporting.")
|
||||
}
|
||||
ExportRunState::Running => String::from(
|
||||
"Export is running. Results stay visible.",
|
||||
),
|
||||
ExportRunState::Idle => String::from("Run a row-returning query before exporting."),
|
||||
ExportRunState::Running => String::from("Export is running. Results stay visible."),
|
||||
ExportRunState::Success => self
|
||||
.last_export_response
|
||||
.as_ref()
|
||||
@@ -1361,7 +1360,8 @@ impl App {
|
||||
};
|
||||
self.last_query_response = Some(response);
|
||||
self.last_query_error = None;
|
||||
self.activity_message = String::from("Query execution finished and workspace state updated.");
|
||||
self.activity_message =
|
||||
String::from("Query execution finished and workspace state updated.");
|
||||
}
|
||||
Err(error) => {
|
||||
self.query_run_state = QueryRunState::Error;
|
||||
@@ -1396,7 +1396,11 @@ impl App {
|
||||
record.failure_message = None;
|
||||
record.browser_schemas = load_result.browser_schemas;
|
||||
record.expanded_schemas = vec![true; record.browser_schemas.len()];
|
||||
record.selected_browser_row = if record.browser_schemas.is_empty() { 0 } else { 0 };
|
||||
record.selected_browser_row = if record.browser_schemas.is_empty() {
|
||||
0
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.activity_message = format!(
|
||||
"Active connection {} loaded with {} schema entries.",
|
||||
record.summary.profile_name,
|
||||
@@ -1424,11 +1428,7 @@ impl App {
|
||||
self.update_surface_state();
|
||||
}
|
||||
|
||||
fn apply_export_result(
|
||||
&mut self,
|
||||
result: Result<ExportResponse, AppError>,
|
||||
duration_ms: u128,
|
||||
) {
|
||||
fn apply_export_result(&mut self, result: Result<ExportResponse, AppError>, duration_ms: u128) {
|
||||
self.pending_export_run_id = None;
|
||||
self.last_export_duration_ms = Some(duration_ms);
|
||||
|
||||
@@ -1453,7 +1453,12 @@ impl App {
|
||||
fn default_export_request(&self, format: ExportFormat) -> ExportRequest {
|
||||
let filename = format!(
|
||||
"dbtool-tui-{}-{}.{}",
|
||||
sanitize_filename(self.active_connection_record().summary.profile_name.as_str()),
|
||||
sanitize_filename(
|
||||
self.active_connection_record()
|
||||
.summary
|
||||
.profile_name
|
||||
.as_str()
|
||||
),
|
||||
sanitize_filename(self.current_query_draft().name),
|
||||
format.file_extension()
|
||||
);
|
||||
@@ -1810,7 +1815,9 @@ fn load_connection_context(profile: &ConnectionProfile) -> Result<ConnectionLoad
|
||||
})
|
||||
}
|
||||
|
||||
fn build_browser_schemas(profile: &ConnectionProfile) -> Result<&'static [BrowserSchema], AppError> {
|
||||
fn build_browser_schemas(
|
||||
profile: &ConnectionProfile,
|
||||
) -> Result<&'static [BrowserSchema], AppError> {
|
||||
let root_request = InspectRequest {
|
||||
schema: None,
|
||||
table: None,
|
||||
@@ -1887,10 +1894,7 @@ fn build_browser_schemas(profile: &ConnectionProfile) -> Result<&'static [Browse
|
||||
name: leak_str(table.name.clone()),
|
||||
kind: object_kind_from_str(table.kind.as_str()),
|
||||
columns: leak_columns(Vec::new()),
|
||||
note: leak_str(format!(
|
||||
"Column inspect failed: {}",
|
||||
error.message
|
||||
)),
|
||||
note: leak_str(format!("Column inspect failed: {}", error.message)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -2018,10 +2022,10 @@ fn postgres_profile() -> ConnectionProfile {
|
||||
ConnectionTarget::new(
|
||||
DatabaseKind::Postgres,
|
||||
ConnectionTransport::Tcp {
|
||||
host: String::from("reporting.internal"),
|
||||
port: 55432,
|
||||
host: tui_demo_host("DBTOOL_TUI_POSTGRES_HOST"),
|
||||
port: tui_demo_port("DBTOOL_TUI_POSTGRES_PORT", 55432),
|
||||
},
|
||||
Some(String::from("analytics")),
|
||||
Some(String::from("dbtool_demo")),
|
||||
Some(String::from("dbtool")),
|
||||
)
|
||||
.expect("postgres target should be valid"),
|
||||
@@ -2036,18 +2040,67 @@ fn mysql_profile() -> ConnectionProfile {
|
||||
ConnectionTarget::new(
|
||||
DatabaseKind::Mysql,
|
||||
ConnectionTransport::Tcp {
|
||||
host: String::from("orders.internal"),
|
||||
port: 53306,
|
||||
host: tui_demo_host("DBTOOL_TUI_MYSQL_HOST"),
|
||||
port: tui_demo_port("DBTOOL_TUI_MYSQL_PORT", 53306),
|
||||
},
|
||||
Some(String::from("qa_demo")),
|
||||
Some(String::from("qa_readonly")),
|
||||
Some(String::from("dbtool")),
|
||||
)
|
||||
.expect("mysql target should be valid"),
|
||||
Some(String::from("ORDERS_DB_PASSWORD")),
|
||||
Some(String::from("DBTOOL_PASSWORD")),
|
||||
)
|
||||
.expect("mysql profile should be valid")
|
||||
}
|
||||
|
||||
fn build_query_drafts(schema_qualifier: &str) -> Vec<QueryDraft> {
|
||||
let accounts_table = format!("{schema_qualifier}.accounts");
|
||||
let tickets_table = format!("{schema_qualifier}.tickets");
|
||||
|
||||
vec![
|
||||
QueryDraft {
|
||||
name: "account_ticket_summary.sql",
|
||||
sql: format!(
|
||||
"select\n a.id as account_id,\n a.display_name,\n count(t.id) as ticket_count,\n sum(case when t.status = 'open' then 1 else 0 end) as open_ticket_count\nfrom {accounts_table} a\nleft join {tickets_table} t on t.account_id = a.id\ngroup by a.id, a.display_name\norder by a.id;"
|
||||
),
|
||||
detail: "Success state keeps execution detail adjacent to the real result table.",
|
||||
},
|
||||
QueryDraft {
|
||||
name: "ticket_export_preview.sql",
|
||||
sql: format!(
|
||||
"select\n t.id as ticket_id,\n a.email,\n t.title,\n t.status,\n t.amount_cents,\n t.created_at\nfrom {tickets_table} t\njoin {accounts_table} a on a.id = t.account_id\norder by t.id;"
|
||||
),
|
||||
detail: "Use [ and ] in Results focus to page across wide real result columns.",
|
||||
},
|
||||
QueryDraft {
|
||||
name: "empty_recent_tickets.sql",
|
||||
sql: format!(
|
||||
"select\n id,\n status,\n created_at\nfrom {tickets_table}\nwhere created_at > '2026-04-01T00:00:00Z'\n and status = 'open'\norder by created_at desc;"
|
||||
),
|
||||
detail: "Empty results stay distinct from query failures and remain exportable.",
|
||||
},
|
||||
QueryDraft {
|
||||
name: "bad_syntax.sql",
|
||||
sql: format!("select id, status\nform {tickets_table}\nwhere status = 'open';"),
|
||||
detail: "Error summary stays visible in the workspace with the offending query buffer.",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn tui_demo_host(env_name: &str) -> String {
|
||||
env::var(env_name)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| String::from("host.docker.internal"))
|
||||
}
|
||||
|
||||
fn tui_demo_port(env_name: &str, default_port: u16) -> u16 {
|
||||
env::var(env_name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.filter(|port| *port != 0)
|
||||
.unwrap_or(default_port)
|
||||
}
|
||||
|
||||
fn sanitize_filename(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
@@ -2258,50 +2311,54 @@ fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
|
||||
fn render_connections(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let items = app.connections.iter().enumerate().map(|(index, connection)| {
|
||||
let marker = if index == app.active_connection {
|
||||
"Current"
|
||||
} else {
|
||||
"Available"
|
||||
};
|
||||
let items = app
|
||||
.connections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, connection)| {
|
||||
let marker = if index == app.active_connection {
|
||||
"Current"
|
||||
} else {
|
||||
"Available"
|
||||
};
|
||||
|
||||
let style = if index == app.selected_connection {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if connection.status == ConnectionHealth::Failed {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if index == app.active_connection {
|
||||
Style::default()
|
||||
.fg(connection.status.accent())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Gray)
|
||||
};
|
||||
let style = if index == app.selected_connection {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if connection.status == ConnectionHealth::Failed {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if index == app.active_connection {
|
||||
Style::default()
|
||||
.fg(connection.status.accent())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Gray)
|
||||
};
|
||||
|
||||
let detail_style = if index == app.selected_connection {
|
||||
Style::default().fg(Color::Black)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let detail_style = if index == app.selected_connection {
|
||||
Style::default().fg(Color::Black)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
ListItem::new(Text::from(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(marker, style),
|
||||
Span::raw(" · "),
|
||||
Span::styled(connection.summary.profile_name.clone(), style),
|
||||
Span::raw(" · "),
|
||||
Span::styled(connection.status.label(), style),
|
||||
]),
|
||||
Line::from(Span::styled(connection.subtitle(), detail_style)),
|
||||
Line::from(Span::styled(connection.endpoint_summary(), detail_style)),
|
||||
Line::from(Span::styled(
|
||||
format!("Validation: {}", connection.validation_label),
|
||||
detail_style,
|
||||
)),
|
||||
]))
|
||||
});
|
||||
ListItem::new(Text::from(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(marker, style),
|
||||
Span::raw(" · "),
|
||||
Span::styled(connection.summary.profile_name.clone(), style),
|
||||
Span::raw(" · "),
|
||||
Span::styled(connection.status.label(), style),
|
||||
]),
|
||||
Line::from(Span::styled(connection.subtitle(), detail_style)),
|
||||
Line::from(Span::styled(connection.endpoint_summary(), detail_style)),
|
||||
Line::from(Span::styled(
|
||||
format!("Validation: {}", connection.validation_label),
|
||||
detail_style,
|
||||
)),
|
||||
]))
|
||||
});
|
||||
|
||||
let widget = List::new(items.collect::<Vec<_>>()).block(panel_block(
|
||||
"Connections",
|
||||
@@ -2477,10 +2534,16 @@ fn render_editor(frame: &mut Frame, area: Rect, app: &App) {
|
||||
selected.summary.profile_name
|
||||
)));
|
||||
lines.push(Line::from(format!("Driver: {}", selected.summary.driver)));
|
||||
lines.push(Line::from(format!("Endpoint: {}", selected.summary.endpoint)));
|
||||
lines.push(Line::from(format!(
|
||||
"Endpoint: {}",
|
||||
selected.summary.endpoint
|
||||
)));
|
||||
lines.push(Line::from(format!("Database: {}", selected.database_label)));
|
||||
lines.push(Line::from(format!("Mode: {}", selected.mode_label)));
|
||||
lines.push(Line::from(format!("Validation: {}", selected.validation_label)));
|
||||
lines.push(Line::from(format!(
|
||||
"Validation: {}",
|
||||
selected.validation_label
|
||||
)));
|
||||
}
|
||||
WorkspaceView::Help => {
|
||||
lines.push(Line::from("Query Workflow"));
|
||||
@@ -2520,10 +2583,7 @@ fn render_results(frame: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
WorkspaceView::Connections => {
|
||||
let widget = Paragraph::new(Text::from(vec![
|
||||
Line::from(format!(
|
||||
"Current target: {}",
|
||||
active.summary.profile_name
|
||||
)),
|
||||
Line::from(format!("Current target: {}", active.summary.profile_name)),
|
||||
Line::from(""),
|
||||
Line::from("Execution workflow"),
|
||||
Line::from("- Enter runs the current query"),
|
||||
@@ -2577,7 +2637,11 @@ fn render_results(frame: &mut Frame, area: Rect, app: &App) {
|
||||
frame.render_widget(table, area);
|
||||
} else {
|
||||
let widget = Paragraph::new(Text::from(vec![
|
||||
Line::from(format!("Schema: {} ({})", schema.name, schema.state.label())),
|
||||
Line::from(format!(
|
||||
"Schema: {} ({})",
|
||||
schema.name,
|
||||
schema.state.label()
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(schema.message),
|
||||
Line::from(""),
|
||||
@@ -2684,19 +2748,17 @@ fn render_results(frame: &mut Frame, area: Rect, app: &App) {
|
||||
.map(|_| Constraint::Length(16))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let table = ratatui::widgets::Table::new(
|
||||
visible_rows.collect::<Vec<_>>(),
|
||||
constraints,
|
||||
)
|
||||
.header(
|
||||
Row::new(header).style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)
|
||||
.block(panel_block("Results", app.focus == FocusArea::Results))
|
||||
.column_spacing(1);
|
||||
let table =
|
||||
ratatui::widgets::Table::new(visible_rows.collect::<Vec<_>>(), constraints)
|
||||
.header(
|
||||
Row::new(header).style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
)
|
||||
.block(panel_block("Results", app.focus == FocusArea::Results))
|
||||
.column_spacing(1);
|
||||
|
||||
frame.render_widget(table, area);
|
||||
}
|
||||
@@ -2859,7 +2921,11 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) {
|
||||
Line::from(format!("Draft: {}", draft.name)),
|
||||
Line::from(app.query_execution_copy()),
|
||||
Line::from(""),
|
||||
Line::from(format!("Browser target: {} ({})", schema.name, schema.state.label())),
|
||||
Line::from(format!(
|
||||
"Browser target: {} ({})",
|
||||
schema.name,
|
||||
schema.state.label()
|
||||
)),
|
||||
Line::from(active.browser_status_message()),
|
||||
];
|
||||
|
||||
@@ -2902,7 +2968,10 @@ fn render_status(frame: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
|
||||
let widget = Paragraph::new(Text::from(lines))
|
||||
.block(panel_block("Status & Activity", app.focus == FocusArea::Status))
|
||||
.block(panel_block(
|
||||
"Status & Activity",
|
||||
app.focus == FocusArea::Status,
|
||||
))
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
frame.render_widget(widget, area);
|
||||
@@ -3050,4 +3119,28 @@ mod tests {
|
||||
assert!(app.current_query_draft().sql.ends_with('x'));
|
||||
assert_eq!(app.editor_mode, EditorMode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tty_requirement_message_allows_real_terminal_pair() {
|
||||
assert_eq!(tty_requirement_message(true, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tty_requirement_message_explains_non_tty_contract() {
|
||||
let message =
|
||||
tty_requirement_message(false, true).expect("non-tty startup should be rejected");
|
||||
|
||||
assert!(message.contains("requires an interactive TTY"));
|
||||
assert!(message.contains("Detected stdin TTY: false"));
|
||||
assert!(message.contains("scripts/tui/smoke-tty.sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_query_drafts_uses_connection_qualifier() {
|
||||
let drafts = build_query_drafts("qa_demo");
|
||||
|
||||
assert!(drafts[0].sql.contains("from qa_demo.accounts a"));
|
||||
assert!(drafts[0].sql.contains("left join qa_demo.tickets t"));
|
||||
assert!(drafts[1].sql.contains("from qa_demo.tickets t"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user