ghostbase-cli 0.1.0__tar.gz → 0.1.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL
5
5
  Author-email: 4yk5 <contact.4yk5@proton.me>
6
6
  License-Expression: MIT
@@ -31,7 +31,7 @@ Dynamic: license-file
31
31
 
32
32
  ```
33
33
 
34
- # GhostBase 👻🗄️
34
+ # GhostBase 👻
35
35
 
36
36
  > **A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL, and MySQL.**
37
37
  > *No Java runtimes. No 1.5GB RAM bloat. Built-in production safety guard, instant SQL beautifier & zero-config `.env` connect.*
@@ -42,6 +42,10 @@ Dynamic: license-file
42
42
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
43
43
  [![UI: Textual](https://img.shields.io/badge/UI-Textual-bd93f9.svg)](https://github.com/Textualize/textual)
44
44
 
45
+ <p align="center">
46
+ <img src="assets/ghostbase.png" alt="GhostBase Terminal Studio" width="100%">
47
+ </p>
48
+
45
49
  ---
46
50
 
47
51
  ## ⚡ Why GhostBase?
@@ -75,10 +79,10 @@ Opening **DBeaver** or **DataGrip** just to run a quick `SELECT`, inspect an ind
75
79
  - ⚡ **1-Key Query Plan (`Ctrl + P`):**
76
80
  - Run instant execution plans (`EXPLAIN QUERY PLAN` / `EXPLAIN`) on any query to see index usage and bottlenecks.
77
81
  - 🔌 **Universal Database Support:** Connects natively to **SQLite**, **PostgreSQL**, and **MySQL**.
78
- - 🔍 **Zero-Config `.env` Auto-Connect:** Just run `ghostbase` in any project folder; it automatically picks up `DATABASE_URL` or standard database environment variables.
79
- - 🔎 **Real-time Table Search (`Ctrl + F`):** Type in the search bar to filter dozens of tables and views instantly.
82
+ - 🔑 **Zero-Config `.env` Auto-Connect:** Just run `ghostbase` in any project folder; it automatically picks up `DATABASE_URL` or standard database environment variables.
83
+ - 🔍 **Real-time Table Search (`Ctrl + F`):** Type in the search bar to filter dozens of tables and views instantly.
80
84
  - 📊 **Explorer with Tables & Views:** Browse tables and views with live row counts.
81
- - 📐 **Instant Schema & Foreign Keys:** See primary keys, column types, nullability, and foreign key references at a glance.
85
+ - 📋 **Instant Schema & Foreign Keys:** See primary keys, column types, nullability, and foreign key references at a glance.
82
86
  - 💾 **Multi-Format Exports:** Instant SQL `INSERT`s (`Ctrl + I`), Markdown (`Ctrl + M`), CSV (`Ctrl + E`), and JSON (`Ctrl + J`).
83
87
  - 📜 **Query History:** Cycle through your last 20 queries with `Ctrl + ↑` and `Ctrl + ↓`.
84
88
  - 🎨 **Clean Cyberpunk Aesthetic:** Fast, high-contrast, zero-flicker terminal interface.
@@ -153,10 +157,9 @@ ghostbase postgresql://prod-db:5432/main --read-only
153
157
 
154
158
  ---
155
159
 
156
- ## 📬 Contact & Inquiries
160
+ ## 🤝 Contributing
157
161
 
158
- For partnerships, feature requests, or business inquiries:
159
- 📧 **[contact.4yk5@proton.me](mailto:contact.4yk5@proton.me)**
162
+ Contributions, feedback, and pull requests are very welcome! Feel free to open an issue for bug reports or feature requests.
160
163
 
161
164
  ---
162
165
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  ```
9
9
 
10
- # GhostBase 👻🗄️
10
+ # GhostBase 👻
11
11
 
12
12
  > **A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL, and MySQL.**
13
13
  > *No Java runtimes. No 1.5GB RAM bloat. Built-in production safety guard, instant SQL beautifier & zero-config `.env` connect.*
@@ -18,6 +18,10 @@
18
18
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
19
19
  [![UI: Textual](https://img.shields.io/badge/UI-Textual-bd93f9.svg)](https://github.com/Textualize/textual)
20
20
 
21
+ <p align="center">
22
+ <img src="assets/ghostbase.png" alt="GhostBase Terminal Studio" width="100%">
23
+ </p>
24
+
21
25
  ---
22
26
 
23
27
  ## ⚡ Why GhostBase?
@@ -51,10 +55,10 @@ Opening **DBeaver** or **DataGrip** just to run a quick `SELECT`, inspect an ind
51
55
  - ⚡ **1-Key Query Plan (`Ctrl + P`):**
52
56
  - Run instant execution plans (`EXPLAIN QUERY PLAN` / `EXPLAIN`) on any query to see index usage and bottlenecks.
53
57
  - 🔌 **Universal Database Support:** Connects natively to **SQLite**, **PostgreSQL**, and **MySQL**.
54
- - 🔍 **Zero-Config `.env` Auto-Connect:** Just run `ghostbase` in any project folder; it automatically picks up `DATABASE_URL` or standard database environment variables.
55
- - 🔎 **Real-time Table Search (`Ctrl + F`):** Type in the search bar to filter dozens of tables and views instantly.
58
+ - 🔑 **Zero-Config `.env` Auto-Connect:** Just run `ghostbase` in any project folder; it automatically picks up `DATABASE_URL` or standard database environment variables.
59
+ - 🔍 **Real-time Table Search (`Ctrl + F`):** Type in the search bar to filter dozens of tables and views instantly.
56
60
  - 📊 **Explorer with Tables & Views:** Browse tables and views with live row counts.
57
- - 📐 **Instant Schema & Foreign Keys:** See primary keys, column types, nullability, and foreign key references at a glance.
61
+ - 📋 **Instant Schema & Foreign Keys:** See primary keys, column types, nullability, and foreign key references at a glance.
58
62
  - 💾 **Multi-Format Exports:** Instant SQL `INSERT`s (`Ctrl + I`), Markdown (`Ctrl + M`), CSV (`Ctrl + E`), and JSON (`Ctrl + J`).
59
63
  - 📜 **Query History:** Cycle through your last 20 queries with `Ctrl + ↑` and `Ctrl + ↓`.
60
64
  - 🎨 **Clean Cyberpunk Aesthetic:** Fast, high-contrast, zero-flicker terminal interface.
@@ -129,10 +133,9 @@ ghostbase postgresql://prod-db:5432/main --read-only
129
133
 
130
134
  ---
131
135
 
132
- ## 📬 Contact & Inquiries
136
+ ## 🤝 Contributing
133
137
 
134
- For partnerships, feature requests, or business inquiries:
135
- 📧 **[contact.4yk5@proton.me](mailto:contact.4yk5@proton.me)**
138
+ Contributions, feedback, and pull requests are very welcome! Feel free to open an issue for bug reports or feature requests.
136
139
 
137
140
  ---
138
141
 
@@ -1,5 +1,7 @@
1
1
  import sys
2
2
  import os
3
+ import time
4
+ import re
3
5
 
4
6
  if sys.platform == "win32":
5
7
  try:
@@ -138,164 +140,127 @@ ListItem.section-header {
138
140
  #schema-title {
139
141
  color: #f0883e;
140
142
  text-style: bold;
141
- margin-bottom: 1;
142
143
  }
143
144
  #schema-content {
144
145
  color: #8b949e;
145
146
  }
146
147
 
147
- /* Query editor */
148
+ /* Content Area */
148
149
  #content-area {
149
150
  width: 1fr;
150
- padding: 0 1;
151
+ background: #0d1117;
151
152
  }
153
+
154
+ /* Query Editor */
152
155
  #query-box {
153
- height: 7;
154
- border: solid #30363d;
156
+ height: 9;
155
157
  background: #010409;
156
- margin-bottom: 0;
158
+ border-bottom: solid #30363d;
159
+ color: #e6edf3;
157
160
  }
158
161
  #query-box:focus {
159
- border: solid #388bfd;
162
+ border-bottom: solid #388bfd;
160
163
  }
161
164
 
162
- /* Actions toolbar */
165
+ /* Toolbar */
163
166
  #toolbar {
164
167
  height: 3;
168
+ background: #161b22;
169
+ border-bottom: solid #30363d;
165
170
  align: left middle;
166
- margin: 0;
167
- overflow-x: hidden;
171
+ padding: 0 1;
172
+ }
173
+ #toolbar Button {
174
+ margin-right: 1;
175
+ min-width: 8;
176
+ height: 1;
177
+ border: none;
168
178
  }
169
179
  #btn-run {
170
180
  background: #238636;
171
181
  color: #ffffff;
172
- border: none;
173
- min-width: 6;
174
- margin-right: 1;
175
182
  }
176
- #btn-run:hover { background: #2ea043; }
177
-
183
+ #btn-run:hover {
184
+ background: #2ea043;
185
+ }
178
186
  #btn-explain {
179
- background: #388bfd;
187
+ background: #1f6feb;
180
188
  color: #ffffff;
181
- border: none;
182
- min-width: 9;
183
- margin-right: 1;
184
189
  }
185
- #btn-explain:hover { background: #58a6ff; }
186
-
190
+ #btn-explain:hover {
191
+ background: #388bfd;
192
+ }
187
193
  #btn-format {
188
- background: #1f6feb;
189
- color: #ffffff;
190
- border: none;
191
- min-width: 8;
192
- margin-right: 1;
194
+ background: #21262d;
195
+ color: #c9d1d9;
196
+ }
197
+ #btn-format:hover {
198
+ background: #30363d;
193
199
  }
194
- #btn-format:hover { background: #388bfd; }
195
-
196
200
  #btn-clear {
197
201
  background: #21262d;
198
202
  color: #8b949e;
199
- border: none;
200
- min-width: 7;
201
- margin-right: 1;
202
203
  }
203
- #btn-clear:hover { background: #30363d; color: #c9d1d9; }
204
-
204
+ #btn-clear:hover {
205
+ background: #30363d;
206
+ color: #f85149;
207
+ }
205
208
  #btn-safe {
209
+ background: #21262d;
210
+ color: #3fb950;
211
+ }
212
+ #btn-safe:hover {
206
213
  background: #30363d;
207
- color: #c9d1d9;
208
- border: none;
209
- min-width: 6;
210
- margin-right: 1;
211
214
  }
212
- #btn-safe:hover { background: #484f58; }
213
-
214
215
  #status-label {
216
+ margin-left: 2;
215
217
  color: #8b949e;
216
- text-overflow: ellipsis;
217
- overflow: hidden;
218
218
  }
219
219
 
220
- /* Results & export bar */
220
+ /* Results Bar */
221
221
  #results-bar {
222
222
  height: 3;
223
+ background: #161b22;
224
+ border-bottom: solid #30363d;
223
225
  align: left middle;
224
226
  padding: 0 1;
225
- background: #161b22;
226
- border: solid #30363d;
227
- border-bottom: none;
228
- overflow-x: hidden;
229
227
  }
230
228
  #results-title {
231
- color: #79c0ff;
229
+ color: #58a6ff;
232
230
  text-style: bold;
233
- margin-right: 1;
234
231
  }
235
232
  #results-count {
236
- color: #8b949e;
237
- margin-right: 1;
233
+ color: #484f58;
234
+ margin-left: 1;
238
235
  }
239
236
  .export-sep {
240
237
  color: #30363d;
241
- margin-right: 1;
238
+ margin: 0 1;
242
239
  }
243
240
  .export-label {
244
241
  color: #484f58;
245
242
  margin-right: 1;
246
243
  }
247
- #btn-csv {
248
- background: #0d419d;
249
- color: #79c0ff;
250
- border: none;
251
- min-width: 5;
244
+ #results-bar Button {
252
245
  margin-right: 1;
253
- }
254
- #btn-csv:hover { background: #1158c7; }
255
-
256
- #btn-json {
257
- background: #5a1e02;
258
- color: #ffa657;
259
- border: none;
260
246
  min-width: 6;
261
- margin-right: 1;
262
- }
263
- #btn-json:hover { background: #7d2d00; }
264
-
265
- #btn-md {
266
- background: #232a35;
267
- color: #a5d6ff;
247
+ height: 1;
268
248
  border: none;
269
- min-width: 4;
270
- margin-right: 1;
249
+ background: #21262d;
250
+ color: #8b949e;
271
251
  }
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;
252
+ #results-bar Button:hover {
253
+ background: #30363d;
254
+ color: #c9d1d9;
280
255
  }
281
- #btn-sql:hover { background: #5a3500; }
282
256
 
283
- /* Results table */
257
+ /* Data Table */
284
258
  #data-table {
285
259
  height: 1fr;
286
- border: solid #30363d;
287
260
  background: #0d1117;
288
261
  }
289
- DataTable > .datatable--header {
290
- background: #161b22;
291
- color: #79c0ff;
292
- text-style: bold;
293
- }
294
- DataTable > .datatable--cursor {
295
- background: #1c2128;
296
- }
297
262
 
298
- /* Status bar */
263
+ /* Status Bar */
299
264
  #status-bar {
300
265
  height: 1;
301
266
  dock: bottom;
@@ -344,18 +309,22 @@ class GhostBaseApp(App):
344
309
  selected_table: reactive[str] = reactive("")
345
310
  safe_mode: reactive[bool] = reactive(False)
346
311
 
347
- def __init__(self, db_path: str = None, read_only: bool = False):
312
+ def __init__(self, db_path: str = None, read_only: bool = False, populate_sample: bool = False):
348
313
  super().__init__()
349
314
  self.db_path = db_path or "sample.db"
315
+ self.populate_sample = populate_sample
350
316
  self.db = DatabaseEngine(self.db_path)
351
317
  self.safe_mode = read_only
352
318
  self.last_columns: list = []
353
319
  self.last_rows: list = []
320
+ self.last_query_table: str = None
354
321
  self.query_history: list = []
355
322
  self.history_index: int = -1
356
323
  self.all_tables: list = []
357
324
  self.all_views: list = []
325
+ self._row_count_cache: dict = {}
358
326
  self._pending_destructive_query = None
327
+ self._pending_destructive_time = 0.0
359
328
 
360
329
  # Layout
361
330
 
@@ -373,7 +342,7 @@ class GhostBaseApp(App):
373
342
  yield Label(f"[{self.db.db_type_label()}]", id="db-type-label")
374
343
  yield Label(db_label, id="db-name-label")
375
344
  yield Label(safe_text, id="safe-badge", classes=safe_class)
376
- yield Label("v0.1.0", id="version-label")
345
+ yield Label("v0.1.2", id="version-label")
377
346
 
378
347
  with Horizontal(id="main-container"):
379
348
  with Vertical(id="sidebar"):
@@ -420,26 +389,49 @@ class GhostBaseApp(App):
420
389
  # Lifecycle
421
390
 
422
391
  async def on_mount(self):
392
+ # Configure database engine level Read-Only mode if safe_mode is enabled
393
+ if self.safe_mode:
394
+ self.db.set_read_only(True)
395
+
423
396
  tables = self.db.get_tables()
424
- if not tables and self.db.db_type == "sqlite":
397
+ # Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
398
+ is_default_sample = (
399
+ self.populate_sample
400
+ or self.db_path == ":memory:"
401
+ or os.path.basename(os.path.abspath(self.db_path)) == "sample.db"
402
+ )
403
+ if not tables and self.db.db_type == "sqlite" and is_default_sample and not self.safe_mode:
425
404
  self._create_sample_data()
426
405
  tables = self.db.get_tables()
427
406
 
428
407
  self.all_tables = tables
429
408
  self.all_views = self.db.get_views()
409
+ self._refresh_row_counts()
430
410
  self._refresh_sidebar(self.all_tables, self.all_views)
431
411
 
432
412
  if self.all_tables:
433
413
  first = self.all_tables[0]
434
414
  self.selected_table = first
415
+ quoted = self.db.quote_table_name(first)
435
416
  self.query_one("#query-box", TextArea).text = (
436
- f"SELECT * FROM {first} LIMIT 50;"
417
+ f"SELECT * FROM {quoted} LIMIT 50;"
437
418
  )
438
419
  self.action_run_query()
439
420
  self._show_schema(first)
421
+ else:
422
+ self.query_one("#query-box", TextArea).text = (
423
+ "-- Connected to database.\n-- Write a query and press Ctrl+R to execute.\n"
424
+ )
440
425
 
441
426
  self._update_status_bar()
442
427
 
428
+ # Row count caching
429
+
430
+ def _refresh_row_counts(self):
431
+ self._row_count_cache = {}
432
+ for t in self.all_tables:
433
+ self._row_count_cache[t] = self.db.get_row_count(t)
434
+
443
435
  # Sample schema
444
436
 
445
437
  def _create_sample_data(self):
@@ -500,7 +492,7 @@ class GhostBaseApp(App):
500
492
  lv.append(_sep_tables)
501
493
 
502
494
  for t in tables:
503
- rc = self.db.get_row_count(t)
495
+ rc = self._row_count_cache.get(t, 0)
504
496
  item = ListItem(Label(f" {t} ({rc})"))
505
497
  item.table_name = t # type: ignore
506
498
  item.is_view = False # type: ignore
@@ -533,6 +525,11 @@ class GhostBaseApp(App):
533
525
  filtered_views = [v for v in self.all_views if q in v.lower()]
534
526
  self._refresh_sidebar(filtered_tables, filtered_views)
535
527
 
528
+ def on_text_area_changed(self, event: TextArea.Changed):
529
+ if event.text_area.id == "query-box" and self._pending_destructive_query is not None:
530
+ self._pending_destructive_query = None
531
+ self._pending_destructive_time = 0.0
532
+
536
533
  # Schema panel
537
534
 
538
535
  def _show_schema(self, table_name: str, is_view: bool = False):
@@ -591,8 +588,12 @@ class GhostBaseApp(App):
591
588
  return
592
589
  is_view = getattr(item, "is_view", False)
593
590
  self.selected_table = t
591
+ self._pending_destructive_query = None
592
+ self._pending_destructive_time = 0.0
593
+
594
+ quoted = self.db.quote_table_name(t)
594
595
  self.query_one("#query-box", TextArea).text = (
595
- f"SELECT * FROM {t} LIMIT 50;"
596
+ f"SELECT * FROM {quoted} LIMIT 50;"
596
597
  )
597
598
  self.action_run_query()
598
599
  self._show_schema(t, is_view=is_view)
@@ -626,26 +627,32 @@ class GhostBaseApp(App):
626
627
  status = self.query_one("#status-label", Label)
627
628
  res_cnt = self.query_one("#results-count", Label)
628
629
 
629
- # Safety guard check
630
- is_destructive, reason = self.db.check_safety(query)
630
+ # Safety guard check (strict read-only in safe mode, destructive guard in normal mode)
631
+ is_destructive, reason = self.db.check_safety(query, is_safe_mode=self.safe_mode)
631
632
  if is_destructive:
632
633
  if self.safe_mode:
633
634
  self.notify(f"BLOCKED BY SAFE MODE: {reason}", severity="error", timeout=5)
634
635
  status.update(f"[bold red]BLOCKED BY SAFE MODE:[/] {reason}")
635
636
  return
636
637
 
637
- if self._pending_destructive_query != query:
638
+ now = time.time()
639
+ if self._pending_destructive_query == query and (now - self._pending_destructive_time) <= 10.0:
640
+ # Double confirmation received within 10s window
641
+ self._pending_destructive_query = None
642
+ self._pending_destructive_time = 0.0
643
+ else:
638
644
  self._pending_destructive_query = query
645
+ self._pending_destructive_time = now
639
646
  self.notify(
640
- f"DANGER: {reason}!\nPress Ctrl+R again to confirm.",
647
+ f"DANGER: {reason}!\nPress Ctrl+R within 10s to confirm.",
641
648
  severity="warning",
642
649
  timeout=5,
643
650
  )
644
651
  status.update(f"[bold red]DANGER:[/] {reason} -> Press Ctrl+R to CONFIRM")
645
652
  return
646
-
647
- # Double confirmation received
653
+ else:
648
654
  self._pending_destructive_query = None
655
+ self._pending_destructive_time = 0.0
649
656
 
650
657
  if not self.query_history or self.query_history[-1] != query:
651
658
  self.query_history.append(query)
@@ -658,6 +665,10 @@ class GhostBaseApp(App):
658
665
  dt.clear(columns=True)
659
666
 
660
667
  if not res["success"]:
668
+ # Clear export cache on error
669
+ self.last_columns = []
670
+ self.last_rows = []
671
+ self.last_query_table = None
661
672
  err = res.get("error", "Unknown error")
662
673
  status.update(f"[bold red]ERROR[/] {err}")
663
674
  res_cnt.update("(Error)")
@@ -667,6 +678,12 @@ class GhostBaseApp(App):
667
678
  if res["is_select"]:
668
679
  self.last_columns = res["columns"]
669
680
  self.last_rows = res["rows"]
681
+
682
+ # Detect target table for export from final SELECT statement (ignoring CTE definitions)
683
+ main_q = re.sub(r"^WITH\s+[\s\S]*?\)\s*", "", query, flags=re.IGNORECASE)
684
+ m = re.search(r"\bFROM\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", main_q, flags=re.IGNORECASE)
685
+ self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
686
+
670
687
  dt.add_columns(*self.last_columns)
671
688
  for row in self.last_rows:
672
689
  styled = []
@@ -685,16 +702,66 @@ class GhostBaseApp(App):
685
702
  rc = res["row_count"]
686
703
  ms = res["elapsed_ms"]
687
704
  status.update(f"[bold green]OK[/] ({ms}ms)")
688
- res_cnt.update(f"({rc} rows)")
705
+
706
+ if res.get("truncated"):
707
+ res_cnt.update(f"({rc}+ rows, capped at 1,000)")
708
+ self.notify("Displaying first 1,000 rows (use LIMIT / OFFSET for more)", severity="information", timeout=4)
709
+ else:
710
+ res_cnt.update(f"({rc} rows)")
711
+
712
+ self._update_status_bar(row_count=rc, ms=ms)
713
+ elif res.get("is_returning"):
714
+ # DML with RETURNING: Render returned rows in DataTable, but treat as write operation
715
+ self.last_columns = res["columns"]
716
+ self.last_rows = res["rows"]
717
+
718
+ # Extract target table from INSERT/UPDATE
719
+ m = re.search(r"\b(?:INTO|UPDATE)\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", query, flags=re.IGNORECASE)
720
+ self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
721
+
722
+ dt.add_columns(*self.last_columns)
723
+ for row in self.last_rows:
724
+ styled = []
725
+ for val in row:
726
+ if val is None:
727
+ styled.append(Text("NULL", style="dim #6e7681"))
728
+ elif isinstance(val, (int, float)):
729
+ styled.append(Text(str(val), style="#79c0ff"))
730
+ else:
731
+ s = str(val).replace("\r\n", " ").replace("\n", " ").replace("\t", " ")
732
+ if len(s) > 200:
733
+ s = s[:197] + "..."
734
+ styled.append(Text(s, style="#c9d1d9"))
735
+ dt.add_row(*styled)
736
+
737
+ aff = res.get("affected_rows", len(self.last_rows))
738
+ rc = res.get("row_count", len(self.last_rows))
739
+ ms = res["elapsed_ms"]
740
+ status.update(f"[bold green]OK[/] ({ms}ms)")
741
+ res_cnt.update(f"({rc} rows returned, {aff} affected)")
742
+ self.notify(f"Done — {aff} row(s) affected (RETURNING {rc} rows)")
743
+
744
+ # Refresh schema & row counts
745
+ self.all_tables = self.db.get_tables()
746
+ self.all_views = self.db.get_views()
747
+ self._refresh_row_counts()
748
+ self._refresh_sidebar(self.all_tables, self.all_views)
689
749
  self._update_status_bar(row_count=rc, ms=ms)
690
750
  else:
751
+ # Pure DML / DDL statement -> clear export cache so previous SELECT results are not mistakenly exported
752
+ self.last_columns = []
753
+ self.last_rows = []
754
+ self.last_query_table = None
755
+
691
756
  aff = res.get("affected_rows", 0)
692
757
  ms = res["elapsed_ms"]
693
758
  status.update(f"[bold green]OK[/] ({ms}ms)")
694
759
  res_cnt.update(f"({aff} rows affected)")
695
760
  self.notify(f"Done — {aff} row(s) affected")
761
+
696
762
  self.all_tables = self.db.get_tables()
697
763
  self.all_views = self.db.get_views()
764
+ self._refresh_row_counts()
698
765
  self._refresh_sidebar(self.all_tables, self.all_views)
699
766
  self._update_status_bar(ms=ms)
700
767
 
@@ -745,21 +812,29 @@ class GhostBaseApp(App):
745
812
  def action_toggle_safe_mode(self):
746
813
  """Toggle Production Safe Mode (Ctrl+S)"""
747
814
  self.safe_mode = not self.safe_mode
815
+ self._pending_destructive_query = None
816
+ self._pending_destructive_time = 0.0
817
+
818
+ # Enforce/revoke database session level Read-Only
819
+ self.db.set_read_only(self.safe_mode)
820
+
748
821
  badge = self.query_one("#safe-badge", Label)
749
822
  if self.safe_mode:
750
823
  badge.update("[SAFE: ON]")
751
824
  badge.remove_class("unsafe")
752
- self.notify("Safe Mode ENABLED: Destructive queries blocked", timeout=3)
825
+ self.notify("Safe Mode ENABLED: Modifications strictly blocked (Read-Only)", timeout=3)
753
826
  else:
754
827
  badge.update("[SAFE: OFF]")
755
828
  badge.add_class("unsafe")
756
- self.notify("Safe Mode DISABLED: Destructive queries allowed with confirmation", severity="warning", timeout=3)
829
+ self.notify("Safe Mode DISABLED: Destructive queries require confirmation", severity="warning", timeout=3)
757
830
  self._update_status_bar()
758
831
 
759
832
  def action_focus_filter(self):
760
833
  self.query_one("#table-search", Input).focus()
761
834
 
762
835
  def action_clear_query(self):
836
+ self._pending_destructive_query = None
837
+ self._pending_destructive_time = 0.0
763
838
  self.query_one("#query-box", TextArea).text = ""
764
839
  self.query_one("#status-label", Label).update("Ready")
765
840
  self.query_one("#query-box", TextArea).focus()
@@ -782,7 +857,7 @@ class GhostBaseApp(App):
782
857
 
783
858
  def action_export_csv(self):
784
859
  if not self.last_columns:
785
- self.notify("Run a query first", severity="warning")
860
+ self.notify("Run a SELECT query first", severity="warning")
786
861
  return
787
862
  db_dir = (
788
863
  os.path.dirname(os.path.abspath(self.db_path))
@@ -795,7 +870,7 @@ class GhostBaseApp(App):
795
870
 
796
871
  def action_export_json(self):
797
872
  if not self.last_columns:
798
- self.notify("Run a query first", severity="warning")
873
+ self.notify("Run a SELECT query first", severity="warning")
799
874
  return
800
875
  db_dir = (
801
876
  os.path.dirname(os.path.abspath(self.db_path))
@@ -808,7 +883,7 @@ class GhostBaseApp(App):
808
883
 
809
884
  def action_export_markdown(self):
810
885
  if not self.last_columns:
811
- self.notify("Run a query first", severity="warning")
886
+ self.notify("Run a SELECT query first", severity="warning")
812
887
  return
813
888
  db_dir = (
814
889
  os.path.dirname(os.path.abspath(self.db_path))
@@ -821,9 +896,9 @@ class GhostBaseApp(App):
821
896
 
822
897
  def action_export_sql(self):
823
898
  if not self.last_columns:
824
- self.notify("Run a query first", severity="warning")
899
+ self.notify("Run a SELECT query first", severity="warning")
825
900
  return
826
- table_name = self.selected_table or "exported_table"
901
+ table_name = getattr(self, "last_query_table", None) or self.selected_table or "exported_table"
827
902
  db_dir = (
828
903
  os.path.dirname(os.path.abspath(self.db_path))
829
904
  if (self.db.db_type == "sqlite" and self.db_path != ":memory:")