sqlide 0.1.0__py3-none-any.whl

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 (85) hide show
  1. sqlide/__init__.py +3 -0
  2. sqlide/__main__.py +5 -0
  3. sqlide/app.py +46 -0
  4. sqlide/cli.py +64 -0
  5. sqlide/clipboard.py +65 -0
  6. sqlide/config/__init__.py +1 -0
  7. sqlide/config/_toml.py +41 -0
  8. sqlide/config/connections.py +76 -0
  9. sqlide/config/keymap.py +25 -0
  10. sqlide/config/paths.py +48 -0
  11. sqlide/config/secrets.py +82 -0
  12. sqlide/config/settings.py +32 -0
  13. sqlide/consoles.py +100 -0
  14. sqlide/db/__init__.py +1 -0
  15. sqlide/db/completion.py +127 -0
  16. sqlide/db/factory.py +37 -0
  17. sqlide/db/metadata.py +174 -0
  18. sqlide/db/result.py +52 -0
  19. sqlide/db/session.py +339 -0
  20. sqlide/db/types.py +84 -0
  21. sqlide/doctor.py +60 -0
  22. sqlide/drivers/__init__.py +1 -0
  23. sqlide/drivers/catalog.toml +103 -0
  24. sqlide/drivers/cli.py +74 -0
  25. sqlide/drivers/custom.py +69 -0
  26. sqlide/drivers/loader.py +75 -0
  27. sqlide/drivers/maven.py +108 -0
  28. sqlide/drivers/registry.py +117 -0
  29. sqlide/export/__init__.py +12 -0
  30. sqlide/export/base.py +86 -0
  31. sqlide/export/csv_.py +40 -0
  32. sqlide/export/html.py +44 -0
  33. sqlide/export/json_.py +47 -0
  34. sqlide/export/markdown.py +27 -0
  35. sqlide/export/service.py +34 -0
  36. sqlide/export/sql_insert.py +37 -0
  37. sqlide/export/xlsx.py +109 -0
  38. sqlide/grid/__init__.py +1 -0
  39. sqlide/grid/copyfmt.py +97 -0
  40. sqlide/grid/formatting.py +43 -0
  41. sqlide/grid/model.py +109 -0
  42. sqlide/history/__init__.py +5 -0
  43. sqlide/history/store.py +117 -0
  44. sqlide/jvm/__init__.py +1 -0
  45. sqlide/jvm/locate.py +98 -0
  46. sqlide/jvm/runtime.py +44 -0
  47. sqlide/sql/__init__.py +1 -0
  48. sqlide/sql/context.py +195 -0
  49. sqlide/sql/dialects.py +93 -0
  50. sqlide/sql/format.py +50 -0
  51. sqlide/sql/keywords.py +143 -0
  52. sqlide/sql/lexer.py +148 -0
  53. sqlide/sql/snippets.py +44 -0
  54. sqlide/sql/splitter.py +325 -0
  55. sqlide/ui/__init__.py +1 -0
  56. sqlide/ui/app.tcss +67 -0
  57. sqlide/ui/commands.py +59 -0
  58. sqlide/ui/keymap.py +32 -0
  59. sqlide/ui/screens/__init__.py +0 -0
  60. sqlide/ui/screens/connection_editor.py +113 -0
  61. sqlide/ui/screens/dialogs.py +110 -0
  62. sqlide/ui/screens/driver_manager.py +195 -0
  63. sqlide/ui/screens/export_dialog.py +158 -0
  64. sqlide/ui/screens/grid_dialogs.py +92 -0
  65. sqlide/ui/screens/history.py +130 -0
  66. sqlide/ui/screens/main.py +383 -0
  67. sqlide/ui/screens/settings.py +77 -0
  68. sqlide/ui/widgets/__init__.py +0 -0
  69. sqlide/ui/widgets/completion_popup.py +81 -0
  70. sqlide/ui/widgets/connections_list.py +54 -0
  71. sqlide/ui/widgets/console_export.py +124 -0
  72. sqlide/ui/widgets/console_tab.py +335 -0
  73. sqlide/ui/widgets/console_tabs.py +117 -0
  74. sqlide/ui/widgets/result_grid.py +435 -0
  75. sqlide/ui/widgets/result_panel.py +73 -0
  76. sqlide/ui/widgets/result_view.py +50 -0
  77. sqlide/ui/widgets/schema_tree.py +145 -0
  78. sqlide/ui/widgets/sql_editor.py +310 -0
  79. sqlide/ui/widgets/status_bar.py +32 -0
  80. sqlide/workspace.py +97 -0
  81. sqlide-0.1.0.dist-info/METADATA +151 -0
  82. sqlide-0.1.0.dist-info/RECORD +85 -0
  83. sqlide-0.1.0.dist-info/WHEEL +4 -0
  84. sqlide-0.1.0.dist-info/entry_points.txt +2 -0
  85. sqlide-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,124 @@
1
+ """Export wiring for a console: dialog -> exporter -> status.
2
+
3
+ Mixed into ConsoleTab. Delete this file (and the base class) to drop export from the UI.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from sqlide import export
13
+ from sqlide.db.result import DbError
14
+ from sqlide.db.session import DbSession
15
+ from sqlide.export import ExportCancelled, ExportOptions
16
+ from sqlide.export.service import export_query, export_rows
17
+ from sqlide.ui.screens.export_dialog import ExportContext, ExportRequest, ExportScreen
18
+ from sqlide.ui.widgets.result_grid import ResultGrid
19
+
20
+ if TYPE_CHECKING:
21
+ from sqlide.ui.widgets.console_tab import ConsoleTab
22
+
23
+
24
+ class ExportActions:
25
+ """Expects the host to be a ConsoleTab (ws, conn, status, panel, app, run_worker)."""
26
+
27
+ _exporting = False
28
+ _export_cancel = False
29
+ _export_session: DbSession | None = None
30
+
31
+ def cancel_export(self) -> bool:
32
+ """Request cancellation of a running export. False if none is running."""
33
+ if not self._exporting:
34
+ return False
35
+ self._export_cancel = True
36
+ if self._export_session is not None:
37
+ self._export_session.cancel()
38
+ self._host.status.update_state(message="cancelling export…")
39
+ return True
40
+
41
+ @property
42
+ def _host(self) -> ConsoleTab:
43
+ return self # type: ignore[return-value]
44
+
45
+ def on_result_grid_export_requested(self, message: ResultGrid.ExportRequested) -> None:
46
+ message.stop()
47
+ host, grid = self._host, message.grid
48
+ if self._exporting:
49
+ host.app.notify("An export is already running", severity="warning")
50
+ return
51
+ r0, c0, r1, c1 = grid.selection_rect()
52
+ ctx = ExportContext(
53
+ n_view=len(grid.model),
54
+ selection=((r1 - r0 + 1, c1 - c0 + 1) if (r1 > r0 or c1 > c0) else None),
55
+ can_requery=bool(grid.sql and host.conn is not None),
56
+ more_rows=grid.source is not None,
57
+ )
58
+ host.app.push_screen(
59
+ ExportScreen(ctx), lambda req: self._start_export(grid, req) if req else None
60
+ )
61
+
62
+ def _start_export(self, grid: ResultGrid, req: ExportRequest) -> None:
63
+ self._host.run_worker(self._do_export(grid, req), group="export", exit_on_error=False)
64
+
65
+ async def _do_export(self, grid: ResultGrid, req: ExportRequest) -> None:
66
+ host = self._host
67
+ exporter = export.get(req.format)
68
+ self._exporting, self._export_cancel = True, False
69
+
70
+ def progress(n: int) -> None: # called from a worker thread
71
+ host.app.call_from_thread(host.status.update_state, message=f"exporting… {n:,} rows")
72
+
73
+ opts = ExportOptions(
74
+ header=req.header,
75
+ delimiter=req.delimiter,
76
+ bom=req.bom,
77
+ table_name=req.table_name,
78
+ progress=progress,
79
+ should_cancel=lambda: self._export_cancel,
80
+ )
81
+ host.status.update_state(message="exporting…")
82
+ try:
83
+ if req.scope == "all":
84
+ count = await self._export_all(exporter, grid, req.path, opts)
85
+ elif req.scope == "selection":
86
+ columns, rows = grid.selection_data()
87
+ count = await export_rows(exporter, columns, rows, req.path, opts)
88
+ else:
89
+ rows = grid.model.rows(0, len(grid.model) - 1)
90
+ count = await export_rows(exporter, grid.model.columns, rows, req.path, opts)
91
+ except ExportCancelled:
92
+ host.app.notify("Export cancelled", severity="warning")
93
+ host.status.update_state(message="export cancelled")
94
+ except Exception as e: # file system, driver, or DB errors: show, never crash the UI
95
+ host.panel.log_error(f"✖ Export failed: {e}")
96
+ host.app.notify(str(e), title="Export failed", severity="error")
97
+ host.status.update_state(message="export failed")
98
+ else:
99
+ size = req.path.stat().st_size
100
+ msg = f"Exported {count:,} rows to {req.path} ({size / 1024:.1f} KB)"
101
+ host.panel.log_line(msg, "green")
102
+ host.app.notify(msg)
103
+ host.status.update_state(message=f"exported {count:,} rows")
104
+ finally:
105
+ self._exporting = False
106
+
107
+ async def _export_all(
108
+ self, exporter: export.Exporter, grid: ResultGrid, path: Path, opts: ExportOptions
109
+ ) -> int:
110
+ """Re-run the statement on a private connection and stream it into the file."""
111
+ host = self._host
112
+ assert host.conn is not None
113
+ password = await host.ws.lookup_password(host.conn)
114
+ session = await host.ws.connect(host.conn, password)
115
+ self._export_session = session
116
+ try:
117
+ return await export_query(exporter, session, grid.sql, path, opts)
118
+ except DbError:
119
+ if self._export_cancel:
120
+ raise ExportCancelled from None
121
+ raise
122
+ finally:
123
+ self._export_session = None
124
+ await asyncio.shield(session.close())
@@ -0,0 +1,335 @@
1
+ """One console: editor + results + status, bound to one DB session and one SQL file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from time import monotonic
7
+ from typing import cast
8
+
9
+ from textual.app import ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Vertical
12
+ from textual.message import Message
13
+ from textual.timer import Timer
14
+ from textual.widgets import TextArea
15
+
16
+ from sqlide.config.connections import Connection
17
+ from sqlide.consoles import CONSOLE, FILE, TabState
18
+ from sqlide.db.completion import candidates
19
+ from sqlide.db.metadata import MetaCache
20
+ from sqlide.db.result import DbError
21
+ from sqlide.db.session import DbSession
22
+ from sqlide.sql.context import analyze
23
+ from sqlide.sql.snippets import is_ddl
24
+ from sqlide.ui.widgets.console_export import ExportActions
25
+ from sqlide.ui.widgets.result_grid import ResultGrid
26
+ from sqlide.ui.widgets.result_panel import ResultPanel
27
+ from sqlide.ui.widgets.sql_editor import SqlEditor
28
+ from sqlide.ui.widgets.status_bar import StatusBar
29
+ from sqlide.workspace import Workspace
30
+
31
+ AUTOSAVE_S = 1.0
32
+
33
+
34
+ def one_line(sql: str, limit: int = 100) -> str:
35
+ text = " ".join(sql.split())
36
+ return text if len(text) <= limit else text[: limit - 1] + "…"
37
+
38
+
39
+ class ConsoleTab(ExportActions, Vertical):
40
+ BINDINGS = [
41
+ Binding("ctrl+f2", "cancel", "Cancel", id="console.cancel"),
42
+ Binding("f8", "toggle_tx", "Auto/Manual tx", id="console.toggle_tx"),
43
+ Binding("f9", "commit", "Commit", id="console.commit"),
44
+ Binding("f10", "rollback", "Rollback", id="console.rollback"),
45
+ ]
46
+
47
+ class TitleChanged(Message):
48
+ def __init__(self, console: ConsoleTab) -> None:
49
+ super().__init__()
50
+ self.console = console
51
+
52
+ class SchemaChanged(Message):
53
+ """A DDL statement ran: cached metadata was dropped, views should reload."""
54
+
55
+ def __init__(self, console: ConsoleTab) -> None:
56
+ super().__init__()
57
+ self.console = console
58
+
59
+ class ConnectRequested(Message):
60
+ """The console has no session yet but knows which connection it wants."""
61
+
62
+ def __init__(self, console: ConsoleTab, conn_name: str, then_run: list[str]) -> None:
63
+ super().__init__()
64
+ self.console, self.conn_name, self.then_run = console, conn_name, then_run
65
+
66
+ def __init__(
67
+ self, ws: Workspace, path: Path, kind: str = CONSOLE, conn_name: str = "", **kw
68
+ ) -> None:
69
+ super().__init__(**kw)
70
+ self.ws = ws
71
+ self.path = path
72
+ self.kind = kind
73
+ self.conn_name = conn_name
74
+ self.conn: Connection | None = None
75
+ self.session: DbSession | None = None
76
+ self.meta: MetaCache | None = None
77
+ self.meta_session: DbSession | None = None
78
+ self._executing = False
79
+ self._autosave: Timer | None = None
80
+ try:
81
+ self._initial, self._newline = ws.consoles.read(path)
82
+ except FileNotFoundError: # a new file named on the command line
83
+ self._initial, self._newline = "", "\n"
84
+ self._saved_text = self._initial
85
+
86
+ def compose(self) -> ComposeResult:
87
+ yield SqlEditor(self._initial, blank_line=self.ws.settings.split_on_blank_line, id="editor")
88
+ yield ResultPanel(id="results")
89
+ yield StatusBar(id="status")
90
+
91
+ # --- parts ---
92
+ @property
93
+ def editor(self) -> SqlEditor:
94
+ return self.query_one("#editor", SqlEditor)
95
+
96
+ @property
97
+ def panel(self) -> ResultPanel:
98
+ return self.query_one("#results", ResultPanel)
99
+
100
+ @property
101
+ def status(self) -> StatusBar:
102
+ return self.query_one("#status", StatusBar)
103
+
104
+ @property
105
+ def running(self) -> bool:
106
+ return self._executing
107
+
108
+ # --- identity / files ---
109
+ @property
110
+ def dirty(self) -> bool:
111
+ if self.kind != FILE:
112
+ return False
113
+ editors = self.query("#editor") # not mounted yet while the tab strip is being built
114
+ return bool(editors) and cast(SqlEditor, editors.first()).text != self._saved_text
115
+
116
+ @property
117
+ def title(self) -> str:
118
+ name = self.path.stem if self.kind == CONSOLE else self.path.name
119
+ return (
120
+ ("● " if self.dirty else "") + name + (f" [{self.conn_name}]" if self.conn_name else "")
121
+ )
122
+
123
+ def tab_state(self, active: bool) -> TabState:
124
+ return TabState(self.kind, str(self.path), self.conn_name, active)
125
+
126
+ def _title_changed(self) -> None:
127
+ self.post_message(self.TitleChanged(self))
128
+
129
+ def on_text_area_changed(self, _: TextArea.Changed) -> None:
130
+ if self.kind == CONSOLE:
131
+ if self._autosave is not None:
132
+ self._autosave.stop()
133
+ self._autosave = self.set_timer(AUTOSAVE_S, self.flush)
134
+ else:
135
+ self._title_changed()
136
+
137
+ def flush(self) -> None:
138
+ """Write a console's text to its autosave file now."""
139
+ if self.kind == CONSOLE and self.is_mounted:
140
+ self.ws.consoles.write(self.path, self.editor.text, self._newline)
141
+
142
+ def save(self, path: Path | None = None) -> None:
143
+ """Save to `path` (Save As) or the current file. A console becomes a file tab."""
144
+ target = path or self.path
145
+ self.ws.consoles.write(target, self.editor.text, self._newline)
146
+ self.path, self.kind = target, FILE
147
+ self._saved_text = self.editor.text
148
+ self._title_changed()
149
+
150
+ # --- connection ---
151
+ async def attach(self, conn: Connection, session: DbSession) -> None:
152
+ await self.detach()
153
+ self.conn, self.session, self.conn_name = conn, session, conn.name
154
+ self.meta = MetaCache(session)
155
+ self.run_worker(self._open_meta_session(conn, self.meta), group="meta", exclusive=True)
156
+ self.editor.dialect = self.ws.driver(conn.driver).dialect
157
+ self._refresh_tx(message="")
158
+ self.status.update_state(connection=f"{conn.name} ({session.product})")
159
+ self.panel.log_line(f"Connected: {conn.name}: {session.product}", "green")
160
+ self._title_changed()
161
+
162
+ async def _open_meta_session(self, conn: Connection, meta: MetaCache) -> None:
163
+ second = await self.ws.connect_for_metadata(conn)
164
+ if second is None:
165
+ return
166
+ if self.meta is not meta: # detached or reconnected while we were connecting
167
+ await second.close()
168
+ return
169
+ self.meta_session = second
170
+ meta.use(second)
171
+
172
+ async def detach(self) -> None:
173
+ self.workers.cancel_group(self, "meta")
174
+ if self.meta_session is not None:
175
+ await self.meta_session.close()
176
+ self.meta_session = None
177
+ if self.session is not None:
178
+ await self.session.close()
179
+ self.conn = self.session = self.meta = None
180
+ self.status.update_state(connection="not connected", tx="", message="")
181
+
182
+ async def shutdown(self) -> None:
183
+ """Called when the tab goes away: persist text, cancel work, close the connection."""
184
+ self.cancel_export()
185
+ self.flush()
186
+ await self.detach()
187
+
188
+ # --- transactions ---
189
+ def _refresh_tx(self, message: str | None = None) -> None:
190
+ s = self.session
191
+ tx = (
192
+ ""
193
+ if s is None
194
+ else "Auto"
195
+ if s.autocommit
196
+ else "Manual" + ("*" if s.pending_tx else "")
197
+ )
198
+ fields = {"tx": tx}
199
+ if message is not None:
200
+ fields["message"] = message
201
+ self.status.update_state(**fields)
202
+
203
+ async def action_toggle_tx(self) -> None:
204
+ if self.session is None or self._executing:
205
+ return
206
+ try:
207
+ await self.session.set_autocommit(not self.session.autocommit)
208
+ except DbError as e:
209
+ self.app.notify(str(e), severity="error")
210
+ self._refresh_tx(
211
+ message=f"transaction mode: {'Auto' if self.session.autocommit else 'Manual'}"
212
+ )
213
+
214
+ async def action_commit(self) -> None:
215
+ await self._finish_tx(commit=True)
216
+
217
+ async def action_rollback(self) -> None:
218
+ await self._finish_tx(commit=False)
219
+
220
+ async def _finish_tx(self, commit: bool) -> None:
221
+ s = self.session
222
+ if s is None or s.autocommit or self._executing:
223
+ return
224
+ try:
225
+ await (s.commit() if commit else s.rollback())
226
+ except DbError as e:
227
+ self.app.notify(str(e), severity="error")
228
+ return
229
+ if self.meta is not None: # DDL committed now becomes visible to the metadata session
230
+ self.meta.refresh()
231
+ self.post_message(self.SchemaChanged(self))
232
+ self.panel.log_line("Committed" if commit else "Rolled back", "green")
233
+ self._refresh_tx(message="committed" if commit else "rolled back")
234
+
235
+ # --- autocomplete ---
236
+ def on_sql_editor_completion_requested(self, message: SqlEditor.CompletionRequested) -> None:
237
+ message.stop()
238
+ self.run_worker(self._complete(message), group="complete", exclusive=True)
239
+
240
+ async def _complete(self, req: SqlEditor.CompletionRequested) -> None:
241
+ dialect = self.editor.dialect
242
+ ctx = analyze(req.text, req.offset, dialect)
243
+ if ctx is None or (not ctx.prefix and not ctx.qualifier and not req.manual):
244
+ self.editor.hide_completions()
245
+ return
246
+ items = await candidates(ctx, self.meta, dialect)
247
+ if self.editor._cursor_index() != req.offset: # the user typed on; a newer request follows
248
+ return
249
+ self.editor.show_completions(items, ctx.replace_len)
250
+
251
+ # --- execution ---
252
+ def on_sql_editor_run_requested(self, message: SqlEditor.RunRequested) -> None:
253
+ message.stop()
254
+ self.run_statements(message.statements)
255
+
256
+ def run_statements(self, statements: list[str]) -> None:
257
+ if self.session is None:
258
+ if self.conn_name and any(c.name == self.conn_name for c in self.ws.connections()):
259
+ self.post_message(self.ConnectRequested(self, self.conn_name, statements))
260
+ else:
261
+ self.app.notify(
262
+ "Not connected: pick a connection in the sidebar", severity="warning"
263
+ )
264
+ elif self._executing:
265
+ self.app.notify("A query is running (Ctrl+F2 cancels it)", severity="warning")
266
+ else:
267
+ self.run_worker(self._run(statements), group="run")
268
+
269
+ def on_result_grid_summary(self, message: ResultGrid.Summary) -> None:
270
+ message.stop()
271
+ self.status.update_state(message=message.text)
272
+
273
+ def action_cancel(self) -> None:
274
+ if self.cancel_export():
275
+ return
276
+ if self.session is not None and self._executing:
277
+ self.session.cancel()
278
+ self.status.update_state(message="cancelling…")
279
+
280
+ def _record(self, sql: str, ok: bool, elapsed_s: float, error: str = "") -> None:
281
+ try:
282
+ self.ws.history.add(self.conn_name, sql, ok, round(elapsed_s * 1000), error)
283
+ except Exception as e: # history must never break running queries
284
+ self.app.log.warning(f"history: {e}")
285
+
286
+ async def _run(self, statements: list[str]) -> None:
287
+ session = self.session
288
+ assert session is not None
289
+ self._executing = True
290
+ self.status.update_state(message="running…")
291
+ await self.panel.reset_results()
292
+ n_results = 0
293
+ last = ""
294
+ try:
295
+ for sql in statements:
296
+ self.panel.log_line(f"▶ {one_line(sql)}", "bold")
297
+ started = monotonic()
298
+ try:
299
+ ex = await session.execute(sql, self.ws.settings.fetch_size)
300
+ except DbError as e:
301
+ self._record(sql, False, monotonic() - started, str(e))
302
+ self.panel.log_error(f"✖ {e}" + (f" [{e.sql_state}]" if e.sql_state else ""))
303
+ if str(e) == "Cancelled by user":
304
+ self.app.notify("Query cancelled", severity="warning")
305
+ else:
306
+ self.app.notify(str(e), title="Query failed", severity="error")
307
+ last = "failed"
308
+ break
309
+ for w in ex.warnings:
310
+ self.panel.log_line(f" {w}", "yellow")
311
+ for item in ex.items:
312
+ if item.has_rows:
313
+ n_results += 1
314
+ grid = await self.panel.add_result(
315
+ f"Result {n_results}",
316
+ item.columns,
317
+ item.rows,
318
+ item.cursor,
319
+ self.ws.settings.fetch_size,
320
+ sql,
321
+ )
322
+ last = grid.summary_text
323
+ else:
324
+ last = f"{item.update_count} rows affected"
325
+ self.panel.log_line(f" {last}")
326
+ self._record(sql, True, ex.elapsed_s)
327
+ if is_ddl(sql) and self.meta is not None:
328
+ self.meta.refresh()
329
+ self.post_message(self.SchemaChanged(self))
330
+ self.panel.log_line(f"✔ {ex.elapsed_s * 1000:.0f} ms", "green")
331
+ last = f"{last} in {ex.elapsed_s * 1000:.0f} ms"
332
+ finally:
333
+ self._executing = False
334
+ self._refresh_tx(message=last)
335
+ self.panel.show_first_result()
@@ -0,0 +1,117 @@
1
+ """The tab strip of consoles: create, restore, close, persist."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.markup import escape
8
+ from textual.widgets import TabbedContent, TabPane
9
+
10
+ from sqlide.consoles import CONSOLE, FILE, TabState
11
+ from sqlide.ui.widgets.console_tab import ConsoleTab
12
+ from sqlide.workspace import Workspace
13
+
14
+
15
+ class ConsoleTabs(TabbedContent):
16
+ def __init__(self, ws: Workspace, panes: list[TabPane], initial: str, seq: int, **kw) -> None:
17
+ super().__init__(initial=initial, **kw)
18
+ for pane in panes:
19
+ self.compose_add_child(pane) # same hook the `with TabbedContent():` form uses
20
+ self.ws = ws
21
+ self._seq = seq
22
+
23
+ @classmethod
24
+ def build(cls, ws: Workspace, states: list[TabState], files: list[Path], **kw) -> ConsoleTabs:
25
+ """Tabs of the previous run plus files named on the command line (synchronous)."""
26
+ consoles: list[tuple[ConsoleTab, bool]] = []
27
+ for st in states:
28
+ path = Path(st.path)
29
+ if path.exists():
30
+ consoles.append((ConsoleTab(ws, path, st.kind, st.connection), st.active))
31
+ open_paths = {c.path.resolve() for c, _ in consoles}
32
+ opened_from_cli: ConsoleTab | None = None
33
+ for f in files:
34
+ known = next((c for c, _ in consoles if c.path.resolve() == f.resolve()), None)
35
+ if known is None and f.resolve() not in open_paths:
36
+ known = ConsoleTab(ws, f, FILE)
37
+ consoles.append((known, False))
38
+ opened_from_cli = known
39
+ if not consoles:
40
+ consoles.append((ConsoleTab(ws, ws.consoles.new_console_file(None), CONSOLE), True))
41
+ active = opened_from_cli or next((c for c, a in consoles if a), consoles[0][0])
42
+ panes, initial = [], ""
43
+ for i, (console, _) in enumerate(consoles, 1):
44
+ pane = TabPane(escape(console.title), console, id=f"c{i}")
45
+ panes.append(pane)
46
+ if console is active:
47
+ initial = f"c{i}"
48
+ return cls(ws, panes, initial, len(consoles), **kw)
49
+
50
+ # --- lookup ---
51
+ def consoles(self) -> list[ConsoleTab]:
52
+ return list(self.query(ConsoleTab))
53
+
54
+ @property
55
+ def active_console(self) -> ConsoleTab | None:
56
+ pane = self.active_pane
57
+ return pane.query_one(ConsoleTab) if pane is not None else None
58
+
59
+ def find_file(self, path: Path) -> ConsoleTab | None:
60
+ want = path.resolve()
61
+ return next((c for c in self.consoles() if c.path.resolve() == want), None)
62
+
63
+ # --- create / close ---
64
+ async def add_console(
65
+ self,
66
+ *,
67
+ kind: str = CONSOLE,
68
+ path: Path | None = None,
69
+ conn_name: str = "",
70
+ activate: bool = True,
71
+ ) -> ConsoleTab:
72
+ if path is None:
73
+ path = self.ws.consoles.new_console_file(conn_name or None)
74
+ self._seq += 1
75
+ pane_id = f"c{self._seq}"
76
+ console = ConsoleTab(self.ws, path, kind, conn_name)
77
+ await self.add_pane(TabPane(escape(console.title), console, id=pane_id))
78
+ if activate:
79
+ self.active = pane_id
80
+ self.persist()
81
+ return console
82
+
83
+ async def close_console(self, console: ConsoleTab) -> None:
84
+ pane = console.parent
85
+ await console.shutdown()
86
+ if isinstance(pane, TabPane) and pane.id:
87
+ await self.remove_pane(pane.id)
88
+ if not self.consoles():
89
+ await self.add_console()
90
+ self.persist()
91
+
92
+ async def shutdown(self) -> None:
93
+ """App is quitting: remember the tabs, then stop every console."""
94
+ self.persist()
95
+ for console in self.consoles():
96
+ await console.shutdown()
97
+
98
+ # --- titles ---
99
+ def on_console_tab_title_changed(self, message: ConsoleTab.TitleChanged) -> None:
100
+ message.stop()
101
+ pane = message.console.parent
102
+ if isinstance(pane, TabPane) and pane.id:
103
+ self.get_tab(pane.id).label = escape(message.console.title) # type: ignore[assignment]
104
+ self.persist()
105
+
106
+ def on_tabbed_content_tab_activated(self, _: TabbedContent.TabActivated) -> None:
107
+ self.persist()
108
+
109
+ # --- persistence ---
110
+ def persist(self) -> None:
111
+ active = self.active_console
112
+ states = [c.tab_state(c is active) for c in self.consoles()]
113
+ if states:
114
+ self.ws.consoles.save_state(states)
115
+
116
+
117
+ __all__ = ["CONSOLE", "FILE", "ConsoleTabs"]