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/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