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,110 @@
|
|
|
1
|
+
"""Small reusable modals: confirm, password prompt, progress."""
|
|
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, ProgressBar
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
13
|
+
BINDINGS = [Binding("escape", "no", "No"), Binding("y", "yes", "Yes"), Binding("n", "no", "No")]
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, yes: str = "Yes", no: str = "No") -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._message, self._yes, self._no = message, yes, no
|
|
18
|
+
|
|
19
|
+
def compose(self) -> ComposeResult:
|
|
20
|
+
with Vertical(classes="dialog"):
|
|
21
|
+
yield Label(self._message)
|
|
22
|
+
with Horizontal(classes="buttons"):
|
|
23
|
+
yield Button(self._yes, variant="primary", id="yes")
|
|
24
|
+
yield Button(self._no, id="no")
|
|
25
|
+
|
|
26
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
27
|
+
self.dismiss(event.button.id == "yes")
|
|
28
|
+
|
|
29
|
+
def action_yes(self) -> None:
|
|
30
|
+
self.dismiss(True)
|
|
31
|
+
|
|
32
|
+
def action_no(self) -> None:
|
|
33
|
+
self.dismiss(False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PasswordPrompt(ModalScreen[str | None]):
|
|
37
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
38
|
+
|
|
39
|
+
def __init__(self, title: str) -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self._title = title
|
|
42
|
+
|
|
43
|
+
def compose(self) -> ComposeResult:
|
|
44
|
+
with Vertical(classes="dialog"):
|
|
45
|
+
yield Label(self._title)
|
|
46
|
+
yield Input(password=True, id="pw", placeholder="password")
|
|
47
|
+
with Horizontal(classes="buttons"):
|
|
48
|
+
yield Button("Connect", variant="primary", id="ok")
|
|
49
|
+
yield Button("Cancel", id="cancel")
|
|
50
|
+
|
|
51
|
+
def on_mount(self) -> None:
|
|
52
|
+
self.query_one("#pw", Input).focus()
|
|
53
|
+
|
|
54
|
+
def on_input_submitted(self, _: Input.Submitted) -> None:
|
|
55
|
+
self.dismiss(self.query_one("#pw", Input).value)
|
|
56
|
+
|
|
57
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
58
|
+
self.dismiss(self.query_one("#pw", Input).value if event.button.id == "ok" else None)
|
|
59
|
+
|
|
60
|
+
def action_cancel(self) -> None:
|
|
61
|
+
self.dismiss(None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProgressScreen(ModalScreen[None]):
|
|
65
|
+
"""Shown while a download runs; the caller dismisses it."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, title: str) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self._title = title
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
with Vertical(classes="dialog"):
|
|
73
|
+
yield Label(self._title)
|
|
74
|
+
yield ProgressBar(total=None, show_eta=False, id="bar")
|
|
75
|
+
|
|
76
|
+
def advance(self, done: int, total: int | None) -> None:
|
|
77
|
+
self.query_one("#bar", ProgressBar).update(total=total, progress=done)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PathPrompt(ModalScreen[str | None]):
|
|
81
|
+
"""Ask for a file path (open / save as)."""
|
|
82
|
+
|
|
83
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
84
|
+
|
|
85
|
+
def __init__(self, title: str, default: str = "", ok: str = "OK") -> None:
|
|
86
|
+
super().__init__()
|
|
87
|
+
self._title, self._default, self._ok = title, default, ok
|
|
88
|
+
|
|
89
|
+
def compose(self) -> ComposeResult:
|
|
90
|
+
with Vertical(classes="dialog wide"):
|
|
91
|
+
yield Label(self._title)
|
|
92
|
+
yield Input(self._default, id="path")
|
|
93
|
+
with Horizontal(classes="buttons"):
|
|
94
|
+
yield Button(self._ok, variant="primary", id="ok")
|
|
95
|
+
yield Button("Cancel", id="cancel")
|
|
96
|
+
|
|
97
|
+
def on_mount(self) -> None:
|
|
98
|
+
self.query_one("#path", Input).focus()
|
|
99
|
+
|
|
100
|
+
def _value(self) -> str | None:
|
|
101
|
+
return self.query_one("#path", Input).value.strip() or None
|
|
102
|
+
|
|
103
|
+
def on_input_submitted(self, _: Input.Submitted) -> None:
|
|
104
|
+
self.dismiss(self._value())
|
|
105
|
+
|
|
106
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
107
|
+
self.dismiss(self._value() if event.button.id == "ok" else None)
|
|
108
|
+
|
|
109
|
+
def action_cancel(self) -> None:
|
|
110
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Driver manager: see what is installed, download, remove, add custom drivers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import work
|
|
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, OptionList, Select
|
|
12
|
+
from textual.widgets.option_list import Option
|
|
13
|
+
|
|
14
|
+
from sqlide.config._toml import ConfigError
|
|
15
|
+
from sqlide.drivers.custom import build_custom_driver
|
|
16
|
+
from sqlide.drivers.maven import MavenError
|
|
17
|
+
from sqlide.drivers.registry import DriverDef
|
|
18
|
+
from sqlide.sql.dialects import RULES
|
|
19
|
+
from sqlide.ui.screens.dialogs import ConfirmScreen, ProgressScreen
|
|
20
|
+
from sqlide.workspace import Workspace
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DriverEditor(ModalScreen[DriverDef | None]):
|
|
24
|
+
"""Add a custom driver from jar files or Maven coordinates."""
|
|
25
|
+
|
|
26
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
with Vertical(classes="dialog wide"):
|
|
30
|
+
yield Label("Custom driver", classes="title")
|
|
31
|
+
yield Label("Id (e.g. my-db)")
|
|
32
|
+
yield Input(id="id")
|
|
33
|
+
yield Label("Name")
|
|
34
|
+
yield Input(id="name")
|
|
35
|
+
yield Label("Jar files (comma separated) — or — Maven group:artifact[:classifier]")
|
|
36
|
+
yield Input(id="source", placeholder="/path/a.jar, /path/b.jar or com.foo:bar")
|
|
37
|
+
yield Label("Driver class (empty: detect from jars)")
|
|
38
|
+
yield Input(id="class")
|
|
39
|
+
yield Label("URL template")
|
|
40
|
+
yield Input(id="url", placeholder="jdbc:foo://{host}:{port}/{database}")
|
|
41
|
+
with Horizontal(classes="row"):
|
|
42
|
+
yield Label("Port")
|
|
43
|
+
yield Input("0", type="integer", id="port")
|
|
44
|
+
yield Label("Dialect")
|
|
45
|
+
yield Select(
|
|
46
|
+
[(d, d) for d in sorted(RULES)],
|
|
47
|
+
value="generic",
|
|
48
|
+
allow_blank=False,
|
|
49
|
+
id="dialect",
|
|
50
|
+
)
|
|
51
|
+
with Horizontal(classes="buttons"):
|
|
52
|
+
yield Button("Add", variant="primary", id="ok")
|
|
53
|
+
yield Button("Cancel", id="cancel")
|
|
54
|
+
yield Label("", id="driver-error", classes="error")
|
|
55
|
+
|
|
56
|
+
def on_mount(self) -> None:
|
|
57
|
+
self.query_one("#id", Input).focus()
|
|
58
|
+
|
|
59
|
+
def _text(self, widget_id: str) -> str:
|
|
60
|
+
return self.query_one(f"#{widget_id}", Input).value
|
|
61
|
+
|
|
62
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
63
|
+
if event.button.id != "ok":
|
|
64
|
+
self.dismiss(None)
|
|
65
|
+
return
|
|
66
|
+
source = self._text("source").strip()
|
|
67
|
+
is_maven = ":" in source and "/" not in source and "\\" not in source
|
|
68
|
+
try:
|
|
69
|
+
driver = build_custom_driver(
|
|
70
|
+
self._text("id"),
|
|
71
|
+
self._text("name"),
|
|
72
|
+
maven=source if is_maven else "",
|
|
73
|
+
jars=[] if is_maven else source.split(","),
|
|
74
|
+
class_name=self._text("class"),
|
|
75
|
+
url_template=self._text("url"),
|
|
76
|
+
default_port=int(self._text("port") or 0),
|
|
77
|
+
dialect=str(self.query_one("#dialect", Select).value),
|
|
78
|
+
)
|
|
79
|
+
except ConfigError as e:
|
|
80
|
+
self.query_one("#driver-error", Label).update(str(e))
|
|
81
|
+
return
|
|
82
|
+
self.dismiss(driver)
|
|
83
|
+
|
|
84
|
+
def action_cancel(self) -> None:
|
|
85
|
+
self.dismiss(None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DriverManager(ModalScreen[None]):
|
|
89
|
+
BINDINGS = [
|
|
90
|
+
Binding("escape", "close", "Close"),
|
|
91
|
+
Binding("enter,d", "download", "Download"),
|
|
92
|
+
Binding("x", "uninstall", "Remove"),
|
|
93
|
+
Binding("a", "add", "Add custom"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def __init__(self, ws: Workspace) -> None:
|
|
97
|
+
super().__init__()
|
|
98
|
+
self.ws = ws
|
|
99
|
+
self._ids: list[str] = []
|
|
100
|
+
|
|
101
|
+
def compose(self) -> ComposeResult:
|
|
102
|
+
with Vertical(id="drivers", classes="dialog wide"):
|
|
103
|
+
yield Label("Drivers", classes="title")
|
|
104
|
+
yield OptionList(id="driver-list")
|
|
105
|
+
yield Label("Enter/d download · x remove · a add custom · Esc close", classes="hint")
|
|
106
|
+
yield Label("", id="driver-message")
|
|
107
|
+
|
|
108
|
+
def on_mount(self) -> None:
|
|
109
|
+
self._reload()
|
|
110
|
+
self.query_one("#driver-list", OptionList).focus()
|
|
111
|
+
|
|
112
|
+
# --- view ---
|
|
113
|
+
def _state(self, d: DriverDef) -> Text:
|
|
114
|
+
reg = self.ws.registry
|
|
115
|
+
text = Text(f"{d.name} ")
|
|
116
|
+
if d.jars:
|
|
117
|
+
ok = reg.is_installed(d.id)
|
|
118
|
+
text.append(
|
|
119
|
+
"custom jars" if ok else "custom: jar missing", style="cyan" if ok else "red"
|
|
120
|
+
)
|
|
121
|
+
elif versions := reg.installed_versions(d.id):
|
|
122
|
+
text.append(f"✔ {versions[-1]}", style="green")
|
|
123
|
+
else:
|
|
124
|
+
text.append("not installed", style="yellow")
|
|
125
|
+
return text
|
|
126
|
+
|
|
127
|
+
def _reload(self, keep: str | None = None) -> None:
|
|
128
|
+
drivers = self.ws.registry.all()
|
|
129
|
+
self._ids = list(drivers)
|
|
130
|
+
lst = self.query_one("#driver-list", OptionList)
|
|
131
|
+
lst.clear_options()
|
|
132
|
+
lst.add_options([Option(self._state(d)) for d in drivers.values()])
|
|
133
|
+
if self._ids:
|
|
134
|
+
lst.highlighted = self._ids.index(keep) if keep in self._ids else 0
|
|
135
|
+
|
|
136
|
+
def _selected(self) -> DriverDef | None:
|
|
137
|
+
i = self.query_one("#driver-list", OptionList).highlighted
|
|
138
|
+
return self.ws.registry.all().get(self._ids[i]) if i is not None else None
|
|
139
|
+
|
|
140
|
+
def _say(self, text: str, error: bool = False) -> None:
|
|
141
|
+
self.query_one("#driver-message", Label).update(Text(text, style="red" if error else ""))
|
|
142
|
+
|
|
143
|
+
# --- actions ---
|
|
144
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
145
|
+
event.stop()
|
|
146
|
+
self.action_download()
|
|
147
|
+
|
|
148
|
+
@work(exclusive=True, group="driver")
|
|
149
|
+
async def action_download(self) -> None:
|
|
150
|
+
d = self._selected()
|
|
151
|
+
if d is None:
|
|
152
|
+
return
|
|
153
|
+
if not d.is_maven:
|
|
154
|
+
self._say(f"'{d.name}' uses local jars: nothing to download")
|
|
155
|
+
return
|
|
156
|
+
progress = ProgressScreen(f"Downloading {d.name}…")
|
|
157
|
+
await self.app.push_screen(progress)
|
|
158
|
+
try:
|
|
159
|
+
await self.ws.install_driver(
|
|
160
|
+
d.id, lambda done, total: self.app.call_from_thread(progress.advance, done, total)
|
|
161
|
+
)
|
|
162
|
+
except (MavenError, OSError) as e:
|
|
163
|
+
self._say(str(e), error=True)
|
|
164
|
+
else:
|
|
165
|
+
self._say(f"{d.name} installed")
|
|
166
|
+
finally:
|
|
167
|
+
progress.dismiss()
|
|
168
|
+
self._reload(d.id)
|
|
169
|
+
|
|
170
|
+
@work(exclusive=True, group="driver")
|
|
171
|
+
async def action_uninstall(self) -> None:
|
|
172
|
+
d = self._selected()
|
|
173
|
+
if d is None or not d.is_maven or not self.ws.registry.installed_versions(d.id):
|
|
174
|
+
return
|
|
175
|
+
if await self.app.push_screen_wait(ConfirmScreen(f"Remove downloaded jars of {d.name}?")):
|
|
176
|
+
self.ws.registry.uninstall(d.id)
|
|
177
|
+
self._reload(d.id)
|
|
178
|
+
|
|
179
|
+
@work(exclusive=True, group="driver")
|
|
180
|
+
async def action_add(self) -> None:
|
|
181
|
+
driver = await self.app.push_screen_wait(DriverEditor())
|
|
182
|
+
if driver is None:
|
|
183
|
+
return
|
|
184
|
+
try:
|
|
185
|
+
self.ws.registry.add_custom(driver)
|
|
186
|
+
except ConfigError as e:
|
|
187
|
+
self._say(str(e), error=True)
|
|
188
|
+
return
|
|
189
|
+
self._reload(driver.id)
|
|
190
|
+
self._say(
|
|
191
|
+
f"Added {driver.name}" + (" (press Enter to download)" if driver.is_maven else "")
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def action_close(self) -> None:
|
|
195
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Export dialog: format, scope, path, per-format options. Returns an ExportRequest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Button, Input, Label, RadioButton, RadioSet, Select, Static, Switch
|
|
14
|
+
|
|
15
|
+
from sqlide import export
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ExportContext:
|
|
20
|
+
n_view: int
|
|
21
|
+
selection: tuple[int, int] | None # (rows, cols) when more than one cell is selected
|
|
22
|
+
can_requery: bool
|
|
23
|
+
more_rows: bool # the loaded rows are only part of the result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ExportRequest:
|
|
28
|
+
format: str
|
|
29
|
+
scope: str # "view" | "selection" | "all"
|
|
30
|
+
path: Path
|
|
31
|
+
header: bool
|
|
32
|
+
delimiter: str
|
|
33
|
+
bom: bool
|
|
34
|
+
table_name: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_path(ext: str) -> str:
|
|
38
|
+
return str(Path.cwd() / f"result_{datetime.now():%Y%m%d_%H%M%S}{ext}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExportScreen(ModalScreen[ExportRequest | None]):
|
|
42
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
43
|
+
|
|
44
|
+
def __init__(self, ctx: ExportContext) -> None:
|
|
45
|
+
super().__init__()
|
|
46
|
+
self._ctx = ctx
|
|
47
|
+
self._prev_ext = export.get("csv").extension
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
c = self._ctx
|
|
51
|
+
default_scope = "all" if c.more_rows and c.can_requery else "view"
|
|
52
|
+
with Vertical(classes="dialog wide"):
|
|
53
|
+
yield Label("Export result", classes="title")
|
|
54
|
+
yield Label("Format")
|
|
55
|
+
yield Select(
|
|
56
|
+
[(e.label, e.name) for e in export.all_exporters()],
|
|
57
|
+
value="csv",
|
|
58
|
+
allow_blank=False,
|
|
59
|
+
id="format",
|
|
60
|
+
)
|
|
61
|
+
yield Label("Data")
|
|
62
|
+
with RadioSet(id="scope"):
|
|
63
|
+
view_label = f"Loaded rows, as shown ({c.n_view})" + (
|
|
64
|
+
" - more rows exist" if c.more_rows else ""
|
|
65
|
+
)
|
|
66
|
+
yield RadioButton(view_label, id="view", value=default_scope == "view")
|
|
67
|
+
if c.selection:
|
|
68
|
+
yield RadioButton(
|
|
69
|
+
f"Selection ({c.selection[0]} x {c.selection[1]})", id="selection"
|
|
70
|
+
)
|
|
71
|
+
if c.can_requery:
|
|
72
|
+
yield RadioButton(
|
|
73
|
+
"Whole result (re-run the query)", id="all", value=default_scope == "all"
|
|
74
|
+
)
|
|
75
|
+
yield Label("File")
|
|
76
|
+
yield Input(default_path(self._prev_ext), id="path")
|
|
77
|
+
with Horizontal(classes="row", id="opt-header"):
|
|
78
|
+
yield Label("Header row")
|
|
79
|
+
yield Switch(True, id="header")
|
|
80
|
+
with Horizontal(classes="row", id="opt-csv"):
|
|
81
|
+
yield Label("Delimiter")
|
|
82
|
+
yield Input(",", id="delimiter", max_length=1)
|
|
83
|
+
yield Label(" UTF-8 BOM (for Excel)")
|
|
84
|
+
yield Switch(False, id="bom")
|
|
85
|
+
with Horizontal(classes="row", id="opt-sql"):
|
|
86
|
+
yield Label("Table name")
|
|
87
|
+
yield Input("table_name", id="table_name")
|
|
88
|
+
with Horizontal(classes="row"):
|
|
89
|
+
yield Label("Overwrite existing file")
|
|
90
|
+
yield Switch(False, id="overwrite")
|
|
91
|
+
yield Static("", id="error", classes="error")
|
|
92
|
+
with Horizontal(classes="buttons"):
|
|
93
|
+
yield Button("Export", variant="primary", id="export")
|
|
94
|
+
yield Button("Cancel", id="cancel")
|
|
95
|
+
|
|
96
|
+
def on_mount(self) -> None:
|
|
97
|
+
self._sync_options("csv")
|
|
98
|
+
self.query_one("#path", Input).focus()
|
|
99
|
+
|
|
100
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
101
|
+
if event.select.id == "format" and isinstance(event.value, str):
|
|
102
|
+
self._swap_extension(event.value)
|
|
103
|
+
self._sync_options(event.value)
|
|
104
|
+
|
|
105
|
+
def _swap_extension(self, fmt: str) -> None:
|
|
106
|
+
path = self.query_one("#path", Input)
|
|
107
|
+
new_ext = export.get(fmt).extension
|
|
108
|
+
if path.value.endswith(self._prev_ext):
|
|
109
|
+
path.value = path.value[: -len(self._prev_ext)] + new_ext
|
|
110
|
+
self._prev_ext = new_ext
|
|
111
|
+
|
|
112
|
+
def _sync_options(self, fmt: str) -> None:
|
|
113
|
+
shows = {
|
|
114
|
+
"opt-header": fmt in ("csv", "tsv", "xlsx", "html", "markdown"),
|
|
115
|
+
"opt-csv": fmt == "csv",
|
|
116
|
+
"opt-sql": fmt == "sql",
|
|
117
|
+
}
|
|
118
|
+
for id_, visible in shows.items():
|
|
119
|
+
self.query_one(f"#{id_}").display = visible
|
|
120
|
+
|
|
121
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
122
|
+
if event.button.id == "cancel":
|
|
123
|
+
self.dismiss(None)
|
|
124
|
+
else:
|
|
125
|
+
self._submit()
|
|
126
|
+
|
|
127
|
+
def on_input_submitted(self, _: Input.Submitted) -> None:
|
|
128
|
+
self._submit()
|
|
129
|
+
|
|
130
|
+
def _submit(self) -> None:
|
|
131
|
+
error = self.query_one("#error", Static)
|
|
132
|
+
raw = self.query_one("#path", Input).value.strip()
|
|
133
|
+
if not raw:
|
|
134
|
+
error.update("Path is empty")
|
|
135
|
+
return
|
|
136
|
+
path = Path(raw).expanduser()
|
|
137
|
+
if path.is_dir():
|
|
138
|
+
error.update(f"{path} is a directory")
|
|
139
|
+
return
|
|
140
|
+
if path.exists() and not self.query_one("#overwrite", Switch).value:
|
|
141
|
+
error.update("File exists: switch on 'Overwrite existing file'")
|
|
142
|
+
return
|
|
143
|
+
scope_set = self.query_one("#scope", RadioSet)
|
|
144
|
+
scope = scope_set.pressed_button.id if scope_set.pressed_button else "view"
|
|
145
|
+
self.dismiss(
|
|
146
|
+
ExportRequest(
|
|
147
|
+
format=str(self.query_one("#format", Select).value),
|
|
148
|
+
scope=scope or "view",
|
|
149
|
+
path=path,
|
|
150
|
+
header=self.query_one("#header", Switch).value,
|
|
151
|
+
delimiter=self.query_one("#delimiter", Input).value or ",",
|
|
152
|
+
bom=self.query_one("#bom", Switch).value,
|
|
153
|
+
table_name=self.query_one("#table_name", Input).value.strip() or "table_name",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def action_cancel(self) -> None:
|
|
158
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Dialogs opened from the result grid: copy-as menu and full value viewer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Label, OptionList, TextArea
|
|
13
|
+
from textual.widgets.option_list import Option
|
|
14
|
+
|
|
15
|
+
COPY_FORMATS: list[tuple[str, str]] = [
|
|
16
|
+
("tsv", "Values (TSV)"),
|
|
17
|
+
("tsv_header", "Values with header (TSV)"),
|
|
18
|
+
("csv", "CSV with header"),
|
|
19
|
+
("markdown", "Markdown table"),
|
|
20
|
+
("json", "JSON"),
|
|
21
|
+
("insert", "SQL INSERT statements"),
|
|
22
|
+
("cell", "Current cell value"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CopyMenu(ModalScreen[str | None]):
|
|
27
|
+
BINDINGS = [Binding("escape", "dismiss_none", "Cancel")]
|
|
28
|
+
|
|
29
|
+
def compose(self) -> ComposeResult:
|
|
30
|
+
with Vertical(classes="dialog"):
|
|
31
|
+
yield Label("Copy selection as", classes="title")
|
|
32
|
+
yield OptionList(*(Option(label, id=key) for key, label in COPY_FORMATS))
|
|
33
|
+
|
|
34
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
35
|
+
self.dismiss(event.option.id)
|
|
36
|
+
|
|
37
|
+
def action_dismiss_none(self) -> None:
|
|
38
|
+
self.dismiss(None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def pretty_if_json(text: str) -> tuple[str, bool]:
|
|
42
|
+
"""Pretty-print text that is a JSON object/array; otherwise return it unchanged."""
|
|
43
|
+
stripped = text.strip()
|
|
44
|
+
if stripped[:1] in "{[":
|
|
45
|
+
try:
|
|
46
|
+
return json.dumps(json.loads(stripped), indent=2, ensure_ascii=False), True
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
return text, False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ValueViewer(ModalScreen[None]):
|
|
53
|
+
BINDINGS = [
|
|
54
|
+
Binding("escape,q", "close", "Close"),
|
|
55
|
+
Binding("c", "copy", "Copy"),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def __init__(self, title: str, text: str) -> None:
|
|
59
|
+
super().__init__()
|
|
60
|
+
self._title = title
|
|
61
|
+
self._text, self._is_json = pretty_if_json(text)
|
|
62
|
+
|
|
63
|
+
def compose(self) -> ComposeResult:
|
|
64
|
+
with Vertical(classes="dialog viewer"):
|
|
65
|
+
yield Label(self._title, classes="title")
|
|
66
|
+
yield TextArea(
|
|
67
|
+
self._text,
|
|
68
|
+
read_only=True,
|
|
69
|
+
language="json" if self._is_json else None,
|
|
70
|
+
show_line_numbers=True,
|
|
71
|
+
soft_wrap=True,
|
|
72
|
+
id="value",
|
|
73
|
+
)
|
|
74
|
+
yield Label("c copy · esc close", classes="hint")
|
|
75
|
+
|
|
76
|
+
def on_mount(self) -> None:
|
|
77
|
+
self.query_one("#value", TextArea).focus()
|
|
78
|
+
|
|
79
|
+
def action_close(self) -> None:
|
|
80
|
+
self.dismiss(None)
|
|
81
|
+
|
|
82
|
+
def action_copy(self) -> None:
|
|
83
|
+
from sqlide import clipboard
|
|
84
|
+
|
|
85
|
+
name = clipboard.copy_native(self._text)
|
|
86
|
+
if name is None:
|
|
87
|
+
self.app.copy_to_clipboard(self._text)
|
|
88
|
+
self.app.notify(f"Copied value ({name or 'terminal clipboard'})")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def describe_cell(column: str, type_name: str, value: Any) -> str:
|
|
92
|
+
return f"{column} · {type_name}" + (" · NULL" if value is None else "")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Query history browser: search, preview, insert into the editor or run again."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Input, Label, OptionList, Static
|
|
13
|
+
from textual.widgets.option_list import Option
|
|
14
|
+
|
|
15
|
+
from sqlide.history import HistoryEntry, HistoryStore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HistoryChoice:
|
|
19
|
+
"""What the user picked: the statement, and whether to run it right away."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ("run", "sql")
|
|
22
|
+
|
|
23
|
+
def __init__(self, sql: str, run: bool) -> None:
|
|
24
|
+
self.sql, self.run = sql, run
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _line(e: HistoryEntry) -> Text:
|
|
28
|
+
when = datetime.fromtimestamp(e.ts).strftime("%m-%d %H:%M")
|
|
29
|
+
t = Text(f"{when} ")
|
|
30
|
+
t.append("✔ " if e.ok else "✖ ", style="green" if e.ok else "red")
|
|
31
|
+
t.append(f"[{e.connection}] ", style="cyan")
|
|
32
|
+
t.append(" ".join(e.sql.split())[:200])
|
|
33
|
+
return t
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HistoryScreen(ModalScreen[HistoryChoice | None]):
|
|
37
|
+
BINDINGS = [
|
|
38
|
+
Binding("escape", "close", "Close"),
|
|
39
|
+
Binding("down", "move(1)", show=False),
|
|
40
|
+
Binding("up", "move(-1)", show=False),
|
|
41
|
+
Binding("pagedown", "move(10)", show=False),
|
|
42
|
+
Binding("pageup", "move(-10)", show=False),
|
|
43
|
+
Binding("enter", "insert", "Insert"),
|
|
44
|
+
Binding("f5,ctrl+j", "run", "Run"),
|
|
45
|
+
Binding("alt+c", "toggle_scope", "This connection only", priority=True),
|
|
46
|
+
Binding("alt+d", "delete", "Delete entry", priority=True),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
def __init__(self, store: HistoryStore, connection: str = "") -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
self._store = store
|
|
52
|
+
self._connection = connection
|
|
53
|
+
self._only_current = False
|
|
54
|
+
self._entries: list[HistoryEntry] = []
|
|
55
|
+
|
|
56
|
+
def compose(self) -> ComposeResult:
|
|
57
|
+
with Vertical(id="history"):
|
|
58
|
+
yield Label(id="history-title")
|
|
59
|
+
yield Input(placeholder="type to filter (all words must match)", id="history-filter")
|
|
60
|
+
yield OptionList(id="history-list")
|
|
61
|
+
yield Static(id="history-preview")
|
|
62
|
+
|
|
63
|
+
def on_mount(self) -> None:
|
|
64
|
+
self.query_one("#history-filter", Input).focus()
|
|
65
|
+
self._reload()
|
|
66
|
+
|
|
67
|
+
# --- data ---
|
|
68
|
+
def _reload(self) -> None:
|
|
69
|
+
text = self.query_one("#history-filter", Input).value
|
|
70
|
+
scope = self._connection if self._only_current and self._connection else None
|
|
71
|
+
self._entries = self._store.search(text, connection=scope)
|
|
72
|
+
lst = self.query_one("#history-list", OptionList)
|
|
73
|
+
lst.clear_options()
|
|
74
|
+
lst.add_options([Option(_line(e)) for e in self._entries])
|
|
75
|
+
if self._entries:
|
|
76
|
+
lst.highlighted = 0
|
|
77
|
+
title = "History" + (f" · {self._connection} only" if scope else " · all connections")
|
|
78
|
+
self.query_one("#history-title", Label).update(f"{title} · {len(self._entries)} entries")
|
|
79
|
+
self._preview()
|
|
80
|
+
|
|
81
|
+
def _current(self) -> HistoryEntry | None:
|
|
82
|
+
i = self.query_one("#history-list", OptionList).highlighted
|
|
83
|
+
return self._entries[i] if i is not None and 0 <= i < len(self._entries) else None
|
|
84
|
+
|
|
85
|
+
def _preview(self) -> None:
|
|
86
|
+
e = self._current()
|
|
87
|
+
text = "" if e is None else e.sql + (f"\n\n-- {e.error}" if e.error else "")
|
|
88
|
+
self.query_one("#history-preview", Static).update(Text(text))
|
|
89
|
+
|
|
90
|
+
# --- events ---
|
|
91
|
+
def on_input_changed(self, _: Input.Changed) -> None:
|
|
92
|
+
self._reload()
|
|
93
|
+
|
|
94
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
95
|
+
event.stop()
|
|
96
|
+
self.action_insert()
|
|
97
|
+
|
|
98
|
+
def on_option_list_option_highlighted(self, _: OptionList.OptionHighlighted) -> None:
|
|
99
|
+
self._preview()
|
|
100
|
+
|
|
101
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
102
|
+
event.stop()
|
|
103
|
+
self.action_insert()
|
|
104
|
+
|
|
105
|
+
# --- actions ---
|
|
106
|
+
def action_move(self, step: int) -> None:
|
|
107
|
+
lst = self.query_one("#history-list", OptionList)
|
|
108
|
+
if self._entries:
|
|
109
|
+
current = lst.highlighted or 0
|
|
110
|
+
lst.highlighted = max(0, min(len(self._entries) - 1, current + step))
|
|
111
|
+
|
|
112
|
+
def action_insert(self) -> None:
|
|
113
|
+
if e := self._current():
|
|
114
|
+
self.dismiss(HistoryChoice(e.sql, run=False))
|
|
115
|
+
|
|
116
|
+
def action_run(self) -> None:
|
|
117
|
+
if e := self._current():
|
|
118
|
+
self.dismiss(HistoryChoice(e.sql, run=True))
|
|
119
|
+
|
|
120
|
+
def action_toggle_scope(self) -> None:
|
|
121
|
+
self._only_current = not self._only_current
|
|
122
|
+
self._reload()
|
|
123
|
+
|
|
124
|
+
def action_delete(self) -> None:
|
|
125
|
+
if e := self._current():
|
|
126
|
+
self._store.delete(e.id)
|
|
127
|
+
self._reload()
|
|
128
|
+
|
|
129
|
+
def action_close(self) -> None:
|
|
130
|
+
self.dismiss(None)
|