ghostbase-cli 0.1.0__tar.gz → 0.1.1__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.
- {ghostbase_cli-0.1.0/ghostbase_cli.egg-info → ghostbase_cli-0.1.1}/PKG-INFO +11 -8
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/README.md +10 -7
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/app.py +138 -110
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/db_engine.py +145 -31
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/exporter.py +43 -4
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1/ghostbase_cli.egg-info}/PKG-INFO +11 -8
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/ghostbase_cli.egg-info/SOURCES.txt +2 -1
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/main.py +6 -2
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/pyproject.toml +1 -1
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/tests/test_exhaustive.py +1 -1
- ghostbase_cli-0.1.1/tests/test_vulnerabilities_fixed.py +216 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/LICENSE +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/ghostbase_cli.egg-info/dependency_links.txt +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/ghostbase_cli.egg-info/entry_points.txt +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/ghostbase_cli.egg-info/requires.txt +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/ghostbase_cli.egg-info/top_level.txt +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/setup.cfg +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/tests/test_all.py +0 -0
- {ghostbase_cli-0.1.0 → ghostbase_cli-0.1.1}/tests/test_real_db.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ghostbase-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
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)
|
|
43
43
|
[](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
|
-
-
|
|
79
|
-
-
|
|
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
|
-
-
|
|
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
|
-
##
|
|
160
|
+
## 🤝 Contributing
|
|
157
161
|
|
|
158
|
-
|
|
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)
|
|
19
19
|
[](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
|
-
-
|
|
55
|
-
-
|
|
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
|
-
-
|
|
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
|
-
##
|
|
136
|
+
## 🤝 Contributing
|
|
133
137
|
|
|
134
|
-
|
|
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
|
-
/*
|
|
148
|
+
/* Content Area */
|
|
148
149
|
#content-area {
|
|
149
150
|
width: 1fr;
|
|
150
|
-
|
|
151
|
+
background: #0d1117;
|
|
151
152
|
}
|
|
153
|
+
|
|
154
|
+
/* Query Editor */
|
|
152
155
|
#query-box {
|
|
153
|
-
height:
|
|
154
|
-
border: solid #30363d;
|
|
156
|
+
height: 9;
|
|
155
157
|
background: #010409;
|
|
156
|
-
|
|
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
|
-
/*
|
|
165
|
+
/* Toolbar */
|
|
163
166
|
#toolbar {
|
|
164
167
|
height: 3;
|
|
168
|
+
background: #161b22;
|
|
169
|
+
border-bottom: solid #30363d;
|
|
165
170
|
align: left middle;
|
|
166
|
-
|
|
167
|
-
|
|
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 {
|
|
177
|
-
|
|
183
|
+
#btn-run:hover {
|
|
184
|
+
background: #2ea043;
|
|
185
|
+
}
|
|
178
186
|
#btn-explain {
|
|
179
|
-
background: #
|
|
187
|
+
background: #1f6feb;
|
|
180
188
|
color: #ffffff;
|
|
181
|
-
border: none;
|
|
182
|
-
min-width: 9;
|
|
183
|
-
margin-right: 1;
|
|
184
189
|
}
|
|
185
|
-
#btn-explain:hover {
|
|
186
|
-
|
|
190
|
+
#btn-explain:hover {
|
|
191
|
+
background: #388bfd;
|
|
192
|
+
}
|
|
187
193
|
#btn-format {
|
|
188
|
-
background: #
|
|
189
|
-
color: #
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 {
|
|
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
|
|
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: #
|
|
229
|
+
color: #58a6ff;
|
|
232
230
|
text-style: bold;
|
|
233
|
-
margin-right: 1;
|
|
234
231
|
}
|
|
235
232
|
#results-count {
|
|
236
|
-
color: #
|
|
237
|
-
margin-
|
|
233
|
+
color: #484f58;
|
|
234
|
+
margin-left: 1;
|
|
238
235
|
}
|
|
239
236
|
.export-sep {
|
|
240
237
|
color: #30363d;
|
|
241
|
-
margin
|
|
238
|
+
margin: 0 1;
|
|
242
239
|
}
|
|
243
240
|
.export-label {
|
|
244
241
|
color: #484f58;
|
|
245
242
|
margin-right: 1;
|
|
246
243
|
}
|
|
247
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
249
|
+
background: #21262d;
|
|
250
|
+
color: #8b949e;
|
|
271
251
|
}
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
#
|
|
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
|
-
/*
|
|
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
|
|
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.
|
|
345
|
+
yield Label("v0.1.1", id="version-label")
|
|
377
346
|
|
|
378
347
|
with Horizontal(id="main-container"):
|
|
379
348
|
with Vertical(id="sidebar"):
|
|
@@ -421,12 +390,19 @@ class GhostBaseApp(App):
|
|
|
421
390
|
|
|
422
391
|
async def on_mount(self):
|
|
423
392
|
tables = self.db.get_tables()
|
|
424
|
-
|
|
393
|
+
# Only populate demo tables when explicitly requested, in-memory, or default sample.db
|
|
394
|
+
is_default_sample = (
|
|
395
|
+
self.populate_sample
|
|
396
|
+
or self.db_path == ":memory:"
|
|
397
|
+
or os.path.basename(os.path.abspath(self.db_path)) == "sample.db"
|
|
398
|
+
)
|
|
399
|
+
if not tables and self.db.db_type == "sqlite" and is_default_sample:
|
|
425
400
|
self._create_sample_data()
|
|
426
401
|
tables = self.db.get_tables()
|
|
427
402
|
|
|
428
403
|
self.all_tables = tables
|
|
429
404
|
self.all_views = self.db.get_views()
|
|
405
|
+
self._refresh_row_counts()
|
|
430
406
|
self._refresh_sidebar(self.all_tables, self.all_views)
|
|
431
407
|
|
|
432
408
|
if self.all_tables:
|
|
@@ -437,9 +413,20 @@ class GhostBaseApp(App):
|
|
|
437
413
|
)
|
|
438
414
|
self.action_run_query()
|
|
439
415
|
self._show_schema(first)
|
|
416
|
+
else:
|
|
417
|
+
self.query_one("#query-box", TextArea).text = (
|
|
418
|
+
"-- Connected to database.\n-- Write a query and press Ctrl+R to execute.\n"
|
|
419
|
+
)
|
|
440
420
|
|
|
441
421
|
self._update_status_bar()
|
|
442
422
|
|
|
423
|
+
# Row count caching
|
|
424
|
+
|
|
425
|
+
def _refresh_row_counts(self):
|
|
426
|
+
self._row_count_cache = {}
|
|
427
|
+
for t in self.all_tables:
|
|
428
|
+
self._row_count_cache[t] = self.db.get_row_count(t)
|
|
429
|
+
|
|
443
430
|
# Sample schema
|
|
444
431
|
|
|
445
432
|
def _create_sample_data(self):
|
|
@@ -500,7 +487,7 @@ class GhostBaseApp(App):
|
|
|
500
487
|
lv.append(_sep_tables)
|
|
501
488
|
|
|
502
489
|
for t in tables:
|
|
503
|
-
rc = self.
|
|
490
|
+
rc = self._row_count_cache.get(t, 0)
|
|
504
491
|
item = ListItem(Label(f" {t} ({rc})"))
|
|
505
492
|
item.table_name = t # type: ignore
|
|
506
493
|
item.is_view = False # type: ignore
|
|
@@ -533,6 +520,11 @@ class GhostBaseApp(App):
|
|
|
533
520
|
filtered_views = [v for v in self.all_views if q in v.lower()]
|
|
534
521
|
self._refresh_sidebar(filtered_tables, filtered_views)
|
|
535
522
|
|
|
523
|
+
def on_text_area_changed(self, event: TextArea.Changed):
|
|
524
|
+
if event.text_area.id == "query-box" and self._pending_destructive_query is not None:
|
|
525
|
+
self._pending_destructive_query = None
|
|
526
|
+
self._pending_destructive_time = 0.0
|
|
527
|
+
|
|
536
528
|
# Schema panel
|
|
537
529
|
|
|
538
530
|
def _show_schema(self, table_name: str, is_view: bool = False):
|
|
@@ -591,6 +583,9 @@ class GhostBaseApp(App):
|
|
|
591
583
|
return
|
|
592
584
|
is_view = getattr(item, "is_view", False)
|
|
593
585
|
self.selected_table = t
|
|
586
|
+
self._pending_destructive_query = None
|
|
587
|
+
self._pending_destructive_time = 0.0
|
|
588
|
+
|
|
594
589
|
self.query_one("#query-box", TextArea).text = (
|
|
595
590
|
f"SELECT * FROM {t} LIMIT 50;"
|
|
596
591
|
)
|
|
@@ -626,26 +621,32 @@ class GhostBaseApp(App):
|
|
|
626
621
|
status = self.query_one("#status-label", Label)
|
|
627
622
|
res_cnt = self.query_one("#results-count", Label)
|
|
628
623
|
|
|
629
|
-
# Safety guard check
|
|
630
|
-
is_destructive, reason = self.db.check_safety(query)
|
|
624
|
+
# Safety guard check (strict read-only in safe mode, destructive guard in normal mode)
|
|
625
|
+
is_destructive, reason = self.db.check_safety(query, is_safe_mode=self.safe_mode)
|
|
631
626
|
if is_destructive:
|
|
632
627
|
if self.safe_mode:
|
|
633
628
|
self.notify(f"BLOCKED BY SAFE MODE: {reason}", severity="error", timeout=5)
|
|
634
629
|
status.update(f"[bold red]BLOCKED BY SAFE MODE:[/] {reason}")
|
|
635
630
|
return
|
|
636
631
|
|
|
637
|
-
|
|
632
|
+
now = time.time()
|
|
633
|
+
if self._pending_destructive_query == query and (now - self._pending_destructive_time) <= 10.0:
|
|
634
|
+
# Double confirmation received within 10s window
|
|
635
|
+
self._pending_destructive_query = None
|
|
636
|
+
self._pending_destructive_time = 0.0
|
|
637
|
+
else:
|
|
638
638
|
self._pending_destructive_query = query
|
|
639
|
+
self._pending_destructive_time = now
|
|
639
640
|
self.notify(
|
|
640
|
-
f"DANGER: {reason}!\nPress Ctrl+R
|
|
641
|
+
f"DANGER: {reason}!\nPress Ctrl+R within 10s to confirm.",
|
|
641
642
|
severity="warning",
|
|
642
643
|
timeout=5,
|
|
643
644
|
)
|
|
644
645
|
status.update(f"[bold red]DANGER:[/] {reason} -> Press Ctrl+R to CONFIRM")
|
|
645
646
|
return
|
|
646
|
-
|
|
647
|
-
# Double confirmation received
|
|
647
|
+
else:
|
|
648
648
|
self._pending_destructive_query = None
|
|
649
|
+
self._pending_destructive_time = 0.0
|
|
649
650
|
|
|
650
651
|
if not self.query_history or self.query_history[-1] != query:
|
|
651
652
|
self.query_history.append(query)
|
|
@@ -658,6 +659,10 @@ class GhostBaseApp(App):
|
|
|
658
659
|
dt.clear(columns=True)
|
|
659
660
|
|
|
660
661
|
if not res["success"]:
|
|
662
|
+
# Clear export cache on error
|
|
663
|
+
self.last_columns = []
|
|
664
|
+
self.last_rows = []
|
|
665
|
+
self.last_query_table = None
|
|
661
666
|
err = res.get("error", "Unknown error")
|
|
662
667
|
status.update(f"[bold red]ERROR[/] {err}")
|
|
663
668
|
res_cnt.update("(Error)")
|
|
@@ -667,6 +672,11 @@ class GhostBaseApp(App):
|
|
|
667
672
|
if res["is_select"]:
|
|
668
673
|
self.last_columns = res["columns"]
|
|
669
674
|
self.last_rows = res["rows"]
|
|
675
|
+
|
|
676
|
+
# Detect target table for export if simple SELECT FROM table
|
|
677
|
+
m = re.search(r"\bFROM\s+[`\"\[]?([a-zA-Z0-9_\.]+)[`\"\]]?", query, flags=re.IGNORECASE)
|
|
678
|
+
self.last_query_table = m.group(1).split(".")[-1] if m else (self.selected_table or "query_result")
|
|
679
|
+
|
|
670
680
|
dt.add_columns(*self.last_columns)
|
|
671
681
|
for row in self.last_rows:
|
|
672
682
|
styled = []
|
|
@@ -685,16 +695,29 @@ class GhostBaseApp(App):
|
|
|
685
695
|
rc = res["row_count"]
|
|
686
696
|
ms = res["elapsed_ms"]
|
|
687
697
|
status.update(f"[bold green]OK[/] ({ms}ms)")
|
|
688
|
-
|
|
698
|
+
|
|
699
|
+
if res.get("truncated"):
|
|
700
|
+
res_cnt.update(f"({rc}+ rows, capped at 1,000)")
|
|
701
|
+
self.notify("Displaying first 1,000 rows (use LIMIT / OFFSET for more)", severity="information", timeout=4)
|
|
702
|
+
else:
|
|
703
|
+
res_cnt.update(f"({rc} rows)")
|
|
704
|
+
|
|
689
705
|
self._update_status_bar(row_count=rc, ms=ms)
|
|
690
706
|
else:
|
|
707
|
+
# Clear export cache so previous SELECT results are not mistakenly exported
|
|
708
|
+
self.last_columns = []
|
|
709
|
+
self.last_rows = []
|
|
710
|
+
self.last_query_table = None
|
|
711
|
+
|
|
691
712
|
aff = res.get("affected_rows", 0)
|
|
692
713
|
ms = res["elapsed_ms"]
|
|
693
714
|
status.update(f"[bold green]OK[/] ({ms}ms)")
|
|
694
715
|
res_cnt.update(f"({aff} rows affected)")
|
|
695
716
|
self.notify(f"Done — {aff} row(s) affected")
|
|
717
|
+
|
|
696
718
|
self.all_tables = self.db.get_tables()
|
|
697
719
|
self.all_views = self.db.get_views()
|
|
720
|
+
self._refresh_row_counts()
|
|
698
721
|
self._refresh_sidebar(self.all_tables, self.all_views)
|
|
699
722
|
self._update_status_bar(ms=ms)
|
|
700
723
|
|
|
@@ -745,21 +768,26 @@ class GhostBaseApp(App):
|
|
|
745
768
|
def action_toggle_safe_mode(self):
|
|
746
769
|
"""Toggle Production Safe Mode (Ctrl+S)"""
|
|
747
770
|
self.safe_mode = not self.safe_mode
|
|
771
|
+
self._pending_destructive_query = None
|
|
772
|
+
self._pending_destructive_time = 0.0
|
|
773
|
+
|
|
748
774
|
badge = self.query_one("#safe-badge", Label)
|
|
749
775
|
if self.safe_mode:
|
|
750
776
|
badge.update("[SAFE: ON]")
|
|
751
777
|
badge.remove_class("unsafe")
|
|
752
|
-
self.notify("Safe Mode ENABLED:
|
|
778
|
+
self.notify("Safe Mode ENABLED: Modifications strictly blocked (Read-Only)", timeout=3)
|
|
753
779
|
else:
|
|
754
780
|
badge.update("[SAFE: OFF]")
|
|
755
781
|
badge.add_class("unsafe")
|
|
756
|
-
self.notify("Safe Mode DISABLED: Destructive queries
|
|
782
|
+
self.notify("Safe Mode DISABLED: Destructive queries require confirmation", severity="warning", timeout=3)
|
|
757
783
|
self._update_status_bar()
|
|
758
784
|
|
|
759
785
|
def action_focus_filter(self):
|
|
760
786
|
self.query_one("#table-search", Input).focus()
|
|
761
787
|
|
|
762
788
|
def action_clear_query(self):
|
|
789
|
+
self._pending_destructive_query = None
|
|
790
|
+
self._pending_destructive_time = 0.0
|
|
763
791
|
self.query_one("#query-box", TextArea).text = ""
|
|
764
792
|
self.query_one("#status-label", Label).update("Ready")
|
|
765
793
|
self.query_one("#query-box", TextArea).focus()
|
|
@@ -782,7 +810,7 @@ class GhostBaseApp(App):
|
|
|
782
810
|
|
|
783
811
|
def action_export_csv(self):
|
|
784
812
|
if not self.last_columns:
|
|
785
|
-
self.notify("Run a query first", severity="warning")
|
|
813
|
+
self.notify("Run a SELECT query first", severity="warning")
|
|
786
814
|
return
|
|
787
815
|
db_dir = (
|
|
788
816
|
os.path.dirname(os.path.abspath(self.db_path))
|
|
@@ -795,7 +823,7 @@ class GhostBaseApp(App):
|
|
|
795
823
|
|
|
796
824
|
def action_export_json(self):
|
|
797
825
|
if not self.last_columns:
|
|
798
|
-
self.notify("Run a query first", severity="warning")
|
|
826
|
+
self.notify("Run a SELECT query first", severity="warning")
|
|
799
827
|
return
|
|
800
828
|
db_dir = (
|
|
801
829
|
os.path.dirname(os.path.abspath(self.db_path))
|
|
@@ -808,7 +836,7 @@ class GhostBaseApp(App):
|
|
|
808
836
|
|
|
809
837
|
def action_export_markdown(self):
|
|
810
838
|
if not self.last_columns:
|
|
811
|
-
self.notify("Run a query first", severity="warning")
|
|
839
|
+
self.notify("Run a SELECT query first", severity="warning")
|
|
812
840
|
return
|
|
813
841
|
db_dir = (
|
|
814
842
|
os.path.dirname(os.path.abspath(self.db_path))
|
|
@@ -821,9 +849,9 @@ class GhostBaseApp(App):
|
|
|
821
849
|
|
|
822
850
|
def action_export_sql(self):
|
|
823
851
|
if not self.last_columns:
|
|
824
|
-
self.notify("Run a query first", severity="warning")
|
|
852
|
+
self.notify("Run a SELECT query first", severity="warning")
|
|
825
853
|
return
|
|
826
|
-
table_name = self.selected_table or "exported_table"
|
|
854
|
+
table_name = getattr(self, "last_query_table", None) or self.selected_table or "exported_table"
|
|
827
855
|
db_dir = (
|
|
828
856
|
os.path.dirname(os.path.abspath(self.db_path))
|
|
829
857
|
if (self.db.db_type == "sqlite" and self.db_path != ":memory:")
|