Files
server-manager/gui/tabs/query_tab.py
chrome-storm-c442 4a6464ede9 v1.8.57: auto-size result columns + working horizontal scrollbar
- Measure column widths from header and data (sample up to 100 rows)
- Clamp width: min 60px, max 400px per column
- Set stretch=False so columns don't compress to fit viewport
- Horizontal scrollbar now works when total column width exceeds view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:53:32 -05:00

730 lines
27 KiB
Python

"""
Query tab — SQL database interaction with tree explorer, editor, results grid, and export.
"""
import csv
import time
import threading
import tkinter as tk
from tkinter import ttk, filedialog
import customtkinter as ctk
from core.i18n import t
from core.icons import icon_text
from core.sql_client import SQLClient
_TREE_THEME_APPLIED = False
def _apply_db_tree_theme():
global _TREE_THEME_APPLIED
if _TREE_THEME_APPLIED:
return
_TREE_THEME_APPLIED = True
style = ttk.Style()
try:
style.theme_use("clam")
except Exception:
pass
style.configure(
"DBTree.Treeview",
background="#1e1e1e",
foreground="#dcdcdc",
fieldbackground="#1e1e1e",
borderwidth=0,
font=("Consolas", 11),
rowheight=24,
)
style.map(
"DBTree.Treeview",
background=[("selected", "#3b82f6")],
foreground=[("selected", "#ffffff")],
)
style.layout("DBTree.Treeview", [
("DBTree.Treeview.treearea", {"sticky": "nswe"}),
])
style.configure(
"Results.Treeview",
background="#1e1e1e",
foreground="#dcdcdc",
fieldbackground="#1e1e1e",
borderwidth=0,
font=("Consolas", 11),
rowheight=24,
)
style.configure(
"Results.Treeview.Heading",
background="#2b2b2b",
foreground="#9ca3af",
borderwidth=0,
font=("Segoe UI", 10, "bold"),
relief="flat",
)
style.map(
"Results.Treeview",
background=[("selected", "#3b82f6")],
foreground=[("selected", "#ffffff")],
)
style.map(
"Results.Treeview.Heading",
background=[("active", "#333333")],
)
style.layout("Results.Treeview", [
("Results.Treeview.treearea", {"sticky": "nswe"}),
])
class QueryTab(ctk.CTkFrame):
def __init__(self, master, store):
super().__init__(master, fg_color="transparent")
self._current_alias: str | None = None
self.store = store
self._client: SQLClient | None = None
self._db_lock = threading.Lock()
self._results: list[list] = []
self._columns: list[str] = []
self._executing = False
self._current_db: str = ""
_apply_db_tree_theme()
self._build_ui()
# ── UI construction ────────────────────────────────────────────
def _build_ui(self):
# === Main PanedWindow: tree | editor+results ===
self._paned = ttk.PanedWindow(self, orient="horizontal")
self._paned.pack(fill="both", expand=True, padx=5, pady=5)
# ── Left panel: Database tree ──
left_frame = tk.Frame(self._paned, bg="#1a1a1a", width=260)
self._paned.add(left_frame, weight=0)
# Tree toolbar
toolbar = tk.Frame(left_frame, bg="#1a1a1a")
toolbar.pack(fill="x", padx=2, pady=(4, 2))
self._tree_title = tk.Label(
toolbar, text=t("tree_databases"), bg="#1a1a1a", fg="#9ca3af",
font=("Segoe UI", 10, "bold"), anchor="w",
)
self._tree_title.pack(side="left", padx=4)
self._refresh_btn = tk.Button(
toolbar, text="\u21bb", bg="#2b2b2b", fg="#9ca3af",
font=("Segoe UI", 11), bd=0, padx=6, pady=0,
activebackground="#3b3b3b", activeforeground="#dcdcdc",
command=self._refresh_tree,
)
self._refresh_btn.pack(side="right", padx=4)
# Tree + scrollbar
tree_container = tk.Frame(left_frame, bg="#1e1e1e")
tree_container.pack(fill="both", expand=True, padx=2, pady=(0, 2))
self._tree_scroll = ttk.Scrollbar(tree_container, orient="vertical")
self._tree_scroll.pack(side="right", fill="y")
self._db_tree = ttk.Treeview(
tree_container, show="tree", selectmode="browse",
style="DBTree.Treeview",
yscrollcommand=self._tree_scroll.set,
)
self._db_tree.pack(fill="both", expand=True)
self._tree_scroll.config(command=self._db_tree.yview)
# Tree bindings
self._db_tree.bind("<<TreeviewOpen>>", self._on_tree_expand)
self._db_tree.bind("<Double-1>", self._on_tree_dblclick)
self._db_tree.bind("<Button-3>", self._on_tree_rightclick)
self._db_tree.bind("<<TreeviewSelect>>", self._on_tree_select)
# ── Right panel: editor + buttons + results ──
right_frame = tk.Frame(self._paned, bg="#1a1a1a")
self._paned.add(right_frame, weight=1)
right_ctk = ctk.CTkFrame(right_frame, fg_color="transparent")
right_ctk.pack(fill="both", expand=True)
# === SQL Editor ===
editor_frame = ctk.CTkFrame(right_ctk, fg_color="transparent")
editor_frame.pack(fill="x", padx=8, pady=(8, 4))
self._editor = ctk.CTkTextbox(
editor_frame,
font=ctk.CTkFont(family="Consolas", size=13),
height=140,
wrap="none",
)
self._editor.pack(fill="both", expand=True)
self._editor.insert("0.0", t("query_editor_placeholder"))
self._editor.bind("<FocusIn>", self._on_editor_focus)
self._editor.bind("<F5>", lambda e: self._execute_query())
self._editor.bind("<Control-Return>", lambda e: self._execute_query())
# === Button row ===
btn_row = ctk.CTkFrame(right_ctk, fg_color="transparent")
btn_row.pack(fill="x", padx=8, pady=4)
self._exec_btn = ctk.CTkButton(
btn_row,
text=icon_text("execute", t("query_execute")),
command=self._execute_query,
width=130,
fg_color="#2563eb",
hover_color="#1d4ed8",
)
self._exec_btn.pack(side="left", padx=(0, 6))
self._clear_btn = ctk.CTkButton(
btn_row,
text=icon_text("clear", t("query_clear")),
command=self._clear_all,
width=80,
fg_color="#6b7280",
hover_color="#4b5563",
)
self._clear_btn.pack(side="left", padx=(0, 6))
self._export_btn = ctk.CTkButton(
btn_row,
text=icon_text("save", t("query_export_csv")),
command=self._export_csv,
width=110,
fg_color="#059669",
hover_color="#047857",
)
self._export_btn.pack(side="left")
# === Results area (Treeview) ===
results_frame = ctk.CTkFrame(right_ctk, fg_color="transparent")
results_frame.pack(fill="both", expand=True, padx=8, pady=(4, 4))
self._res_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
self._res_xscroll.pack(side="bottom", fill="x")
self._res_yscroll = ttk.Scrollbar(results_frame, orient="vertical")
self._res_yscroll.pack(side="right", fill="y")
self._results_tree = ttk.Treeview(
results_frame,
show="headings",
style="Results.Treeview",
xscrollcommand=self._res_xscroll.set,
yscrollcommand=self._res_yscroll.set,
)
self._results_tree.pack(fill="both", expand=True)
self._res_xscroll.config(command=self._results_tree.xview)
self._res_yscroll.config(command=self._results_tree.yview)
# === Status bar ===
self._status_label = ctk.CTkLabel(
right_ctk, text="", anchor="w",
font=ctk.CTkFont(size=12), text_color="#9ca3af",
)
self._status_label.pack(fill="x", padx=10, pady=(0, 6))
# ── Editor placeholder logic ───────────────────────────────────
def _on_editor_focus(self, event=None):
content = self._editor.get("0.0", "end").strip()
if content == t("query_editor_placeholder"):
self._editor.delete("0.0", "end")
# ── Connection management (thread-safe) ────────────────────────
def _ensure_connected(self):
"""Check connection, reconnect if broken. MUST be called under _db_lock."""
if not self._client:
raise ConnectionError("No client")
try:
if not self._client.check_connection():
raise Exception("lost")
except Exception:
server = self.store.get_server(self._current_alias)
if not server:
raise ConnectionError("Server not found")
self._client.disconnect()
self._client = SQLClient(server)
if not self._client.connect():
self._client = None
raise ConnectionError("Reconnect failed")
if self._current_db:
try:
self._client.switch_database(self._current_db)
except Exception:
pass
# ── Server / database connection ───────────────────────────────
def set_server(self, alias: str | None):
"""Called when user selects a server in the sidebar."""
self._current_alias = alias
self._disconnect()
self._clear_results()
self._clear_tree()
self._set_status("")
self._current_db = ""
if not alias:
return
self._set_status(f"Connecting to {alias}...")
threading.Thread(
target=self._connect_and_load_tree,
args=(alias,),
daemon=True,
).start()
def _connect_and_load_tree(self, alias: str):
"""Background: create SQLClient, fetch databases, populate tree."""
try:
server = self.store.get_server(alias)
if not server:
self.after(0, lambda: self._set_status(t("query_error"), error=True))
return
client = SQLClient(server)
if not client.connect():
self.after(0, lambda: self._set_status(
t("query_error") + ": connection failed", error=True))
return
self._db_lock.acquire(timeout=10)
try:
self._client = client
databases = client.list_databases()
finally:
self._db_lock.release()
def _update():
if self._current_alias != alias:
return
self._populate_tree_databases(databases)
if databases:
self._current_db = databases[0]
self._set_status(t("tree_connected"))
self.after(0, _update)
except Exception as exc:
self.after(0, lambda: self._set_status(str(exc), error=True))
def _disconnect(self):
with self._db_lock:
if self._client:
try:
self._client.disconnect()
except Exception:
pass
self._client = None
# ── Database tree ──────────────────────────────────────────────
def _clear_tree(self):
self._db_tree.delete(*self._db_tree.get_children())
def _populate_tree_databases(self, databases: list[str]):
self._db_tree.delete(*self._db_tree.get_children())
for db in databases:
iid = f"db:{db}"
self._db_tree.insert(
"", "end", iid=iid,
text=f" \U0001f5c4 {db}",
open=False,
)
self._db_tree.insert(iid, "end", iid=f"_dummy:{db}",
text=f" {t('tree_loading')}")
def _on_tree_expand(self, event):
"""Lazy-load children when a node is expanded for the first time."""
node = self._db_tree.focus()
if not node:
return
children = self._db_tree.get_children(node)
if len(children) == 1 and str(children[0]).startswith("_dummy:"):
if node.startswith("db:"):
self._load_tables(node)
elif node.startswith("tbl:"):
self._load_columns(node)
def _load_tables(self, db_node: str):
"""Background: load tables for a database node."""
db_name = db_node[3:]
def _do():
try:
if not self._db_lock.acquire(timeout=10):
return
try:
self._ensure_connected()
tables = self._client.list_tables(db_name)
finally:
self._db_lock.release()
def _update():
for ch in self._db_tree.get_children(db_node):
self._db_tree.delete(ch)
if not tables:
self._db_tree.insert(
db_node, "end",
text=f" {t('tree_no_tables')}",
)
return
for tbl in tables:
tbl_iid = f"tbl:{db_name}.{tbl}"
self._db_tree.insert(
db_node, "end", iid=tbl_iid,
text=f" \U0001f4cb {tbl}",
open=False,
)
self._db_tree.insert(
tbl_iid, "end",
iid=f"_dummy:{db_name}.{tbl}",
text=f" {t('tree_loading')}",
)
self.after(0, _update)
except Exception as exc:
self.after(0, lambda: self._set_status(f"Error: {exc}", error=True))
threading.Thread(target=_do, daemon=True).start()
def _load_columns(self, tbl_node: str):
"""Background: load columns for a table node."""
parts = tbl_node[4:].split(".", 1)
if len(parts) != 2:
return
db_name, tbl_name = parts
def _do():
try:
if not self._db_lock.acquire(timeout=10):
return
try:
self._ensure_connected()
self._client.switch_database(db_name)
columns = self._client.describe_table(tbl_name)
finally:
self._db_lock.release()
def _update():
for ch in self._db_tree.get_children(tbl_node):
self._db_tree.delete(ch)
if not columns:
self._db_tree.insert(
tbl_node, "end",
text=f" {t('tree_no_columns')}",
)
return
for col in columns:
name = col.get("name", "?")
dtype = col.get("type", "")
key = col.get("key", "")
icon = "\U0001f511" if "PRI" in str(key) else "\U0001f4dd"
label = f" {icon} {name} ({dtype})"
col_iid = f"col:{db_name}.{tbl_name}.{name}"
self._db_tree.insert(
tbl_node, "end", iid=col_iid,
text=label,
)
self.after(0, _update)
except Exception as exc:
self.after(0, lambda: self._set_status(f"Error: {exc}", error=True))
threading.Thread(target=_do, daemon=True).start()
def _on_tree_select(self, event):
"""When user clicks a node, just remember the active DB (no network calls)."""
node = self._db_tree.focus()
if not node:
return
if node.startswith("db:"):
self._current_db = node[3:]
elif node.startswith("tbl:"):
self._current_db = node[4:].split(".", 1)[0]
def _on_tree_dblclick(self, event):
"""Double-click on table → SELECT * LIMIT 1000."""
node = self._db_tree.identify_row(event.y)
if not node:
return
if node.startswith("tbl:"):
parts = node[4:].split(".", 1)
if len(parts) == 2:
db_name, tbl_name = parts
self._select_top(tbl_name, db_name)
elif node.startswith("col:"):
parts = node[4:].split(".")
if len(parts) >= 3:
col_name = parts[-1]
self._editor.insert("insert", f"`{col_name}`")
def _on_tree_rightclick(self, event):
"""Context menu on tree nodes."""
node = self._db_tree.identify_row(event.y)
if not node:
return
self._db_tree.selection_set(node)
menu = tk.Menu(self, tearoff=0, bg="#2b2b2b", fg="#dcdcdc",
activebackground="#3b82f6", activeforeground="#ffffff",
font=("Segoe UI", 10))
if node.startswith("db:"):
db_name = node[3:]
menu.add_command(label=t("tree_use_db"),
command=lambda: self._use_database(db_name))
menu.add_command(label=t("tree_refresh"),
command=lambda: self._refresh_node(node))
menu.add_separator()
menu.add_command(label=t("tree_copy_name"),
command=lambda: self._copy_to_clipboard(db_name))
elif node.startswith("tbl:"):
parts = node[4:].split(".", 1)
if len(parts) == 2:
db_name, tbl_name = parts
menu.add_command(label=t("tree_select_top"),
command=lambda: self._select_top(tbl_name, db_name))
menu.add_command(label=t("tree_describe"),
command=lambda: self._describe_table_query(tbl_name, db_name))
menu.add_separator()
menu.add_command(label=t("tree_copy_name"),
command=lambda: self._copy_to_clipboard(tbl_name))
menu.add_command(label=t("tree_refresh"),
command=lambda: self._refresh_node(node))
elif node.startswith("col:"):
parts = node[4:].split(".")
if len(parts) >= 3:
col_name = parts[-1]
menu.add_command(label=t("tree_copy_name"),
command=lambda: self._copy_to_clipboard(col_name))
menu.add_command(
label=t("tree_select_column"),
command=lambda: self._insert_text(f"`{col_name}`"),
)
menu.tk_popup(event.x_root, event.y_root)
def _refresh_tree(self):
"""Reload entire tree from server."""
if not self._client:
return
self._set_status(t("tree_loading"))
def _do():
try:
if not self._db_lock.acquire(timeout=10):
self.after(0, lambda: self._set_status("Database busy", error=True))
return
try:
self._ensure_connected()
dbs = self._client.list_databases()
finally:
self._db_lock.release()
self.after(0, lambda: self._populate_tree_databases(dbs))
self.after(0, lambda: self._set_status(t("tree_connected")))
except Exception as exc:
self.after(0, lambda: self._set_status(str(exc), error=True))
threading.Thread(target=_do, daemon=True).start()
def _refresh_node(self, node: str):
"""Refresh a specific node (database or table)."""
if node.startswith("db:"):
for ch in self._db_tree.get_children(node):
self._db_tree.delete(ch)
db_name = node[3:]
self._db_tree.insert(node, "end", iid=f"_dummy:{db_name}",
text=f" {t('tree_loading')}")
self._db_tree.item(node, open=True)
self._load_tables(node)
elif node.startswith("tbl:"):
for ch in self._db_tree.get_children(node):
self._db_tree.delete(ch)
parts = node[4:]
self._db_tree.insert(node, "end", iid=f"_dummy:{parts}",
text=f" {t('tree_loading')}")
self._db_tree.item(node, open=True)
self._load_columns(node)
# ── Tree actions ───────────────────────────────────────────────
def _use_database(self, db_name: str):
self._current_db = db_name
self._set_status(f"Database: {db_name}")
def _select_top(self, table: str, db_name: str = ""):
"""Insert SELECT * FROM table LIMIT 1000 and execute."""
if db_name:
self._current_db = db_name
sql = f"SELECT * FROM `{table}` LIMIT 1000"
self._editor.delete("0.0", "end")
self._editor.insert("0.0", sql)
self._execute_query()
def _describe_table_query(self, table: str, db_name: str = ""):
"""Insert DESCRIBE/SHOW COLUMNS and execute."""
if db_name:
self._current_db = db_name
if self._client and self._client._type in ("mariadb", "mysql"):
sql = f"SHOW COLUMNS FROM `{table}`"
else:
sql = f"SELECT * FROM `{table}` WHERE 1=0"
self._editor.delete("0.0", "end")
self._editor.insert("0.0", sql)
self._execute_query()
def _copy_to_clipboard(self, text: str):
self.clipboard_clear()
self.clipboard_append(text)
def _insert_text(self, text: str):
self._editor.insert("insert", text)
# ── Query execution ────────────────────────────────────────────
def _execute_query(self):
"""Run the SQL query in a background thread."""
if self._executing or not self._client:
return
sql = self._editor.get("0.0", "end").strip()
if not sql or sql == t("query_editor_placeholder"):
return
self._executing = True
self._exec_btn.configure(state="disabled")
self._set_status("Executing...")
threading.Thread(
target=self._run_query,
args=(sql,),
daemon=True,
).start()
def _run_query(self, sql: str):
"""Background thread: execute SQL with lock, auto-reconnect, auto-switch DB."""
start = time.perf_counter()
try:
acquired = self._db_lock.acquire(timeout=10)
if not acquired:
self.after(0, lambda: self._set_status("Database busy, try again", error=True))
return
try:
self._ensure_connected()
if self._current_db:
self._client.switch_database(self._current_db)
result = self._client.execute_query(sql)
finally:
self._db_lock.release()
columns = result["columns"]
rows = result["rows"]
elapsed = time.perf_counter() - start
def _update():
self._columns = columns
self._results = rows
self._populate_results(columns, rows)
self._set_status(
t("query_status_rows").format(
rows=len(rows), time=f"{elapsed:.3f}"
)
)
self.after(0, _update)
except Exception as exc:
self.after(0, lambda: self._set_status(
f"{t('query_error')}: {exc}", error=True))
finally:
def _reset():
self._executing = False
self._exec_btn.configure(state="normal")
self.after(0, _reset)
# ── Results Treeview population ────────────────────────────────
def _populate_results(self, columns: list[str], rows: list[list]):
"""Clear and populate the results Treeview with auto-sized columns."""
self._results_tree.delete(*self._results_tree.get_children())
if not columns:
self._results_tree["columns"] = ()
return
self._results_tree["columns"] = columns
# Measure optimal width per column: max(header, data) + padding
import tkinter.font as tkfont
heading_font = tkfont.Font(family="Segoe UI", size=10, weight="bold")
data_font = tkfont.Font(family="Consolas", size=11)
col_widths = []
for i, col in enumerate(columns):
max_w = heading_font.measure(col) + 20 # header + sort arrow space
# Sample up to 100 rows to avoid slow measuring on huge result sets
for row in rows[:100]:
val = str(row[i]) if i < len(row) and row[i] is not None else "NULL"
w = data_font.measure(val) + 16 # data + padding
if w > max_w:
max_w = w
# Clamp: min 60px, max 400px
col_widths.append(max(60, min(400, max_w)))
for col, width in zip(columns, col_widths):
self._results_tree.heading(col, text=col, anchor="w")
self._results_tree.column(col, width=width, minwidth=60,
anchor="w", stretch=False)
for row in rows:
display = [str(v) if v is not None else "NULL" for v in row]
self._results_tree.insert("", "end", values=display)
def _clear_results(self):
self._results_tree.delete(*self._results_tree.get_children())
self._results_tree["columns"] = ()
self._columns = []
self._results = []
# ── Button actions ─────────────────────────────────────────────
def _clear_all(self):
self._editor.delete("0.0", "end")
self._clear_results()
self._set_status("")
def _export_csv(self):
if not self._columns or not self._results:
return
path = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
title=t("query_export_csv"),
)
if not path:
return
try:
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(self._columns)
for row in self._results:
writer.writerow(
[str(v) if v is not None else "" for v in row]
)
self._set_status(f"Exported {len(self._results)} rows to {path}")
except Exception as exc:
self._set_status(f"{t('query_error')}: {exc}", error=True)
# ── Status bar ─────────────────────────────────────────────────
def _set_status(self, text: str, error: bool = False):
color = "#ef4444" if error else "#9ca3af"
self._status_label.configure(text=text, text_color=color)