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
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)