ghostbase-cli 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.
- app.py +856 -0
- db_engine.py +396 -0
- exporter.py +55 -0
- ghostbase_cli-0.1.0.dist-info/METADATA +165 -0
- ghostbase_cli-0.1.0.dist-info/RECORD +10 -0
- ghostbase_cli-0.1.0.dist-info/WHEEL +5 -0
- ghostbase_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ghostbase_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghostbase_cli-0.1.0.dist-info/top_level.txt +4 -0
- main.py +117 -0
app.py
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
if sys.platform == "win32":
|
|
5
|
+
try:
|
|
6
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
7
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
8
|
+
except Exception:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.widgets import (
|
|
14
|
+
Footer, Label, Button, ListView, ListItem,
|
|
15
|
+
DataTable, TextArea, Static, Input
|
|
16
|
+
)
|
|
17
|
+
from textual.binding import Binding
|
|
18
|
+
from textual.reactive import reactive
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from db_engine import DatabaseEngine
|
|
22
|
+
from exporter import Exporter
|
|
23
|
+
|
|
24
|
+
TCSS = """
|
|
25
|
+
Screen {
|
|
26
|
+
background: #0d1117;
|
|
27
|
+
color: #c9d1d9;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Header */
|
|
31
|
+
#header-bar {
|
|
32
|
+
height: 3;
|
|
33
|
+
dock: top;
|
|
34
|
+
background: #161b22;
|
|
35
|
+
border-bottom: solid #30363d;
|
|
36
|
+
align: left middle;
|
|
37
|
+
padding: 0 1;
|
|
38
|
+
}
|
|
39
|
+
#title-label {
|
|
40
|
+
color: #79c0ff;
|
|
41
|
+
text-style: bold;
|
|
42
|
+
margin-right: 2;
|
|
43
|
+
}
|
|
44
|
+
#db-type-label {
|
|
45
|
+
color: #3fb950;
|
|
46
|
+
text-style: bold;
|
|
47
|
+
margin-right: 1;
|
|
48
|
+
}
|
|
49
|
+
#db-name-label {
|
|
50
|
+
color: #8b949e;
|
|
51
|
+
margin-right: 2;
|
|
52
|
+
}
|
|
53
|
+
#safe-badge {
|
|
54
|
+
color: #3fb950;
|
|
55
|
+
text-style: bold;
|
|
56
|
+
margin-right: 2;
|
|
57
|
+
}
|
|
58
|
+
#safe-badge.unsafe {
|
|
59
|
+
color: #f85149;
|
|
60
|
+
}
|
|
61
|
+
#version-label {
|
|
62
|
+
color: #484f58;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Layout */
|
|
66
|
+
#main-container {
|
|
67
|
+
height: 1fr;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* Sidebar */
|
|
71
|
+
#sidebar {
|
|
72
|
+
width: 22;
|
|
73
|
+
background: #0d1117;
|
|
74
|
+
border-right: solid #30363d;
|
|
75
|
+
}
|
|
76
|
+
#sidebar-header {
|
|
77
|
+
height: 3;
|
|
78
|
+
background: #161b22;
|
|
79
|
+
border-bottom: solid #30363d;
|
|
80
|
+
align: left middle;
|
|
81
|
+
padding: 0 1;
|
|
82
|
+
}
|
|
83
|
+
#sidebar-title {
|
|
84
|
+
color: #f0883e;
|
|
85
|
+
text-style: bold;
|
|
86
|
+
}
|
|
87
|
+
#sidebar-count {
|
|
88
|
+
color: #484f58;
|
|
89
|
+
margin-left: 1;
|
|
90
|
+
}
|
|
91
|
+
#table-search {
|
|
92
|
+
height: 3;
|
|
93
|
+
background: #010409;
|
|
94
|
+
border: none;
|
|
95
|
+
border-bottom: solid #30363d;
|
|
96
|
+
color: #c9d1d9;
|
|
97
|
+
padding: 0 1;
|
|
98
|
+
}
|
|
99
|
+
#table-search:focus {
|
|
100
|
+
border-bottom: solid #388bfd;
|
|
101
|
+
}
|
|
102
|
+
#tables-list {
|
|
103
|
+
height: 1fr;
|
|
104
|
+
background: #0d1117;
|
|
105
|
+
}
|
|
106
|
+
ListItem {
|
|
107
|
+
padding: 0 1;
|
|
108
|
+
background: #0d1117;
|
|
109
|
+
color: #c9d1d9;
|
|
110
|
+
}
|
|
111
|
+
ListItem:hover {
|
|
112
|
+
background: #161b22;
|
|
113
|
+
color: #79c0ff;
|
|
114
|
+
}
|
|
115
|
+
ListItem.--highlight {
|
|
116
|
+
background: #1c2128;
|
|
117
|
+
color: #79c0ff;
|
|
118
|
+
}
|
|
119
|
+
ListItem.section-header {
|
|
120
|
+
color: #484f58;
|
|
121
|
+
background: #0d1117;
|
|
122
|
+
text-style: bold;
|
|
123
|
+
padding: 1 1 0 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Schema panel */
|
|
127
|
+
#schema-panel {
|
|
128
|
+
height: auto;
|
|
129
|
+
max-height: 14;
|
|
130
|
+
background: #010409;
|
|
131
|
+
border-top: solid #30363d;
|
|
132
|
+
padding: 1 1;
|
|
133
|
+
display: none;
|
|
134
|
+
}
|
|
135
|
+
#schema-panel.visible {
|
|
136
|
+
display: block;
|
|
137
|
+
}
|
|
138
|
+
#schema-title {
|
|
139
|
+
color: #f0883e;
|
|
140
|
+
text-style: bold;
|
|
141
|
+
margin-bottom: 1;
|
|
142
|
+
}
|
|
143
|
+
#schema-content {
|
|
144
|
+
color: #8b949e;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* Query editor */
|
|
148
|
+
#content-area {
|
|
149
|
+
width: 1fr;
|
|
150
|
+
padding: 0 1;
|
|
151
|
+
}
|
|
152
|
+
#query-box {
|
|
153
|
+
height: 7;
|
|
154
|
+
border: solid #30363d;
|
|
155
|
+
background: #010409;
|
|
156
|
+
margin-bottom: 0;
|
|
157
|
+
}
|
|
158
|
+
#query-box:focus {
|
|
159
|
+
border: solid #388bfd;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* Actions toolbar */
|
|
163
|
+
#toolbar {
|
|
164
|
+
height: 3;
|
|
165
|
+
align: left middle;
|
|
166
|
+
margin: 0;
|
|
167
|
+
overflow-x: hidden;
|
|
168
|
+
}
|
|
169
|
+
#btn-run {
|
|
170
|
+
background: #238636;
|
|
171
|
+
color: #ffffff;
|
|
172
|
+
border: none;
|
|
173
|
+
min-width: 6;
|
|
174
|
+
margin-right: 1;
|
|
175
|
+
}
|
|
176
|
+
#btn-run:hover { background: #2ea043; }
|
|
177
|
+
|
|
178
|
+
#btn-explain {
|
|
179
|
+
background: #388bfd;
|
|
180
|
+
color: #ffffff;
|
|
181
|
+
border: none;
|
|
182
|
+
min-width: 9;
|
|
183
|
+
margin-right: 1;
|
|
184
|
+
}
|
|
185
|
+
#btn-explain:hover { background: #58a6ff; }
|
|
186
|
+
|
|
187
|
+
#btn-format {
|
|
188
|
+
background: #1f6feb;
|
|
189
|
+
color: #ffffff;
|
|
190
|
+
border: none;
|
|
191
|
+
min-width: 8;
|
|
192
|
+
margin-right: 1;
|
|
193
|
+
}
|
|
194
|
+
#btn-format:hover { background: #388bfd; }
|
|
195
|
+
|
|
196
|
+
#btn-clear {
|
|
197
|
+
background: #21262d;
|
|
198
|
+
color: #8b949e;
|
|
199
|
+
border: none;
|
|
200
|
+
min-width: 7;
|
|
201
|
+
margin-right: 1;
|
|
202
|
+
}
|
|
203
|
+
#btn-clear:hover { background: #30363d; color: #c9d1d9; }
|
|
204
|
+
|
|
205
|
+
#btn-safe {
|
|
206
|
+
background: #30363d;
|
|
207
|
+
color: #c9d1d9;
|
|
208
|
+
border: none;
|
|
209
|
+
min-width: 6;
|
|
210
|
+
margin-right: 1;
|
|
211
|
+
}
|
|
212
|
+
#btn-safe:hover { background: #484f58; }
|
|
213
|
+
|
|
214
|
+
#status-label {
|
|
215
|
+
color: #8b949e;
|
|
216
|
+
text-overflow: ellipsis;
|
|
217
|
+
overflow: hidden;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* Results & export bar */
|
|
221
|
+
#results-bar {
|
|
222
|
+
height: 3;
|
|
223
|
+
align: left middle;
|
|
224
|
+
padding: 0 1;
|
|
225
|
+
background: #161b22;
|
|
226
|
+
border: solid #30363d;
|
|
227
|
+
border-bottom: none;
|
|
228
|
+
overflow-x: hidden;
|
|
229
|
+
}
|
|
230
|
+
#results-title {
|
|
231
|
+
color: #79c0ff;
|
|
232
|
+
text-style: bold;
|
|
233
|
+
margin-right: 1;
|
|
234
|
+
}
|
|
235
|
+
#results-count {
|
|
236
|
+
color: #8b949e;
|
|
237
|
+
margin-right: 1;
|
|
238
|
+
}
|
|
239
|
+
.export-sep {
|
|
240
|
+
color: #30363d;
|
|
241
|
+
margin-right: 1;
|
|
242
|
+
}
|
|
243
|
+
.export-label {
|
|
244
|
+
color: #484f58;
|
|
245
|
+
margin-right: 1;
|
|
246
|
+
}
|
|
247
|
+
#btn-csv {
|
|
248
|
+
background: #0d419d;
|
|
249
|
+
color: #79c0ff;
|
|
250
|
+
border: none;
|
|
251
|
+
min-width: 5;
|
|
252
|
+
margin-right: 1;
|
|
253
|
+
}
|
|
254
|
+
#btn-csv:hover { background: #1158c7; }
|
|
255
|
+
|
|
256
|
+
#btn-json {
|
|
257
|
+
background: #5a1e02;
|
|
258
|
+
color: #ffa657;
|
|
259
|
+
border: none;
|
|
260
|
+
min-width: 6;
|
|
261
|
+
margin-right: 1;
|
|
262
|
+
}
|
|
263
|
+
#btn-json:hover { background: #7d2d00; }
|
|
264
|
+
|
|
265
|
+
#btn-md {
|
|
266
|
+
background: #232a35;
|
|
267
|
+
color: #a5d6ff;
|
|
268
|
+
border: none;
|
|
269
|
+
min-width: 4;
|
|
270
|
+
margin-right: 1;
|
|
271
|
+
}
|
|
272
|
+
#btn-md:hover { background: #30363d; }
|
|
273
|
+
|
|
274
|
+
#btn-sql {
|
|
275
|
+
background: #3b2300;
|
|
276
|
+
color: #ffc680;
|
|
277
|
+
border: none;
|
|
278
|
+
min-width: 5;
|
|
279
|
+
margin-right: 1;
|
|
280
|
+
}
|
|
281
|
+
#btn-sql:hover { background: #5a3500; }
|
|
282
|
+
|
|
283
|
+
/* Results table */
|
|
284
|
+
#data-table {
|
|
285
|
+
height: 1fr;
|
|
286
|
+
border: solid #30363d;
|
|
287
|
+
background: #0d1117;
|
|
288
|
+
}
|
|
289
|
+
DataTable > .datatable--header {
|
|
290
|
+
background: #161b22;
|
|
291
|
+
color: #79c0ff;
|
|
292
|
+
text-style: bold;
|
|
293
|
+
}
|
|
294
|
+
DataTable > .datatable--cursor {
|
|
295
|
+
background: #1c2128;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* Status bar */
|
|
299
|
+
#status-bar {
|
|
300
|
+
height: 1;
|
|
301
|
+
dock: bottom;
|
|
302
|
+
background: #161b22;
|
|
303
|
+
border-top: solid #30363d;
|
|
304
|
+
align: left middle;
|
|
305
|
+
padding: 0 2;
|
|
306
|
+
}
|
|
307
|
+
#status-bar-text {
|
|
308
|
+
color: #484f58;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
Footer {
|
|
312
|
+
background: #010409;
|
|
313
|
+
color: #484f58;
|
|
314
|
+
height: 1;
|
|
315
|
+
}
|
|
316
|
+
Footer > .footer--key {
|
|
317
|
+
color: #79c0ff;
|
|
318
|
+
background: #21262d;
|
|
319
|
+
}
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class GhostBaseApp(App):
|
|
324
|
+
TITLE = "GhostBase 👻 - Terminal Database Studio"
|
|
325
|
+
CSS = TCSS
|
|
326
|
+
ENABLE_COMMAND_PALETTE = False
|
|
327
|
+
|
|
328
|
+
BINDINGS = [
|
|
329
|
+
Binding("ctrl+r", "run_query", "Run Query", priority=True),
|
|
330
|
+
Binding("ctrl+p", "explain_query", "Explain Plan", priority=True),
|
|
331
|
+
Binding("ctrl+b", "beautify_sql", "Beautify SQL", priority=True),
|
|
332
|
+
Binding("ctrl+s", "toggle_safe_mode", "Safe Mode", priority=True),
|
|
333
|
+
Binding("ctrl+n", "clear_query", "New Query", priority=True),
|
|
334
|
+
Binding("ctrl+e", "export_csv", "Export CSV", priority=True, show=False),
|
|
335
|
+
Binding("ctrl+j", "export_json", "Export JSON", priority=True, show=False),
|
|
336
|
+
Binding("ctrl+m", "export_markdown", "Export MD", priority=True, show=False),
|
|
337
|
+
Binding("ctrl+i", "export_sql", "Export SQL", priority=True, show=False),
|
|
338
|
+
Binding("ctrl+f", "focus_filter", "Find Table", priority=True, show=False),
|
|
339
|
+
Binding("ctrl+up", "history_prev", "Prev Query", priority=True, show=False),
|
|
340
|
+
Binding("ctrl+down", "history_next", "Next Query", priority=True, show=False),
|
|
341
|
+
Binding("ctrl+q", "quit", "Quit", priority=True),
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
selected_table: reactive[str] = reactive("")
|
|
345
|
+
safe_mode: reactive[bool] = reactive(False)
|
|
346
|
+
|
|
347
|
+
def __init__(self, db_path: str = None, read_only: bool = False):
|
|
348
|
+
super().__init__()
|
|
349
|
+
self.db_path = db_path or "sample.db"
|
|
350
|
+
self.db = DatabaseEngine(self.db_path)
|
|
351
|
+
self.safe_mode = read_only
|
|
352
|
+
self.last_columns: list = []
|
|
353
|
+
self.last_rows: list = []
|
|
354
|
+
self.query_history: list = []
|
|
355
|
+
self.history_index: int = -1
|
|
356
|
+
self.all_tables: list = []
|
|
357
|
+
self.all_views: list = []
|
|
358
|
+
self._pending_destructive_query = None
|
|
359
|
+
|
|
360
|
+
# Layout
|
|
361
|
+
|
|
362
|
+
def compose(self) -> ComposeResult:
|
|
363
|
+
db_label = (
|
|
364
|
+
self.db.db_name
|
|
365
|
+
if self.db.db_type != "sqlite"
|
|
366
|
+
else os.path.basename(self.db_path)
|
|
367
|
+
)
|
|
368
|
+
safe_text = "[SAFE: ON]" if self.safe_mode else "[SAFE: OFF]"
|
|
369
|
+
safe_class = "" if self.safe_mode else "unsafe"
|
|
370
|
+
|
|
371
|
+
with Horizontal(id="header-bar"):
|
|
372
|
+
yield Label("GHOSTBASE", id="title-label")
|
|
373
|
+
yield Label(f"[{self.db.db_type_label()}]", id="db-type-label")
|
|
374
|
+
yield Label(db_label, id="db-name-label")
|
|
375
|
+
yield Label(safe_text, id="safe-badge", classes=safe_class)
|
|
376
|
+
yield Label("v0.1.0", id="version-label")
|
|
377
|
+
|
|
378
|
+
with Horizontal(id="main-container"):
|
|
379
|
+
with Vertical(id="sidebar"):
|
|
380
|
+
with Horizontal(id="sidebar-header"):
|
|
381
|
+
yield Label("EXPLORER", id="sidebar-title")
|
|
382
|
+
yield Label("", id="sidebar-count")
|
|
383
|
+
yield Input(placeholder="Filter (Ctrl+F)...", id="table-search")
|
|
384
|
+
yield ListView(id="tables-list")
|
|
385
|
+
with Vertical(id="schema-panel"):
|
|
386
|
+
yield Label("", id="schema-title")
|
|
387
|
+
yield Static("", id="schema-content")
|
|
388
|
+
|
|
389
|
+
with Vertical(id="content-area"):
|
|
390
|
+
yield TextArea(
|
|
391
|
+
id="query-box",
|
|
392
|
+
language="sql",
|
|
393
|
+
text="SELECT * FROM users LIMIT 50;",
|
|
394
|
+
)
|
|
395
|
+
with Horizontal(id="toolbar"):
|
|
396
|
+
yield Button("RUN", id="btn-run", variant="success")
|
|
397
|
+
yield Button("EXPLAIN", id="btn-explain")
|
|
398
|
+
yield Button("FORMAT", id="btn-format")
|
|
399
|
+
yield Button("CLEAR", id="btn-clear")
|
|
400
|
+
yield Button("SAFE", id="btn-safe")
|
|
401
|
+
yield Label("Ready", id="status-label")
|
|
402
|
+
|
|
403
|
+
with Horizontal(id="results-bar"):
|
|
404
|
+
yield Label("RESULTS", id="results-title")
|
|
405
|
+
yield Label("", id="results-count")
|
|
406
|
+
yield Label("|", classes="export-sep")
|
|
407
|
+
yield Label("Export:", classes="export-label")
|
|
408
|
+
yield Button("CSV", id="btn-csv")
|
|
409
|
+
yield Button("JSON", id="btn-json")
|
|
410
|
+
yield Button("MD", id="btn-md")
|
|
411
|
+
yield Button("SQL", id="btn-sql")
|
|
412
|
+
|
|
413
|
+
yield DataTable(id="data-table", zebra_stripes=True, cursor_type="row")
|
|
414
|
+
|
|
415
|
+
with Horizontal(id="status-bar"):
|
|
416
|
+
yield Label("", id="status-bar-text")
|
|
417
|
+
|
|
418
|
+
yield Footer()
|
|
419
|
+
|
|
420
|
+
# Lifecycle
|
|
421
|
+
|
|
422
|
+
async def on_mount(self):
|
|
423
|
+
tables = self.db.get_tables()
|
|
424
|
+
if not tables and self.db.db_type == "sqlite":
|
|
425
|
+
self._create_sample_data()
|
|
426
|
+
tables = self.db.get_tables()
|
|
427
|
+
|
|
428
|
+
self.all_tables = tables
|
|
429
|
+
self.all_views = self.db.get_views()
|
|
430
|
+
self._refresh_sidebar(self.all_tables, self.all_views)
|
|
431
|
+
|
|
432
|
+
if self.all_tables:
|
|
433
|
+
first = self.all_tables[0]
|
|
434
|
+
self.selected_table = first
|
|
435
|
+
self.query_one("#query-box", TextArea).text = (
|
|
436
|
+
f"SELECT * FROM {first} LIMIT 50;"
|
|
437
|
+
)
|
|
438
|
+
self.action_run_query()
|
|
439
|
+
self._show_schema(first)
|
|
440
|
+
|
|
441
|
+
self._update_status_bar()
|
|
442
|
+
|
|
443
|
+
# Sample schema
|
|
444
|
+
|
|
445
|
+
def _create_sample_data(self):
|
|
446
|
+
self.db.execute_query("""
|
|
447
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
448
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
449
|
+
name TEXT NOT NULL,
|
|
450
|
+
email TEXT UNIQUE,
|
|
451
|
+
role TEXT,
|
|
452
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
453
|
+
);
|
|
454
|
+
""")
|
|
455
|
+
self.db.execute_query("""
|
|
456
|
+
INSERT OR IGNORE INTO users (name, email, role) VALUES
|
|
457
|
+
('Alice Smith', 'alice@example.com', 'Admin'),
|
|
458
|
+
('Bob Johnson', 'bob@example.com', 'Developer'),
|
|
459
|
+
('Charlie Brown', 'charlie@example.com', 'Analyst'),
|
|
460
|
+
('Diana Prince', 'diana@example.com', 'Manager');
|
|
461
|
+
""")
|
|
462
|
+
self.db.execute_query("""
|
|
463
|
+
CREATE TABLE IF NOT EXISTS products (
|
|
464
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
465
|
+
owner_id INTEGER REFERENCES users(id),
|
|
466
|
+
name TEXT NOT NULL,
|
|
467
|
+
price REAL,
|
|
468
|
+
stock INTEGER DEFAULT 0
|
|
469
|
+
);
|
|
470
|
+
""")
|
|
471
|
+
self.db.execute_query("""
|
|
472
|
+
INSERT OR IGNORE INTO products (owner_id, name, price, stock) VALUES
|
|
473
|
+
(1, 'Ghost CLI', 29.99, 100),
|
|
474
|
+
(2, 'GhostBase Pro', 49.99, 50),
|
|
475
|
+
(1, 'Dark Terminal', 9.99, 200);
|
|
476
|
+
""")
|
|
477
|
+
self.db.execute_query("""
|
|
478
|
+
CREATE VIEW IF NOT EXISTS admin_products AS
|
|
479
|
+
SELECT p.name, p.price, u.name AS owner
|
|
480
|
+
FROM products p JOIN users u ON p.owner_id = u.id
|
|
481
|
+
WHERE u.role = 'Admin';
|
|
482
|
+
""")
|
|
483
|
+
|
|
484
|
+
# Sidebar and table search
|
|
485
|
+
|
|
486
|
+
def _refresh_sidebar(self, tables: list, views: list):
|
|
487
|
+
lv = self.query_one("#tables-list", ListView)
|
|
488
|
+
count = self.query_one("#sidebar-count", Label)
|
|
489
|
+
lv.clear()
|
|
490
|
+
|
|
491
|
+
total = len(tables) + len(views)
|
|
492
|
+
count.update(f"({total})")
|
|
493
|
+
|
|
494
|
+
if tables:
|
|
495
|
+
_sep_tables = ListItem(Label(f"TABLES ({len(tables)})"))
|
|
496
|
+
_sep_tables.table_name = None # type: ignore
|
|
497
|
+
_sep_tables.is_view = False # type: ignore
|
|
498
|
+
_sep_tables.is_separator = True # type: ignore
|
|
499
|
+
_sep_tables.add_class("section-header")
|
|
500
|
+
lv.append(_sep_tables)
|
|
501
|
+
|
|
502
|
+
for t in tables:
|
|
503
|
+
rc = self.db.get_row_count(t)
|
|
504
|
+
item = ListItem(Label(f" {t} ({rc})"))
|
|
505
|
+
item.table_name = t # type: ignore
|
|
506
|
+
item.is_view = False # type: ignore
|
|
507
|
+
item.is_separator = False # type: ignore
|
|
508
|
+
lv.append(item)
|
|
509
|
+
|
|
510
|
+
if views:
|
|
511
|
+
_sep_views = ListItem(Label(f"VIEWS ({len(views)})"))
|
|
512
|
+
_sep_views.table_name = None # type: ignore
|
|
513
|
+
_sep_views.is_view = True # type: ignore
|
|
514
|
+
_sep_views.is_separator = True # type: ignore
|
|
515
|
+
_sep_views.add_class("section-header")
|
|
516
|
+
lv.append(_sep_views)
|
|
517
|
+
|
|
518
|
+
for v in views:
|
|
519
|
+
item = ListItem(Label(f" {v}"))
|
|
520
|
+
item.table_name = v # type: ignore
|
|
521
|
+
item.is_view = True # type: ignore
|
|
522
|
+
item.is_separator = False # type: ignore
|
|
523
|
+
lv.append(item)
|
|
524
|
+
|
|
525
|
+
def on_input_changed(self, event: Input.Changed):
|
|
526
|
+
if event.input.id == "table-search":
|
|
527
|
+
q = event.value.strip().lower()
|
|
528
|
+
if not q:
|
|
529
|
+
filtered_tables = self.all_tables
|
|
530
|
+
filtered_views = self.all_views
|
|
531
|
+
else:
|
|
532
|
+
filtered_tables = [t for t in self.all_tables if q in t.lower()]
|
|
533
|
+
filtered_views = [v for v in self.all_views if q in v.lower()]
|
|
534
|
+
self._refresh_sidebar(filtered_tables, filtered_views)
|
|
535
|
+
|
|
536
|
+
# Schema panel
|
|
537
|
+
|
|
538
|
+
def _show_schema(self, table_name: str, is_view: bool = False):
|
|
539
|
+
panel = self.query_one("#schema-panel")
|
|
540
|
+
title = self.query_one("#schema-title", Label)
|
|
541
|
+
content = self.query_one("#schema-content", Static)
|
|
542
|
+
|
|
543
|
+
schema = self.db.get_table_schema(table_name)
|
|
544
|
+
fks = [] if is_view else self.db.get_foreign_keys(table_name)
|
|
545
|
+
|
|
546
|
+
if not schema:
|
|
547
|
+
panel.remove_class("visible")
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
kind = "VIEW" if is_view else "TABLE"
|
|
551
|
+
title.update(f"{table_name.upper()} [{kind}]")
|
|
552
|
+
|
|
553
|
+
lines = Text()
|
|
554
|
+
for col in schema:
|
|
555
|
+
pk = col.get("pk", 0)
|
|
556
|
+
name = col.get("name", "")
|
|
557
|
+
col_type = col.get("type", "TEXT")
|
|
558
|
+
notnull = col.get("notnull", 0)
|
|
559
|
+
|
|
560
|
+
if pk:
|
|
561
|
+
lines.append(" [PK] ", style="bold #ffa657")
|
|
562
|
+
lines.append(f"{name}", style="bold #e6edf3")
|
|
563
|
+
lines.append(f" {col_type}\n", style="#58a6ff")
|
|
564
|
+
else:
|
|
565
|
+
lines.append(" ", style="#484f58")
|
|
566
|
+
lines.append(f"{name}", style="#c9d1d9")
|
|
567
|
+
lines.append(f" {col_type}", style="#3fb950")
|
|
568
|
+
if notnull:
|
|
569
|
+
lines.append(" NOT NULL", style="#484f58")
|
|
570
|
+
lines.append("\n")
|
|
571
|
+
|
|
572
|
+
if fks:
|
|
573
|
+
lines.append("\nFOREIGN KEYS\n", style="bold #8b949e")
|
|
574
|
+
for fk in fks:
|
|
575
|
+
lines.append(
|
|
576
|
+
f" {fk['from']} -> {fk['to_table']}.{fk['to_col']}\n",
|
|
577
|
+
style="#6e7681",
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
content.update(lines)
|
|
581
|
+
panel.add_class("visible")
|
|
582
|
+
|
|
583
|
+
# Event handlers
|
|
584
|
+
|
|
585
|
+
def on_list_view_selected(self, event: ListView.Selected):
|
|
586
|
+
item = event.item
|
|
587
|
+
if getattr(item, "is_separator", False):
|
|
588
|
+
return
|
|
589
|
+
t = getattr(item, "table_name", None)
|
|
590
|
+
if not t:
|
|
591
|
+
return
|
|
592
|
+
is_view = getattr(item, "is_view", False)
|
|
593
|
+
self.selected_table = t
|
|
594
|
+
self.query_one("#query-box", TextArea).text = (
|
|
595
|
+
f"SELECT * FROM {t} LIMIT 50;"
|
|
596
|
+
)
|
|
597
|
+
self.action_run_query()
|
|
598
|
+
self._show_schema(t, is_view=is_view)
|
|
599
|
+
|
|
600
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
601
|
+
dispatch = {
|
|
602
|
+
"btn-run": self.action_run_query,
|
|
603
|
+
"btn-explain": self.action_explain_query,
|
|
604
|
+
"btn-format": self.action_beautify_sql,
|
|
605
|
+
"btn-clear": self.action_clear_query,
|
|
606
|
+
"btn-safe": self.action_toggle_safe_mode,
|
|
607
|
+
"btn-csv": self.action_export_csv,
|
|
608
|
+
"btn-json": self.action_export_json,
|
|
609
|
+
"btn-md": self.action_export_markdown,
|
|
610
|
+
"btn-sql": self.action_export_sql,
|
|
611
|
+
}
|
|
612
|
+
fn = dispatch.get(event.button.id)
|
|
613
|
+
if fn:
|
|
614
|
+
fn()
|
|
615
|
+
|
|
616
|
+
# Query actions
|
|
617
|
+
|
|
618
|
+
def action_run_query(self):
|
|
619
|
+
raw = self.query_one("#query-box", TextArea).text.strip()
|
|
620
|
+
lines = [l for l in raw.splitlines() if not l.strip().startswith("--")]
|
|
621
|
+
query = "\n".join(lines).strip()
|
|
622
|
+
if not query:
|
|
623
|
+
self.notify("Query is empty", severity="warning")
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
status = self.query_one("#status-label", Label)
|
|
627
|
+
res_cnt = self.query_one("#results-count", Label)
|
|
628
|
+
|
|
629
|
+
# Safety guard check
|
|
630
|
+
is_destructive, reason = self.db.check_safety(query)
|
|
631
|
+
if is_destructive:
|
|
632
|
+
if self.safe_mode:
|
|
633
|
+
self.notify(f"BLOCKED BY SAFE MODE: {reason}", severity="error", timeout=5)
|
|
634
|
+
status.update(f"[bold red]BLOCKED BY SAFE MODE:[/] {reason}")
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
if self._pending_destructive_query != query:
|
|
638
|
+
self._pending_destructive_query = query
|
|
639
|
+
self.notify(
|
|
640
|
+
f"DANGER: {reason}!\nPress Ctrl+R again to confirm.",
|
|
641
|
+
severity="warning",
|
|
642
|
+
timeout=5,
|
|
643
|
+
)
|
|
644
|
+
status.update(f"[bold red]DANGER:[/] {reason} -> Press Ctrl+R to CONFIRM")
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
# Double confirmation received
|
|
648
|
+
self._pending_destructive_query = None
|
|
649
|
+
|
|
650
|
+
if not self.query_history or self.query_history[-1] != query:
|
|
651
|
+
self.query_history.append(query)
|
|
652
|
+
if len(self.query_history) > 20:
|
|
653
|
+
self.query_history.pop(0)
|
|
654
|
+
self.history_index = len(self.query_history)
|
|
655
|
+
|
|
656
|
+
dt = self.query_one("#data-table", DataTable)
|
|
657
|
+
res = self.db.execute_query(query)
|
|
658
|
+
dt.clear(columns=True)
|
|
659
|
+
|
|
660
|
+
if not res["success"]:
|
|
661
|
+
err = res.get("error", "Unknown error")
|
|
662
|
+
status.update(f"[bold red]ERROR[/] {err}")
|
|
663
|
+
res_cnt.update("(Error)")
|
|
664
|
+
self.notify(f"SQL Error: {err}", severity="error")
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
if res["is_select"]:
|
|
668
|
+
self.last_columns = res["columns"]
|
|
669
|
+
self.last_rows = res["rows"]
|
|
670
|
+
dt.add_columns(*self.last_columns)
|
|
671
|
+
for row in self.last_rows:
|
|
672
|
+
styled = []
|
|
673
|
+
for val in row:
|
|
674
|
+
if val is None:
|
|
675
|
+
styled.append(Text("NULL", style="dim #6e7681"))
|
|
676
|
+
elif isinstance(val, (int, float)):
|
|
677
|
+
styled.append(Text(str(val), style="#79c0ff"))
|
|
678
|
+
else:
|
|
679
|
+
s = str(val).replace("\r\n", " ").replace("\n", " ").replace("\t", " ")
|
|
680
|
+
if len(s) > 200:
|
|
681
|
+
s = s[:197] + "..."
|
|
682
|
+
styled.append(Text(s, style="#c9d1d9"))
|
|
683
|
+
dt.add_row(*styled)
|
|
684
|
+
|
|
685
|
+
rc = res["row_count"]
|
|
686
|
+
ms = res["elapsed_ms"]
|
|
687
|
+
status.update(f"[bold green]OK[/] ({ms}ms)")
|
|
688
|
+
res_cnt.update(f"({rc} rows)")
|
|
689
|
+
self._update_status_bar(row_count=rc, ms=ms)
|
|
690
|
+
else:
|
|
691
|
+
aff = res.get("affected_rows", 0)
|
|
692
|
+
ms = res["elapsed_ms"]
|
|
693
|
+
status.update(f"[bold green]OK[/] ({ms}ms)")
|
|
694
|
+
res_cnt.update(f"({aff} rows affected)")
|
|
695
|
+
self.notify(f"Done — {aff} row(s) affected")
|
|
696
|
+
self.all_tables = self.db.get_tables()
|
|
697
|
+
self.all_views = self.db.get_views()
|
|
698
|
+
self._refresh_sidebar(self.all_tables, self.all_views)
|
|
699
|
+
self._update_status_bar(ms=ms)
|
|
700
|
+
|
|
701
|
+
def action_beautify_sql(self):
|
|
702
|
+
"""Format and Beautify SQL query (Ctrl+B)"""
|
|
703
|
+
qa = self.query_one("#query-box", TextArea)
|
|
704
|
+
raw = qa.text
|
|
705
|
+
formatted = DatabaseEngine.format_sql(raw)
|
|
706
|
+
qa.text = formatted
|
|
707
|
+
self.notify("SQL formatted and prettified")
|
|
708
|
+
|
|
709
|
+
def action_explain_query(self):
|
|
710
|
+
"""Execute 1-Key EXPLAIN Query Plan (Ctrl+P)"""
|
|
711
|
+
raw = self.query_one("#query-box", TextArea).text.strip()
|
|
712
|
+
lines = [l for l in raw.splitlines() if not l.strip().startswith("--")]
|
|
713
|
+
query = "\n".join(lines).strip()
|
|
714
|
+
if not query:
|
|
715
|
+
self.notify("No query to explain", severity="warning")
|
|
716
|
+
return
|
|
717
|
+
|
|
718
|
+
dt = self.query_one("#data-table", DataTable)
|
|
719
|
+
status = self.query_one("#status-label", Label)
|
|
720
|
+
res_cnt = self.query_one("#results-count", Label)
|
|
721
|
+
|
|
722
|
+
res = self.db.explain_query(query)
|
|
723
|
+
dt.clear(columns=True)
|
|
724
|
+
|
|
725
|
+
if not res["success"]:
|
|
726
|
+
err = res.get("error", "Unknown error")
|
|
727
|
+
status.update(f"[bold red]EXPLAIN ERROR[/] {err}")
|
|
728
|
+
self.notify(f"Explain Error: {err}", severity="error")
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
self.last_columns = res["columns"]
|
|
732
|
+
self.last_rows = res["rows"]
|
|
733
|
+
dt.add_columns(*self.last_columns)
|
|
734
|
+
for row in self.last_rows:
|
|
735
|
+
styled = []
|
|
736
|
+
for val in row:
|
|
737
|
+
styled.append(Text(str(val), style="#58a6ff" if str(val).isdigit() else "#c9d1d9"))
|
|
738
|
+
dt.add_row(*styled)
|
|
739
|
+
|
|
740
|
+
ms = res["elapsed_ms"]
|
|
741
|
+
status.update(f"[bold cyan]PLAN[/] ({ms}ms)")
|
|
742
|
+
res_cnt.update(f"({len(self.last_rows)} steps)")
|
|
743
|
+
self.notify("Execution Plan analyzed successfully")
|
|
744
|
+
|
|
745
|
+
def action_toggle_safe_mode(self):
|
|
746
|
+
"""Toggle Production Safe Mode (Ctrl+S)"""
|
|
747
|
+
self.safe_mode = not self.safe_mode
|
|
748
|
+
badge = self.query_one("#safe-badge", Label)
|
|
749
|
+
if self.safe_mode:
|
|
750
|
+
badge.update("[SAFE: ON]")
|
|
751
|
+
badge.remove_class("unsafe")
|
|
752
|
+
self.notify("Safe Mode ENABLED: Destructive queries blocked", timeout=3)
|
|
753
|
+
else:
|
|
754
|
+
badge.update("[SAFE: OFF]")
|
|
755
|
+
badge.add_class("unsafe")
|
|
756
|
+
self.notify("Safe Mode DISABLED: Destructive queries allowed with confirmation", severity="warning", timeout=3)
|
|
757
|
+
self._update_status_bar()
|
|
758
|
+
|
|
759
|
+
def action_focus_filter(self):
|
|
760
|
+
self.query_one("#table-search", Input).focus()
|
|
761
|
+
|
|
762
|
+
def action_clear_query(self):
|
|
763
|
+
self.query_one("#query-box", TextArea).text = ""
|
|
764
|
+
self.query_one("#status-label", Label).update("Ready")
|
|
765
|
+
self.query_one("#query-box", TextArea).focus()
|
|
766
|
+
|
|
767
|
+
def action_history_prev(self):
|
|
768
|
+
if not self.query_history:
|
|
769
|
+
return
|
|
770
|
+
self.history_index = max(0, self.history_index - 1)
|
|
771
|
+
self.query_one("#query-box", TextArea).text = (
|
|
772
|
+
self.query_history[self.history_index]
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
def action_history_next(self):
|
|
776
|
+
if not self.query_history:
|
|
777
|
+
return
|
|
778
|
+
self.history_index = min(len(self.query_history) - 1, self.history_index + 1)
|
|
779
|
+
self.query_one("#query-box", TextArea).text = (
|
|
780
|
+
self.query_history[self.history_index]
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
def action_export_csv(self):
|
|
784
|
+
if not self.last_columns:
|
|
785
|
+
self.notify("Run a query first", severity="warning")
|
|
786
|
+
return
|
|
787
|
+
db_dir = (
|
|
788
|
+
os.path.dirname(os.path.abspath(self.db_path))
|
|
789
|
+
if (self.db.db_type == "sqlite" and self.db_path != ":memory:")
|
|
790
|
+
else os.getcwd()
|
|
791
|
+
)
|
|
792
|
+
out = os.path.join(db_dir, "ghostbase_export.csv")
|
|
793
|
+
Exporter.to_csv(self.last_columns, self.last_rows, out)
|
|
794
|
+
self.notify(f"Saved: {out}", timeout=5)
|
|
795
|
+
|
|
796
|
+
def action_export_json(self):
|
|
797
|
+
if not self.last_columns:
|
|
798
|
+
self.notify("Run a query first", severity="warning")
|
|
799
|
+
return
|
|
800
|
+
db_dir = (
|
|
801
|
+
os.path.dirname(os.path.abspath(self.db_path))
|
|
802
|
+
if (self.db.db_type == "sqlite" and self.db_path != ":memory:")
|
|
803
|
+
else os.getcwd()
|
|
804
|
+
)
|
|
805
|
+
out = os.path.join(db_dir, "ghostbase_export.json")
|
|
806
|
+
Exporter.to_json(self.last_columns, self.last_rows, out)
|
|
807
|
+
self.notify(f"Saved: {out}", timeout=5)
|
|
808
|
+
|
|
809
|
+
def action_export_markdown(self):
|
|
810
|
+
if not self.last_columns:
|
|
811
|
+
self.notify("Run a query first", severity="warning")
|
|
812
|
+
return
|
|
813
|
+
db_dir = (
|
|
814
|
+
os.path.dirname(os.path.abspath(self.db_path))
|
|
815
|
+
if (self.db.db_type == "sqlite" and self.db_path != ":memory:")
|
|
816
|
+
else os.getcwd()
|
|
817
|
+
)
|
|
818
|
+
out = os.path.join(db_dir, "ghostbase_export.md")
|
|
819
|
+
Exporter.to_markdown(self.last_columns, self.last_rows, out)
|
|
820
|
+
self.notify(f"Saved: {out}", timeout=5)
|
|
821
|
+
|
|
822
|
+
def action_export_sql(self):
|
|
823
|
+
if not self.last_columns:
|
|
824
|
+
self.notify("Run a query first", severity="warning")
|
|
825
|
+
return
|
|
826
|
+
table_name = self.selected_table or "exported_table"
|
|
827
|
+
db_dir = (
|
|
828
|
+
os.path.dirname(os.path.abspath(self.db_path))
|
|
829
|
+
if (self.db.db_type == "sqlite" and self.db_path != ":memory:")
|
|
830
|
+
else os.getcwd()
|
|
831
|
+
)
|
|
832
|
+
out = os.path.join(db_dir, "ghostbase_export.sql")
|
|
833
|
+
Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out)
|
|
834
|
+
self.notify(f"Saved: {out}", timeout=5)
|
|
835
|
+
|
|
836
|
+
# Status bar
|
|
837
|
+
|
|
838
|
+
def _update_status_bar(self, row_count: int = None, ms: float = None):
|
|
839
|
+
tables = self.all_tables
|
|
840
|
+
views = self.all_views
|
|
841
|
+
db_type = self.db.db_type_label()
|
|
842
|
+
parts = [
|
|
843
|
+
f"{db_type}",
|
|
844
|
+
f"Tables: {len(tables)}",
|
|
845
|
+
f"Views: {len(views)}",
|
|
846
|
+
]
|
|
847
|
+
if self.selected_table:
|
|
848
|
+
parts.append(f"Active: {self.selected_table}")
|
|
849
|
+
if row_count is not None:
|
|
850
|
+
parts.append(f"Rows: {row_count}")
|
|
851
|
+
if ms is not None:
|
|
852
|
+
parts.append(f"{ms}ms")
|
|
853
|
+
self.query_one("#status-bar-text", Label).update(" | ".join(parts))
|
|
854
|
+
|
|
855
|
+
def on_unmount(self):
|
|
856
|
+
self.db.close()
|