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,383 @@
|
|
|
1
|
+
"""Main screen: connections sidebar + tabs of consoles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from textual import work
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.screen import Screen
|
|
13
|
+
from textual.widgets import Footer, Header, ListView, TabbedContent, TabPane
|
|
14
|
+
|
|
15
|
+
from sqlide.config._toml import ConfigError
|
|
16
|
+
from sqlide.config.connections import Connection
|
|
17
|
+
from sqlide.config.settings import save_settings
|
|
18
|
+
from sqlide.consoles import FILE
|
|
19
|
+
from sqlide.db.result import DbError
|
|
20
|
+
from sqlide.drivers.loader import DriverError
|
|
21
|
+
from sqlide.drivers.maven import MavenError
|
|
22
|
+
from sqlide.jvm.locate import JvmNotFound
|
|
23
|
+
from sqlide.jvm.runtime import ensure_jvm
|
|
24
|
+
from sqlide.sql.snippets import qualified_name, select_all
|
|
25
|
+
from sqlide.ui.screens.connection_editor import ConnectionEditor
|
|
26
|
+
from sqlide.ui.screens.dialogs import ConfirmScreen, PasswordPrompt, PathPrompt, ProgressScreen
|
|
27
|
+
from sqlide.ui.screens.driver_manager import DriverManager
|
|
28
|
+
from sqlide.ui.screens.history import HistoryScreen
|
|
29
|
+
from sqlide.ui.screens.settings import SettingsScreen
|
|
30
|
+
from sqlide.ui.widgets.connections_list import ConnectionItem, ConnectionsList
|
|
31
|
+
from sqlide.ui.widgets.console_tab import ConsoleTab
|
|
32
|
+
from sqlide.ui.widgets.console_tabs import ConsoleTabs
|
|
33
|
+
from sqlide.ui.widgets.result_grid import ResultGrid
|
|
34
|
+
from sqlide.ui.widgets.schema_tree import SchemaTree
|
|
35
|
+
from sqlide.workspace import Workspace
|
|
36
|
+
|
|
37
|
+
EXPECTED_ERRORS = (ConfigError, MavenError, DriverError, DbError, JvmNotFound)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MainScreen(Screen):
|
|
41
|
+
BINDINGS = [
|
|
42
|
+
Binding("ctrl+n", "new_connection", "New connection", id="main.new_connection"),
|
|
43
|
+
Binding("ctrl+t", "new_console", "New console", id="main.new_console"),
|
|
44
|
+
Binding(
|
|
45
|
+
"ctrl+f4,alt+w", "close_console", "Close tab", priority=True, id="main.close_console"
|
|
46
|
+
),
|
|
47
|
+
Binding("ctrl+alt+e,alt+e", "history", "History", priority=True, id="main.history"),
|
|
48
|
+
Binding("ctrl+o", "open_file", "Open file", id="main.open_file"),
|
|
49
|
+
Binding("ctrl+s", "save_file", "Save", id="main.save_file"),
|
|
50
|
+
Binding("alt+right", "tab(1)", "Next tab", show=False, id="main.next_tab"),
|
|
51
|
+
Binding("alt+left", "tab(-1)", "Previous tab", show=False, id="main.prev_tab"),
|
|
52
|
+
# Alt+digits never reach us in most terminals (Textual maps ESC+digit to Mac Option
|
|
53
|
+
# characters), so each focus action also has an Alt+letter, and F6 cycles the panes.
|
|
54
|
+
Binding(
|
|
55
|
+
"alt+c,alt+1",
|
|
56
|
+
"focus_sidebar",
|
|
57
|
+
"Connections",
|
|
58
|
+
False,
|
|
59
|
+
priority=True,
|
|
60
|
+
id="main.focus_sidebar",
|
|
61
|
+
),
|
|
62
|
+
Binding(
|
|
63
|
+
"alt+d,alt+4", "focus_schema", "Schema", False, priority=True, id="main.focus_schema"
|
|
64
|
+
),
|
|
65
|
+
Binding(
|
|
66
|
+
"alt+q,alt+2", "focus_editor", "Editor", False, priority=True, id="main.focus_editor"
|
|
67
|
+
),
|
|
68
|
+
Binding(
|
|
69
|
+
"alt+r,alt+3", "focus_results", "Results", False, priority=True, id="main.focus_results"
|
|
70
|
+
),
|
|
71
|
+
Binding("f6", "cycle_focus(1)", "Next pane", False, priority=True, id="main.next_pane"),
|
|
72
|
+
Binding(
|
|
73
|
+
"shift+f6",
|
|
74
|
+
"cycle_focus(-1)",
|
|
75
|
+
"Previous pane",
|
|
76
|
+
False,
|
|
77
|
+
priority=True,
|
|
78
|
+
id="main.prev_pane",
|
|
79
|
+
),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def __init__(self, ws: Workspace, files: list[Path] | None = None) -> None:
|
|
83
|
+
super().__init__()
|
|
84
|
+
self.ws = ws
|
|
85
|
+
self._files = files or []
|
|
86
|
+
|
|
87
|
+
def compose(self) -> ComposeResult:
|
|
88
|
+
yield Header()
|
|
89
|
+
with Horizontal():
|
|
90
|
+
with Vertical(id="side"):
|
|
91
|
+
yield ConnectionsList(id="sidebar")
|
|
92
|
+
yield SchemaTree(id="schema")
|
|
93
|
+
yield ConsoleTabs.build(self.ws, self.ws.consoles.load_state(), self._files, id="tabs")
|
|
94
|
+
yield Footer()
|
|
95
|
+
|
|
96
|
+
# --- parts ---
|
|
97
|
+
@property
|
|
98
|
+
def sidebar(self) -> ConnectionsList:
|
|
99
|
+
return self.query_one("#sidebar", ConnectionsList)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def schema(self) -> SchemaTree:
|
|
103
|
+
return self.query_one("#schema", SchemaTree)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def tabs(self) -> ConsoleTabs:
|
|
107
|
+
return self.query_one("#tabs", ConsoleTabs)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def console(self) -> ConsoleTab:
|
|
111
|
+
console = self.tabs.active_console
|
|
112
|
+
assert console is not None, "there is always at least one console"
|
|
113
|
+
return console
|
|
114
|
+
|
|
115
|
+
def on_mount(self) -> None:
|
|
116
|
+
self.app.title = "sqlide"
|
|
117
|
+
self._reload()
|
|
118
|
+
self.sidebar.focus()
|
|
119
|
+
self._sync_subtitle()
|
|
120
|
+
self.run_worker(self._warm_jvm, thread=True, group="jvm")
|
|
121
|
+
if not self.ws.connections():
|
|
122
|
+
self.console.panel.log_line("No connections yet: press Ctrl+N to add one.", "yellow")
|
|
123
|
+
|
|
124
|
+
def _warm_jvm(self) -> None:
|
|
125
|
+
# start in the background so the first connect is fast; errors surface on connect
|
|
126
|
+
with contextlib.suppress(JvmNotFound):
|
|
127
|
+
ensure_jvm()
|
|
128
|
+
|
|
129
|
+
def _reload(self) -> None:
|
|
130
|
+
self.sidebar.reload(self.ws.connections())
|
|
131
|
+
|
|
132
|
+
def _sync_subtitle(self) -> None:
|
|
133
|
+
console = self.tabs.active_console
|
|
134
|
+
self.app.sub_title = console.conn_name if console else ""
|
|
135
|
+
if console is not None:
|
|
136
|
+
self.schema.show(console.meta, console.conn_name)
|
|
137
|
+
|
|
138
|
+
def on_console_tab_schema_changed(self, msg: ConsoleTab.SchemaChanged) -> None:
|
|
139
|
+
msg.stop()
|
|
140
|
+
if msg.console is self.tabs.active_console:
|
|
141
|
+
self.schema.show(msg.console.meta, msg.console.conn_name)
|
|
142
|
+
|
|
143
|
+
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
144
|
+
if event.tabbed_content is self.tabs: # not the result tabs inside a console
|
|
145
|
+
self._sync_subtitle()
|
|
146
|
+
|
|
147
|
+
def _report(self, e: Exception, console: ConsoleTab | None = None) -> None:
|
|
148
|
+
(console or self.console).panel.log_error(f"✖ {e}")
|
|
149
|
+
self.app.notify(str(e).splitlines()[0], title="Error", severity="error", timeout=10)
|
|
150
|
+
|
|
151
|
+
# --- focus ---
|
|
152
|
+
def action_cycle_focus(self, step: int) -> None:
|
|
153
|
+
"""F6: connections -> schema tree -> editor -> results."""
|
|
154
|
+
panel = self.console.panel
|
|
155
|
+
grids = panel.tabs.get_pane(panel.tabs.active).query(ResultGrid)
|
|
156
|
+
order = [
|
|
157
|
+
self.sidebar,
|
|
158
|
+
self.schema,
|
|
159
|
+
self.console.editor,
|
|
160
|
+
grids.first() if grids else panel.tabs,
|
|
161
|
+
]
|
|
162
|
+
current = next((i for i, w in enumerate(order) if w is self.focused), -1)
|
|
163
|
+
order[(current + step) % len(order)].focus()
|
|
164
|
+
|
|
165
|
+
def action_focus_sidebar(self) -> None:
|
|
166
|
+
self.sidebar.focus()
|
|
167
|
+
|
|
168
|
+
def action_focus_schema(self) -> None:
|
|
169
|
+
self.schema.focus()
|
|
170
|
+
|
|
171
|
+
def on_schema_tree_table_chosen(self, msg: SchemaTree.TableChosen) -> None:
|
|
172
|
+
msg.stop()
|
|
173
|
+
console = self.console
|
|
174
|
+
dialect = console.editor.dialect
|
|
175
|
+
name = qualified_name([msg.table.namespace, msg.table.name], dialect)
|
|
176
|
+
editor = console.editor
|
|
177
|
+
if msg.action == "insert":
|
|
178
|
+
editor.insert(name)
|
|
179
|
+
editor.focus()
|
|
180
|
+
return
|
|
181
|
+
sql = select_all(name, dialect)
|
|
182
|
+
editor.move_cursor(editor.document.end)
|
|
183
|
+
editor.insert(("\n\n" if editor.text.strip() else "") + sql + ";")
|
|
184
|
+
editor.focus()
|
|
185
|
+
console.run_statements([sql])
|
|
186
|
+
|
|
187
|
+
def action_focus_editor(self) -> None:
|
|
188
|
+
self.console.editor.focus()
|
|
189
|
+
|
|
190
|
+
def action_focus_results(self) -> None:
|
|
191
|
+
self.console.panel.focus_active()
|
|
192
|
+
|
|
193
|
+
# --- tabs and files ---
|
|
194
|
+
def action_tab(self, step: int) -> None:
|
|
195
|
+
ids = [p.id for p in self.tabs.query(TabPane) if p.id and p.id.startswith("c")]
|
|
196
|
+
ids = [i for i in ids if self.tabs.get_pane(i).query(ConsoleTab)]
|
|
197
|
+
if len(ids) > 1 and self.tabs.active in ids:
|
|
198
|
+
self.tabs.active = ids[(ids.index(self.tabs.active) + step) % len(ids)]
|
|
199
|
+
|
|
200
|
+
@work(group="tabs")
|
|
201
|
+
async def action_new_console(self) -> None:
|
|
202
|
+
current = self.tabs.active_console
|
|
203
|
+
await self.tabs.add_console(conn_name=current.conn_name if current else "")
|
|
204
|
+
self.console.editor.focus()
|
|
205
|
+
|
|
206
|
+
@work(group="tabs")
|
|
207
|
+
async def action_close_console(self) -> None:
|
|
208
|
+
console = self.tabs.active_console
|
|
209
|
+
if console is None:
|
|
210
|
+
return
|
|
211
|
+
if console.dirty and not await self.app.push_screen_wait(
|
|
212
|
+
ConfirmScreen(f"Discard unsaved changes in {console.path.name}?", yes="Discard")
|
|
213
|
+
):
|
|
214
|
+
return
|
|
215
|
+
await self.tabs.close_console(console)
|
|
216
|
+
self._sync_subtitle()
|
|
217
|
+
|
|
218
|
+
@work(exclusive=True, group="dialog")
|
|
219
|
+
async def action_open_file(self) -> None:
|
|
220
|
+
raw = await self.app.push_screen_wait(
|
|
221
|
+
PathPrompt("Open SQL file", default=f"{Path.cwd()}/", ok="Open")
|
|
222
|
+
)
|
|
223
|
+
if raw is None:
|
|
224
|
+
return
|
|
225
|
+
path = Path(raw).expanduser()
|
|
226
|
+
if not path.is_file():
|
|
227
|
+
self.app.notify(f"No such file: {path}", severity="error")
|
|
228
|
+
return
|
|
229
|
+
await self.open_path(path)
|
|
230
|
+
|
|
231
|
+
async def open_path(self, path: Path) -> None:
|
|
232
|
+
existing = self.tabs.find_file(path)
|
|
233
|
+
if existing is not None and existing.parent is not None:
|
|
234
|
+
pane = existing.parent
|
|
235
|
+
if isinstance(pane, TabPane) and pane.id:
|
|
236
|
+
self.tabs.active = pane.id
|
|
237
|
+
return
|
|
238
|
+
current = self.tabs.active_console
|
|
239
|
+
await self.tabs.add_console(
|
|
240
|
+
kind=FILE, path=path, conn_name=current.conn_name if current else ""
|
|
241
|
+
)
|
|
242
|
+
self.console.editor.focus()
|
|
243
|
+
|
|
244
|
+
@work(exclusive=True, group="dialog")
|
|
245
|
+
async def action_save_file(self) -> None:
|
|
246
|
+
console = self.console
|
|
247
|
+
try:
|
|
248
|
+
if console.kind == FILE:
|
|
249
|
+
console.save()
|
|
250
|
+
else:
|
|
251
|
+
raw = await self.app.push_screen_wait(
|
|
252
|
+
PathPrompt("Save console as", default=f"{Path.cwd()}/query.sql", ok="Save")
|
|
253
|
+
)
|
|
254
|
+
if raw is None:
|
|
255
|
+
return
|
|
256
|
+
console.save(Path(raw).expanduser())
|
|
257
|
+
except OSError as e:
|
|
258
|
+
self.app.notify(str(e), title="Cannot save", severity="error")
|
|
259
|
+
return
|
|
260
|
+
self.app.notify(f"Saved {console.path}")
|
|
261
|
+
|
|
262
|
+
# --- settings and drivers ---
|
|
263
|
+
@work(exclusive=True, group="dialog")
|
|
264
|
+
async def action_settings(self) -> None:
|
|
265
|
+
new = await self.app.push_screen_wait(
|
|
266
|
+
SettingsScreen(self.ws.settings, sorted(self.app.available_themes))
|
|
267
|
+
)
|
|
268
|
+
if new is None:
|
|
269
|
+
return
|
|
270
|
+
self.ws.settings = new
|
|
271
|
+
self.ws.history.limit = new.history_limit
|
|
272
|
+
for console in self.tabs.consoles():
|
|
273
|
+
console.editor.blank_line = new.split_on_blank_line
|
|
274
|
+
self.app.theme = new.theme # also persisted by the theme watcher
|
|
275
|
+
save_settings(new)
|
|
276
|
+
|
|
277
|
+
@work(exclusive=True, group="dialog")
|
|
278
|
+
async def action_drivers(self) -> None:
|
|
279
|
+
await self.app.push_screen_wait(DriverManager(self.ws))
|
|
280
|
+
|
|
281
|
+
# --- history ---
|
|
282
|
+
@work(exclusive=True, group="dialog")
|
|
283
|
+
async def action_history(self) -> None:
|
|
284
|
+
console = self.console
|
|
285
|
+
choice = await self.app.push_screen_wait(HistoryScreen(self.ws.history, console.conn_name))
|
|
286
|
+
if choice is None:
|
|
287
|
+
return
|
|
288
|
+
editor = console.editor
|
|
289
|
+
editor.move_cursor(editor.document.end)
|
|
290
|
+
editor.insert(("\n\n" if editor.text.strip() else "") + choice.sql + ";")
|
|
291
|
+
editor.focus()
|
|
292
|
+
if choice.run:
|
|
293
|
+
console.run_statements([choice.sql])
|
|
294
|
+
|
|
295
|
+
# --- connection CRUD ---
|
|
296
|
+
@work(exclusive=True, group="dialog")
|
|
297
|
+
async def action_new_connection(self) -> None:
|
|
298
|
+
await self._edit(None)
|
|
299
|
+
|
|
300
|
+
@work(exclusive=True, group="dialog")
|
|
301
|
+
async def on_connections_list_edit_requested(self, msg: ConnectionsList.EditRequested) -> None:
|
|
302
|
+
await self._edit(msg.conn)
|
|
303
|
+
|
|
304
|
+
async def _edit(self, existing: Connection | None) -> None:
|
|
305
|
+
taken = {c.name for c in self.ws.connections()} - ({existing.name} if existing else set())
|
|
306
|
+
conn = await self.app.push_screen_wait(
|
|
307
|
+
ConnectionEditor(self.ws.registry.all(), existing, taken)
|
|
308
|
+
)
|
|
309
|
+
if conn is not None:
|
|
310
|
+
self.ws.save_connection(conn, replaces=existing.name if existing else None)
|
|
311
|
+
self._reload()
|
|
312
|
+
|
|
313
|
+
@work(exclusive=True, group="dialog")
|
|
314
|
+
async def on_connections_list_delete_requested(
|
|
315
|
+
self, msg: ConnectionsList.DeleteRequested
|
|
316
|
+
) -> None:
|
|
317
|
+
if await self.app.push_screen_wait(ConfirmScreen(f"Delete connection '{msg.conn.name}'?")):
|
|
318
|
+
self.ws.delete_connection(msg.conn.name)
|
|
319
|
+
self._reload()
|
|
320
|
+
|
|
321
|
+
# --- connecting ---
|
|
322
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
323
|
+
if isinstance(event.item, ConnectionItem):
|
|
324
|
+
self._connect(event.item.conn, self.console, None)
|
|
325
|
+
|
|
326
|
+
def on_console_tab_connect_requested(self, msg: ConsoleTab.ConnectRequested) -> None:
|
|
327
|
+
msg.stop()
|
|
328
|
+
conn = next((c for c in self.ws.connections() if c.name == msg.conn_name), None)
|
|
329
|
+
if conn is not None:
|
|
330
|
+
self._connect(conn, msg.console, msg.then_run)
|
|
331
|
+
|
|
332
|
+
@work(exclusive=True, group="connect")
|
|
333
|
+
async def _connect(
|
|
334
|
+
self, conn: Connection, console: ConsoleTab, then_run: list[str] | None
|
|
335
|
+
) -> None:
|
|
336
|
+
try:
|
|
337
|
+
if not self.ws.driver_ready(conn) and not await self._offer_driver(conn, console):
|
|
338
|
+
return
|
|
339
|
+
password = await self.ws.lookup_password(conn)
|
|
340
|
+
if password is None and self.ws.needs_password_prompt(conn):
|
|
341
|
+
password = await self.app.push_screen_wait(
|
|
342
|
+
PasswordPrompt(f"Password for {conn.user}@{conn.name}")
|
|
343
|
+
)
|
|
344
|
+
if password is None:
|
|
345
|
+
return
|
|
346
|
+
self.ws.resolver.store(conn.name, password)
|
|
347
|
+
console.status.update_state(message=f"connecting to {conn.name}…")
|
|
348
|
+
session = await self.ws.connect(conn, password)
|
|
349
|
+
except EXPECTED_ERRORS as e:
|
|
350
|
+
console.status.update_state(message="")
|
|
351
|
+
self.ws.resolver.forget(conn.name) # a wrong password must not be cached
|
|
352
|
+
self._report(e, console)
|
|
353
|
+
return
|
|
354
|
+
await console.attach(conn, session)
|
|
355
|
+
self._sync_subtitle()
|
|
356
|
+
console.editor.focus()
|
|
357
|
+
if then_run:
|
|
358
|
+
console.run_statements(then_run)
|
|
359
|
+
|
|
360
|
+
async def _offer_driver(self, conn: Connection, console: ConsoleTab) -> bool:
|
|
361
|
+
defn = self.ws.driver(conn.driver)
|
|
362
|
+
if not defn.is_maven:
|
|
363
|
+
raise DriverError(f"driver '{defn.id}' has no jar files on disk: {defn.jars}")
|
|
364
|
+
ok = await self.app.push_screen_wait(
|
|
365
|
+
ConfirmScreen(
|
|
366
|
+
f"Driver '{defn.name}' is not installed.\n"
|
|
367
|
+
f"Download {defn.group}:{defn.artifact} from Maven Central?",
|
|
368
|
+
yes="Download",
|
|
369
|
+
no="Cancel",
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
if not ok:
|
|
373
|
+
return False
|
|
374
|
+
progress = ProgressScreen(f"Downloading {defn.name}…")
|
|
375
|
+
await self.app.push_screen(progress)
|
|
376
|
+
try:
|
|
377
|
+
await self.ws.install_driver(
|
|
378
|
+
defn.id, lambda d, t: self.app.call_from_thread(progress.advance, d, t)
|
|
379
|
+
)
|
|
380
|
+
finally:
|
|
381
|
+
progress.dismiss()
|
|
382
|
+
console.panel.log_line(f"Driver installed: {defn.name}", "green")
|
|
383
|
+
return True
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Settings form. Returns a new Settings (or None when cancelled)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Button, Input, Label, Select, Switch
|
|
12
|
+
|
|
13
|
+
from sqlide.config.settings import Settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SettingsScreen(ModalScreen[Settings | None]):
|
|
17
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
18
|
+
|
|
19
|
+
def __init__(self, settings: Settings, themes: list[str]) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
self._settings = settings
|
|
22
|
+
self._themes = themes
|
|
23
|
+
|
|
24
|
+
def compose(self) -> ComposeResult:
|
|
25
|
+
s = self._settings
|
|
26
|
+
with Vertical(classes="dialog", id="settings"):
|
|
27
|
+
yield Label("Settings", classes="title")
|
|
28
|
+
yield Label("Theme")
|
|
29
|
+
yield Select(
|
|
30
|
+
[(t, t) for t in self._themes],
|
|
31
|
+
value=s.theme if s.theme in self._themes else Select.BLANK,
|
|
32
|
+
allow_blank=False,
|
|
33
|
+
id="theme",
|
|
34
|
+
)
|
|
35
|
+
yield Label("Rows per page")
|
|
36
|
+
yield Input(str(s.fetch_size), type="integer", id="fetch")
|
|
37
|
+
yield Label("History entries to keep")
|
|
38
|
+
yield Input(str(s.history_limit), type="integer", id="keep")
|
|
39
|
+
with Horizontal(classes="row"):
|
|
40
|
+
yield Switch(s.split_on_blank_line, id="blank")
|
|
41
|
+
yield Label(" A blank line ends a statement")
|
|
42
|
+
with Horizontal(classes="buttons"):
|
|
43
|
+
yield Button("Save", variant="primary", id="save")
|
|
44
|
+
yield Button("Cancel", id="cancel")
|
|
45
|
+
yield Label("", id="settings-error", classes="error")
|
|
46
|
+
|
|
47
|
+
def _collect(self) -> Settings | str:
|
|
48
|
+
try:
|
|
49
|
+
fetch = int(self.query_one("#fetch", Input).value)
|
|
50
|
+
keep = int(self.query_one("#keep", Input).value)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return "Numbers only"
|
|
53
|
+
if not 1 <= fetch <= 100_000:
|
|
54
|
+
return "Rows per page: 1 – 100000"
|
|
55
|
+
if not 10 <= keep <= 1_000_000:
|
|
56
|
+
return "History entries: 10 – 1000000"
|
|
57
|
+
theme = self.query_one("#theme", Select).value
|
|
58
|
+
return replace(
|
|
59
|
+
self._settings,
|
|
60
|
+
theme=str(theme) if isinstance(theme, str) else self._settings.theme,
|
|
61
|
+
fetch_size=fetch,
|
|
62
|
+
history_limit=keep,
|
|
63
|
+
split_on_blank_line=self.query_one("#blank", Switch).value,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
67
|
+
if event.button.id != "save":
|
|
68
|
+
self.dismiss(None)
|
|
69
|
+
return
|
|
70
|
+
result = self._collect()
|
|
71
|
+
if isinstance(result, str):
|
|
72
|
+
self.query_one("#settings-error", Label).update(result)
|
|
73
|
+
else:
|
|
74
|
+
self.dismiss(result)
|
|
75
|
+
|
|
76
|
+
def action_cancel(self) -> None:
|
|
77
|
+
self.dismiss(None)
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The completion list shown under the editor cursor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.geometry import Offset
|
|
7
|
+
from textual.widgets import OptionList
|
|
8
|
+
from textual.widgets.option_list import Option
|
|
9
|
+
|
|
10
|
+
from sqlide.db.completion import Candidate
|
|
11
|
+
|
|
12
|
+
ICONS = {
|
|
13
|
+
"column": "▪",
|
|
14
|
+
"table": "▤",
|
|
15
|
+
"view": "◫",
|
|
16
|
+
"schema": "▣",
|
|
17
|
+
"keyword": "·",
|
|
18
|
+
"function": "ƒ",
|
|
19
|
+
}
|
|
20
|
+
MAX_ROWS = 10
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _prompt(c: Candidate) -> Text:
|
|
24
|
+
t = Text(f"{ICONS.get(c.kind, ' ')} {c.text}")
|
|
25
|
+
if c.detail:
|
|
26
|
+
t.append(f" {c.detail}", style="dim")
|
|
27
|
+
return t
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CompletionPopup(OptionList):
|
|
31
|
+
"""Pure view: the editor decides when to show it and what accepting means."""
|
|
32
|
+
|
|
33
|
+
DEFAULT_CSS = """
|
|
34
|
+
CompletionPopup {
|
|
35
|
+
overlay: screen;
|
|
36
|
+
position: absolute;
|
|
37
|
+
display: none;
|
|
38
|
+
width: 44;
|
|
39
|
+
height: auto;
|
|
40
|
+
max-height: 12;
|
|
41
|
+
border: round $primary;
|
|
42
|
+
background: $surface;
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
super().__init__(compact=True)
|
|
48
|
+
self.can_focus = False # the editor keeps focus; it forwards the navigation keys
|
|
49
|
+
self._items: list[Candidate] = []
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def shown(self) -> bool:
|
|
53
|
+
return bool(self.display)
|
|
54
|
+
|
|
55
|
+
def show(self, items: list[Candidate], at: Offset) -> None:
|
|
56
|
+
self._items = items
|
|
57
|
+
self.clear_options()
|
|
58
|
+
self.add_options([Option(_prompt(c)) for c in items])
|
|
59
|
+
self.highlighted = 0
|
|
60
|
+
rows = min(len(items), MAX_ROWS) + 2
|
|
61
|
+
self.styles.height = rows
|
|
62
|
+
screen = self.screen.size
|
|
63
|
+
x = max(0, min(at.x, screen.width - 46))
|
|
64
|
+
y = at.y + 1
|
|
65
|
+
if y + rows + 1 > screen.height: # no room below: open upwards
|
|
66
|
+
y = max(0, at.y - rows - 1)
|
|
67
|
+
self.styles.offset = (x, y)
|
|
68
|
+
self.display = True
|
|
69
|
+
|
|
70
|
+
def hide(self) -> None:
|
|
71
|
+
self.display = False
|
|
72
|
+
self._items = []
|
|
73
|
+
|
|
74
|
+
def move(self, step: int) -> None:
|
|
75
|
+
if self._items:
|
|
76
|
+
self.highlighted = ((self.highlighted or 0) + step) % len(self._items)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def current(self) -> Candidate | None:
|
|
80
|
+
i = self.highlighted
|
|
81
|
+
return self._items[i] if i is not None and 0 <= i < len(self._items) else None
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Sidebar list of saved connections (replaced by the schema tree in stage 9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.widgets import Label, ListItem, ListView
|
|
8
|
+
|
|
9
|
+
from sqlide.config.connections import Connection
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConnectionItem(ListItem):
|
|
13
|
+
def __init__(self, conn: Connection) -> None:
|
|
14
|
+
super().__init__(Label(conn.name), Label(conn.url, classes="dim"))
|
|
15
|
+
self.conn = conn
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConnectionsList(ListView):
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
Binding("e", "edit", "Edit"),
|
|
21
|
+
Binding("delete", "delete", "Delete"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
class EditRequested(Message):
|
|
25
|
+
def __init__(self, conn: Connection) -> None:
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.conn = conn
|
|
28
|
+
|
|
29
|
+
class DeleteRequested(EditRequested):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def reload(self, connections: list[Connection]) -> None:
|
|
33
|
+
self.clear()
|
|
34
|
+
for c in connections:
|
|
35
|
+
self.append(ConnectionItem(c))
|
|
36
|
+
if not connections:
|
|
37
|
+
self.append(
|
|
38
|
+
ListItem(Label("No connections yet"), Label("Ctrl+N to add one", classes="dim"))
|
|
39
|
+
)
|
|
40
|
+
if connections: # Enter must work without pressing Down first
|
|
41
|
+
self.call_after_refresh(setattr, self, "index", 0)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def highlighted_conn(self) -> Connection | None:
|
|
45
|
+
item = self.highlighted_child
|
|
46
|
+
return item.conn if isinstance(item, ConnectionItem) else None
|
|
47
|
+
|
|
48
|
+
def action_edit(self) -> None:
|
|
49
|
+
if conn := self.highlighted_conn:
|
|
50
|
+
self.post_message(self.EditRequested(conn))
|
|
51
|
+
|
|
52
|
+
def action_delete(self) -> None:
|
|
53
|
+
if conn := self.highlighted_conn:
|
|
54
|
+
self.post_message(self.DeleteRequested(conn))
|