sqlide 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. sqlide/__init__.py +3 -0
  2. sqlide/__main__.py +5 -0
  3. sqlide/app.py +46 -0
  4. sqlide/cli.py +64 -0
  5. sqlide/clipboard.py +65 -0
  6. sqlide/config/__init__.py +1 -0
  7. sqlide/config/_toml.py +41 -0
  8. sqlide/config/connections.py +76 -0
  9. sqlide/config/keymap.py +25 -0
  10. sqlide/config/paths.py +48 -0
  11. sqlide/config/secrets.py +82 -0
  12. sqlide/config/settings.py +32 -0
  13. sqlide/consoles.py +100 -0
  14. sqlide/db/__init__.py +1 -0
  15. sqlide/db/completion.py +127 -0
  16. sqlide/db/factory.py +37 -0
  17. sqlide/db/metadata.py +174 -0
  18. sqlide/db/result.py +52 -0
  19. sqlide/db/session.py +339 -0
  20. sqlide/db/types.py +84 -0
  21. sqlide/doctor.py +60 -0
  22. sqlide/drivers/__init__.py +1 -0
  23. sqlide/drivers/catalog.toml +103 -0
  24. sqlide/drivers/cli.py +74 -0
  25. sqlide/drivers/custom.py +69 -0
  26. sqlide/drivers/loader.py +75 -0
  27. sqlide/drivers/maven.py +108 -0
  28. sqlide/drivers/registry.py +117 -0
  29. sqlide/export/__init__.py +12 -0
  30. sqlide/export/base.py +86 -0
  31. sqlide/export/csv_.py +40 -0
  32. sqlide/export/html.py +44 -0
  33. sqlide/export/json_.py +47 -0
  34. sqlide/export/markdown.py +27 -0
  35. sqlide/export/service.py +34 -0
  36. sqlide/export/sql_insert.py +37 -0
  37. sqlide/export/xlsx.py +109 -0
  38. sqlide/grid/__init__.py +1 -0
  39. sqlide/grid/copyfmt.py +97 -0
  40. sqlide/grid/formatting.py +43 -0
  41. sqlide/grid/model.py +109 -0
  42. sqlide/history/__init__.py +5 -0
  43. sqlide/history/store.py +117 -0
  44. sqlide/jvm/__init__.py +1 -0
  45. sqlide/jvm/locate.py +98 -0
  46. sqlide/jvm/runtime.py +44 -0
  47. sqlide/sql/__init__.py +1 -0
  48. sqlide/sql/context.py +195 -0
  49. sqlide/sql/dialects.py +93 -0
  50. sqlide/sql/format.py +50 -0
  51. sqlide/sql/keywords.py +143 -0
  52. sqlide/sql/lexer.py +148 -0
  53. sqlide/sql/snippets.py +44 -0
  54. sqlide/sql/splitter.py +325 -0
  55. sqlide/ui/__init__.py +1 -0
  56. sqlide/ui/app.tcss +67 -0
  57. sqlide/ui/commands.py +59 -0
  58. sqlide/ui/keymap.py +32 -0
  59. sqlide/ui/screens/__init__.py +0 -0
  60. sqlide/ui/screens/connection_editor.py +113 -0
  61. sqlide/ui/screens/dialogs.py +110 -0
  62. sqlide/ui/screens/driver_manager.py +195 -0
  63. sqlide/ui/screens/export_dialog.py +158 -0
  64. sqlide/ui/screens/grid_dialogs.py +92 -0
  65. sqlide/ui/screens/history.py +130 -0
  66. sqlide/ui/screens/main.py +383 -0
  67. sqlide/ui/screens/settings.py +77 -0
  68. sqlide/ui/widgets/__init__.py +0 -0
  69. sqlide/ui/widgets/completion_popup.py +81 -0
  70. sqlide/ui/widgets/connections_list.py +54 -0
  71. sqlide/ui/widgets/console_export.py +124 -0
  72. sqlide/ui/widgets/console_tab.py +335 -0
  73. sqlide/ui/widgets/console_tabs.py +117 -0
  74. sqlide/ui/widgets/result_grid.py +435 -0
  75. sqlide/ui/widgets/result_panel.py +73 -0
  76. sqlide/ui/widgets/result_view.py +50 -0
  77. sqlide/ui/widgets/schema_tree.py +145 -0
  78. sqlide/ui/widgets/sql_editor.py +310 -0
  79. sqlide/ui/widgets/status_bar.py +32 -0
  80. sqlide/workspace.py +97 -0
  81. sqlide-0.1.0.dist-info/METADATA +151 -0
  82. sqlide-0.1.0.dist-info/RECORD +85 -0
  83. sqlide-0.1.0.dist-info/WHEEL +4 -0
  84. sqlide-0.1.0.dist-info/entry_points.txt +2 -0
  85. sqlide-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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))