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
sqlide/sql/splitter.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Split a script into statement spans and find the statement under the cursor.
|
|
2
|
+
|
|
3
|
+
Spans cover the statement body only: leading/trailing whitespace and comments are
|
|
4
|
+
excluded, and the terminator ';' is excluded too, except for procedural blocks where
|
|
5
|
+
it is part of the statement (Oracle needs `END;`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import bisect
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from sqlide.sql.dialects import BlockRules, Rules, rules_for
|
|
15
|
+
from sqlide.sql.lexer import (
|
|
16
|
+
BLOCK_COMMENT,
|
|
17
|
+
LINE_COMMENT,
|
|
18
|
+
LPAREN,
|
|
19
|
+
PUNCT,
|
|
20
|
+
QIDENT,
|
|
21
|
+
RPAREN,
|
|
22
|
+
SEMI,
|
|
23
|
+
STRING,
|
|
24
|
+
WORD,
|
|
25
|
+
WS,
|
|
26
|
+
Token,
|
|
27
|
+
tokenize,
|
|
28
|
+
) # fmt: skip
|
|
29
|
+
|
|
30
|
+
_END_CLOSERS = frozenset({"IF", "LOOP", "CASE", "WHILE", "REPEAT", "FOR", "TRY", "CATCH"})
|
|
31
|
+
_STMT_START = frozenset({"", ";", "THEN", "ELSE", "BEGIN", "LOOP", "DO", "REPEAT", ":"})
|
|
32
|
+
_NOT_ROUTINE = frozenset(
|
|
33
|
+
{"TABLE", "VIEW", "INDEX", "SEQUENCE", "SCHEMA", "DATABASE", "USER", "ROLE", "SYNONYM",
|
|
34
|
+
"DOMAIN", "EXTENSION", "LANGUAGE", "CAST", "RULE", "POLICY", "STATISTICS", "LOGIN"}
|
|
35
|
+
) # fmt: skip
|
|
36
|
+
_HEAD_SCAN_LIMIT = 12
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Span:
|
|
41
|
+
start: int
|
|
42
|
+
end: int # exclusive
|
|
43
|
+
|
|
44
|
+
def text(self, src: str) -> str:
|
|
45
|
+
return src[self.start : self.end]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Chunk:
|
|
49
|
+
"""State of the statement currently being collected."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.first: int | None = None # start of first significant token
|
|
53
|
+
self.end = 0 # end of last significant token
|
|
54
|
+
self.paren = 0
|
|
55
|
+
self.prev = "" # previous significant token, upper-cased for words
|
|
56
|
+
self.n_words = 0
|
|
57
|
+
# block machine (only meaningful when ctx is True)
|
|
58
|
+
self.ctx = False
|
|
59
|
+
self.depth = 0
|
|
60
|
+
self.had_block = False # a BEGIN-style block really opened
|
|
61
|
+
self.expect_begin = False
|
|
62
|
+
self.slash_only = False
|
|
63
|
+
self.await_catch = False
|
|
64
|
+
self.pending_begin = False
|
|
65
|
+
self.pending_end = False
|
|
66
|
+
self.scan_create = False
|
|
67
|
+
self.type_pending = False
|
|
68
|
+
self.batch_scoped = False # body runs to GO, unless a BEGIN..END block ends it
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def nested(self) -> bool:
|
|
72
|
+
"""Inside a block: ';' and blank lines must not end the statement."""
|
|
73
|
+
return self.ctx and (
|
|
74
|
+
self.depth > 0
|
|
75
|
+
or self.expect_begin
|
|
76
|
+
or self.slash_only
|
|
77
|
+
or self.await_catch
|
|
78
|
+
or self.pending_begin
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_DELIMITER_LINE = re.compile(r"^[ \t]*delimiter[ \t]+(\S+)[ \t]*$", re.I | re.M)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def split(text: str, dialect: str = "generic", blank_line: bool = True) -> list[Span]:
|
|
86
|
+
rules = rules_for(dialect)
|
|
87
|
+
if rules.delimiter_command and _DELIMITER_LINE.search(text):
|
|
88
|
+
return _split_with_delimiter(text, dialect, blank_line, rules)
|
|
89
|
+
spans: list[Span] = []
|
|
90
|
+
ch = _Chunk()
|
|
91
|
+
|
|
92
|
+
def close(include_terminator: int | None = None) -> None:
|
|
93
|
+
nonlocal ch
|
|
94
|
+
if ch.first is not None:
|
|
95
|
+
_resolve_pending(ch, None, "", rules)
|
|
96
|
+
end = include_terminator if (include_terminator and ch.had_block) else ch.end
|
|
97
|
+
spans.append(Span(ch.first, end))
|
|
98
|
+
ch = _Chunk()
|
|
99
|
+
|
|
100
|
+
skip_to = 0 # tokens before this offset belong to a consumed 'GO 5' line
|
|
101
|
+
for t in tokenize(text, rules):
|
|
102
|
+
if t.start < skip_to:
|
|
103
|
+
continue
|
|
104
|
+
kind = t.kind
|
|
105
|
+
if kind == WS:
|
|
106
|
+
if (
|
|
107
|
+
blank_line
|
|
108
|
+
and ch.first is not None
|
|
109
|
+
and ch.paren == 0
|
|
110
|
+
and not ch.nested
|
|
111
|
+
and text.count("\n", t.start, t.end) >= 2
|
|
112
|
+
):
|
|
113
|
+
close()
|
|
114
|
+
continue
|
|
115
|
+
if kind in (LINE_COMMENT, BLOCK_COMMENT):
|
|
116
|
+
continue
|
|
117
|
+
if (line_end := _batch_separator_end(text, t, rules)) != -1:
|
|
118
|
+
close()
|
|
119
|
+
skip_to = line_end
|
|
120
|
+
continue
|
|
121
|
+
if kind == SEMI:
|
|
122
|
+
_resolve_pending(ch, SEMI, ";", rules)
|
|
123
|
+
if ch.paren == 0 and not ch.nested:
|
|
124
|
+
close(include_terminator=t.end)
|
|
125
|
+
continue
|
|
126
|
+
word = text[t.start : t.end].upper() if kind == WORD else ""
|
|
127
|
+
if kind == WORD and rules.block is not None:
|
|
128
|
+
_on_word(ch, word, rules.block)
|
|
129
|
+
elif kind != WORD and ch.first is not None:
|
|
130
|
+
_resolve_pending(ch, kind, "", rules)
|
|
131
|
+
if kind == LPAREN:
|
|
132
|
+
ch.paren += 1
|
|
133
|
+
elif kind == RPAREN:
|
|
134
|
+
ch.paren = max(0, ch.paren - 1)
|
|
135
|
+
if ch.first is None:
|
|
136
|
+
ch.first = t.start
|
|
137
|
+
ch.end = t.end
|
|
138
|
+
ch.prev = word or (";" if kind == SEMI else text[t.start : t.end])
|
|
139
|
+
if kind == WORD:
|
|
140
|
+
ch.n_words += 1
|
|
141
|
+
close()
|
|
142
|
+
return spans
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _split_with_delimiter(text: str, dialect: str, blank_line: bool, rules: Rules) -> list[Span]:
|
|
146
|
+
"""MySQL client semantics: 'DELIMITER x' lines switch the terminator; they are not SQL."""
|
|
147
|
+
spans: list[Span] = []
|
|
148
|
+
delim, pos = ";", 0
|
|
149
|
+
for m in [*_DELIMITER_LINE.finditer(text), None]:
|
|
150
|
+
end = m.start() if m else len(text)
|
|
151
|
+
segment = text[pos:end]
|
|
152
|
+
if delim == ";":
|
|
153
|
+
spans += [Span(s.start + pos, s.end + pos) for s in split(segment, dialect, blank_line)]
|
|
154
|
+
else:
|
|
155
|
+
spans += [Span(s.start + pos, s.end + pos) for s in _split_on(segment, delim, rules)]
|
|
156
|
+
if m:
|
|
157
|
+
delim, pos = m.group(1), m.end()
|
|
158
|
+
return spans
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _split_on(text: str, delim: str, rules: Rules) -> list[Span]:
|
|
162
|
+
"""Split on a custom terminator outside strings and comments; bodies stay whole."""
|
|
163
|
+
spans: list[Span] = []
|
|
164
|
+
first: int | None = None
|
|
165
|
+
last = 0
|
|
166
|
+
skip_to = 0
|
|
167
|
+
for t in tokenize(text, rules):
|
|
168
|
+
if t.start < skip_to or t.kind == WS or t.kind in (LINE_COMMENT, BLOCK_COMMENT):
|
|
169
|
+
continue
|
|
170
|
+
idx = (
|
|
171
|
+
-1 if t.kind in (STRING, QIDENT) else text.find(delim, t.start, t.end + len(delim) - 1)
|
|
172
|
+
)
|
|
173
|
+
if idx != -1 and idx < t.end: # the terminator can be glued to a word: `end$$`
|
|
174
|
+
if idx > t.start:
|
|
175
|
+
if first is None:
|
|
176
|
+
first = t.start
|
|
177
|
+
last = idx
|
|
178
|
+
if first is not None:
|
|
179
|
+
spans.append(Span(first, last))
|
|
180
|
+
first, skip_to = None, idx + len(delim)
|
|
181
|
+
continue
|
|
182
|
+
if first is None:
|
|
183
|
+
first = t.start
|
|
184
|
+
last = t.end
|
|
185
|
+
if first is not None:
|
|
186
|
+
spans.append(Span(first, last))
|
|
187
|
+
return spans
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _batch_separator_end(text: str, t: Token, rules: Rules) -> int:
|
|
191
|
+
"""If `t` is an Oracle '/' or MSSQL 'GO [n]' alone on its line: that line's end, else -1."""
|
|
192
|
+
word = text[t.start : t.end]
|
|
193
|
+
if t.kind == PUNCT and rules.slash_lines and word == "/":
|
|
194
|
+
tail_ok = lambda rest: not rest # noqa: E731
|
|
195
|
+
elif t.kind == WORD and rules.go_batches and word.upper() == "GO":
|
|
196
|
+
tail_ok = lambda rest: not rest or rest.isdigit() # noqa: E731
|
|
197
|
+
else:
|
|
198
|
+
return -1
|
|
199
|
+
ls = text.rfind("\n", 0, t.start) + 1
|
|
200
|
+
le = text.find("\n", t.end)
|
|
201
|
+
le = len(text) if le == -1 else le
|
|
202
|
+
ok = not text[ls : t.start].strip() and tail_ok(text[t.end : le].strip())
|
|
203
|
+
return le if ok else -1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --- block machine -------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resolve_pending(ch: _Chunk, kind: int | None, word: str, rules: Rules) -> None:
|
|
210
|
+
"""Settle a pending BEGIN/END using the token that followed it (None = chunk ends)."""
|
|
211
|
+
if rules.block is None:
|
|
212
|
+
return
|
|
213
|
+
if ch.pending_begin:
|
|
214
|
+
ch.pending_begin = False
|
|
215
|
+
if (kind == WORD and word in rules.block.tx_words) or kind in (SEMI, None):
|
|
216
|
+
if ch.n_words == 1: # statement-initial BEGIN was a transaction start
|
|
217
|
+
ch.ctx = False
|
|
218
|
+
else:
|
|
219
|
+
_open(ch)
|
|
220
|
+
if ch.pending_end:
|
|
221
|
+
ch.pending_end = False
|
|
222
|
+
_close_block(ch)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _open(ch: _Chunk) -> None:
|
|
226
|
+
ch.depth += 1
|
|
227
|
+
ch.had_block = True
|
|
228
|
+
ch.expect_begin = False
|
|
229
|
+
ch.await_catch = False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _close_block(ch: _Chunk) -> None:
|
|
233
|
+
ch.depth = max(0, ch.depth - 1)
|
|
234
|
+
if ch.batch_scoped and ch.depth == 0 and ch.had_block:
|
|
235
|
+
ch.slash_only = False # a real BEGIN..END body is complete: ';' may end the statement
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _on_word(ch: _Chunk, word: str, br: BlockRules) -> None:
|
|
239
|
+
if ch.pending_begin:
|
|
240
|
+
ch.pending_begin = False
|
|
241
|
+
if word in br.tx_words:
|
|
242
|
+
if ch.n_words == 1:
|
|
243
|
+
ch.ctx = False
|
|
244
|
+
else:
|
|
245
|
+
_open(ch)
|
|
246
|
+
if ch.pending_end:
|
|
247
|
+
ch.pending_end = False
|
|
248
|
+
if word in _END_CLOSERS:
|
|
249
|
+
_close_block(ch)
|
|
250
|
+
if word == "TRY":
|
|
251
|
+
ch.await_catch = True
|
|
252
|
+
ch.prev = word
|
|
253
|
+
return
|
|
254
|
+
_close_block(ch)
|
|
255
|
+
_detect_head(ch, word, br)
|
|
256
|
+
if not ch.ctx:
|
|
257
|
+
return
|
|
258
|
+
if word == "BEGIN":
|
|
259
|
+
ch.pending_begin = True
|
|
260
|
+
elif word == "END":
|
|
261
|
+
ch.pending_end = True
|
|
262
|
+
elif (
|
|
263
|
+
word == "CASE"
|
|
264
|
+
or word in br.inline_openers
|
|
265
|
+
or (word in br.openers and ch.prev in _STMT_START)
|
|
266
|
+
):
|
|
267
|
+
ch.depth += 1
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _detect_head(ch: _Chunk, word: str, br: BlockRules) -> None:
|
|
271
|
+
"""Decide from the first words whether this statement is a procedural block."""
|
|
272
|
+
if ch.n_words == 0:
|
|
273
|
+
if word == "BEGIN" and br.top_begin_block:
|
|
274
|
+
ch.ctx = True
|
|
275
|
+
elif word == "DECLARE" and br.declare_starts_block:
|
|
276
|
+
ch.ctx = ch.expect_begin = True
|
|
277
|
+
elif word == "CREATE":
|
|
278
|
+
ch.scan_create = True
|
|
279
|
+
return
|
|
280
|
+
if not ch.scan_create or ch.n_words > _HEAD_SCAN_LIMIT:
|
|
281
|
+
return
|
|
282
|
+
if ch.type_pending:
|
|
283
|
+
ch.type_pending = ch.scan_create = False
|
|
284
|
+
if word == "BODY" and "TYPE" in br.slash_only_for:
|
|
285
|
+
ch.slash_only = True
|
|
286
|
+
return
|
|
287
|
+
if word in br.routines:
|
|
288
|
+
ch.scan_create = False
|
|
289
|
+
ch.ctx = True
|
|
290
|
+
if word in br.expect_begin_for:
|
|
291
|
+
ch.expect_begin = True
|
|
292
|
+
if word in br.batch_scoped:
|
|
293
|
+
ch.slash_only = ch.batch_scoped = True
|
|
294
|
+
if word == "TYPE":
|
|
295
|
+
ch.type_pending, ch.scan_create = True, True # TYPE BODY is decided by next word
|
|
296
|
+
elif word in br.slash_only_for:
|
|
297
|
+
ch.slash_only = True
|
|
298
|
+
elif word in _NOT_ROUTINE:
|
|
299
|
+
ch.scan_create = False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# --- cursor lookup -------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def statement_at(spans: list[Span], text: str, offset: int) -> Span | None:
|
|
306
|
+
"""Statement the cursor belongs to.
|
|
307
|
+
|
|
308
|
+
Inside a span -> that span. In a gap: right after a statement on the same line
|
|
309
|
+
(e.g. after its ';') -> that statement; in the indentation before a statement on
|
|
310
|
+
the same line -> that statement; otherwise None.
|
|
311
|
+
"""
|
|
312
|
+
i = bisect.bisect_right([s.start for s in spans], offset) # spans[:i] start <= offset
|
|
313
|
+
if i and offset <= spans[i - 1].end:
|
|
314
|
+
return spans[i - 1]
|
|
315
|
+
if i and "\n" not in text[spans[i - 1].end : offset]:
|
|
316
|
+
return spans[i - 1]
|
|
317
|
+
if i < len(spans) and "\n" not in text[offset : spans[i].start]:
|
|
318
|
+
return spans[i]
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def span_lines(text: str, span: Span) -> tuple[int, int]:
|
|
323
|
+
"""0-based (first_line, last_line) the span occupies."""
|
|
324
|
+
first = text.count("\n", 0, span.start)
|
|
325
|
+
return first, first + text.count("\n", span.start, span.end)
|
sqlide/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Textual UI. May import every other block; nothing imports it except app/cli."""
|
sqlide/ui/app.tcss
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#side {
|
|
2
|
+
width: 28%;
|
|
3
|
+
min-width: 24;
|
|
4
|
+
max-width: 44;
|
|
5
|
+
border-right: solid $primary-darken-2;
|
|
6
|
+
}
|
|
7
|
+
#sidebar { height: 35%; }
|
|
8
|
+
#schema { height: 1fr; border-top: solid $primary-darken-2; }
|
|
9
|
+
#sidebar ListItem { padding: 0 1; }
|
|
10
|
+
#sidebar .dim { color: $text-muted; text-style: italic; }
|
|
11
|
+
#editor { height: 3fr; }
|
|
12
|
+
#results { height: 2fr; border-top: solid $primary-darken-2; }
|
|
13
|
+
|
|
14
|
+
ConfirmScreen, PasswordPrompt, ProgressScreen, ConnectionEditor, SettingsScreen,
|
|
15
|
+
DriverManager, DriverEditor { align: center middle; }
|
|
16
|
+
.dialog {
|
|
17
|
+
width: 60;
|
|
18
|
+
height: auto;
|
|
19
|
+
padding: 1 2;
|
|
20
|
+
border: thick $primary;
|
|
21
|
+
background: $surface;
|
|
22
|
+
}
|
|
23
|
+
.dialog.wide { width: 80; }
|
|
24
|
+
.dialog { max-height: 100%; overflow-y: auto; }
|
|
25
|
+
.dialog .title { text-style: bold; margin-bottom: 1; }
|
|
26
|
+
.dialog .buttons { height: auto; margin-top: 1; }
|
|
27
|
+
.dialog .buttons Button { margin-right: 2; }
|
|
28
|
+
.dialog .row { height: 3; }
|
|
29
|
+
.dialog .row Label { margin-right: 2; padding-top: 1; }
|
|
30
|
+
.dialog .error { color: $error; }
|
|
31
|
+
|
|
32
|
+
.dialog.viewer { width: 90%; height: 80%; }
|
|
33
|
+
.dialog.viewer TextArea { height: 1fr; }
|
|
34
|
+
.dialog .hint { color: $text-muted; margin-top: 1; }
|
|
35
|
+
ValueViewer, CopyMenu { align: center middle; }
|
|
36
|
+
CopyMenu OptionList { height: auto; max-height: 12; }
|
|
37
|
+
|
|
38
|
+
ExportScreen { align: center middle; }
|
|
39
|
+
ExportScreen RadioSet { height: auto; }
|
|
40
|
+
ExportScreen .row Input { width: 12; }
|
|
41
|
+
ExportScreen #table_name { width: 40; }
|
|
42
|
+
|
|
43
|
+
#tabs { width: 1fr; }
|
|
44
|
+
ConsoleTabs > ContentSwitcher { height: 1fr; }
|
|
45
|
+
ConsoleTabs TabPane { padding: 0; height: 1fr; }
|
|
46
|
+
ConsoleTab { height: 1fr; }
|
|
47
|
+
PathPrompt { align: center middle; }
|
|
48
|
+
|
|
49
|
+
HistoryScreen { align: center middle; }
|
|
50
|
+
#history {
|
|
51
|
+
width: 90%;
|
|
52
|
+
height: 85%;
|
|
53
|
+
padding: 1 2;
|
|
54
|
+
border: round $primary;
|
|
55
|
+
background: $surface;
|
|
56
|
+
}
|
|
57
|
+
#history-title { text-style: bold; }
|
|
58
|
+
#history-list { height: 1fr; }
|
|
59
|
+
#history-preview {
|
|
60
|
+
height: 8;
|
|
61
|
+
padding: 0 1;
|
|
62
|
+
border-top: solid $primary-darken-2;
|
|
63
|
+
overflow-y: auto;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#driver-list { height: 12; }
|
|
67
|
+
.dialog .hint { color: $text-muted; margin-top: 1; }
|
sqlide/ui/commands.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Entries of the command palette (Ctrl+P): every user action by name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from textual.app import SystemCommand
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sqlide.app import SqlideApp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def commands(app: SqlideApp) -> Iterator[SystemCommand]:
|
|
15
|
+
main = app.main
|
|
16
|
+
|
|
17
|
+
def on_console(method: str) -> Callable[[], None]:
|
|
18
|
+
def run() -> None:
|
|
19
|
+
getattr(main.console, method)()
|
|
20
|
+
|
|
21
|
+
return run
|
|
22
|
+
|
|
23
|
+
def on_editor(method: str) -> Callable[[], None]:
|
|
24
|
+
def run() -> None:
|
|
25
|
+
getattr(main.console.editor, method)()
|
|
26
|
+
|
|
27
|
+
return run
|
|
28
|
+
|
|
29
|
+
def screen(action: str) -> Callable[[], None]:
|
|
30
|
+
def run() -> None:
|
|
31
|
+
getattr(main, action)()
|
|
32
|
+
|
|
33
|
+
return run
|
|
34
|
+
|
|
35
|
+
table: list[tuple[str, str, Callable[[], None]]] = [
|
|
36
|
+
(
|
|
37
|
+
"Run statement",
|
|
38
|
+
"Execute the framed statement or selection",
|
|
39
|
+
on_editor("action_run_statement"),
|
|
40
|
+
),
|
|
41
|
+
("Run all", "Execute every statement in the editor", on_editor("action_run_all")),
|
|
42
|
+
("Cancel query", "Cancel the running query or export", on_console("action_cancel")),
|
|
43
|
+
("Commit", "Commit the manual transaction", on_console("action_commit")),
|
|
44
|
+
("Rollback", "Roll back the manual transaction", on_console("action_rollback")),
|
|
45
|
+
("Toggle auto/manual transaction", "Switch commit mode", on_console("action_toggle_tx")),
|
|
46
|
+
("Format SQL", "Pretty-print selection or statement", on_editor("action_format")),
|
|
47
|
+
("Toggle comment", "Comment/uncomment lines", on_editor("action_toggle_comment")),
|
|
48
|
+
("New connection", "Add a database connection", screen("action_new_connection")),
|
|
49
|
+
("New console", "Open a new SQL console tab", screen("action_new_console")),
|
|
50
|
+
("Close tab", "Close the current tab", screen("action_close_console")),
|
|
51
|
+
("Open SQL file", "Open a .sql file in a tab", screen("action_open_file")),
|
|
52
|
+
("Save", "Save the console or file", screen("action_save_file")),
|
|
53
|
+
("Query history", "Search executed statements", screen("action_history")),
|
|
54
|
+
("Refresh schema", "Reload the schema tree", lambda: main.schema.action_refresh()),
|
|
55
|
+
("Drivers", "Download or add JDBC drivers", screen("action_drivers")),
|
|
56
|
+
("Settings", "Theme, paging, history size", screen("action_settings")),
|
|
57
|
+
]
|
|
58
|
+
for title, help_text, callback in table:
|
|
59
|
+
yield SystemCommand(title, help_text, callback)
|
sqlide/ui/keymap.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Catalogue of rebindable keys, read from the BINDINGS of the UI classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
|
|
9
|
+
from sqlide.app import SqlideApp
|
|
10
|
+
from sqlide.ui.screens.main import MainScreen
|
|
11
|
+
from sqlide.ui.widgets.console_tab import ConsoleTab
|
|
12
|
+
from sqlide.ui.widgets.result_grid import ResultGrid
|
|
13
|
+
from sqlide.ui.widgets.schema_tree import SchemaTree
|
|
14
|
+
from sqlide.ui.widgets.sql_editor import SqlEditor
|
|
15
|
+
|
|
16
|
+
SOURCES = (SqlideApp, MainScreen, SqlEditor, ConsoleTab, SchemaTree, ResultGrid)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class KeyInfo:
|
|
21
|
+
id: str
|
|
22
|
+
keys: str
|
|
23
|
+
description: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def catalogue() -> list[KeyInfo]:
|
|
27
|
+
out = []
|
|
28
|
+
for cls in SOURCES:
|
|
29
|
+
for b in cls.BINDINGS: # type: ignore[attr-defined]
|
|
30
|
+
if isinstance(b, Binding) and b.id:
|
|
31
|
+
out.append(KeyInfo(b.id, b.key, b.description))
|
|
32
|
+
return out
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Create/edit a saved connection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Horizontal, Vertical
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, Input, Label, Select, Static, Switch
|
|
10
|
+
|
|
11
|
+
from sqlide.config._toml import ConfigError
|
|
12
|
+
from sqlide.config.connections import Connection
|
|
13
|
+
from sqlide.drivers.registry import DriverDef
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def url_hint(d: DriverDef) -> str:
|
|
17
|
+
"""Starting URL for a driver: its template with sensible defaults."""
|
|
18
|
+
return d.url_template.format(host="localhost", port=d.default_port, database="")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectionEditor(ModalScreen[Connection | None]):
|
|
22
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
drivers: dict[str, DriverDef],
|
|
27
|
+
existing: Connection | None = None,
|
|
28
|
+
taken_names: set[str] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self._drivers = drivers
|
|
32
|
+
self._existing = existing
|
|
33
|
+
self._taken = taken_names or set()
|
|
34
|
+
self._auto_url = "" # last URL we generated; replaced only while the user has not edited
|
|
35
|
+
|
|
36
|
+
def compose(self) -> ComposeResult:
|
|
37
|
+
c = self._existing
|
|
38
|
+
first = next(iter(self._drivers))
|
|
39
|
+
with Vertical(classes="dialog wide"):
|
|
40
|
+
yield Label("Connection" if c is None else f"Edit {c.name}", classes="title")
|
|
41
|
+
yield Label("Name")
|
|
42
|
+
yield Input(c.name if c else "", id="name")
|
|
43
|
+
yield Label("Driver")
|
|
44
|
+
yield Select(
|
|
45
|
+
[(d.name, d.id) for d in self._drivers.values()],
|
|
46
|
+
value=c.driver if c else first,
|
|
47
|
+
allow_blank=False,
|
|
48
|
+
id="driver",
|
|
49
|
+
)
|
|
50
|
+
yield Label("JDBC URL")
|
|
51
|
+
yield Input(c.url if c else "", id="url")
|
|
52
|
+
yield Label("User (empty = no credentials, no password prompt)")
|
|
53
|
+
yield Input(c.user if c else "", id="user")
|
|
54
|
+
yield Label("Password source: empty = ask on connect, or ${env:VAR}")
|
|
55
|
+
yield Input(c.password_ref if c else "", id="password_ref")
|
|
56
|
+
yield Label("Password command (optional, stdout is the password)")
|
|
57
|
+
yield Input(c.password_cmd if c else "", id="password_cmd")
|
|
58
|
+
with Horizontal(classes="row"):
|
|
59
|
+
yield Label("Autocommit")
|
|
60
|
+
yield Switch(c.autocommit if c else True, id="autocommit")
|
|
61
|
+
yield Static("", id="error", classes="error")
|
|
62
|
+
with Horizontal(classes="buttons"):
|
|
63
|
+
yield Button("Save", variant="primary", id="save")
|
|
64
|
+
yield Button("Cancel", id="cancel")
|
|
65
|
+
|
|
66
|
+
def on_mount(self) -> None:
|
|
67
|
+
if self._existing is None:
|
|
68
|
+
self._apply_template(self.query_one("#driver", Select).value)
|
|
69
|
+
self.query_one("#name", Input).focus()
|
|
70
|
+
|
|
71
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
72
|
+
if event.select.id == "driver" and event.value in self._drivers:
|
|
73
|
+
self._apply_template(event.value)
|
|
74
|
+
|
|
75
|
+
def _apply_template(self, driver_id: object) -> None:
|
|
76
|
+
url = self.query_one("#url", Input)
|
|
77
|
+
if url.value in ("", self._auto_url) and driver_id in self._drivers:
|
|
78
|
+
self._auto_url = url_hint(self._drivers[str(driver_id)])
|
|
79
|
+
url.value = self._auto_url
|
|
80
|
+
|
|
81
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
82
|
+
if event.button.id == "cancel":
|
|
83
|
+
self.dismiss(None)
|
|
84
|
+
return
|
|
85
|
+
self._save()
|
|
86
|
+
|
|
87
|
+
def _val(self, id_: str) -> str:
|
|
88
|
+
return self.query_one(f"#{id_}", Input).value.strip()
|
|
89
|
+
|
|
90
|
+
def _save(self) -> None:
|
|
91
|
+
name = self._val("name")
|
|
92
|
+
try:
|
|
93
|
+
if name in self._taken:
|
|
94
|
+
raise ConfigError(f"connection '{name}' already exists")
|
|
95
|
+
if not self._val("url"):
|
|
96
|
+
raise ConfigError("JDBC URL is empty")
|
|
97
|
+
conn = Connection(
|
|
98
|
+
name=name,
|
|
99
|
+
driver=str(self.query_one("#driver", Select).value),
|
|
100
|
+
url=self._val("url"),
|
|
101
|
+
user=self._val("user"),
|
|
102
|
+
password_ref=self._val("password_ref"),
|
|
103
|
+
password_cmd=self._val("password_cmd"),
|
|
104
|
+
properties=self._existing.properties if self._existing else {},
|
|
105
|
+
autocommit=self.query_one("#autocommit", Switch).value,
|
|
106
|
+
)
|
|
107
|
+
except ConfigError as e:
|
|
108
|
+
self.query_one("#error", Static).update(str(e))
|
|
109
|
+
return
|
|
110
|
+
self.dismiss(conn)
|
|
111
|
+
|
|
112
|
+
def action_cancel(self) -> None:
|
|
113
|
+
self.dismiss(None)
|