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