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