openduck-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.
- openduck/__init__.py +42 -0
- openduck/__main__.py +6 -0
- openduck/core/__init__.py +36 -0
- openduck/core/clipboard.py +57 -0
- openduck/core/config.py +108 -0
- openduck/core/error_classifier.py +53 -0
- openduck/core/sql_utils.py +63 -0
- openduck/gui/__init__.py +5 -0
- openduck/gui/app.py +802 -0
- openduck/gui/dialogs/__init__.py +7 -0
- openduck/gui/dialogs/add_connection_dialog.py +77 -0
- openduck/gui/dialogs/export_dialog.py +109 -0
- openduck/gui/dialogs/save_query_dialog.py +41 -0
- openduck/gui/screens/__init__.py +6 -0
- openduck/gui/screens/about.py +84 -0
- openduck/gui/screens/matrix.py +90 -0
- openduck/gui/styles.py +71 -0
- openduck/gui/widgets/__init__.py +17 -0
- openduck/gui/widgets/database_tree.py +106 -0
- openduck/gui/widgets/file_tree.py +14 -0
- openduck/gui/widgets/query_tab.py +63 -0
- openduck/gui/widgets/resize_handle.py +36 -0
- openduck/gui/widgets/results_table.py +54 -0
- openduck/gui/widgets/sql_editor.py +207 -0
- openduck/main.py +25 -0
- openduck_cli-0.1.0.dist-info/METADATA +204 -0
- openduck_cli-0.1.0.dist-info/RECORD +30 -0
- openduck_cli-0.1.0.dist-info/WHEEL +5 -0
- openduck_cli-0.1.0.dist-info/entry_points.txt +3 -0
- openduck_cli-0.1.0.dist-info/top_level.txt +1 -0
openduck/gui/app.py
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"""Main OpenDuck application class."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import resource
|
|
15
|
+
except ImportError:
|
|
16
|
+
resource = None
|
|
17
|
+
|
|
18
|
+
import duckdb
|
|
19
|
+
from textual.app import App, ComposeResult
|
|
20
|
+
from textual.containers import Horizontal, Vertical
|
|
21
|
+
from textual.events import Click
|
|
22
|
+
from textual.widgets import (
|
|
23
|
+
Button, DataTable, DirectoryTree, Footer, Header, Input,
|
|
24
|
+
TabbedContent, TabPane, Tree, TextArea
|
|
25
|
+
)
|
|
26
|
+
from textual.widgets._tabbed_content import ContentTab, ContentTabs
|
|
27
|
+
from textual.widgets.text_area import Selection
|
|
28
|
+
|
|
29
|
+
from openduck.core.clipboard import copy_to_system_clipboard, get_system_clipboard
|
|
30
|
+
from openduck.core.config import (
|
|
31
|
+
CONFIG_FILE,
|
|
32
|
+
CWD,
|
|
33
|
+
MAX_UI_ROWS,
|
|
34
|
+
add_to_history,
|
|
35
|
+
delete_connection,
|
|
36
|
+
load_config,
|
|
37
|
+
save_connection,
|
|
38
|
+
)
|
|
39
|
+
from openduck.core.error_classifier import classify_file_error
|
|
40
|
+
from openduck.core.sql_utils import is_duckdb_file, sql_for_file
|
|
41
|
+
from openduck.gui.dialogs.add_connection_dialog import AddConnectionDialog
|
|
42
|
+
from openduck.gui.dialogs.export_dialog import ExportDialog
|
|
43
|
+
from openduck.gui.dialogs.save_query_dialog import SaveQueryDialog
|
|
44
|
+
from openduck.gui.screens.about import AboutScreen
|
|
45
|
+
from openduck.gui.styles import APP_CSS
|
|
46
|
+
from openduck.gui.widgets.database_tree import DatabaseTree
|
|
47
|
+
from openduck.gui.widgets.file_tree import DuckTree
|
|
48
|
+
from openduck.gui.widgets.query_tab import QueryTab
|
|
49
|
+
from openduck.gui.widgets.resize_handle import ResizeHandle
|
|
50
|
+
from openduck.gui.widgets.results_table import ResultsTable
|
|
51
|
+
from openduck.gui.widgets.sql_editor import SqlTextArea
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DuckCLI(App):
|
|
57
|
+
"""The OpenDuck Terminal SQL IDE application."""
|
|
58
|
+
|
|
59
|
+
ENABLE_MOUSE_SUPPORT = True
|
|
60
|
+
ALLOW_SELECT = False
|
|
61
|
+
CSS = APP_CSS
|
|
62
|
+
|
|
63
|
+
BINDINGS = [
|
|
64
|
+
("ctrl+enter,super+enter,cmd+enter,command+enter", "run_query", "Run"),
|
|
65
|
+
("ctrl+s,super+s,cmd+s,command+s", "save_query", "Save Query"),
|
|
66
|
+
("f3,ctrl+slash,super+slash,cmd+slash,command+slash,ctrl+underscore,super+underscore,cmd+underscore,command+underscore,ctrl+7,super+7,ctrl+minus,super+minus", "toggle_comment", "Comment (F3)"),
|
|
67
|
+
("ctrl+z,super+z,cmd+z,command+z", "undo", "Undo"),
|
|
68
|
+
("ctrl+y,super+y,cmd+y,command+y,shift+ctrl+z,ctrl+shift+z,shift+super+z,super+shift+z,shift+cmd+z,cmd+shift+z,shift+command+z,command+shift+z", "redo", "Redo"),
|
|
69
|
+
("ctrl+w,super+w,cmd+w,command+w", "close_tab", "Close Tab"),
|
|
70
|
+
("ctrl+m", "toggle_mouse", "Toggle Mouse"),
|
|
71
|
+
("f1", "about", "About"),
|
|
72
|
+
("ctrl+q,super+q,cmd+q,command+q", "quit", "Quit")
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
super().__init__()
|
|
77
|
+
self.con = duckdb.connect()
|
|
78
|
+
self.config = load_config()
|
|
79
|
+
self.db_connections: dict = {}
|
|
80
|
+
|
|
81
|
+
def compose(self) -> ComposeResult:
|
|
82
|
+
with Horizontal():
|
|
83
|
+
with Vertical(id="sidebar"):
|
|
84
|
+
yield DuckTree(CWD, id="files")
|
|
85
|
+
# yield DatabaseTree() # Temporarily disabled
|
|
86
|
+
yield Tree("Saved Queries", id="saved-queries-tree")
|
|
87
|
+
yield Tree("History", id="history-tree")
|
|
88
|
+
yield ResizeHandle()
|
|
89
|
+
with Vertical(id="main-content"):
|
|
90
|
+
yield TabbedContent(id="tabs")
|
|
91
|
+
yield Header()
|
|
92
|
+
yield Footer()
|
|
93
|
+
|
|
94
|
+
def on_mount(self):
|
|
95
|
+
# Let Textual initialize its standard mouse driver cleanly without raw ANSI overrides
|
|
96
|
+
self.load_history_list()
|
|
97
|
+
self.load_saved_queries_list()
|
|
98
|
+
# self.load_saved_connections() # Temporarily disabled
|
|
99
|
+
self.set_interval(1.0, self.update_memory_info)
|
|
100
|
+
|
|
101
|
+
sql = ""
|
|
102
|
+
if self.config["history"]:
|
|
103
|
+
sql = self.config["history"][-1]["sql"]
|
|
104
|
+
# Delay adding the first tab slightly to allow UI to settle
|
|
105
|
+
self.set_timer(0.2, lambda: self.run_worker(self.add_new_tab("Main", sql)))
|
|
106
|
+
|
|
107
|
+
def update_memory_info(self):
|
|
108
|
+
"""Update the memory info in the active tab's footer bar."""
|
|
109
|
+
if resource is None:
|
|
110
|
+
return
|
|
111
|
+
try:
|
|
112
|
+
mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
113
|
+
if sys.platform == 'darwin':
|
|
114
|
+
mem_mb = mem / (1024 * 1024)
|
|
115
|
+
else:
|
|
116
|
+
mem_mb = mem / 1024
|
|
117
|
+
|
|
118
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
119
|
+
if tabs.active:
|
|
120
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
121
|
+
mem_bar = tab.query_one("#memory-bar")
|
|
122
|
+
color = "red" if mem_mb > 2048 else "cyan"
|
|
123
|
+
mem_bar.update(f"Memory: [{color}]{mem_mb:.1f} MB[/{color}]")
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
def load_saved_connections(self):
|
|
128
|
+
"""Load saved connections from config and attempt to connect."""
|
|
129
|
+
for conn_info in self.config.get("connections", []):
|
|
130
|
+
self.run_worker(self.connect_database(conn_info))
|
|
131
|
+
|
|
132
|
+
async def connect_database(self, conn_info: dict):
|
|
133
|
+
"""Establish a database connection and populate tree."""
|
|
134
|
+
conn_id = conn_info["id"]
|
|
135
|
+
db_tree = self.query_one("#db-tree", DatabaseTree)
|
|
136
|
+
db_tree.add_connection_node(conn_info)
|
|
137
|
+
try:
|
|
138
|
+
if conn_info["type"] == "mysql":
|
|
139
|
+
tables = await self._connect_mysql(conn_info)
|
|
140
|
+
elif conn_info["type"] == "mssql":
|
|
141
|
+
tables = await self._connect_mssql(conn_info)
|
|
142
|
+
else:
|
|
143
|
+
raise ValueError(f"Unknown type: {conn_info['type']}")
|
|
144
|
+
db_tree.update_tables(conn_id, tables)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
db_tree.remove_connection_node(conn_id)
|
|
147
|
+
err_node = db_tree.original_tree.root.add(
|
|
148
|
+
f"[red]ERR: {conn_info['display_name']}[/red]",
|
|
149
|
+
data={"type": "error", "conn_id": conn_id},
|
|
150
|
+
)
|
|
151
|
+
err_node.add_leaf(f"[red]{str(e)[:60]}[/red]")
|
|
152
|
+
self.db_connections.pop(conn_id, None)
|
|
153
|
+
|
|
154
|
+
async def _connect_mysql(self, conn_info: dict) -> list:
|
|
155
|
+
conn_id = conn_info["id"]
|
|
156
|
+
alias = conn_id.replace("-", "_")
|
|
157
|
+
attach_str = (
|
|
158
|
+
f"host={conn_info['host']} "
|
|
159
|
+
f"user={conn_info['user']} "
|
|
160
|
+
f"port={conn_info['port']} "
|
|
161
|
+
f"database={conn_info['database']} "
|
|
162
|
+
f"password={conn_info['password']}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def do_attach():
|
|
166
|
+
self.con.execute("INSTALL mysql; LOAD mysql;")
|
|
167
|
+
try:
|
|
168
|
+
self.con.execute(f"DETACH {alias}")
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
self.con.execute(f"ATTACH '{attach_str}' AS {alias} (TYPE MYSQL)")
|
|
172
|
+
result = self.con.execute(
|
|
173
|
+
f"SELECT table_name FROM information_schema.tables "
|
|
174
|
+
f"WHERE table_catalog = '{alias}'"
|
|
175
|
+
).fetchall()
|
|
176
|
+
return [row[0] for row in result]
|
|
177
|
+
|
|
178
|
+
tables = await asyncio.to_thread(do_attach)
|
|
179
|
+
self.db_connections[conn_id] = {"type": "mysql", "alias": alias}
|
|
180
|
+
return tables
|
|
181
|
+
|
|
182
|
+
async def _connect_mssql(self, conn_info: dict) -> list:
|
|
183
|
+
import pymssql
|
|
184
|
+
conn_id = conn_info["id"]
|
|
185
|
+
|
|
186
|
+
def do_connect():
|
|
187
|
+
conn = pymssql.connect(
|
|
188
|
+
server=conn_info["host"],
|
|
189
|
+
port=conn_info["port"],
|
|
190
|
+
user=conn_info["user"],
|
|
191
|
+
password=conn_info["password"],
|
|
192
|
+
database=conn_info["database"],
|
|
193
|
+
login_timeout=10,
|
|
194
|
+
)
|
|
195
|
+
cursor = conn.cursor()
|
|
196
|
+
cursor.execute(
|
|
197
|
+
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
|
|
198
|
+
"WHERE TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME"
|
|
199
|
+
)
|
|
200
|
+
tables = [row[0] for row in cursor.fetchall()]
|
|
201
|
+
return conn, tables
|
|
202
|
+
|
|
203
|
+
conn, tables = await asyncio.to_thread(do_connect)
|
|
204
|
+
self.db_connections[conn_id] = {"type": "mssql", "connection": conn}
|
|
205
|
+
return tables
|
|
206
|
+
|
|
207
|
+
def _disconnect(self, conn_id: str):
|
|
208
|
+
conn_meta = self.db_connections.pop(conn_id, None)
|
|
209
|
+
if conn_meta:
|
|
210
|
+
if conn_meta["type"] == "mysql":
|
|
211
|
+
try:
|
|
212
|
+
self.con.execute(f"DETACH {conn_meta['alias']}")
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
elif conn_meta["type"] == "mssql":
|
|
216
|
+
try:
|
|
217
|
+
conn_meta["connection"].close()
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
db_tree = self.query_one("#db-tree", DatabaseTree)
|
|
221
|
+
db_tree.remove_connection_node(conn_id)
|
|
222
|
+
delete_connection(conn_id)
|
|
223
|
+
self.config = load_config()
|
|
224
|
+
|
|
225
|
+
def action_disconnect_db(self):
|
|
226
|
+
try:
|
|
227
|
+
db_tree = self.query_one("#db-tree", DatabaseTree)
|
|
228
|
+
node = db_tree.original_tree.cursor_node
|
|
229
|
+
if node and node.data:
|
|
230
|
+
conn_id = node.data.get("conn_id")
|
|
231
|
+
if conn_id:
|
|
232
|
+
self._disconnect(conn_id)
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
async def add_new_tab(self, name: str, sql: str, run: bool = False, connection_id: str = None):
|
|
237
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
238
|
+
tab_id = f"t{int(datetime.now().timestamp() * 1000)}"
|
|
239
|
+
await tabs.add_pane(TabPane(f"{name} ✕", QueryTab(sql, connection_id=connection_id), id=tab_id))
|
|
240
|
+
tabs.active = tab_id
|
|
241
|
+
if run:
|
|
242
|
+
self.set_timer(0.1, self.action_run_query)
|
|
243
|
+
|
|
244
|
+
def _classify_file_error(self, e: Exception, file_path: str = "") -> str:
|
|
245
|
+
"""Classify file-related errors and return user-friendly message."""
|
|
246
|
+
return classify_file_error(e, file_path)
|
|
247
|
+
|
|
248
|
+
async def _load_and_register_excel(self, file_path: Path) -> str:
|
|
249
|
+
"""Load Excel file using pandas in a worker thread and register as DuckDB table."""
|
|
250
|
+
stem = file_path.stem.replace(' ', '_').replace('-', '_')
|
|
251
|
+
table_name = re.sub(r'\W|^(?=\d)', '_', stem)
|
|
252
|
+
|
|
253
|
+
def do_register():
|
|
254
|
+
import pandas as pd
|
|
255
|
+
df = pd.read_excel(file_path)
|
|
256
|
+
self.con.execute(f'CREATE OR REPLACE TABLE "{table_name}" AS SELECT * FROM df')
|
|
257
|
+
logger.debug(f"Registered Excel file '{file_path}' as table '{table_name}' ({len(df)} rows)")
|
|
258
|
+
|
|
259
|
+
await asyncio.to_thread(do_register)
|
|
260
|
+
return table_name
|
|
261
|
+
|
|
262
|
+
async def action_run_query(self):
|
|
263
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
264
|
+
if not tabs.active:
|
|
265
|
+
logger.debug("No active tab found")
|
|
266
|
+
return
|
|
267
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
268
|
+
editor = tab.query_one(SqlTextArea)
|
|
269
|
+
|
|
270
|
+
# Execute selected text if any is selected (like VS Code / DBeaver)
|
|
271
|
+
selected_sql = editor.selected_text.strip()
|
|
272
|
+
sql = selected_sql if selected_sql else editor.text.strip()
|
|
273
|
+
if not sql:
|
|
274
|
+
logger.debug("No SQL query to execute")
|
|
275
|
+
self.notify("Geen SQL query om uit te voeren", timeout=1.5)
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
non_comment_lines = []
|
|
279
|
+
for line in sql.splitlines():
|
|
280
|
+
s = line.strip()
|
|
281
|
+
if s and not s.startswith("--"):
|
|
282
|
+
non_comment_lines.append(s)
|
|
283
|
+
cleaned_sql = " ".join(non_comment_lines).strip()
|
|
284
|
+
cleaned_sql = re.sub(r'/\*.*?\*/', '', cleaned_sql, flags=re.DOTALL).strip()
|
|
285
|
+
if not cleaned_sql:
|
|
286
|
+
self.notify("Query is uitgecommentarieerd (--), niet uitgevoerd", timeout=2.0)
|
|
287
|
+
meta = tab.query_one("#metadata-bar")
|
|
288
|
+
meta.update("Overgeslagen (alleen commentaar)")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
if selected_sql:
|
|
292
|
+
self.notify("Geselecteerde query uitvoeren...", timeout=1.0)
|
|
293
|
+
|
|
294
|
+
logger.debug(f"Executing query: {sql[:100]}{'...' if len(sql) > 100 else ''}")
|
|
295
|
+
|
|
296
|
+
# Update config after adding to history
|
|
297
|
+
add_to_history(sql)
|
|
298
|
+
self.config = load_config() # Reload config to ensure it's up to date
|
|
299
|
+
self.load_history_list() # Refresh the history list in UI
|
|
300
|
+
|
|
301
|
+
tbl = tab.query_one(DataTable)
|
|
302
|
+
meta = tab.query_one("#metadata-bar")
|
|
303
|
+
tbl.loading = True
|
|
304
|
+
meta.update("Executing...")
|
|
305
|
+
|
|
306
|
+
# Track this task so it can be cancelled
|
|
307
|
+
tab.running_task = asyncio.current_task()
|
|
308
|
+
logger.debug("Query execution started, task tracked")
|
|
309
|
+
|
|
310
|
+
conn_id = tab.connection_id
|
|
311
|
+
conn_meta = self.db_connections.get(conn_id) if conn_id else None
|
|
312
|
+
logger.debug(f"Connection info: {conn_meta['type'] if conn_meta else 'None'}")
|
|
313
|
+
|
|
314
|
+
def execute():
|
|
315
|
+
start = time.perf_counter()
|
|
316
|
+
logger.debug("Executing SQL query in thread")
|
|
317
|
+
|
|
318
|
+
if conn_meta and conn_meta["type"] == "mssql":
|
|
319
|
+
mssql_conn = conn_meta["connection"]
|
|
320
|
+
cursor = mssql_conn.cursor()
|
|
321
|
+
cursor.execute(sql)
|
|
322
|
+
if not cursor.description:
|
|
323
|
+
logger.debug("Query returned no results (MS SQL)")
|
|
324
|
+
return [], ["Info"], None, time.perf_counter() - start, False
|
|
325
|
+
cols = [d[0] for d in cursor.description]
|
|
326
|
+
raw_data = cursor.fetchmany(MAX_UI_ROWS + 1)
|
|
327
|
+
is_truncated = len(raw_data) > MAX_UI_ROWS
|
|
328
|
+
data = [list(r) for r in raw_data[:MAX_UI_ROWS]]
|
|
329
|
+
logger.debug(f"MS SQL query executed: {len(data)} rows (truncated={is_truncated}), {len(cols)} columns")
|
|
330
|
+
return data, cols, None, time.perf_counter() - start, is_truncated
|
|
331
|
+
else:
|
|
332
|
+
cursor = self.con.cursor()
|
|
333
|
+
res = cursor.execute(sql)
|
|
334
|
+
if not res.description:
|
|
335
|
+
duration = time.perf_counter() - start
|
|
336
|
+
logger.debug("Query returned no results (DuckDB)")
|
|
337
|
+
return [], ["Info"], [["Success"]], duration, False
|
|
338
|
+
cols = [d[0] for d in res.description]
|
|
339
|
+
raw_data = res.fetchmany(MAX_UI_ROWS + 1)
|
|
340
|
+
is_truncated = len(raw_data) > MAX_UI_ROWS
|
|
341
|
+
data = [list(r) for r in raw_data[:MAX_UI_ROWS]]
|
|
342
|
+
duration = time.perf_counter() - start
|
|
343
|
+
logger.debug(f"DuckDB query executed: {len(data)} rows (truncated={is_truncated}), {len(cols)} columns")
|
|
344
|
+
return data, cols, None, duration, is_truncated
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
data, cols, _, duration, is_truncated = await asyncio.to_thread(execute)
|
|
348
|
+
tab.column_names = cols
|
|
349
|
+
tab.full_data = data
|
|
350
|
+
tab.col_states = {i: {"filter": "", "sort": None} for i in range(len(cols))}
|
|
351
|
+
logger.debug(f"Setting up table with {len(cols)} columns and {len(data)} rows")
|
|
352
|
+
self.refresh_tab_table(tab)
|
|
353
|
+
conn_label = ""
|
|
354
|
+
if conn_meta:
|
|
355
|
+
conn_label = f" | via {conn_meta['type'].upper()}"
|
|
356
|
+
rows_label = f"{MAX_UI_ROWS}+ (truncated)" if is_truncated else str(len(data))
|
|
357
|
+
meta.update(f"Rows: {rows_label} | Time: {duration:.4f}s{conn_label} | Finished: {datetime.now().strftime('%H:%M:%S')}")
|
|
358
|
+
logger.debug(f"Query completed successfully: {len(data)} rows in {duration:.4f}s")
|
|
359
|
+
except asyncio.CancelledError:
|
|
360
|
+
logger.debug("Query was cancelled")
|
|
361
|
+
meta.update("Query cancelled")
|
|
362
|
+
except Exception as e:
|
|
363
|
+
logger.error(f"Error executing query: {str(e)}")
|
|
364
|
+
tbl.clear(columns=True)
|
|
365
|
+
tbl.add_column("Error")
|
|
366
|
+
|
|
367
|
+
# Try to extract file path from the SQL for better error messages
|
|
368
|
+
file_path = ""
|
|
369
|
+
csv_match = re.search(r"read_csv_auto\('([^']+)'\)", sql)
|
|
370
|
+
json_match = re.search(r"read_json_auto\('([^']+)'\)", sql)
|
|
371
|
+
file_match = re.search(r"FROM '([^']+)'", sql)
|
|
372
|
+
|
|
373
|
+
if csv_match:
|
|
374
|
+
file_path = csv_match.group(1)
|
|
375
|
+
elif json_match:
|
|
376
|
+
file_path = json_match.group(1)
|
|
377
|
+
elif file_match:
|
|
378
|
+
file_path = file_match.group(1)
|
|
379
|
+
|
|
380
|
+
error_message = self._classify_file_error(e, file_path) if file_path else str(e)
|
|
381
|
+
|
|
382
|
+
tbl.add_row(error_message)
|
|
383
|
+
meta.update("Error occurred")
|
|
384
|
+
finally:
|
|
385
|
+
tbl.loading = False
|
|
386
|
+
tab.running_task = None # Clear the running task reference
|
|
387
|
+
logger.debug("Query execution completed, task reference cleared")
|
|
388
|
+
|
|
389
|
+
def action_save_query(self):
|
|
390
|
+
"""Save the current query with a name."""
|
|
391
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
392
|
+
if not tabs.active:
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
396
|
+
sql = tab.query_one(TextArea).text.strip()
|
|
397
|
+
if not sql:
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
def handle_result(result):
|
|
401
|
+
if result and result.get("saved"):
|
|
402
|
+
# Reload the config and refresh the UI
|
|
403
|
+
self.config = load_config()
|
|
404
|
+
self.load_saved_queries_list()
|
|
405
|
+
|
|
406
|
+
self.push_screen(SaveQueryDialog(sql), handle_result)
|
|
407
|
+
|
|
408
|
+
def load_query_in_current_tab(self, sql: str):
|
|
409
|
+
"""Load a query into the current active tab."""
|
|
410
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
411
|
+
if not tabs.active:
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
415
|
+
textarea = tab.query_one(TextArea)
|
|
416
|
+
textarea.text = sql
|
|
417
|
+
textarea.selection = Selection((0, 0), (0, 0))
|
|
418
|
+
textarea.focus()
|
|
419
|
+
|
|
420
|
+
def load_history_list(self):
|
|
421
|
+
"""Load history into the history tree."""
|
|
422
|
+
tree = self.query_one("#history-tree", Tree)
|
|
423
|
+
tree.root.expand()
|
|
424
|
+
# Remove old children
|
|
425
|
+
for child in list(tree.root.children):
|
|
426
|
+
child.remove()
|
|
427
|
+
for i, item in enumerate(reversed(self.config["history"])):
|
|
428
|
+
timestamp = datetime.fromisoformat(item["timestamp"]).strftime("%H:%M:%S")
|
|
429
|
+
sql_preview = item["sql"][:50] + ("..." if len(item["sql"]) > 50 else "")
|
|
430
|
+
label = f"[{timestamp}] {sql_preview}"
|
|
431
|
+
tree.root.add_leaf(label, data={"type": "history", "sql": item["sql"]})
|
|
432
|
+
|
|
433
|
+
def load_saved_queries_list(self):
|
|
434
|
+
"""Load saved queries into the saved queries tree."""
|
|
435
|
+
tree = self.query_one("#saved-queries-tree", Tree)
|
|
436
|
+
tree.root.expand()
|
|
437
|
+
for child in list(tree.root.children):
|
|
438
|
+
child.remove()
|
|
439
|
+
for i, item in enumerate(self.config["saved_queries"]):
|
|
440
|
+
timestamp = datetime.fromisoformat(item["timestamp"]).strftime("%d/%m %H:%M")
|
|
441
|
+
label = f"{item['name']} [{timestamp}]"
|
|
442
|
+
tree.root.add_leaf(label, data={"type": "saved", "sql": item["sql"]})
|
|
443
|
+
|
|
444
|
+
def refresh_tab_table(self, tab: QueryTab):
|
|
445
|
+
logger.debug(f"Refreshing table: {len(tab.full_data)} rows, {len(tab.column_names)} columns")
|
|
446
|
+
|
|
447
|
+
tbl, data = tab.query_one(DataTable), [r[:] for r in tab.full_data]
|
|
448
|
+
|
|
449
|
+
# Apply filters first
|
|
450
|
+
for i, s in tab.col_states.items():
|
|
451
|
+
if s["filter"]:
|
|
452
|
+
f = s["filter"].lower()
|
|
453
|
+
initial_count = len(data)
|
|
454
|
+
data = [r for r in data if f in str(r[i]).lower()]
|
|
455
|
+
logger.debug(f"Applied filter on column {i}: {initial_count} -> {len(data)} rows")
|
|
456
|
+
|
|
457
|
+
# Apply sorting: sort None values safely without TypeError
|
|
458
|
+
sort_applied = False
|
|
459
|
+
for i, s in tab.col_states.items():
|
|
460
|
+
if s["sort"]:
|
|
461
|
+
initial_count = len(data)
|
|
462
|
+
try:
|
|
463
|
+
data.sort(key=lambda x: (x[i] is None, x[i]), reverse=(s["sort"] == "desc"))
|
|
464
|
+
except TypeError:
|
|
465
|
+
data.sort(key=lambda x: (x[i] is None, str(x[i])), reverse=(s["sort"] == "desc"))
|
|
466
|
+
sort_applied = True
|
|
467
|
+
logger.debug(f"Applied sort on column {i} ({tab.column_names[i] if i < len(tab.column_names) else 'N/A'}): {s['sort']}, {initial_count} rows sorted")
|
|
468
|
+
break
|
|
469
|
+
|
|
470
|
+
tbl.clear(columns=True)
|
|
471
|
+
# Add columns with sort indicators
|
|
472
|
+
for i, name in enumerate(tab.column_names):
|
|
473
|
+
s = tab.col_states[i]
|
|
474
|
+
sort_indicator = ''
|
|
475
|
+
if s['sort'] == 'asc':
|
|
476
|
+
sort_indicator = ' ▲'
|
|
477
|
+
elif s['sort'] == 'desc':
|
|
478
|
+
sort_indicator = ' ▼'
|
|
479
|
+
|
|
480
|
+
filter_indicator = ' 🔍' if s['filter'] else ''
|
|
481
|
+
lbl = f"{name}{sort_indicator}{filter_indicator}"
|
|
482
|
+
tbl.add_column(lbl, key=str(i))
|
|
483
|
+
logger.debug(f"Added column {i}: {lbl}")
|
|
484
|
+
|
|
485
|
+
# Add data rows
|
|
486
|
+
logger.debug(f"Adding {len(data)} data rows to table")
|
|
487
|
+
for r in data:
|
|
488
|
+
tbl.add_row(*[str(v) if v is not None else "NULL" for v in r])
|
|
489
|
+
|
|
490
|
+
logger.debug(f"Table refresh completed: {len(data)} rows, {len(tab.column_names)} columns")
|
|
491
|
+
|
|
492
|
+
def on_data_table_header_selected(self, event: DataTable.HeaderSelected):
|
|
493
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
494
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
495
|
+
idx = int(event.column_key.value)
|
|
496
|
+
|
|
497
|
+
logger.debug(f"Header selected: column index {idx}, column name {tab.column_names[idx] if idx < len(tab.column_names) else 'N/A'}")
|
|
498
|
+
|
|
499
|
+
# Toggle sort direction: None -> ASC -> DESC -> None
|
|
500
|
+
current_sort = tab.col_states[idx]["sort"]
|
|
501
|
+
logger.debug(f"Current sort state for column {idx}: {current_sort}")
|
|
502
|
+
|
|
503
|
+
if current_sort is None:
|
|
504
|
+
new_sort = "asc"
|
|
505
|
+
elif current_sort == "asc":
|
|
506
|
+
new_sort = "desc"
|
|
507
|
+
else: # current_sort == "desc"
|
|
508
|
+
new_sort = None
|
|
509
|
+
|
|
510
|
+
logger.debug(f"New sort state for column {idx}: {new_sort}")
|
|
511
|
+
|
|
512
|
+
# Clear all other sorts
|
|
513
|
+
for i in tab.col_states:
|
|
514
|
+
tab.col_states[i]["sort"] = None
|
|
515
|
+
|
|
516
|
+
# Apply new sort
|
|
517
|
+
tab.col_states[idx]["sort"] = new_sort
|
|
518
|
+
logger.debug(f"Applied sort: {new_sort} to column {idx}, refreshing table")
|
|
519
|
+
self.refresh_tab_table(tab)
|
|
520
|
+
|
|
521
|
+
async def _open_file_path(self, file_path: Path) -> None:
|
|
522
|
+
"""Handle opening a file from the file explorer: load into active tab or create new tab."""
|
|
523
|
+
if not is_duckdb_file(file_path):
|
|
524
|
+
return
|
|
525
|
+
try:
|
|
526
|
+
suffix = file_path.suffix.lower()
|
|
527
|
+
if suffix in {".xlsx", ".xls"}:
|
|
528
|
+
table_name = await self._load_and_register_excel(file_path)
|
|
529
|
+
sql = f'SELECT * FROM "{table_name}" LIMIT 100;'
|
|
530
|
+
else:
|
|
531
|
+
sql = sql_for_file(file_path)
|
|
532
|
+
|
|
533
|
+
try:
|
|
534
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
535
|
+
except Exception:
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
if tabs.active:
|
|
539
|
+
self.load_query_in_current_tab(sql)
|
|
540
|
+
try:
|
|
541
|
+
active_pane = self.query_one(f"#{tabs.active}", TabPane)
|
|
542
|
+
active_pane._title = f"{file_path.name} ✕"
|
|
543
|
+
content_tabs = tabs.get_child_by_type(ContentTabs)
|
|
544
|
+
target_id = f"--content-tab-{tabs.active}"
|
|
545
|
+
for ct in content_tabs.query(ContentTab):
|
|
546
|
+
if ct.id == target_id:
|
|
547
|
+
ct.label = f"{file_path.name} ✕"
|
|
548
|
+
break
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
self.run_worker(self.action_run_query())
|
|
552
|
+
else:
|
|
553
|
+
await self.add_new_tab(file_path.name, sql, run=True)
|
|
554
|
+
except Exception as e:
|
|
555
|
+
logger.error(f"Error opening file {file_path}: {str(e)}")
|
|
556
|
+
error_msg = self._classify_file_error(e, str(file_path))
|
|
557
|
+
error_sql = f"-- Error opening file: {file_path.name}\n-- {error_msg}"
|
|
558
|
+
try:
|
|
559
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
560
|
+
if tabs.active:
|
|
561
|
+
self.load_query_in_current_tab(error_sql)
|
|
562
|
+
else:
|
|
563
|
+
await self.add_new_tab(f"❌ {file_path.name}", error_sql, run=False)
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
async def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected):
|
|
568
|
+
await self._open_file_path(Path(event.path))
|
|
569
|
+
|
|
570
|
+
on_duck_tree_file_selected = on_directory_tree_file_selected
|
|
571
|
+
|
|
572
|
+
def copy_to_clipboard(self, text: str) -> None:
|
|
573
|
+
copy_to_system_clipboard(text)
|
|
574
|
+
super().copy_to_clipboard(text)
|
|
575
|
+
|
|
576
|
+
@property
|
|
577
|
+
def clipboard(self) -> str:
|
|
578
|
+
text = get_system_clipboard()
|
|
579
|
+
if text:
|
|
580
|
+
self._clipboard = text
|
|
581
|
+
return text
|
|
582
|
+
return super().clipboard
|
|
583
|
+
|
|
584
|
+
def action_toggle_comment(self):
|
|
585
|
+
"""Toggle SQL comments in the currently active tab like VS Code."""
|
|
586
|
+
try:
|
|
587
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
588
|
+
if not tabs.active:
|
|
589
|
+
return
|
|
590
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
591
|
+
editor = tab.query_one(SqlTextArea)
|
|
592
|
+
editor.action_toggle_comment()
|
|
593
|
+
editor.focus()
|
|
594
|
+
except Exception as e:
|
|
595
|
+
logger.error(f"Error toggling comment: {e}")
|
|
596
|
+
|
|
597
|
+
def _enable_smooth_mouse(self):
|
|
598
|
+
"""Re-enable standard Textual mouse tracking."""
|
|
599
|
+
if hasattr(self, "_driver") and self._driver:
|
|
600
|
+
self._driver.write("\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h")
|
|
601
|
+
self._driver.flush()
|
|
602
|
+
|
|
603
|
+
def _disable_all_mouse(self):
|
|
604
|
+
"""Disable all terminal mouse tracking (giving 100% native terminal selection like nano)."""
|
|
605
|
+
if hasattr(self, "_driver") and self._driver:
|
|
606
|
+
self._driver.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l")
|
|
607
|
+
self._driver.flush()
|
|
608
|
+
|
|
609
|
+
def action_toggle_mouse(self):
|
|
610
|
+
"""Toggle mouse tracking on/off (allowing native terminal selection like nano)."""
|
|
611
|
+
if not hasattr(self, "_mouse_reporting_enabled"):
|
|
612
|
+
self._mouse_reporting_enabled = True
|
|
613
|
+
|
|
614
|
+
if self._mouse_reporting_enabled:
|
|
615
|
+
self._disable_all_mouse()
|
|
616
|
+
self._mouse_reporting_enabled = False
|
|
617
|
+
self.notify("Native terminal selectie actief (zoals in nano). Druk nogmaals op Ctrl+M voor TUI muis.", title="Muis-modus", timeout=3)
|
|
618
|
+
else:
|
|
619
|
+
self._enable_smooth_mouse()
|
|
620
|
+
self._mouse_reporting_enabled = True
|
|
621
|
+
self.notify("TUI muis-modus actief (knoppen & tabs klikbaar).", title="Muis-modus", timeout=2)
|
|
622
|
+
|
|
623
|
+
def action_about(self):
|
|
624
|
+
self.push_screen(AboutScreen())
|
|
625
|
+
|
|
626
|
+
def on_click(self, event: Click) -> None:
|
|
627
|
+
"""Handle clicks on tab close buttons (✕ in tab titles)."""
|
|
628
|
+
try:
|
|
629
|
+
widget, _ = self.screen.get_widget_at(event.screen_x, event.screen_y)
|
|
630
|
+
except Exception:
|
|
631
|
+
return
|
|
632
|
+
if isinstance(widget, ContentTab) and widget.label_text.endswith(" ✕"):
|
|
633
|
+
# Check if click was on the ✕ area (last 3 characters of the tab)
|
|
634
|
+
relative_x = event.screen_x - widget.region.x
|
|
635
|
+
if relative_x >= widget.region.width - 3:
|
|
636
|
+
pane_id = ContentTab.sans_prefix(widget.id)
|
|
637
|
+
self.query_one("#tabs", TabbedContent).remove_pane(pane_id)
|
|
638
|
+
event.stop()
|
|
639
|
+
|
|
640
|
+
def action_undo(self):
|
|
641
|
+
"""Undo last action in current query editor."""
|
|
642
|
+
try:
|
|
643
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
644
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
645
|
+
editor = tab.query_one(SqlTextArea)
|
|
646
|
+
editor.action_undo()
|
|
647
|
+
except Exception as e:
|
|
648
|
+
logger.error(f"Error executing undo: {e}")
|
|
649
|
+
|
|
650
|
+
def action_redo(self):
|
|
651
|
+
"""Redo last undone action in current query editor."""
|
|
652
|
+
try:
|
|
653
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
654
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
655
|
+
editor = tab.query_one(SqlTextArea)
|
|
656
|
+
editor.action_redo()
|
|
657
|
+
except Exception as e:
|
|
658
|
+
logger.error(f"Error executing redo: {e}")
|
|
659
|
+
|
|
660
|
+
def on_key(self, event) -> None:
|
|
661
|
+
"""Global key handler for undo/redo shortcuts when focus is outside the editor."""
|
|
662
|
+
if len(self.screen_stack) > 1:
|
|
663
|
+
return
|
|
664
|
+
key = (event.key or "").lower()
|
|
665
|
+
if key in ("super+z", "cmd+z", "command+z", "ctrl+z"):
|
|
666
|
+
self.action_undo()
|
|
667
|
+
event.stop()
|
|
668
|
+
event.prevent_default()
|
|
669
|
+
elif key in (
|
|
670
|
+
"super+shift+z", "shift+super+z", "cmd+shift+z", "shift+cmd+z",
|
|
671
|
+
"command+shift+z", "shift+command+z", "ctrl+shift+z", "shift+ctrl+z",
|
|
672
|
+
"super+y", "cmd+y", "command+y", "ctrl+y"
|
|
673
|
+
):
|
|
674
|
+
self.action_redo()
|
|
675
|
+
event.stop()
|
|
676
|
+
event.prevent_default()
|
|
677
|
+
|
|
678
|
+
def action_close_tab(self):
|
|
679
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
680
|
+
if tabs.active:
|
|
681
|
+
tabs.remove_pane(tabs.active)
|
|
682
|
+
|
|
683
|
+
def action_quit(self):
|
|
684
|
+
self.exit()
|
|
685
|
+
|
|
686
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected):
|
|
687
|
+
"""Handle selection in database tree, saved queries tree, and history tree."""
|
|
688
|
+
node = event.node
|
|
689
|
+
if not node.data:
|
|
690
|
+
return
|
|
691
|
+
if not isinstance(node.data, dict):
|
|
692
|
+
path = getattr(node.data, "path", None)
|
|
693
|
+
if path and is_duckdb_file(Path(path)):
|
|
694
|
+
self.run_worker(self._open_file_path(Path(path)))
|
|
695
|
+
return
|
|
696
|
+
node_type = node.data.get("type")
|
|
697
|
+
if node_type == "table":
|
|
698
|
+
conn_id = node.data["conn_id"]
|
|
699
|
+
table_name = node.data["table"]
|
|
700
|
+
conn_meta = self.db_connections.get(conn_id)
|
|
701
|
+
if conn_meta and conn_meta["type"] == "mysql":
|
|
702
|
+
alias = conn_meta["alias"]
|
|
703
|
+
sql = f"SELECT * FROM {alias}.{table_name} LIMIT 100;"
|
|
704
|
+
self.run_worker(self.add_new_tab(table_name, sql, run=True))
|
|
705
|
+
elif conn_meta and conn_meta["type"] == "mssql":
|
|
706
|
+
sql = f"SELECT TOP 100 * FROM [{table_name}];"
|
|
707
|
+
self.run_worker(self.add_new_tab(table_name, sql, run=True, connection_id=conn_id))
|
|
708
|
+
elif node_type == "error":
|
|
709
|
+
conn_id = node.data.get("conn_id")
|
|
710
|
+
for c in self.config.get("connections", []):
|
|
711
|
+
if c["id"] == conn_id:
|
|
712
|
+
def handle_retry(result):
|
|
713
|
+
if result:
|
|
714
|
+
save_connection(result)
|
|
715
|
+
self.config = load_config()
|
|
716
|
+
self.run_worker(self.connect_database(result))
|
|
717
|
+
self.push_screen(AddConnectionDialog(existing_conn=c), handle_retry)
|
|
718
|
+
break
|
|
719
|
+
elif node_type == "add_connection":
|
|
720
|
+
def handle_new_conn(result):
|
|
721
|
+
if result:
|
|
722
|
+
save_connection(result)
|
|
723
|
+
self.config = load_config()
|
|
724
|
+
self.run_worker(self.connect_database(result))
|
|
725
|
+
self.push_screen(AddConnectionDialog(), handle_new_conn)
|
|
726
|
+
elif node_type in ("history", "saved"):
|
|
727
|
+
sql = node.data.get("sql")
|
|
728
|
+
if sql:
|
|
729
|
+
self.load_query_in_current_tab(sql)
|
|
730
|
+
|
|
731
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
732
|
+
"""Handle button presses."""
|
|
733
|
+
if event.button.id == "btn-run-query":
|
|
734
|
+
self.run_worker(self.action_run_query())
|
|
735
|
+
return
|
|
736
|
+
elif event.button.id == "btn-comment":
|
|
737
|
+
self.action_toggle_comment()
|
|
738
|
+
return
|
|
739
|
+
|
|
740
|
+
if event.button.id in ["export-csv", "export-excel"]:
|
|
741
|
+
# Find the current tab and its data
|
|
742
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
743
|
+
if not tabs.active:
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
747
|
+
if not tab.full_data or not tab.column_names:
|
|
748
|
+
# No data to export
|
|
749
|
+
meta = tab.query_one("#metadata-bar") if tab else None
|
|
750
|
+
if meta:
|
|
751
|
+
meta.update("No data to export")
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
# Determine export type based on button
|
|
755
|
+
export_type = "csv" if event.button.id == "export-csv" else "excel"
|
|
756
|
+
|
|
757
|
+
def handle_export(result):
|
|
758
|
+
if result and result.get("exported"):
|
|
759
|
+
filename = result["filename"]
|
|
760
|
+
meta = tab.query_one("#metadata-bar")
|
|
761
|
+
meta.update(f"Exported to {filename}")
|
|
762
|
+
|
|
763
|
+
# Show export dialog
|
|
764
|
+
self.push_screen(ExportDialog(tab, export_type), handle_export)
|
|
765
|
+
|
|
766
|
+
elif event.button.id == "cancel-query":
|
|
767
|
+
# Cancel the running query
|
|
768
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
769
|
+
if not tabs.active:
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
tab = self.query_one(f"#{tabs.active}").query_one(QueryTab)
|
|
773
|
+
if tab.running_task and not tab.running_task.done():
|
|
774
|
+
logger.debug("Cancelling query task and interrupting DuckDB")
|
|
775
|
+
# Interrupt DuckDB execution
|
|
776
|
+
try:
|
|
777
|
+
self.con.interrupt()
|
|
778
|
+
except Exception as e:
|
|
779
|
+
logger.error(f"Error interrupting DuckDB: {e}")
|
|
780
|
+
|
|
781
|
+
tab.running_task.cancel()
|
|
782
|
+
meta = tab.query_one("#metadata-bar")
|
|
783
|
+
meta.update("Query cancelled")
|
|
784
|
+
tbl = tab.query_one(DataTable)
|
|
785
|
+
tbl.loading = False
|
|
786
|
+
|
|
787
|
+
elif event.button.id == "close-tab":
|
|
788
|
+
# Close the current tab
|
|
789
|
+
tabs = self.query_one("#tabs", TabbedContent)
|
|
790
|
+
if tabs.active:
|
|
791
|
+
tabs.remove_pane(tabs.active)
|
|
792
|
+
|
|
793
|
+
def on_input_changed(self, event: Input.Changed):
|
|
794
|
+
"""Handle search input changes for database tables."""
|
|
795
|
+
if event.input.id == "db-search-input":
|
|
796
|
+
# Find the database tree and filter tables
|
|
797
|
+
try:
|
|
798
|
+
db_tree = self.query_one("#db-tree", DatabaseTree)
|
|
799
|
+
db_tree.filter_tables(event.value)
|
|
800
|
+
except Exception:
|
|
801
|
+
# If the element doesn't exist yet, ignore
|
|
802
|
+
pass
|