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.
@@ -0,0 +1,7 @@
1
+ """Modal dialogs for OpenDuck."""
2
+
3
+ from openduck.gui.dialogs.add_connection_dialog import AddConnectionDialog
4
+ from openduck.gui.dialogs.export_dialog import ExportDialog
5
+ from openduck.gui.dialogs.save_query_dialog import SaveQueryDialog
6
+
7
+ __all__ = ["SaveQueryDialog", "ExportDialog", "AddConnectionDialog"]
@@ -0,0 +1,77 @@
1
+ """Modal dialog for adding database connections (MySQL / MSSQL)."""
2
+
3
+ from datetime import datetime
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Horizontal, Vertical
6
+ from textual.screen import ModalScreen
7
+ from textual.widgets import Button, Input, Select, Static
8
+
9
+
10
+ class AddConnectionDialog(ModalScreen):
11
+ """Modal dialog to add a MySQL or MSSQL connection."""
12
+
13
+ def __init__(self, existing_conn: dict = None):
14
+ super().__init__()
15
+ self.existing_conn = existing_conn
16
+
17
+ def compose(self) -> ComposeResult:
18
+ with Vertical(id="conn-dialog"):
19
+ yield Static("Add Database Connection", id="conn-dialog-title")
20
+ yield Static("Type:")
21
+ yield Select(
22
+ [("MySQL", "mysql"), ("MSSQL", "mssql")],
23
+ prompt="Select type",
24
+ id="conn-type",
25
+ allow_blank=False,
26
+ value="mysql",
27
+ )
28
+ yield Input(placeholder="Display name...", id="conn-display-name")
29
+ yield Input(placeholder="Host (e.g. localhost)", id="conn-host", value="localhost")
30
+ yield Input(placeholder="Port", id="conn-port", value="3306")
31
+ yield Input(placeholder="Username", id="conn-user")
32
+ yield Input(placeholder="Password", id="conn-password", password=True)
33
+ yield Input(placeholder="Database name", id="conn-database")
34
+ with Horizontal():
35
+ yield Button("Connect", variant="primary", id="btn-connect")
36
+ yield Button("Cancel", id="btn-conn-cancel")
37
+ yield Static("", id="conn-status")
38
+
39
+ def on_mount(self):
40
+ self.query_one("#conn-display-name").focus()
41
+ if self.existing_conn:
42
+ self.query_one("#conn-type", Select).value = self.existing_conn["type"]
43
+ self.query_one("#conn-display-name", Input).value = self.existing_conn.get("display_name", "")
44
+ self.query_one("#conn-host", Input).value = self.existing_conn.get("host", "localhost")
45
+ self.query_one("#conn-port", Input).value = str(self.existing_conn.get("port", "3306"))
46
+ self.query_one("#conn-user", Input).value = self.existing_conn.get("user", "")
47
+ self.query_one("#conn-password", Input).value = self.existing_conn.get("password", "")
48
+ self.query_one("#conn-database", Input).value = self.existing_conn.get("database", "")
49
+
50
+ def on_select_changed(self, event: Select.Changed):
51
+ if event.select.id == "conn-type":
52
+ port_input = self.query_one("#conn-port", Input)
53
+ if event.value == "mysql" and port_input.value in ("1433", ""):
54
+ port_input.value = "3306"
55
+ elif event.value == "mssql" and port_input.value in ("3306", ""):
56
+ port_input.value = "1433"
57
+
58
+ def on_button_pressed(self, event: Button.Pressed):
59
+ if event.button.id == "btn-conn-cancel":
60
+ self.dismiss()
61
+ elif event.button.id == "btn-connect":
62
+ conn_info = {
63
+ "id": self.existing_conn["id"] if self.existing_conn else f"conn_{int(datetime.now().timestamp() * 1000)}",
64
+ "display_name": self.query_one("#conn-display-name", Input).value.strip(),
65
+ "type": self.query_one("#conn-type", Select).value,
66
+ "host": self.query_one("#conn-host", Input).value.strip(),
67
+ "port": int(self.query_one("#conn-port", Input).value.strip() or "0"),
68
+ "user": self.query_one("#conn-user", Input).value.strip(),
69
+ "password": self.query_one("#conn-password", Input).value,
70
+ "database": self.query_one("#conn-database", Input).value.strip(),
71
+ }
72
+ if not conn_info["display_name"]:
73
+ conn_info["display_name"] = f"{conn_info['type']}://{conn_info['host']}/{conn_info['database']}"
74
+ if not all([conn_info["host"], conn_info["database"]]):
75
+ self.query_one("#conn-status").update("[red]Host and database are required[/red]")
76
+ return
77
+ self.dismiss(conn_info)
@@ -0,0 +1,109 @@
1
+ """Modal dialog for exporting query results to CSV or Excel."""
2
+
3
+ import asyncio
4
+ import csv
5
+ import logging
6
+ import re
7
+ from typing import TYPE_CHECKING, Tuple
8
+ from textual.app import ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Button, Input, Static, TextArea
12
+
13
+ from openduck.core.config import CWD
14
+
15
+ if TYPE_CHECKING:
16
+ from openduck.gui.widgets.query_tab import QueryTab
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ExportDialog(ModalScreen):
22
+ """Modal dialog to export query results into CSV or XLSX files."""
23
+
24
+ def __init__(self, tab: 'QueryTab', export_type: str):
25
+ super().__init__()
26
+ self.tab = tab
27
+ self.export_type = export_type
28
+
29
+ def compose(self) -> ComposeResult:
30
+ with Vertical(id="export-modal"):
31
+ yield Static(f"Export {self.export_type.upper()}", id="export-modal-title")
32
+ yield Input(placeholder="Enter filename...", id="export-filename")
33
+ yield Static("Current query:", id="current-query-label")
34
+ yield Static(
35
+ self.tab.query_one(TextArea).text[:100]
36
+ + ("..." if len(self.tab.query_one(TextArea).text) > 100 else ""),
37
+ id="current-query-preview",
38
+ )
39
+ with Horizontal():
40
+ yield Button("Export", variant="primary", id="btn-export")
41
+ yield Button("Cancel", id="btn-cancel")
42
+
43
+ def on_mount(self):
44
+ # Pre-populate filename based on query content
45
+ query_text = self.tab.query_one(TextArea).text.strip()
46
+ table_match = re.search(r'FROM\s+(\w+)', query_text, re.IGNORECASE)
47
+ base_name = table_match.group(1) if table_match else "query_result"
48
+ base_name = re.sub(r'[^\w\-_]', '_', base_name)
49
+ extension = ".csv" if self.export_type == "csv" else ".xlsx"
50
+ default_filename = f"{base_name}{extension}"
51
+
52
+ self.query_one("#export-filename").value = default_filename
53
+ self.query_one("#export-filename").focus()
54
+
55
+ def export_to_csv(self, filename: str) -> Tuple[bool, str]:
56
+ """Export table data to CSV file."""
57
+ try:
58
+ filepath = CWD / filename
59
+ with open(filepath, 'w', newline='', encoding='utf-8') as csvfile:
60
+ writer = csv.writer(csvfile)
61
+ writer.writerow(self.tab.column_names)
62
+ for row in self.tab.full_data:
63
+ writer.writerow(row)
64
+ return True, ""
65
+ except Exception as e:
66
+ logger.error(f"Failed to export CSV: {e}")
67
+ return False, str(e)
68
+
69
+ def export_to_excel(self, filename: str) -> Tuple[bool, str]:
70
+ """Export table data to Excel file."""
71
+ try:
72
+ import pandas as pd
73
+ except ImportError:
74
+ return False, "pandas is not installed (pip install pandas openpyxl)"
75
+ try:
76
+ import openpyxl # noqa: F401
77
+ except ImportError:
78
+ return False, "openpyxl is not installed (pip install openpyxl)"
79
+ try:
80
+ filepath = CWD / filename
81
+ df = pd.DataFrame(self.tab.full_data, columns=self.tab.column_names)
82
+ df.to_excel(filepath, index=False)
83
+ return True, ""
84
+ except Exception as e:
85
+ logger.error(f"Failed to export Excel: {e}")
86
+ return False, str(e)
87
+
88
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
89
+ if event.button.id == "btn-cancel":
90
+ self.dismiss()
91
+ elif event.button.id == "btn-export":
92
+ filename = self.query_one("#export-filename", Input).value.strip()
93
+ if not filename:
94
+ self.query_one("#export-filename").value = ""
95
+ self.query_one("#export-filename").focus()
96
+ return
97
+
98
+ event.button.disabled = True
99
+ if self.export_type == "csv":
100
+ success, error_msg = await asyncio.to_thread(self.export_to_csv, filename)
101
+ else:
102
+ success, error_msg = await asyncio.to_thread(self.export_to_excel, filename)
103
+
104
+ event.button.disabled = False
105
+ if success:
106
+ self.app.notify(f"Geëxporteerd naar {filename}", timeout=2.0)
107
+ self.dismiss({"exported": True, "filename": filename})
108
+ else:
109
+ self.app.notify(f"Export mislukt: {error_msg}", severity="error", timeout=4.0)
@@ -0,0 +1,41 @@
1
+ """Modal dialog for saving named queries."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.screen import ModalScreen
6
+ from textual.widgets import Button, Input, Static
7
+
8
+ from openduck.core.config import save_query
9
+
10
+
11
+ class SaveQueryDialog(ModalScreen):
12
+ """Modal dialog to save current query text with a custom name."""
13
+
14
+ def __init__(self, query_text: str):
15
+ super().__init__()
16
+ self.query_text = query_text
17
+
18
+ def compose(self) -> ComposeResult:
19
+ with Vertical(id="save-dialog"):
20
+ yield Static("Save Query", id="dialog-title")
21
+ yield Input(placeholder="Enter query name...", id="query-name")
22
+ yield Static("Query:", id="query-label")
23
+ yield Static(self.query_text[:100] + ("..." if len(self.query_text) > 100 else ""), id="query-preview")
24
+ with Horizontal():
25
+ yield Button("Save", variant="primary", id="btn-save")
26
+ yield Button("Cancel", id="btn-cancel")
27
+
28
+ def on_mount(self):
29
+ self.query_one("#query-name").focus()
30
+
31
+ def on_button_pressed(self, event: Button.Pressed):
32
+ if event.button.id == "btn-cancel":
33
+ self.dismiss()
34
+ elif event.button.id == "btn-save":
35
+ name = self.query_one("#query-name", Input).value
36
+ if name.strip():
37
+ save_query(name, self.query_text)
38
+ self.dismiss({"saved": True, "name": name})
39
+ else:
40
+ self.query_one("#query-name").value = ""
41
+ self.query_one("#query-name").focus()
@@ -0,0 +1,6 @@
1
+ """Modal screens for OpenDuck."""
2
+
3
+ from openduck.gui.screens.about import AboutScreen, DUCK_ART, LOGO_RAW, lolcat
4
+ from openduck.gui.screens.matrix import MatrixScreen
5
+
6
+ __all__ = ["AboutScreen", "MatrixScreen", "DUCK_ART", "LOGO_RAW", "lolcat"]
@@ -0,0 +1,84 @@
1
+ """About OpenDuck modal dialog screen."""
2
+
3
+ from rich.text import Text
4
+ from textual.app import ComposeResult
5
+ from textual.containers import Middle, Vertical
6
+ from textual.events import Click
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Static
9
+
10
+ from openduck.gui.screens.matrix import MatrixScreen
11
+ from openduck.gui.styles import ABOUT_CSS
12
+
13
+ LOGO_RAW = (
14
+ " ___ _ _ \n"
15
+ " / _ \\ _ __ ___ _ __ __| |_ _ ___| | __\n"
16
+ " | | | | '_ \\ / _ \\ '_ \\ / _` | | | |/ __| |/ /\n"
17
+ " | |_| | |_) | __/ | | | (_| | |_| | (__| < \n"
18
+ " \\___/| .__/ \\___|_| |_|\\__,_|\\__,_|\\___|_|\\_\\\n"
19
+ " |_| "
20
+ )
21
+
22
+ LOLCAT_COLORS = [
23
+ "#ff0000", "#ff4400", "#ff8800", "#ffcc00", "#ffff00", "#88ff00",
24
+ "#00ff00", "#00ff88", "#00ffff", "#0088ff", "#0000ff", "#4400ff",
25
+ "#8800ff", "#cc00ff", "#ff00ff", "#ff0088",
26
+ ]
27
+
28
+
29
+ def lolcat(text: str) -> Text:
30
+ """Apply rainbow colors to text, Rich markup style."""
31
+ rich_text = Text(text)
32
+ lines = text.split("\n")
33
+ pos = 0
34
+ for row, line in enumerate(lines):
35
+ for col, ch in enumerate(line):
36
+ if ch != " ":
37
+ color = LOLCAT_COLORS[(col + row * 3) % len(LOLCAT_COLORS)]
38
+ rich_text.stylize(color, pos, pos + 1)
39
+ pos += 1
40
+ pos += 1 # newline
41
+ return rich_text
42
+
43
+
44
+ DUCK_ART = r""" (o. H
45
+ _o) | . : (o]H
46
+ \\\__/ \\\_| : :. \\\_\ H
47
+ <____).....<_____).:.::.......<_____).H"""
48
+
49
+
50
+ class AboutScreen(ModalScreen):
51
+ """About dialog screen with ASCII duck and Easter egg link."""
52
+
53
+ DEFAULT_CSS = ABOUT_CSS
54
+
55
+ def compose(self) -> ComposeResult:
56
+ with Middle():
57
+ with Vertical(id="about-inner"):
58
+ yield Static(lolcat(LOGO_RAW), id="about-title")
59
+ yield Static("────────────────────────────────────────────────────")
60
+ yield Static("[b]GitHub:[/b] github.com/bertatron/duckcli")
61
+ yield Static("[b]Author:[/b] Bertatron")
62
+ yield Static("[b]License:[/b] MIT")
63
+ yield Static("\nKISS - Metadata & Async enabled.")
64
+ yield Static("Don't click here", id="easter-egg")
65
+ yield Static(DUCK_ART, id="duck-art")
66
+ yield Static("\n[Press any key to close]", id="about-footer")
67
+
68
+ def on_key(self) -> None:
69
+ self.dismiss()
70
+
71
+ def on_click(self, event: Click) -> None:
72
+ if hasattr(event, 'widget') and event.widget and event.widget.id == "easter-egg":
73
+ self.dismiss()
74
+ self.app.push_screen(MatrixScreen())
75
+ else:
76
+ try:
77
+ widget, _ = self.screen.get_widget_at(event.screen_x, event.screen_y)
78
+ if widget.id == "easter-egg":
79
+ self.dismiss()
80
+ self.app.push_screen(MatrixScreen())
81
+ return
82
+ except Exception:
83
+ pass
84
+ self.dismiss()
@@ -0,0 +1,90 @@
1
+ """Matrix rain screensaver screen (Easter egg)."""
2
+
3
+ import random
4
+ from textual.app import ComposeResult
5
+ from textual.screen import ModalScreen
6
+ from textual.widgets import Static
7
+
8
+ from openduck.gui.styles import MATRIX_CSS
9
+
10
+ MATRIX_CHARS = "アイウエオカキクケコサシスセソタチツテトナニヌネノ0123456789"
11
+ # ANSI 256: 82=#87ff00, 46=#00ff00, 40=#00d700, 34=#00af00, 28=#008700, 22=#005f00
12
+ MATRIX_SHADES = ["#87ff00", "#00ff00", "#00d700", "#00af00", "#008700", "#005f00"]
13
+
14
+
15
+ class MatrixScreen(ModalScreen):
16
+ """Matrix code rain modal screensaver."""
17
+
18
+ DEFAULT_CSS = MATRIX_CSS
19
+
20
+ def __init__(self):
21
+ super().__init__()
22
+ self._drops = []
23
+ self._grid = []
24
+ self._colors = []
25
+ self._timer = None
26
+
27
+ def compose(self) -> ComposeResult:
28
+ yield Static("", id="matrix-canvas")
29
+
30
+ def on_mount(self) -> None:
31
+ cols = self.screen.size.width
32
+ rows = self.screen.size.height
33
+ self._drops = [random.randint(0, rows) for _ in range(cols)]
34
+ # Persistent grid - characters stay until overwritten
35
+ self._grid = [[" "] * cols for _ in range(rows)]
36
+ self._colors = [["#005f00"] * cols for _ in range(rows)]
37
+ self._timer = self.set_interval(0.03, self._tick)
38
+
39
+ def _tick(self) -> None:
40
+ cols = self.screen.size.width
41
+ rows = self.screen.size.height
42
+ # Resize grids if terminal changed
43
+ while len(self._drops) < cols:
44
+ self._drops.append(0)
45
+ self._drops = self._drops[:cols]
46
+ while len(self._grid) < rows:
47
+ self._grid.append([" "] * cols)
48
+ self._colors.append(["#005f00"] * cols)
49
+ self._grid = self._grid[:rows]
50
+ self._colors = self._colors[:rows]
51
+ for r in range(rows):
52
+ while len(self._grid[r]) < cols:
53
+ self._grid[r].append(" ")
54
+ self._colors[r].append("#005f00")
55
+ self._grid[r] = self._grid[r][:cols]
56
+ self._colors[r] = self._colors[r][:cols]
57
+ # Draw drops - characters persist on the grid like the original
58
+ for i in range(cols):
59
+ if random.random() > 0.97:
60
+ self._drops[i] = 0
61
+ for j, shade in enumerate(MATRIX_SHADES):
62
+ y = self._drops[i] - j
63
+ if 0 <= y < rows:
64
+ self._grid[y][i] = random.choice(MATRIX_CHARS)
65
+ self._colors[y][i] = shade
66
+ self._drops[i] += 1
67
+ if self._drops[i] >= rows:
68
+ self._drops[i] = 0
69
+ # Render
70
+ lines = []
71
+ for r in range(rows):
72
+ parts = []
73
+ for c in range(cols):
74
+ ch = self._grid[r][c]
75
+ if ch != " ":
76
+ parts.append(f"[{self._colors[r][c]}]{ch}[/]")
77
+ else:
78
+ parts.append(" ")
79
+ lines.append("".join(parts))
80
+ self.query_one("#matrix-canvas").update("\n".join(lines))
81
+
82
+ def on_key(self) -> None:
83
+ if self._timer:
84
+ self._timer.stop()
85
+ self.dismiss()
86
+
87
+ def on_click(self) -> None:
88
+ if self._timer:
89
+ self._timer.stop()
90
+ self.dismiss()
openduck/gui/styles.py ADDED
@@ -0,0 +1,71 @@
1
+ """Textual CSS styles for OpenDuck UI components."""
2
+
3
+ APP_CSS = """
4
+ #sidebar { width: 30; height: 100%; background: $surface; }
5
+ #files { height: 2fr; min-height: 8; }
6
+ #db-tree { height: 1fr; min-height: 8; }
7
+ #db-tree-inner { height: 1fr; }
8
+ #saved-queries-tree { height: 1fr; min-height: 5; }
9
+ #history-tree { height: 1fr; min-height: 5; }
10
+ #main-content { width: 1fr; }
11
+ TabbedContent { width: 100%; height: 1fr; }
12
+ SqlTextArea, TextArea { height: 35%; border-bottom: tall $primary; }
13
+ ResultsTable, DataTable { height: 1fr; }
14
+ #export-sidebar { width: 14; margin-left: 1; background: $panel; border: round $primary; padding: 0; overflow-y: auto; }
15
+ .export-btn { margin: 0; padding: 0; height: 3; width: 100%; }
16
+ #metadata-container {
17
+ height: 1;
18
+ background: $primary;
19
+ color: $text;
20
+ layout: horizontal;
21
+ }
22
+ #metadata-bar {
23
+ width: 1fr;
24
+ padding: 0 1;
25
+ }
26
+ #memory-bar {
27
+ width: auto;
28
+ padding: 0 1;
29
+ text-align: right;
30
+ }
31
+ #menu { padding: 1 2; background: $surface; border: tall $primary; width: 50; height: auto; }
32
+ #menu-title { text-style: bold; margin-bottom: 1; }
33
+ #filter-input { margin-bottom: 1; }
34
+ Button { margin: 0 1; min-width: 10; }
35
+ AboutScreen { align: center middle; background: rgba(0, 0, 0, 0.7); }
36
+ #about-inner { width: 64; height: auto; background: $surface; border: tall $primary; padding: 2 4; }
37
+ #about-title { text-align: center; color: $accent; }
38
+ #about-footer { text-align: center; color: $text-disabled; }
39
+ #save-dialog { width: 50; height: auto; background: $surface; border: tall $primary; padding: 2; }
40
+ #dialog-title { text-style: bold; margin-bottom: 1; text-align: center; }
41
+ #query-preview { height: 3; border: solid $primary; padding: 1; }
42
+ #export-modal { width: 50; height: auto; background: $surface; border: tall $primary; padding: 2; }
43
+ #export-modal-title { text-style: bold; margin-bottom: 1; text-align: center; }
44
+ #export-filename { margin-bottom: 1; }
45
+ #db-search-input { margin-bottom: 1; }
46
+ #conn-dialog { width: 60; height: auto; background: $surface; border: tall $primary; padding: 2; }
47
+ #conn-dialog-title { text-style: bold; margin-bottom: 1; text-align: center; }
48
+ #conn-status { height: 1; margin-top: 1; }
49
+ AddConnectionDialog { align: center middle; background: rgba(0, 0, 0, 0.7); }
50
+ """
51
+
52
+ MATRIX_CSS = """
53
+ MatrixScreen { background: black; }
54
+ #matrix-canvas { width: 100%; height: 100%; background: black; }
55
+ """
56
+
57
+ ABOUT_CSS = """
58
+ #easter-egg { color: $text-disabled; }
59
+ #duck-art { color: $accent; }
60
+ """
61
+
62
+ RESIZE_CSS = """
63
+ ResizeHandle {
64
+ width: 1;
65
+ height: 100%;
66
+ background: $primary;
67
+ }
68
+ ResizeHandle:hover {
69
+ background: $accent;
70
+ }
71
+ """
@@ -0,0 +1,17 @@
1
+ """UI widgets for OpenDuck."""
2
+
3
+ from openduck.gui.widgets.database_tree import DatabaseTree
4
+ from openduck.gui.widgets.file_tree import DuckTree
5
+ from openduck.gui.widgets.query_tab import QueryTab
6
+ from openduck.gui.widgets.resize_handle import ResizeHandle
7
+ from openduck.gui.widgets.results_table import ResultsTable
8
+ from openduck.gui.widgets.sql_editor import SqlTextArea
9
+
10
+ __all__ = [
11
+ "DatabaseTree",
12
+ "DuckTree",
13
+ "QueryTab",
14
+ "ResizeHandle",
15
+ "ResultsTable",
16
+ "SqlTextArea",
17
+ ]
@@ -0,0 +1,106 @@
1
+ """Database connections explorer tree with table search."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Vertical
5
+ from textual.widgets import Input, Tree
6
+
7
+
8
+ class DatabaseTree(Vertical):
9
+ """Tree widget showing database connections and their tables with search functionality."""
10
+
11
+ def __init__(self):
12
+ super().__init__(id="db-tree")
13
+ self.connections_data = {}
14
+ self.original_tree = Tree("Databases", id="db-tree-inner")
15
+ self.search_input = Input(placeholder="Search tables...", id="db-search-input")
16
+
17
+ def compose(self) -> ComposeResult:
18
+ yield self.search_input
19
+ yield self.original_tree
20
+
21
+ def on_mount(self):
22
+ self.original_tree.root.expand()
23
+ self._ensure_add_node()
24
+
25
+ def _ensure_add_node(self):
26
+ """Make sure the '+ Add connection' node is always the last leaf."""
27
+ for child in list(self.original_tree.root.children):
28
+ if child.data and child.data.get("type") == "add_connection":
29
+ child.remove()
30
+ self.original_tree.root.add_leaf("+ Add connection", data={"type": "add_connection"})
31
+
32
+ def add_connection_node(self, conn_info: dict, tables: list = None):
33
+ conn_id = conn_info["id"]
34
+ type_icon = "\U0001f42c" if conn_info["type"] == "mysql" else "\U0001f537"
35
+ label = f"{type_icon} {conn_info['display_name']}"
36
+ self.remove_connection_node(conn_id)
37
+ # Store tables in connection info for table filtering
38
+ conn_info["tables"] = tables or []
39
+ self.connections_data[conn_id] = conn_info
40
+ conn_node = self.original_tree.root.add(
41
+ label,
42
+ data={"type": "connection", "conn_id": conn_id},
43
+ expand=True,
44
+ )
45
+ if tables:
46
+ for table_name in sorted(tables):
47
+ conn_node.add_leaf(
48
+ f" {table_name}",
49
+ data={"type": "table", "conn_id": conn_id, "table": table_name, "database": conn_info["database"]},
50
+ )
51
+ else:
52
+ conn_node.add_leaf("Loading...", data={"type": "loading", "conn_id": conn_id})
53
+ self._ensure_add_node()
54
+
55
+ def remove_connection_node(self, conn_id: str):
56
+ for child in list(self.original_tree.root.children):
57
+ if child.data and child.data.get("conn_id") == conn_id:
58
+ child.remove()
59
+ break
60
+ self.connections_data.pop(conn_id, None)
61
+ self._ensure_add_node()
62
+
63
+ def filter_tables(self, search_term: str):
64
+ """Filter the tree to show only tables that match the search term."""
65
+ search_term = search_term.strip().lower()
66
+
67
+ # Clear the tree and rebuild with filtered content
68
+ self.original_tree.clear()
69
+
70
+ for conn_id, conn_info in self.connections_data.items():
71
+ type_icon = "\U0001f42c" if conn_info["type"] == "mysql" else "\U0001f537"
72
+ label = f"{type_icon} {conn_info['display_name']}"
73
+
74
+ all_tables = conn_info.get("tables", [])
75
+ if search_term:
76
+ filtered_tables = [table for table in all_tables if search_term in table.lower()]
77
+ else:
78
+ filtered_tables = all_tables
79
+
80
+ conn_node = self.original_tree.root.add(
81
+ label,
82
+ data={"type": "connection", "conn_id": conn_id},
83
+ expand=True,
84
+ )
85
+
86
+ if filtered_tables:
87
+ for table_name in sorted(filtered_tables):
88
+ conn_node.add_leaf(
89
+ f" {table_name}",
90
+ data={"type": "table", "conn_id": conn_id, "table": table_name, "database": conn_info["database"]},
91
+ )
92
+ elif search_term:
93
+ conn_node.add_leaf(
94
+ " No matching tables",
95
+ data={"type": "no_match", "conn_id": conn_id},
96
+ )
97
+ else:
98
+ conn_node.add_leaf("Loading...", data={"type": "loading", "conn_id": conn_id})
99
+
100
+ self._ensure_add_node()
101
+ self.original_tree.root.expand()
102
+
103
+ def update_tables(self, conn_id: str, tables: list):
104
+ conn_info = self.connections_data.get(conn_id)
105
+ if conn_info:
106
+ self.add_connection_node(conn_info, tables)
@@ -0,0 +1,14 @@
1
+ """Filtered directory tree for DuckDB-supported files."""
2
+
3
+ from pathlib import Path
4
+ from typing import Iterable
5
+ from textual.widgets import DirectoryTree
6
+
7
+ from openduck.core.sql_utils import is_duckdb_file
8
+
9
+
10
+ class DuckTree(DirectoryTree):
11
+ """Directory tree that only shows directories and DuckDB-supported data files."""
12
+
13
+ def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
14
+ return [p for p in paths if p.is_dir() or is_duckdb_file(p)]