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.
- sqlide/__init__.py +3 -0
- sqlide/__main__.py +5 -0
- sqlide/app.py +46 -0
- sqlide/cli.py +64 -0
- sqlide/clipboard.py +65 -0
- sqlide/config/__init__.py +1 -0
- sqlide/config/_toml.py +41 -0
- sqlide/config/connections.py +76 -0
- sqlide/config/keymap.py +25 -0
- sqlide/config/paths.py +48 -0
- sqlide/config/secrets.py +82 -0
- sqlide/config/settings.py +32 -0
- sqlide/consoles.py +100 -0
- sqlide/db/__init__.py +1 -0
- sqlide/db/completion.py +127 -0
- sqlide/db/factory.py +37 -0
- sqlide/db/metadata.py +174 -0
- sqlide/db/result.py +52 -0
- sqlide/db/session.py +339 -0
- sqlide/db/types.py +84 -0
- sqlide/doctor.py +60 -0
- sqlide/drivers/__init__.py +1 -0
- sqlide/drivers/catalog.toml +103 -0
- sqlide/drivers/cli.py +74 -0
- sqlide/drivers/custom.py +69 -0
- sqlide/drivers/loader.py +75 -0
- sqlide/drivers/maven.py +108 -0
- sqlide/drivers/registry.py +117 -0
- sqlide/export/__init__.py +12 -0
- sqlide/export/base.py +86 -0
- sqlide/export/csv_.py +40 -0
- sqlide/export/html.py +44 -0
- sqlide/export/json_.py +47 -0
- sqlide/export/markdown.py +27 -0
- sqlide/export/service.py +34 -0
- sqlide/export/sql_insert.py +37 -0
- sqlide/export/xlsx.py +109 -0
- sqlide/grid/__init__.py +1 -0
- sqlide/grid/copyfmt.py +97 -0
- sqlide/grid/formatting.py +43 -0
- sqlide/grid/model.py +109 -0
- sqlide/history/__init__.py +5 -0
- sqlide/history/store.py +117 -0
- sqlide/jvm/__init__.py +1 -0
- sqlide/jvm/locate.py +98 -0
- sqlide/jvm/runtime.py +44 -0
- sqlide/sql/__init__.py +1 -0
- sqlide/sql/context.py +195 -0
- sqlide/sql/dialects.py +93 -0
- sqlide/sql/format.py +50 -0
- sqlide/sql/keywords.py +143 -0
- sqlide/sql/lexer.py +148 -0
- sqlide/sql/snippets.py +44 -0
- sqlide/sql/splitter.py +325 -0
- sqlide/ui/__init__.py +1 -0
- sqlide/ui/app.tcss +67 -0
- sqlide/ui/commands.py +59 -0
- sqlide/ui/keymap.py +32 -0
- sqlide/ui/screens/__init__.py +0 -0
- sqlide/ui/screens/connection_editor.py +113 -0
- sqlide/ui/screens/dialogs.py +110 -0
- sqlide/ui/screens/driver_manager.py +195 -0
- sqlide/ui/screens/export_dialog.py +158 -0
- sqlide/ui/screens/grid_dialogs.py +92 -0
- sqlide/ui/screens/history.py +130 -0
- sqlide/ui/screens/main.py +383 -0
- sqlide/ui/screens/settings.py +77 -0
- sqlide/ui/widgets/__init__.py +0 -0
- sqlide/ui/widgets/completion_popup.py +81 -0
- sqlide/ui/widgets/connections_list.py +54 -0
- sqlide/ui/widgets/console_export.py +124 -0
- sqlide/ui/widgets/console_tab.py +335 -0
- sqlide/ui/widgets/console_tabs.py +117 -0
- sqlide/ui/widgets/result_grid.py +435 -0
- sqlide/ui/widgets/result_panel.py +73 -0
- sqlide/ui/widgets/result_view.py +50 -0
- sqlide/ui/widgets/schema_tree.py +145 -0
- sqlide/ui/widgets/sql_editor.py +310 -0
- sqlide/ui/widgets/status_bar.py +32 -0
- sqlide/workspace.py +97 -0
- sqlide-0.1.0.dist-info/METADATA +151 -0
- sqlide-0.1.0.dist-info/RECORD +85 -0
- sqlide-0.1.0.dist-info/WHEEL +4 -0
- sqlide-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|