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,145 @@
|
|
|
1
|
+
"""Schema tree: namespaces → tables/views → columns, loaded lazily from a MetaCache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.message import Message
|
|
8
|
+
from textual.widgets import Tree
|
|
9
|
+
from textual.widgets.tree import TreeNode
|
|
10
|
+
|
|
11
|
+
from sqlide.db.metadata import Column, MetaCache, Namespace, Table, is_system_namespace
|
|
12
|
+
from sqlide.db.result import DbError
|
|
13
|
+
|
|
14
|
+
ICON_NS, ICON_TABLE, ICON_VIEW = "▣", "▤", "◫"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SchemaTree(Tree[object]):
|
|
18
|
+
BINDINGS = [
|
|
19
|
+
Binding("f5", "refresh", "Refresh", id="schema.refresh"),
|
|
20
|
+
Binding("i", "insert_name", "Insert name", id="schema.insert_name"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
class TableChosen(Message):
|
|
24
|
+
"""`action` is "select" (open first rows) or "insert" (put the name into the editor)."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, table: Table, action: str) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.table, self.action = table, action
|
|
29
|
+
|
|
30
|
+
def __init__(self, **kw) -> None:
|
|
31
|
+
super().__init__("not connected", **kw)
|
|
32
|
+
self.auto_expand = False # Enter on a table runs it; Space / arrows expand
|
|
33
|
+
self.show_root = True
|
|
34
|
+
self._meta: MetaCache | None = None
|
|
35
|
+
self._label = ""
|
|
36
|
+
|
|
37
|
+
# --- binding to a connection ---
|
|
38
|
+
def show(self, meta: MetaCache | None, label: str = "") -> None:
|
|
39
|
+
"""Show another connection's metadata (None: disconnected)."""
|
|
40
|
+
self._meta, self._label = meta, label
|
|
41
|
+
self.root.set_label(label or "not connected")
|
|
42
|
+
self.root.remove_children()
|
|
43
|
+
self.root.data = None
|
|
44
|
+
if meta is not None:
|
|
45
|
+
self.root.data = meta
|
|
46
|
+
self.root.expand()
|
|
47
|
+
self.run_worker(self._load_namespaces(meta, self.root), group="schema", exclusive=True)
|
|
48
|
+
|
|
49
|
+
def action_refresh(self) -> None:
|
|
50
|
+
if self._meta is not None:
|
|
51
|
+
self._meta.refresh()
|
|
52
|
+
self.show(self._meta, self._label)
|
|
53
|
+
|
|
54
|
+
# --- lazy loading ---
|
|
55
|
+
def _placeholder(self, node: TreeNode, text: str = "loading…") -> None:
|
|
56
|
+
node.remove_children()
|
|
57
|
+
node.add_leaf(Text(text, style="dim italic"))
|
|
58
|
+
|
|
59
|
+
def _fail(self, node: TreeNode, e: Exception) -> None:
|
|
60
|
+
node.remove_children()
|
|
61
|
+
node.add_leaf(Text(f"✖ {str(e).splitlines()[0] if str(e) else e!r}", style="red"))
|
|
62
|
+
|
|
63
|
+
async def _load_namespaces(self, meta: MetaCache, node: TreeNode) -> None:
|
|
64
|
+
self._placeholder(node)
|
|
65
|
+
try:
|
|
66
|
+
spaces = await meta.namespaces()
|
|
67
|
+
except DbError as e:
|
|
68
|
+
self._fail(node, e)
|
|
69
|
+
return
|
|
70
|
+
if meta is not self._meta:
|
|
71
|
+
return # the user switched tabs meanwhile
|
|
72
|
+
try:
|
|
73
|
+
current = (await meta.current_namespace()).lower()
|
|
74
|
+
except DbError:
|
|
75
|
+
current = ""
|
|
76
|
+
# the working schema first, system schemas last and dimmed
|
|
77
|
+
spaces = sorted(
|
|
78
|
+
spaces, key=lambda n: (is_system_namespace(n.name), n.name.lower() != current)
|
|
79
|
+
)
|
|
80
|
+
node.remove_children()
|
|
81
|
+
opened = None
|
|
82
|
+
for ns in spaces:
|
|
83
|
+
style = "dim" if is_system_namespace(ns.name) else ""
|
|
84
|
+
child = node.add(Text(f"{ICON_NS} {ns.name or 'main'}", style=style), data=ns)
|
|
85
|
+
if opened is None and ns.name.lower() == current:
|
|
86
|
+
opened = child
|
|
87
|
+
if opened is None and len(spaces) == 1:
|
|
88
|
+
opened = node.children[0]
|
|
89
|
+
if opened is not None:
|
|
90
|
+
opened.expand()
|
|
91
|
+
|
|
92
|
+
async def _load_tables(self, meta: MetaCache, node: TreeNode, ns: Namespace) -> None:
|
|
93
|
+
self._placeholder(node)
|
|
94
|
+
try:
|
|
95
|
+
tables = await meta.tables(ns)
|
|
96
|
+
except DbError as e:
|
|
97
|
+
self._fail(node, e)
|
|
98
|
+
return
|
|
99
|
+
node.remove_children()
|
|
100
|
+
for t in tables:
|
|
101
|
+
icon = ICON_VIEW if t.is_view else ICON_TABLE
|
|
102
|
+
node.add(Text(f"{icon} {t.name}"), data=t)
|
|
103
|
+
if not tables:
|
|
104
|
+
node.add_leaf(Text("(empty)", style="dim italic"))
|
|
105
|
+
|
|
106
|
+
async def _load_columns(self, meta: MetaCache, node: TreeNode, table: Table) -> None:
|
|
107
|
+
self._placeholder(node)
|
|
108
|
+
try:
|
|
109
|
+
cols = await meta.columns(table)
|
|
110
|
+
except DbError as e:
|
|
111
|
+
self._fail(node, e)
|
|
112
|
+
return
|
|
113
|
+
node.remove_children()
|
|
114
|
+
for c in cols:
|
|
115
|
+
node.add_leaf(_column_label(c), data=c)
|
|
116
|
+
|
|
117
|
+
def on_tree_node_expanded(self, event: Tree.NodeExpanded[object]) -> None:
|
|
118
|
+
node, meta = event.node, self._meta
|
|
119
|
+
if meta is None or node.children:
|
|
120
|
+
return # already loaded
|
|
121
|
+
if isinstance(node.data, Namespace):
|
|
122
|
+
self.run_worker(self._load_tables(meta, node, node.data), group=f"ns-{node.id}")
|
|
123
|
+
elif isinstance(node.data, Table):
|
|
124
|
+
self.run_worker(self._load_columns(meta, node, node.data), group=f"t-{node.id}")
|
|
125
|
+
|
|
126
|
+
# --- actions ---
|
|
127
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected[object]) -> None:
|
|
128
|
+
event.stop()
|
|
129
|
+
if isinstance(event.node.data, Table):
|
|
130
|
+
self.post_message(self.TableChosen(event.node.data, "select"))
|
|
131
|
+
elif event.node.allow_expand:
|
|
132
|
+
event.node.toggle()
|
|
133
|
+
|
|
134
|
+
def action_insert_name(self) -> None:
|
|
135
|
+
node = self.cursor_node
|
|
136
|
+
if node is not None and isinstance(node.data, Table):
|
|
137
|
+
self.post_message(self.TableChosen(node.data, "insert"))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _column_label(c: Column) -> Text:
|
|
141
|
+
text = Text()
|
|
142
|
+
text.append("🔑 " if c.primary_key else " ")
|
|
143
|
+
text.append(c.name)
|
|
144
|
+
text.append(f" {c.type_name}{'' if c.nullable else ' not null'}", style="dim")
|
|
145
|
+
return text
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""SQL editor: TextArea + a frame around the statement that would run now."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from rich.segment import Segment
|
|
8
|
+
from rich.style import Style
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.color import Color
|
|
11
|
+
from textual.document._document import Document
|
|
12
|
+
from textual.events import Key
|
|
13
|
+
from textual.geometry import Offset
|
|
14
|
+
from textual.message import Message
|
|
15
|
+
from textual.strip import Strip
|
|
16
|
+
from textual.timer import Timer
|
|
17
|
+
from textual.widgets import TextArea
|
|
18
|
+
|
|
19
|
+
from sqlide.db.completion import Candidate
|
|
20
|
+
from sqlide.sql.format import FormatError, format_sql, toggle_line_comments
|
|
21
|
+
from sqlide.sql.splitter import Span, span_lines, split, statement_at
|
|
22
|
+
from sqlide.ui.widgets.completion_popup import CompletionPopup
|
|
23
|
+
|
|
24
|
+
IMMEDIATE_RECALC_LIMIT = 20_000 # chars; above this, recompute spans with a short debounce
|
|
25
|
+
DEBOUNCE_S = 0.08
|
|
26
|
+
COMPLETE_DEBOUNCE_S = 0.05
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SqlEditor(TextArea):
|
|
30
|
+
BINDINGS = [
|
|
31
|
+
Binding("f5,ctrl+j,ctrl+enter", "run_statement", "Run", priority=True, id="editor.run"),
|
|
32
|
+
Binding(
|
|
33
|
+
"shift+f5,ctrl+shift+enter", "run_all", "Run all", priority=True, id="editor.run_all"
|
|
34
|
+
),
|
|
35
|
+
Binding("ctrl+space,ctrl+@", "complete", "Complete", show=False, id="editor.complete"),
|
|
36
|
+
Binding("ctrl+alt+l,f7", "format", "Format", show=False, id="editor.format"),
|
|
37
|
+
Binding(
|
|
38
|
+
"ctrl+slash,ctrl+underscore,alt+slash",
|
|
39
|
+
"toggle_comment",
|
|
40
|
+
"Comment",
|
|
41
|
+
show=False,
|
|
42
|
+
id="editor.toggle_comment",
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
class RunRequested(Message):
|
|
47
|
+
"""User wants to execute these statements, in order."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, statements: list[str]) -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.statements = statements
|
|
52
|
+
|
|
53
|
+
class CompletionRequested(Message):
|
|
54
|
+
"""Cursor context changed (or the user asked): the owner supplies candidates."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, offset: int, text: str, manual: bool) -> None:
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.offset, self.text, self.manual = offset, text, manual
|
|
59
|
+
|
|
60
|
+
def __init__(self, text: str = "", *, dialect: str = "generic", blank_line: bool = True,
|
|
61
|
+
**kw) -> None: # fmt: skip
|
|
62
|
+
super().__init__(
|
|
63
|
+
text,
|
|
64
|
+
language="sql",
|
|
65
|
+
theme="monokai",
|
|
66
|
+
show_line_numbers=True,
|
|
67
|
+
soft_wrap=False,
|
|
68
|
+
tab_behavior="indent",
|
|
69
|
+
placeholder="-- connect (sidebar: Enter), then F5 / Ctrl+J runs the framed statement",
|
|
70
|
+
**kw,
|
|
71
|
+
)
|
|
72
|
+
self._dialect = dialect
|
|
73
|
+
self._blank_line = blank_line
|
|
74
|
+
self._spans: list[Span] = []
|
|
75
|
+
self._frame: tuple[int, int] | None = None
|
|
76
|
+
self._timer: Timer | None = None
|
|
77
|
+
self._popup: CompletionPopup | None = None
|
|
78
|
+
self._complete_timer: Timer | None = None
|
|
79
|
+
self._prefix_len = 0
|
|
80
|
+
self._accepting = False
|
|
81
|
+
|
|
82
|
+
# --- configuration ---
|
|
83
|
+
@property
|
|
84
|
+
def dialect(self) -> str:
|
|
85
|
+
return self._dialect
|
|
86
|
+
|
|
87
|
+
@dialect.setter
|
|
88
|
+
def dialect(self, value: str) -> None:
|
|
89
|
+
self._dialect = value
|
|
90
|
+
self._recalc()
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def blank_line(self) -> bool:
|
|
94
|
+
return self._blank_line
|
|
95
|
+
|
|
96
|
+
@blank_line.setter
|
|
97
|
+
def blank_line(self, value: bool) -> None:
|
|
98
|
+
self._blank_line = value
|
|
99
|
+
self._recalc()
|
|
100
|
+
|
|
101
|
+
# --- spans and frame ---
|
|
102
|
+
def _recalc(self) -> None:
|
|
103
|
+
self._spans = split(self.text, self._dialect, self._blank_line)
|
|
104
|
+
self._update_frame()
|
|
105
|
+
|
|
106
|
+
def _cursor_index(self) -> int:
|
|
107
|
+
"""Cursor as a character offset into `self.text`."""
|
|
108
|
+
return cast(Document, self.document).get_index_from_location(self.cursor_location)
|
|
109
|
+
|
|
110
|
+
def _update_frame(self) -> None:
|
|
111
|
+
frame = None
|
|
112
|
+
if self.selection.is_empty:
|
|
113
|
+
span = statement_at(self._spans, self.text, self._cursor_index())
|
|
114
|
+
if span:
|
|
115
|
+
frame = span_lines(self.text, span)
|
|
116
|
+
if frame != self._frame:
|
|
117
|
+
self._frame = frame
|
|
118
|
+
self.refresh()
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def frame_lines(self) -> tuple[int, int] | None:
|
|
122
|
+
"""(first, last) 0-based lines of the framed statement, or None."""
|
|
123
|
+
return self._frame
|
|
124
|
+
|
|
125
|
+
def on_mount(self) -> None:
|
|
126
|
+
self._recalc()
|
|
127
|
+
|
|
128
|
+
def on_text_area_changed(self, _: TextArea.Changed) -> None:
|
|
129
|
+
self._maybe_complete()
|
|
130
|
+
if len(self.text) <= IMMEDIATE_RECALC_LIMIT:
|
|
131
|
+
self._recalc()
|
|
132
|
+
return
|
|
133
|
+
if self._timer:
|
|
134
|
+
self._timer.stop()
|
|
135
|
+
self._timer = self.set_timer(DEBOUNCE_S, self._recalc)
|
|
136
|
+
|
|
137
|
+
def on_text_area_selection_changed(self, _: TextArea.SelectionChanged) -> None:
|
|
138
|
+
self._update_frame()
|
|
139
|
+
if self.completing:
|
|
140
|
+
self._request_completion(manual=False) # re-filter, or close when the cursor left
|
|
141
|
+
|
|
142
|
+
# --- run actions ---
|
|
143
|
+
def statements_to_run(self) -> list[str]:
|
|
144
|
+
"""Selection (split into statements) wins; otherwise the framed statement."""
|
|
145
|
+
sel = self.selected_text
|
|
146
|
+
if sel.strip():
|
|
147
|
+
parts = [s.text(sel) for s in split(sel, self._dialect, self._blank_line)]
|
|
148
|
+
return parts or [sel.strip()]
|
|
149
|
+
self._recalc() # never run from stale spans
|
|
150
|
+
span = statement_at(self._spans, self.text, self._cursor_index())
|
|
151
|
+
return [span.text(self.text)] if span else []
|
|
152
|
+
|
|
153
|
+
def action_run_statement(self) -> None:
|
|
154
|
+
stmts = self.statements_to_run()
|
|
155
|
+
if stmts:
|
|
156
|
+
self.post_message(self.RunRequested(stmts))
|
|
157
|
+
else:
|
|
158
|
+
self.app.notify("No statement at cursor", severity="warning")
|
|
159
|
+
|
|
160
|
+
def action_run_all(self) -> None:
|
|
161
|
+
self._recalc()
|
|
162
|
+
stmts = [s.text(self.text) for s in self._spans]
|
|
163
|
+
if stmts:
|
|
164
|
+
self.post_message(self.RunRequested(stmts))
|
|
165
|
+
else:
|
|
166
|
+
self.app.notify("Nothing to run", severity="warning")
|
|
167
|
+
|
|
168
|
+
# --- editing helpers ---
|
|
169
|
+
def action_format(self) -> None:
|
|
170
|
+
"""Pretty-print the selection, or the framed statement."""
|
|
171
|
+
sel = self.selection
|
|
172
|
+
if not sel.is_empty:
|
|
173
|
+
start, end = sorted((sel.start, sel.end))
|
|
174
|
+
src = self.get_text_range(start, end)
|
|
175
|
+
else:
|
|
176
|
+
self._recalc()
|
|
177
|
+
span = statement_at(self._spans, self.text, self._cursor_index())
|
|
178
|
+
if span is None:
|
|
179
|
+
self.app.notify("No statement at cursor", severity="warning")
|
|
180
|
+
return
|
|
181
|
+
doc = cast(Document, self.document)
|
|
182
|
+
start, end = (
|
|
183
|
+
doc.get_location_from_index(span.start),
|
|
184
|
+
doc.get_location_from_index(span.end),
|
|
185
|
+
)
|
|
186
|
+
src = span.text(self.text)
|
|
187
|
+
try:
|
|
188
|
+
formatted = format_sql(src, self._dialect)
|
|
189
|
+
except FormatError as e:
|
|
190
|
+
self.app.notify(str(e), title="Cannot format", severity="warning")
|
|
191
|
+
return
|
|
192
|
+
if formatted != src:
|
|
193
|
+
self.replace(formatted, start, end)
|
|
194
|
+
|
|
195
|
+
def action_toggle_comment(self) -> None:
|
|
196
|
+
sel = self.selection
|
|
197
|
+
first, last = sorted((sel.start[0], sel.end[0]))
|
|
198
|
+
if not sel.is_empty and sel.end[1] == 0 and sel.end[0] > sel.start[0]:
|
|
199
|
+
last -= 1 # a selection ending at column 0 does not include that line
|
|
200
|
+
lines = [self.document.get_line(i) for i in range(first, last + 1)]
|
|
201
|
+
new = toggle_line_comments(lines)
|
|
202
|
+
if new != lines:
|
|
203
|
+
end_col = len(lines[-1])
|
|
204
|
+
self.replace("\n".join(new), (first, 0), (last, end_col))
|
|
205
|
+
|
|
206
|
+
# --- drawing ---
|
|
207
|
+
def render_line(self, y: int) -> Strip:
|
|
208
|
+
strip = super().render_line(y)
|
|
209
|
+
if self._frame is None or not self.show_line_numbers:
|
|
210
|
+
return strip
|
|
211
|
+
row = y + self.scroll_offset.y
|
|
212
|
+
if row >= self.wrapped_document.height: # empty space below the text: no frame
|
|
213
|
+
return strip
|
|
214
|
+
line, _ = self.wrapped_document.offset_to_location(Offset(0, row))
|
|
215
|
+
first, last = self._frame
|
|
216
|
+
if not first <= line <= last:
|
|
217
|
+
return strip
|
|
218
|
+
mark = "▏" if first == last else "┏" if line == first else "┗" if line == last else "┃"
|
|
219
|
+
accent, tint = self._frame_colors()
|
|
220
|
+
segs = list(strip)
|
|
221
|
+
head = segs[0]
|
|
222
|
+
segs[0] = Segment(mark + head.text[1:], (head.style or Style()) + Style(color=accent))
|
|
223
|
+
segs = [Segment(s.text, (s.style or Style()) + tint, s.control) for s in segs]
|
|
224
|
+
return Strip(segs, strip.cell_length)
|
|
225
|
+
|
|
226
|
+
def _frame_colors(self) -> tuple[str, Style]:
|
|
227
|
+
theme = self.app.current_theme
|
|
228
|
+
accent = Color.parse(theme.primary)
|
|
229
|
+
base = Color.parse(theme.background if theme.background else "#1e1e1e")
|
|
230
|
+
return accent.hex, Style(bgcolor=base.blend(accent, 0.14).hex)
|
|
231
|
+
|
|
232
|
+
# --- autocomplete (the data comes from the owner via CompletionRequested) ---
|
|
233
|
+
@property
|
|
234
|
+
def completing(self) -> bool:
|
|
235
|
+
return self._popup is not None and self._popup.shown
|
|
236
|
+
|
|
237
|
+
def action_complete(self) -> None:
|
|
238
|
+
self._request_completion(manual=True)
|
|
239
|
+
|
|
240
|
+
def _maybe_complete(self) -> None:
|
|
241
|
+
"""Auto-open after a dot; keep an open popup in sync while typing."""
|
|
242
|
+
if self._accepting:
|
|
243
|
+
return
|
|
244
|
+
before = self.text[self._cursor_index() - 1 : self._cursor_index()]
|
|
245
|
+
if before == "." or self.completing:
|
|
246
|
+
self._request_completion(manual=False)
|
|
247
|
+
|
|
248
|
+
def _request_completion(self, manual: bool) -> None:
|
|
249
|
+
if self._complete_timer is not None:
|
|
250
|
+
self._complete_timer.stop()
|
|
251
|
+
self._complete_timer = self.set_timer(
|
|
252
|
+
COMPLETE_DEBOUNCE_S,
|
|
253
|
+
lambda: self.post_message(
|
|
254
|
+
self.CompletionRequested(self._cursor_index(), self.text, manual)
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def show_completions(self, items: list[Candidate], prefix_len: int) -> None:
|
|
259
|
+
if not items or not self.has_focus:
|
|
260
|
+
self.hide_completions()
|
|
261
|
+
return
|
|
262
|
+
if self._popup is None:
|
|
263
|
+
self._popup = CompletionPopup()
|
|
264
|
+
self.screen.mount(self._popup)
|
|
265
|
+
self._prefix_len = prefix_len
|
|
266
|
+
self._popup.show(items, self.cursor_screen_offset)
|
|
267
|
+
|
|
268
|
+
def hide_completions(self) -> None:
|
|
269
|
+
if self._popup is not None:
|
|
270
|
+
self._popup.hide()
|
|
271
|
+
|
|
272
|
+
def _accept_completion(self) -> None:
|
|
273
|
+
popup = self._popup
|
|
274
|
+
choice = popup.current if popup else None
|
|
275
|
+
self.hide_completions()
|
|
276
|
+
if choice is None:
|
|
277
|
+
return
|
|
278
|
+
end = self._cursor_index()
|
|
279
|
+
doc = cast(Document, self.document)
|
|
280
|
+
self._accepting = True
|
|
281
|
+
try:
|
|
282
|
+
self.replace(
|
|
283
|
+
choice.text,
|
|
284
|
+
doc.get_location_from_index(end - self._prefix_len),
|
|
285
|
+
doc.get_location_from_index(end),
|
|
286
|
+
)
|
|
287
|
+
finally:
|
|
288
|
+
self._accepting = False
|
|
289
|
+
|
|
290
|
+
def on_blur(self) -> None:
|
|
291
|
+
self.hide_completions()
|
|
292
|
+
|
|
293
|
+
# Tab indents instead of moving focus (tab_behavior="indent"); Shift+Tab moves focus out.
|
|
294
|
+
def on_key(self, event: Key) -> None:
|
|
295
|
+
if self.completing and self._popup is not None:
|
|
296
|
+
handled = {
|
|
297
|
+
"down": lambda: self._popup.move(1), # type: ignore[union-attr]
|
|
298
|
+
"up": lambda: self._popup.move(-1), # type: ignore[union-attr]
|
|
299
|
+
"enter": self._accept_completion,
|
|
300
|
+
"tab": self._accept_completion,
|
|
301
|
+
"escape": self.hide_completions,
|
|
302
|
+
}.get(event.key)
|
|
303
|
+
if handled:
|
|
304
|
+
event.stop()
|
|
305
|
+
event.prevent_default()
|
|
306
|
+
handled()
|
|
307
|
+
return
|
|
308
|
+
if event.key == "shift+tab":
|
|
309
|
+
event.stop()
|
|
310
|
+
self.screen.focus_previous()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""One-line status: connection, transaction mode, last run summary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StatusBar(Static):
|
|
10
|
+
DEFAULT_CSS = """
|
|
11
|
+
StatusBar { height: 1; padding: 0 1; background: $panel; color: $text-muted; }
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, **kw) -> None:
|
|
15
|
+
super().__init__("", **kw)
|
|
16
|
+
self.connection = "not connected"
|
|
17
|
+
self.tx = ""
|
|
18
|
+
self.message = ""
|
|
19
|
+
self._refresh_text()
|
|
20
|
+
|
|
21
|
+
def update_state(self, **fields: str) -> None:
|
|
22
|
+
for k, v in fields.items():
|
|
23
|
+
setattr(self, k, v)
|
|
24
|
+
self._refresh_text()
|
|
25
|
+
|
|
26
|
+
def _refresh_text(self) -> None:
|
|
27
|
+
parts = [self.connection]
|
|
28
|
+
if self.tx:
|
|
29
|
+
parts.append(f"Tx: {self.tx}")
|
|
30
|
+
if self.message:
|
|
31
|
+
parts.append(self.message)
|
|
32
|
+
self.update(Text(" · ".join(parts)))
|
sqlide/workspace.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Application service: saved connections, drivers, passwords, sessions.
|
|
2
|
+
|
|
3
|
+
Everything the UI needs from the non-UI blocks, in one place. Holds no widgets.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from sqlide.config.connections import Connection, ConnectionStore
|
|
12
|
+
from sqlide.config.secrets import PasswordResolver
|
|
13
|
+
from sqlide.config.settings import Settings, load_settings
|
|
14
|
+
from sqlide.consoles import ConsoleStore
|
|
15
|
+
from sqlide.db.factory import create_session, is_private_database
|
|
16
|
+
from sqlide.db.session import DbSession
|
|
17
|
+
from sqlide.drivers.registry import DriverDef, DriverRegistry
|
|
18
|
+
from sqlide.history import HistoryStore
|
|
19
|
+
from sqlide.jvm.runtime import ensure_jvm
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Workspace:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
store: ConnectionStore | None = None,
|
|
26
|
+
registry: DriverRegistry | None = None,
|
|
27
|
+
resolver: PasswordResolver | None = None,
|
|
28
|
+
settings: Settings | None = None,
|
|
29
|
+
consoles: ConsoleStore | None = None,
|
|
30
|
+
history: HistoryStore | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.store = store or ConnectionStore()
|
|
33
|
+
self.registry = registry or DriverRegistry()
|
|
34
|
+
self.resolver = resolver or PasswordResolver()
|
|
35
|
+
self.settings = settings or load_settings()
|
|
36
|
+
self.consoles = consoles or ConsoleStore()
|
|
37
|
+
self.history = history or HistoryStore(limit=self.settings.history_limit)
|
|
38
|
+
|
|
39
|
+
# --- connections ---
|
|
40
|
+
def connections(self) -> list[Connection]:
|
|
41
|
+
return self.store.load()
|
|
42
|
+
|
|
43
|
+
def save_connection(self, conn: Connection, replaces: str | None = None) -> None:
|
|
44
|
+
if replaces and replaces != conn.name:
|
|
45
|
+
self.store.remove(replaces)
|
|
46
|
+
self.store.upsert(conn)
|
|
47
|
+
|
|
48
|
+
def delete_connection(self, name: str) -> None:
|
|
49
|
+
self.store.remove(name)
|
|
50
|
+
self.resolver.forget(name)
|
|
51
|
+
|
|
52
|
+
# --- drivers ---
|
|
53
|
+
def driver(self, driver_id: str) -> DriverDef:
|
|
54
|
+
return self.registry.get(driver_id)
|
|
55
|
+
|
|
56
|
+
def driver_ready(self, conn: Connection) -> bool:
|
|
57
|
+
return self.registry.is_installed(conn.driver)
|
|
58
|
+
|
|
59
|
+
async def install_driver(
|
|
60
|
+
self, driver_id: str, progress: Callable[[int, int | None], None] | None = None
|
|
61
|
+
) -> None:
|
|
62
|
+
await asyncio.to_thread(self.registry.install, driver_id, None, None, progress)
|
|
63
|
+
|
|
64
|
+
# --- sessions ---
|
|
65
|
+
@staticmethod
|
|
66
|
+
def needs_password_prompt(conn: Connection) -> bool:
|
|
67
|
+
"""No user means no credentials (sqlite, h2, duckdb): never ask."""
|
|
68
|
+
return bool(conn.user)
|
|
69
|
+
|
|
70
|
+
async def lookup_password(self, conn: Connection) -> str | None:
|
|
71
|
+
return await asyncio.to_thread(self.resolver.lookup, conn)
|
|
72
|
+
|
|
73
|
+
async def connect_for_metadata(self, conn: Connection) -> DbSession | None:
|
|
74
|
+
"""A second connection for schema reads, so they never queue behind a running query.
|
|
75
|
+
|
|
76
|
+
None when that is not possible (in-memory database, password not known, any error):
|
|
77
|
+
the caller then keeps using the main session.
|
|
78
|
+
"""
|
|
79
|
+
if is_private_database(conn.url):
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
password = await self.lookup_password(conn)
|
|
83
|
+
if password is None and self.needs_password_prompt(conn):
|
|
84
|
+
return None
|
|
85
|
+
return await self.connect(conn, password)
|
|
86
|
+
except Exception: # noqa: BLE001 - purely an optimisation
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
async def connect(self, conn: Connection, password: str | None) -> DbSession:
|
|
90
|
+
await asyncio.to_thread(ensure_jvm)
|
|
91
|
+
session = await asyncio.to_thread(create_session, conn, password or None, self.registry)
|
|
92
|
+
try:
|
|
93
|
+
await session.open()
|
|
94
|
+
except BaseException:
|
|
95
|
+
await session.close()
|
|
96
|
+
raise
|
|
97
|
+
return session
|