dbctl 0.7.3__tar.gz → 0.7.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {dbctl-0.7.3 → dbctl-0.7.4}/CHANGELOG.md +45 -0
  2. {dbctl-0.7.3 → dbctl-0.7.4}/PKG-INFO +62 -8
  3. {dbctl-0.7.3 → dbctl-0.7.4}/README.md +59 -7
  4. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/cli.py +11 -1
  5. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/db.py +12 -4
  6. dbctl-0.7.4/dbctl/ui/__init__.py +8 -0
  7. dbctl-0.7.4/dbctl/ui/app.py +234 -0
  8. dbctl-0.7.4/dbctl/ui/connection_tree.py +539 -0
  9. dbctl-0.7.4/dbctl/ui/editor_tab.py +164 -0
  10. dbctl-0.7.4/dbctl/ui/grouping.py +63 -0
  11. dbctl-0.7.4/dbctl/ui/operation_tab.py +171 -0
  12. dbctl-0.7.4/dbctl/ui/registry.py +39 -0
  13. dbctl-0.7.4/dbctl/ui/results.py +29 -0
  14. dbctl-0.7.4/dbctl/ui/schema.py +89 -0
  15. dbctl-0.7.4/dbctl/ui/screens.py +146 -0
  16. dbctl-0.7.4/dbctl/ui/session.py +137 -0
  17. dbctl-0.7.4/dbctl/ui/splitter.py +109 -0
  18. dbctl-0.7.4/dbctl/ui/sql_templates.py +55 -0
  19. dbctl-0.7.4/dbctl/ui/tabs.py +87 -0
  20. dbctl-0.7.4/docs/logo_small.png +0 -0
  21. dbctl-0.7.4/docs/tui.md +184 -0
  22. {dbctl-0.7.3 → dbctl-0.7.4}/pyproject.toml +14 -2
  23. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_regressions.py +3 -3
  24. dbctl-0.7.4/tests/ui/conftest.py +76 -0
  25. dbctl-0.7.4/tests/ui/test_app.py +43 -0
  26. dbctl-0.7.4/tests/ui/test_connection_tree.py +140 -0
  27. dbctl-0.7.4/tests/ui/test_connection_tree_grouping.py +200 -0
  28. dbctl-0.7.4/tests/ui/test_editor_tab.py +99 -0
  29. dbctl-0.7.4/tests/ui/test_grouping.py +112 -0
  30. dbctl-0.7.4/tests/ui/test_operation_launcher.py +119 -0
  31. dbctl-0.7.4/tests/ui/test_operation_tab.py +126 -0
  32. dbctl-0.7.4/tests/ui/test_resize.py +79 -0
  33. dbctl-0.7.4/tests/ui/test_schema.py +50 -0
  34. dbctl-0.7.4/tests/ui/test_screens.py +115 -0
  35. dbctl-0.7.4/tests/ui/test_session.py +74 -0
  36. dbctl-0.7.4/tests/ui/test_sql_templates.py +89 -0
  37. dbctl-0.7.4/tests/ui/test_status_bar_and_loading.py +135 -0
  38. dbctl-0.7.4/tests/ui/test_tab_resize.py +139 -0
  39. dbctl-0.7.4/uv.lock +1196 -0
  40. dbctl-0.7.3/docs/logo_small.png +0 -0
  41. dbctl-0.7.3/uv.lock +0 -810
  42. {dbctl-0.7.3 → dbctl-0.7.4}/.dbctl/connections.yaml +0 -0
  43. {dbctl-0.7.3 → dbctl-0.7.4}/.dbctl/operations.yaml +0 -0
  44. {dbctl-0.7.3 → dbctl-0.7.4}/.github/workflows/ci.yml +0 -0
  45. {dbctl-0.7.3 → dbctl-0.7.4}/.github-local/ci.yml +0 -0
  46. {dbctl-0.7.3 → dbctl-0.7.4}/.gitignore +0 -0
  47. {dbctl-0.7.3 → dbctl-0.7.4}/Makefile +0 -0
  48. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/__init__.py +0 -0
  49. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/__main__.py +0 -0
  50. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/audit.py +0 -0
  51. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/config.py +0 -0
  52. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/connections.py +0 -0
  53. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/execute.py +0 -0
  54. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/init.py +0 -0
  55. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/multi.py +0 -0
  56. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/operations.py +0 -0
  57. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/refs.py +0 -0
  58. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/reports.py +0 -0
  59. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/runtime.py +0 -0
  60. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/__init__.py +0 -0
  61. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/azure.py +0 -0
  62. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/base.py +0 -0
  63. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/direct.py +0 -0
  64. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/gcp.py +0 -0
  65. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/k8s.py +0 -0
  66. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/ssh.py +0 -0
  67. {dbctl-0.7.3 → dbctl-0.7.4}/dbctl/tunnels/ssm.py +0 -0
  68. {dbctl-0.7.3 → dbctl-0.7.4}/docker-compose.yml +0 -0
  69. {dbctl-0.7.3 → dbctl-0.7.4}/docs/ACTION_OUTPUT.md +0 -0
  70. {dbctl-0.7.3 → dbctl-0.7.4}/docs/DESIGN.md +0 -0
  71. {dbctl-0.7.3 → dbctl-0.7.4}/docs/SESSION_STATE.md +0 -0
  72. {dbctl-0.7.3 → dbctl-0.7.4}/docs/connections.md +0 -0
  73. {dbctl-0.7.3 → dbctl-0.7.4}/docs/logo.png +0 -0
  74. {dbctl-0.7.3 → dbctl-0.7.4}/docs/operations.md +0 -0
  75. {dbctl-0.7.3 → dbctl-0.7.4}/docs/tutorial.md +0 -0
  76. {dbctl-0.7.3 → dbctl-0.7.4}/seed/mssql.sql +0 -0
  77. {dbctl-0.7.3 → dbctl-0.7.4}/seed/mysql.sql +0 -0
  78. {dbctl-0.7.3 → dbctl-0.7.4}/seed/postgres.sql +0 -0
  79. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_azure_tunnel.py +0 -0
  80. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_bastion_tags.py +0 -0
  81. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_connections_loader.py +0 -0
  82. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_copy_features.py +0 -0
  83. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_gcp_tunnel.py +0 -0
  84. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_k8s_tunnel.py +0 -0
  85. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_refs.py +0 -0
  86. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_smoke.py +0 -0
  87. {dbctl-0.7.3 → dbctl-0.7.4}/tests/test_sso_cache.py +0 -0
@@ -5,6 +5,51 @@ All notable changes to this project will be documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.4] — 2026-08-10
9
+
10
+ ### Added
11
+
12
+ - **`dbctl ui`** — an interactive [Textual](https://textual.textualize.io/)
13
+ TUI on top of the same connections/operations/tunnels/audit-log stack as
14
+ the CLI (`textual` is a base dependency, no separate install step). See
15
+ `docs/tui.md` for the full reference.
16
+ - **Connection tree** doubling as a lazily-loaded schema browser: `m`
17
+ toggles a simple `connection → table → column` view and a fuller
18
+ `connection → schema → Tables/Views → table/view → Columns/Indexes`
19
+ view; `g` toggles a flat list and a grouping that treats `-` as a path
20
+ separator (`in-gateway-ifp-dev` nests under `in-gateway` → `ifp`), with
21
+ single-child folders compressed and support for a name that's both a
22
+ group and a real connection at once (`ifp` / `ifp-gateway`). `c`/`d`/`t`
23
+ connect/disconnect/test-tunnel; `a`/`e` open `connections.yaml` in
24
+ `$EDITOR`.
25
+ - **Tabbed workspace**: a SQL editor (syntax-highlighted) or an
26
+ operation-launcher (a form built from a declared operation's
27
+ parameters) per tab, each with an independently resizable results
28
+ table and a status line (row count / rows-affected + duration) below
29
+ it. `Ctrl+N` new tab, `Ctrl+O` searchable operation launcher (type to
30
+ filter), `Ctrl+W` close, `Ctrl+R` run. Activating a table/view in the
31
+ tree opens a SQL tab pre-filled with a dialect-correct preview query
32
+ (`LIMIT` / `TOP` / `FETCH FIRST … ROWS ONLY`, with per-dialect
33
+ identifier quoting for mixed-case table/schema names).
34
+ - Connect/disconnect/test-tunnel and every SQL/operation run happen on a
35
+ background thread with a loading indicator, so the UI stays responsive
36
+ instead of freezing while a tunnel spins up or a query runs.
37
+ - Every run still respects `safety.read_only` / `safety.confirm` /
38
+ `allowed_operations` and is appended to `~/.dbctl/history.jsonl`,
39
+ exactly like a CLI-driven run.
40
+
41
+ ### Fixed
42
+
43
+ - **`uv.lock` could resolve against a contributor's local package-index
44
+ override instead of public PyPI** — a machine-level `uv` config pointing
45
+ at an internal package mirror silently became the index every regenerated
46
+ `uv.lock` entry was resolved and hashed against, which both leaked an
47
+ internal hostname into the lock file and broke `uv sync --frozen` for
48
+ anyone (CI included) without access to that mirror. Pinned
49
+ `[[tool.uv.index]]` in `pyproject.toml` with `default = true` so this
50
+ project always locks against `pypi.org`/`files.pythonhosted.org`
51
+ regardless of what index a contributor's environment defaults to.
52
+
8
53
  ## [0.7.3] — 2026-08-04
9
54
 
10
55
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbctl
3
- Version: 0.7.3
3
+ Version: 0.7.4
4
4
  Summary: Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection.
5
5
  Author: dbctl contributors
6
6
  License: MIT
@@ -25,8 +25,10 @@ Requires-Dist: pyodbc>=5
25
25
  Requires-Dist: pyyaml>=6
26
26
  Requires-Dist: rich>=13
27
27
  Requires-Dist: sqlalchemy>=2
28
+ Requires-Dist: textual>=0.60
28
29
  Provides-Extra: dev
29
30
  Requires-Dist: mypy>=1.10; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
30
32
  Requires-Dist: pytest-cov>=5; extra == 'dev'
31
33
  Requires-Dist: pytest>=8; extra == 'dev'
32
34
  Requires-Dist: ruff>=0.5; extra == 'dev'
@@ -71,11 +73,13 @@ leaving the terminal.
71
73
  | DML is one `Ctrl-Enter` away | DML is **dry-run by default** until `--apply`; `--yes` skips the prompt |
72
74
  | No cross-DB diff | `dbctl diff user-count prod-tenant1 int-tenant1` |
73
75
 
74
- `dbctl` is **not** a schema browser or a query playground — it deliberately
75
- has no ad-hoc query command. Declaring operations in YAML keeps "what can be
76
- run against this DB" discoverable from a versioned file instead of buried in
77
- your shell history. (For ad-hoc exploration open the tunnel with
78
- `dbctl tunnel open <conn>` and point your favourite client at the local bind.)
76
+ The declarative CLI above is still the primary interface: declaring
77
+ operations in YAML keeps "what can be run against this DB" discoverable from
78
+ a versioned file instead of buried in your shell history. For ad-hoc
79
+ exploration, `dbctl ui` (see below) gives you a schema browser and a SQL
80
+ editor without leaving `dbctl`'s tunnel/safety/audit model - or open the
81
+ tunnel with `dbctl tunnel open <conn>` and point your favourite client at
82
+ the local bind.
79
83
 
80
84
  ### How it reaches a database
81
85
 
@@ -352,6 +356,41 @@ reference.
352
356
  Secret-typed **operation** parameters (`type: secret`) are redacted in the
353
357
  audit log regardless of which DB password source the connection uses.
354
358
 
359
+ ## Interactive UI (`dbctl ui`)
360
+
361
+ A [Textual](https://textual.textualize.io/) TUI for interactive work, on top
362
+ of the same connections/operations/tunnels/audit-log stack as the CLI - not
363
+ a separate code path with its own rules. `textual` is a base dependency, so
364
+ no separate install step.
365
+
366
+ ```bash
367
+ dbctl ui
368
+ ```
369
+
370
+ - **Left pane**: a connection tree that doubles as a lazily-loaded schema
371
+ browser (`m` toggles a simple `connection → table → column` view and a
372
+ fuller `connection → schema → Tables/Views → table/view →
373
+ Columns/Indexes` view). Expanding a connection connects it if needed;
374
+ activating (Enter) a table/view opens a SQL tab pre-filled with a
375
+ dialect-correct preview query using the real table name. `g` toggles a
376
+ grouped view that treats `-` as a path separator (`in-gateway-ifp-dev`
377
+ nests under `in-gateway` → `ifp`) - handy once a fleet has more than a
378
+ handful of connections.
379
+ - **Right pane**: a tabbed workspace of SQL editor / operation-launcher
380
+ tabs (`Ctrl+N` new tab, `Ctrl+O` searchable operation launcher, `Ctrl+W`
381
+ close, `Ctrl+R` run), each with an independently resizable results table
382
+ below it and a status line (row count / rows affected + duration) after
383
+ each run.
384
+ - Connecting, disconnecting, testing a tunnel, and every SQL/operation run
385
+ happen on a background thread with a loading indicator, so the UI stays
386
+ responsive instead of freezing while a tunnel spins up or a query runs.
387
+ - Every run still respects `safety.read_only` / `safety.confirm` /
388
+ `allowed_operations` and is appended to `~/.dbctl/history.jsonl`, exactly
389
+ like a CLI-driven run.
390
+
391
+ See [`docs/tui.md`](docs/tui.md) for the full keybinding reference and
392
+ per-dialect SQL details (row-limit clause, identifier quoting).
393
+
355
394
  ## Shell completion
356
395
 
357
396
  ```bash
@@ -375,6 +414,7 @@ dbctl/
375
414
  ├── docs/
376
415
  │ ├── connections.md # connections.yaml reference
377
416
  │ ├── operations.md # operations.yaml reference
417
+ │ ├── tui.md # dbctl ui reference (keybindings, dialect SQL)
378
418
  │ └── DESIGN.md # architecture and design decisions
379
419
  └── dbctl/
380
420
  ├── cli.py # dynamic groups + per-op Click commands
@@ -388,7 +428,21 @@ dbctl/
388
428
  ├── reports.py # rich tables / json / csv / yaml + diff + copy/sync/validate rendering
389
429
  ├── audit.py # history.jsonl
390
430
  ├── runtime.py # opened_conn() ctx-mgr (tunnel+engine+healthcheck)
391
- └── init.py # dbctl init wizard
431
+ ├── init.py # dbctl init wizard
432
+ └── ui/ # Textual TUI (`dbctl ui`)
433
+ ├── app.py # DbctlApp: connection tree + tabbed workspace
434
+ ├── session.py # per-connection tunnel+engine kept open across tab-runs
435
+ ├── connection_tree.py # lazy schema browser (simple/normal + flat/grouped views)
436
+ ├── grouping.py # "-" as a path separator -> compressed connection-name trie
437
+ ├── schema.py # sqlalchemy.inspect() wrapper for the tree
438
+ ├── editor_tab.py # SQL editor tab
439
+ ├── operation_tab.py # operation-launcher tab
440
+ ├── sql_templates.py # dialect-aware default SELECT + identifier quoting
441
+ ├── screens.py # confirm / new-tab / operation-launcher modal screens
442
+ ├── splitter.py # draggable pane-resize bars
443
+ ├── tabs.py # shared tab base: resize, toolbar, status bar, loading indicator
444
+ ├── results.py # DataTable rendering helpers
445
+ └── registry.py # tolerant connections/operations loading
392
446
  ```
393
447
 
394
448
  ## Development
@@ -397,7 +451,7 @@ dbctl/
397
451
  uv sync --extra dev
398
452
  make help # list all Makefile targets
399
453
  make check # lint + unit tests (the pre-commit gate)
400
- make test # unit tests (~80 tests, in-memory SQLite, no docker)
454
+ make test # unit tests (~270 tests, sqlite-backed, no docker)
401
455
  make typecheck # mypy strict (pre-existing debt; non-blocking)
402
456
  make smoke # docker compose up + dbctl doctor against the fleet
403
457
  make build # wheel + sdist via uv
@@ -37,11 +37,13 @@ leaving the terminal.
37
37
  | DML is one `Ctrl-Enter` away | DML is **dry-run by default** until `--apply`; `--yes` skips the prompt |
38
38
  | No cross-DB diff | `dbctl diff user-count prod-tenant1 int-tenant1` |
39
39
 
40
- `dbctl` is **not** a schema browser or a query playground — it deliberately
41
- has no ad-hoc query command. Declaring operations in YAML keeps "what can be
42
- run against this DB" discoverable from a versioned file instead of buried in
43
- your shell history. (For ad-hoc exploration open the tunnel with
44
- `dbctl tunnel open <conn>` and point your favourite client at the local bind.)
40
+ The declarative CLI above is still the primary interface: declaring
41
+ operations in YAML keeps "what can be run against this DB" discoverable from
42
+ a versioned file instead of buried in your shell history. For ad-hoc
43
+ exploration, `dbctl ui` (see below) gives you a schema browser and a SQL
44
+ editor without leaving `dbctl`'s tunnel/safety/audit model - or open the
45
+ tunnel with `dbctl tunnel open <conn>` and point your favourite client at
46
+ the local bind.
45
47
 
46
48
  ### How it reaches a database
47
49
 
@@ -318,6 +320,41 @@ reference.
318
320
  Secret-typed **operation** parameters (`type: secret`) are redacted in the
319
321
  audit log regardless of which DB password source the connection uses.
320
322
 
323
+ ## Interactive UI (`dbctl ui`)
324
+
325
+ A [Textual](https://textual.textualize.io/) TUI for interactive work, on top
326
+ of the same connections/operations/tunnels/audit-log stack as the CLI - not
327
+ a separate code path with its own rules. `textual` is a base dependency, so
328
+ no separate install step.
329
+
330
+ ```bash
331
+ dbctl ui
332
+ ```
333
+
334
+ - **Left pane**: a connection tree that doubles as a lazily-loaded schema
335
+ browser (`m` toggles a simple `connection → table → column` view and a
336
+ fuller `connection → schema → Tables/Views → table/view →
337
+ Columns/Indexes` view). Expanding a connection connects it if needed;
338
+ activating (Enter) a table/view opens a SQL tab pre-filled with a
339
+ dialect-correct preview query using the real table name. `g` toggles a
340
+ grouped view that treats `-` as a path separator (`in-gateway-ifp-dev`
341
+ nests under `in-gateway` → `ifp`) - handy once a fleet has more than a
342
+ handful of connections.
343
+ - **Right pane**: a tabbed workspace of SQL editor / operation-launcher
344
+ tabs (`Ctrl+N` new tab, `Ctrl+O` searchable operation launcher, `Ctrl+W`
345
+ close, `Ctrl+R` run), each with an independently resizable results table
346
+ below it and a status line (row count / rows affected + duration) after
347
+ each run.
348
+ - Connecting, disconnecting, testing a tunnel, and every SQL/operation run
349
+ happen on a background thread with a loading indicator, so the UI stays
350
+ responsive instead of freezing while a tunnel spins up or a query runs.
351
+ - Every run still respects `safety.read_only` / `safety.confirm` /
352
+ `allowed_operations` and is appended to `~/.dbctl/history.jsonl`, exactly
353
+ like a CLI-driven run.
354
+
355
+ See [`docs/tui.md`](docs/tui.md) for the full keybinding reference and
356
+ per-dialect SQL details (row-limit clause, identifier quoting).
357
+
321
358
  ## Shell completion
322
359
 
323
360
  ```bash
@@ -341,6 +378,7 @@ dbctl/
341
378
  ├── docs/
342
379
  │ ├── connections.md # connections.yaml reference
343
380
  │ ├── operations.md # operations.yaml reference
381
+ │ ├── tui.md # dbctl ui reference (keybindings, dialect SQL)
344
382
  │ └── DESIGN.md # architecture and design decisions
345
383
  └── dbctl/
346
384
  ├── cli.py # dynamic groups + per-op Click commands
@@ -354,7 +392,21 @@ dbctl/
354
392
  ├── reports.py # rich tables / json / csv / yaml + diff + copy/sync/validate rendering
355
393
  ├── audit.py # history.jsonl
356
394
  ├── runtime.py # opened_conn() ctx-mgr (tunnel+engine+healthcheck)
357
- └── init.py # dbctl init wizard
395
+ ├── init.py # dbctl init wizard
396
+ └── ui/ # Textual TUI (`dbctl ui`)
397
+ ├── app.py # DbctlApp: connection tree + tabbed workspace
398
+ ├── session.py # per-connection tunnel+engine kept open across tab-runs
399
+ ├── connection_tree.py # lazy schema browser (simple/normal + flat/grouped views)
400
+ ├── grouping.py # "-" as a path separator -> compressed connection-name trie
401
+ ├── schema.py # sqlalchemy.inspect() wrapper for the tree
402
+ ├── editor_tab.py # SQL editor tab
403
+ ├── operation_tab.py # operation-launcher tab
404
+ ├── sql_templates.py # dialect-aware default SELECT + identifier quoting
405
+ ├── screens.py # confirm / new-tab / operation-launcher modal screens
406
+ ├── splitter.py # draggable pane-resize bars
407
+ ├── tabs.py # shared tab base: resize, toolbar, status bar, loading indicator
408
+ ├── results.py # DataTable rendering helpers
409
+ └── registry.py # tolerant connections/operations loading
358
410
  ```
359
411
 
360
412
  ## Development
@@ -363,7 +415,7 @@ dbctl/
363
415
  uv sync --extra dev
364
416
  make help # list all Makefile targets
365
417
  make check # lint + unit tests (the pre-commit gate)
366
- make test # unit tests (~80 tests, in-memory SQLite, no docker)
418
+ make test # unit tests (~270 tests, sqlite-backed, no docker)
367
419
  make typecheck # mypy strict (pre-existing debt; non-blocking)
368
420
  make smoke # docker compose up + dbctl doctor against the fleet
369
421
  make build # wheel + sdist via uv
@@ -1020,7 +1020,7 @@ def _aliases(conns):
1020
1020
 
1021
1021
  def _root_list(ctx: click.Context) -> list[str]:
1022
1022
  conns, ops = registries(ctx)
1023
- static = ["connections", "operations", "status", "doctor", "init", "history", "tunnel"]
1023
+ static = ["connections", "operations", "status", "doctor", "init", "history", "tunnel", "ui"]
1024
1024
  # multi-op operation-first top-level commands + deprecated verb-first groups
1025
1025
  multi_ops = {n for n, o in ops.items() if o.scope.value == "multi"}
1026
1026
  multi_modes = {o.mode.value for o in ops.values() if o.scope.value == "multi"}
@@ -1039,6 +1039,7 @@ def _root_get(ctx: click.Context, name: str):
1039
1039
  "init": init_cmd,
1040
1040
  "history": history_cmd,
1041
1041
  "tunnel": tunnel_cmd,
1042
+ "ui": ui_cmd,
1042
1043
  }
1043
1044
  if name in static:
1044
1045
  return static[name]
@@ -1329,6 +1330,15 @@ def init_cmd(ctx):
1329
1330
  run_wizard(profile=ctx.obj.get("profile"))
1330
1331
 
1331
1332
 
1333
+ @click.command("ui")
1334
+ @click.pass_context
1335
+ def ui_cmd(ctx):
1336
+ """Launch the interactive TUI: connection tree + tabbed SQL editor / operations."""
1337
+ from dbctl.ui.app import DbctlApp
1338
+
1339
+ DbctlApp(profile=ctx.obj.get("profile")).run()
1340
+
1341
+
1332
1342
  @main.group("history")
1333
1343
  def history_cmd():
1334
1344
  """Show the audit log."""
@@ -60,7 +60,7 @@ def resolve_password(conn: Connection) -> str | None:
60
60
  raise DBError("no password source configured")
61
61
 
62
62
 
63
- def _driver_name(conn: Connection) -> str:
63
+ def driver_name(conn: Connection) -> str:
64
64
  """Return the SQLAlchemy driver scheme for this connection.
65
65
 
66
66
  When the user provides a full ``url:`` string, the driver is the
@@ -79,7 +79,7 @@ def _driver_name(conn: Connection) -> str:
79
79
  def _connect_args(conn: Connection, timeout: float) -> dict:
80
80
  """Driver-specific connect-time knobs (mainly connect_timeout)."""
81
81
  args: dict = {}
82
- driver = _driver_name(conn)
82
+ driver = driver_name(conn)
83
83
  if driver.startswith(("postgresql", "mysql", "mariadb", "oracle")):
84
84
  args["connect_timeout"] = int(max(1, timeout))
85
85
  # sqlite + duckdb are file-based — no connect_timeout; SQLAlchemy
@@ -111,7 +111,7 @@ def build_engine(conn: Connection, tunnel: Tunnel, *, echo: bool = False) -> Eng
111
111
 
112
112
  conn = resolve_connection(conn)
113
113
 
114
- driver = _driver_name(conn)
114
+ driver = driver_name(conn)
115
115
  _check_driver_available(driver)
116
116
 
117
117
  from sqlalchemy import URL, make_url
@@ -264,4 +264,12 @@ def fmt_db_error(e: BaseException) -> str:
264
264
  return msg
265
265
 
266
266
 
267
- __all__ = ["build_engine", "resolve_password", "healthcheck", "DBError", "fmt_db_error", "text"]
267
+ __all__ = [
268
+ "build_engine",
269
+ "resolve_password",
270
+ "healthcheck",
271
+ "DBError",
272
+ "fmt_db_error",
273
+ "driver_name",
274
+ "text",
275
+ ]
@@ -0,0 +1,8 @@
1
+ """Interactive Textual UI for dbctl (`dbctl ui`).
2
+
3
+ This package only *consumes* the existing loaders/executors
4
+ (``dbctl.connections``, ``dbctl.operations``, ``dbctl.tunnels``, ``dbctl.db``,
5
+ ``dbctl.execute``, ``dbctl.audit``) - it does not change their behavior.
6
+ """
7
+
8
+ from __future__ import annotations
@@ -0,0 +1,234 @@
1
+ """``dbctl ui`` entry point.
2
+
3
+ Composes a connection tree (left) with a tabbed workspace of SQL editor /
4
+ operation-launcher tabs (right), each with a results table below. Reuses the
5
+ same loaders/executors as the CLI - the only new state this module owns is
6
+ the per-connection tunnel+engine lifecycle in ``dbctl.ui.session.SessionManager``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+
13
+ from textual import on
14
+ from textual.app import App, ComposeResult
15
+ from textual.containers import Horizontal
16
+ from textual.css.query import NoMatches
17
+ from textual.reactive import reactive
18
+ from textual.widgets import Footer, Header, TabbedContent, TabPane
19
+
20
+ from dbctl.ui.connection_tree import ConnectionActivated, ConnectionTree, TableActivated
21
+ from dbctl.ui.editor_tab import SqlEditorPane
22
+ from dbctl.ui.operation_tab import OperationPane
23
+ from dbctl.ui.registry import load_registries
24
+ from dbctl.ui.screens import NewTabScreen, OperationLauncherScreen
25
+ from dbctl.ui.session import SessionManager
26
+ from dbctl.ui.splitter import VerticalSplitter
27
+ from dbctl.ui.sql_templates import default_select, qualified_table
28
+ from dbctl.ui.tabs import EDITOR_HEIGHT_STEP, MAX_EDITOR_HEIGHT, MIN_EDITOR_HEIGHT, RunnableTab
29
+
30
+ MIN_TREE_WIDTH = 20
31
+ MAX_TREE_WIDTH = 80
32
+ DEFAULT_TREE_WIDTH = 32
33
+ TREE_WIDTH_STEP = 4
34
+
35
+
36
+ class DbctlApp(App[None]):
37
+ """dbctl's interactive TUI: connection tree + tabbed SQL/operation workspace."""
38
+
39
+ TITLE = "dbctl"
40
+
41
+ CSS = """
42
+ #connection-tree {
43
+ border-right: solid $panel;
44
+ }
45
+ .pane-header {
46
+ padding: 0 1;
47
+ background: $panel;
48
+ }
49
+ .pane-toolbar {
50
+ height: auto;
51
+ padding: 0 1;
52
+ }
53
+ .param-row {
54
+ height: auto;
55
+ padding: 0 1;
56
+ }
57
+ .param-row Label {
58
+ width: 20;
59
+ content-align: right middle;
60
+ }
61
+ #sql-input, #param-form {
62
+ height: 1fr;
63
+ }
64
+ #results-table {
65
+ height: 1fr;
66
+ }
67
+ #run-loading {
68
+ height: 1fr;
69
+ }
70
+ #status-bar {
71
+ height: auto;
72
+ padding: 0 1;
73
+ background: $panel;
74
+ color: $text-muted;
75
+ }
76
+ #confirm-dialog, #new-tab-dialog, #operation-launcher-dialog {
77
+ width: 60;
78
+ height: auto;
79
+ padding: 1 2;
80
+ background: $panel;
81
+ border: thick $primary;
82
+ }
83
+ """
84
+
85
+ BINDINGS = [
86
+ ("ctrl+r", "run_tab", "Run"),
87
+ ("ctrl+n", "new_tab", "New tab"),
88
+ ("ctrl+w", "close_tab", "Close tab"),
89
+ ("ctrl+o", "launch_operation", "Run operation"),
90
+ ("ctrl+left", "narrow_tree", "Narrow tree"),
91
+ ("ctrl+right", "widen_tree", "Widen tree"),
92
+ ("ctrl+up", "grow_editor", "Grow editor"),
93
+ ("ctrl+down", "shrink_editor", "Shrink editor"),
94
+ ]
95
+
96
+ tree_width: reactive[int] = reactive(DEFAULT_TREE_WIDTH)
97
+
98
+ def __init__(self, profile: str | None = None) -> None:
99
+ super().__init__()
100
+ self.profile = profile
101
+ self.connections, self.operations, self._load_warnings = load_registries(profile)
102
+ self.sessions = SessionManager(self.connections)
103
+ self._tab_seq = 0
104
+
105
+ def compose(self) -> ComposeResult:
106
+ yield Header()
107
+ with Horizontal():
108
+ yield ConnectionTree(self.connections, self.sessions, profile=self.profile, id="connection-tree")
109
+ yield VerticalSplitter(min_value=MIN_TREE_WIDTH, max_value=MAX_TREE_WIDTH, id="tree-splitter")
110
+ yield TabbedContent(id="workspace")
111
+ yield Footer()
112
+
113
+ def on_mount(self) -> None:
114
+ for warning in self._load_warnings:
115
+ self.notify(warning, severity="warning", timeout=10)
116
+
117
+ def watch_tree_width(self, width: int) -> None:
118
+ with contextlib.suppress(NoMatches): # not mounted yet - re-fires once it is
119
+ self.query_one("#connection-tree").styles.width = width
120
+
121
+ def action_narrow_tree(self) -> None:
122
+ self.tree_width = max(MIN_TREE_WIDTH, self.tree_width - TREE_WIDTH_STEP)
123
+
124
+ def action_widen_tree(self) -> None:
125
+ self.tree_width = min(MAX_TREE_WIDTH, self.tree_width + TREE_WIDTH_STEP)
126
+
127
+ def on_unmount(self) -> None:
128
+ self.sessions.disconnect_all()
129
+
130
+ @on(ConnectionActivated)
131
+ def _open_default_tab(self, message: ConnectionActivated) -> None:
132
+ self.open_sql_tab(message.name)
133
+
134
+ @on(TableActivated)
135
+ def _open_table_tab(self, message: TableActivated) -> None:
136
+ conn = self.connections[message.conn_name]
137
+ target = qualified_table(conn, message.table, message.schema_name)
138
+ self.open_sql_tab(message.conn_name, sql=default_select(conn, target))
139
+
140
+ def _next_tab_id(self) -> str:
141
+ self._tab_seq += 1
142
+ return f"tab-{self._tab_seq}"
143
+
144
+ def open_sql_tab(self, conn_name: str, *, sql: str | None = None) -> None:
145
+ tabbed = self.query_one(TabbedContent)
146
+ tab_id = self._next_tab_id()
147
+ initial_sql = sql or default_select(self.connections[conn_name])
148
+ pane = SqlEditorPane(conn_name, self.sessions, initial_sql, self.profile, id=f"pane-{tab_id}")
149
+ tabbed.add_pane(TabPane(f"{conn_name}: sql", pane, id=tab_id))
150
+ tabbed.active = tab_id
151
+
152
+ def open_operation_tab(self, conn_name: str, op_name: str) -> None:
153
+ tabbed = self.query_one(TabbedContent)
154
+ tab_id = self._next_tab_id()
155
+ op = self.operations[op_name]
156
+ pane = OperationPane(conn_name, op_name, op, self.sessions, self.profile, id=f"pane-{tab_id}")
157
+ tabbed.add_pane(TabPane(f"{conn_name}: {op_name}", pane, id=tab_id))
158
+ tabbed.active = tab_id
159
+
160
+ def action_new_tab(self) -> None:
161
+ if not self.connections:
162
+ self.notify("no connections configured", severity="warning")
163
+ return
164
+
165
+ def handle(result: tuple[str, str, str | None] | None) -> None:
166
+ if result is None:
167
+ return
168
+ kind, conn_name, op_name = result
169
+ if kind == "sql":
170
+ self.open_sql_tab(conn_name)
171
+ elif op_name is not None:
172
+ self.open_operation_tab(conn_name, op_name)
173
+
174
+ singles = {n: o for n, o in self.operations.items() if o.scope.value == "single"}
175
+ self.push_screen(NewTabScreen(list(self.connections), singles), handle)
176
+
177
+ def action_close_tab(self) -> None:
178
+ tabbed = self.query_one(TabbedContent)
179
+ if tabbed.active:
180
+ tabbed.remove_pane(tabbed.active)
181
+
182
+ def _active_runnable(self) -> RunnableTab | None:
183
+ pane = self.query_one(TabbedContent).active_pane
184
+ if pane is None:
185
+ return None
186
+ try:
187
+ return pane.query_one(RunnableTab)
188
+ except NoMatches:
189
+ return None
190
+
191
+ def action_run_tab(self) -> None:
192
+ runnable = self._active_runnable()
193
+ if runnable is not None:
194
+ runnable.run_tab()
195
+
196
+ def action_grow_editor(self) -> None:
197
+ runnable = self._active_runnable()
198
+ if runnable is not None:
199
+ runnable.editor_height = min(MAX_EDITOR_HEIGHT, runnable.editor_height + EDITOR_HEIGHT_STEP)
200
+
201
+ def action_shrink_editor(self) -> None:
202
+ runnable = self._active_runnable()
203
+ if runnable is not None:
204
+ runnable.editor_height = max(MIN_EDITOR_HEIGHT, runnable.editor_height - EDITOR_HEIGHT_STEP)
205
+
206
+ def _resolve_launch_connection(self) -> str | None:
207
+ """Pick the connection Ctrl+O should target: the active tab's
208
+ connection, else whatever's highlighted in the tree, else the only
209
+ connection if there's just one."""
210
+ runnable = self._active_runnable()
211
+ if runnable is not None:
212
+ return runnable.conn_name
213
+ name = self.query_one(ConnectionTree).connection_name_at_cursor()
214
+ if name:
215
+ return name
216
+ if len(self.connections) == 1:
217
+ return next(iter(self.connections))
218
+ return None
219
+
220
+ def action_launch_operation(self) -> None:
221
+ singles = {n: o for n, o in self.operations.items() if o.scope.value == "single"}
222
+ if not singles:
223
+ self.notify("no operations configured", severity="warning")
224
+ return
225
+ conn_name = self._resolve_launch_connection()
226
+ if conn_name is None:
227
+ self.notify("highlight a connection in the tree first", severity="warning")
228
+ return
229
+
230
+ def handle(op_name: str | None) -> None:
231
+ if op_name is not None:
232
+ self.open_operation_tab(conn_name, op_name)
233
+
234
+ self.push_screen(OperationLauncherScreen(singles, conn_name), handle)