feat(usable): package gui-host validation snapshot
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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
@@ -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 = pass(demo 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`
|
||||
|
||||
@@ -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