feat(usable): package gui-host validation snapshot
Some checks failed
release-smoke / macos-13 / x86_64-apple-darwin (push) Has been cancelled
release-smoke / ubuntu-latest / x86_64-unknown-linux-gnu (push) Has been cancelled
release-smoke / windows-latest / x86_64-pc-windows-msvc (push) Has been cancelled

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip CTO
2026-03-31 10:21:36 +00:00
parent 19aeb7784b
commit a28dab4cd9
47 changed files with 6894 additions and 771 deletions

View File

@@ -10,9 +10,12 @@ name = "dbtool"
path = "src/main.rs"
[dependencies]
db-app.workspace = true
db-config.workspace = true
db-core.workspace = true
db-drivers.workspace = true
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
[dev-dependencies]
rusqlite.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
- 键盘驱动的连接切换工作流,以及连接 `loading / success / failure` 可视状态
- `sqlite-local` 的真实 connect / inspect 路径schema browser 可展示 live schema / table / column
- `sqlite-local` 的真实 query / results / export 工作流,直接复用 `crates/db-app`
- PostgreSQL / MySQL 的 Docker demo live activation / query / export 路径
- 查询草稿切换、基础键入编辑、执行触发与执行状态反馈
- 成功 / 空结果 / 错误三类查询结果展示,以及宽表列分页提示
- 当前结果集导出到 `/tmp` 的 CSV / JSON 反馈
@@ -23,11 +24,25 @@
当前不包含:
- 真实网络数据库连接激活
- 多连接并发会话
- 自定义新增 / 编辑连接表单
- 非 demo 自定义 network profile
- 完整 SQL 编辑器能力
## usable-v1 口径边界
当前 TUI 能力要按三层结论理解,避免把局部 live path 误记为整体通过:
| 层级 | 当前结论 | 含义 |
| --- | --- | --- |
| shell baseline | `pass` | 六区布局、焦点、状态、TTY 启动与退出可直接复核 |
| `sqlite-local` local live | `partial` | 单机会话内可复核 connect / inspect / query / export但只覆盖本地 SQLite |
| PostgreSQL / MySQL network live | `pass` | 当前 runner 可通过 `host.docker.internal`、Docker demo 容器与 TTY smoke 脚本复核真实激活、查询、导出 |
- `sqlite-local` 仍是最轻量的本地单机验证入口,但不再是唯一 live 路径。
- 当前 usable-v1 的 runner 内 network live 回放口径已经建立,可直接复核 PostgreSQL / MySQL。
- QA 在对外汇报时应明确写成“shell baseline = pass、`sqlite-local` = partial、PostgreSQL/MySQL network live = passdemo path”。
## 运行方式
在项目根目录执行:
@@ -38,6 +53,7 @@ cargo run -p dbtool-tui
当前默认 live 路径是 `sqlite-local`,底层使用 `examples/tmp/dbtool-demo.sqlite`
启动后会自动对 `sqlite-local` 执行 connect + inspect切换连接时会重新触发 live activation。
内置 `reporting-postgres` / `orders-mysql` profile 默认走 `host.docker.internal:55432/53306`,并分别复用 `dbtool_demo` / `qa_demo` demo 数据。
如果当前 runner 缺少 Rust 工具链,可先使用已有产物:
@@ -45,6 +61,38 @@ cargo run -p dbtool-tui
./target/debug/dbtool-tui
```
## TTY smoke 契约
- `dbtool-tui` 需要交互式 TTY正常终端启动与自动化 smoke 都必须走 TTY 路径。
- 当前最小可重复入口:
```bash
scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui
```
- PostgreSQL live smoke
```bash
export DBTOOL_PASSWORD=dbtool
scripts/tui/live-network-smoke.sh postgres ./target/debug/dbtool-tui
```
- MySQL live smoke
```bash
export DBTOOL_PASSWORD=dbtool
scripts/tui/live-network-smoke.sh mysql ./target/debug/dbtool-tui
```
- 小终端降级入口:
```bash
scripts/tui/smoke-tty.sh ./target/debug/dbtool-tui 20 90 /tmp/dbtool-tui-small.log
```
- `./target/debug/dbtool-tui --help` 不是有效 smoke当前它只用于确认“非 TTY 会被明确拒绝”这一限制。
- 共享 runbook 见 `TUI_SMOKE_RUNBOOK.md`
## 键盘交互
- `Tab` / `Shift+Tab`:切换焦点面板
@@ -59,6 +107,14 @@ cargo run -p dbtool-tui
- `Esc`:回到默认工作台
- `q`:退出
## 状态恢复与键位一致性
- `Esc` 在两个场景下都承担“回到稳定态”的职责:编辑模式下先退回 navigate mode普通导航下回到默认 `Workspace` + `Query Editor` 焦点。
- `r` 是唯一的显式恢复键:会同时清空 query 结果、query 错误、export 成功/失败反馈,并把界面状态收口回 `Ready`
- `[` / `]` 只在两个焦点区生效:`Query Editor` 下切换草稿,`Results` 下横向翻页;其他面板按下不会触发隐藏状态变化。
- `x` / `j` 只在当前工作区已有“成功且可导出”的结果集时成立;若查询仍在运行或当前没有可导出结果,会保留明确错误/提示,而不是静默失败。
- `Enter` 的语义由当前焦点决定:`Connections` = 激活连接,`Schema Browser` = 展开/确认对象,`Query Editor` = 执行查询;插入模式下 `Enter` 只插入换行。
## 快速测试路径
如果你只是想快速走通一遍当前可见工作流,推荐按下面步骤操作:
@@ -103,12 +159,14 @@ QA 可按以下步骤复核:
## 当前限制
- `sqlite-local` 是当前唯一可在本地 runner 直接完成 live connect / inspect / query / export 路径
- Postgres / MySQL 连接仍以可读失败态保留,用于验证多目标上下文和错误持续可见性
- `sqlite-local` 是当前最快的本地 runner live connect / inspect / query / export 路径
- Postgres / MySQL 当前默认指向 Docker demo 容器;若 demo stack 未启动,会回退为可读失败态
- 当前 runner 若缺少 `cargo`,需要使用已有二进制做交互 smoke源码重建与测试需在具备 Rust 工具链的环境完成
- 非 TTY 启动当前会明确报错并退出;这是已记录限制,不是可支持的帮助命令路径
共享 QA 文档:
- `TUI_ACCEPTANCE_CHECKLIST.md`
- `TUI_SMOKE_RUNBOOK.md`
- `TUI_TEST_STRATEGY.md`
- `TUI_REGRESSION_CHECKLIST.md`

View File

@@ -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"));
}
}