Files
server-manager/gui/tabs/query_tab.py
chrome-storm-c442 1e729fcf3a v1.9.1: PNG Material Design icons — 28 icons, dark/light theme, HiDPI, graceful Unicode fallback
- 56 PNG icons (28 unique × 2 color variants) from Material Design Icons (round style, 96×96px)
- core/icons.py: ctk_icon(), make_icon_button(), reconfigure_icon_button() with CTkImage cache
- Updated 15 GUI files: app.py, sidebar.py, server_dialog.py, all tabs
- build.py: auto-include assets/icons/ in PyInstaller bundle, patch rollover at 99→minor+1
- tools/download_icons.py: icon download script
- Automatic dark↔light theme switching via CTkImage dual-image support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:27:49 -05:00

1010 lines
37 KiB
Python

"""
Query tab — SQL database interaction with tree explorer, editor, results grid, and export.
"""
import csv
import io
import json
import re
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, make_icon_button
from core.sql_client import SQLClient
_TREE_THEME_APPLIED = False
_SCROLLBAR_THEME_APPLIED = False
def apply_dark_scrollbar_style():
"""Apply dark scrollbar styles globally. Safe to call multiple times."""
global _SCROLLBAR_THEME_APPLIED
if _SCROLLBAR_THEME_APPLIED:
return
_SCROLLBAR_THEME_APPLIED = True
style = ttk.Style()
style.configure(
"Dark.Vertical.TScrollbar",
background="#3B8ED0",
troughcolor="#2b2b2b",
bordercolor="#2b2b2b",
arrowcolor="#a8d4f0",
relief="flat",
)
style.map(
"Dark.Vertical.TScrollbar",
background=[("active", "#5BA3DB"), ("disabled", "#1F6AA5")],
arrowcolor=[("active", "#cce5f7"), ("disabled", "#1F6AA5")],
)
style.configure(
"Dark.Horizontal.TScrollbar",
background="#3B8ED0",
troughcolor="#2b2b2b",
bordercolor="#2b2b2b",
arrowcolor="#a8d4f0",
relief="flat",
)
style.map(
"Dark.Horizontal.TScrollbar",
background=[("active", "#5BA3DB"), ("disabled", "#1F6AA5")],
arrowcolor=[("active", "#cce5f7"), ("disabled", "#1F6AA5")],
)
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"}),
])
apply_dark_scrollbar_style()
class QueryTab(ctk.CTkFrame):
_FORMAT_OPTIONS = [
("Excel CSV", "csv"),
("Delimited Text (Tab)", "tsv"),
("HTML Table", "html"),
("XML", "xml"),
("SQL INSERTs", "sql_insert"),
("SQL INSERT IGNOREs", "sql_insert_ignore"),
("SQL REPLACEs", "sql_replace"),
("SQL DELETE/INSERTs", "sql_delete_insert"),
("SQL UPDATEs", "sql_update"),
("LaTeX", "latex"),
("Textile", "textile"),
("Jira Textile", "jira"),
("PHP Array", "php"),
("Markdown", "markdown"),
("JSON", "json"),
("JSON Lines", "jsonl"),
]
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", style="Dark.Vertical.TScrollbar")
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 = make_icon_button(
btn_row, "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 = make_icon_button(
btn_row, "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 = make_icon_button(
btn_row, "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", style="Dark.Horizontal.TScrollbar")
self._res_xscroll.pack(side="bottom", fill="x")
self._res_yscroll = ttk.Scrollbar(results_frame, orient="vertical", style="Dark.Vertical.TScrollbar")
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._results_tree.bind("<Button-3>", self._on_results_rightclick)
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)
# ── Results context menu ────────────────────────────────────────
def _on_results_rightclick(self, event):
"""Context menu on results Treeview — copy cell/row/all in 16 formats."""
if not self._columns or not self._results:
return
row_iid = self._results_tree.identify_row(event.y)
col_id = self._results_tree.identify_column(event.x)
if row_iid:
self._results_tree.selection_set(row_iid)
row_values = None
if row_iid:
row_values = list(self._results_tree.item(row_iid, "values"))
cell_value = None
if row_values and col_id:
try:
col_index = int(col_id.replace("#", "")) - 1
if 0 <= col_index < len(row_values):
cell_value = row_values[col_index]
except (ValueError, IndexError):
pass
table_name = self._extract_table_name()
_menu_kw = dict(tearoff=0, bg="#2b2b2b", fg="#dcdcdc",
activebackground="#3b82f6", activeforeground="#ffffff",
font=("Segoe UI", 10))
menu = tk.Menu(self, **_menu_kw)
if cell_value is not None:
menu.add_command(
label=t("res_copy_cell"),
command=lambda: self._copy_to_clipboard(str(cell_value)),
)
if row_values is not None:
menu.add_command(
label=t("res_copy_row"),
command=lambda: self._copy_to_clipboard(
"\t".join(str(v) for v in row_values)),
)
if cell_value is not None or row_values is not None:
menu.add_separator()
if row_values is not None:
row_sub = tk.Menu(menu, **_menu_kw)
for label, fmt_key in self._FORMAT_OPTIONS:
row_sub.add_command(
label=label,
command=lambda fk=fmt_key, rv=row_values: self._copy_to_clipboard(
self._format_data(fk, self._columns, [rv], table_name)),
)
menu.add_cascade(label=t("res_copy_row_as"), menu=row_sub)
all_sub = tk.Menu(menu, **_menu_kw)
all_rows = [list(self._results_tree.item(iid, "values"))
for iid in self._results_tree.get_children()]
for label, fmt_key in self._FORMAT_OPTIONS:
all_sub.add_command(
label=label,
command=lambda fk=fmt_key, ar=all_rows: self._copy_to_clipboard(
self._format_data(fk, self._columns, ar, table_name)),
)
menu.add_cascade(label=t("res_copy_all_as"), menu=all_sub)
menu.tk_popup(event.x_root, event.y_root)
def _extract_table_name(self) -> str:
sql = self._editor.get("0.0", "end").strip()
m = re.search(r'\bFROM\s+`?(\w+)`?', sql, re.IGNORECASE)
return m.group(1) if m else "table"
# ── Format converters ──────────────────────────────────────────
def _format_data(self, fmt: str, columns: list[str],
rows: list[list], table: str) -> str:
formatter = getattr(self, f"_fmt_{fmt}", None)
if formatter:
return formatter(columns, rows, table)
return "\t".join(columns) + "\n" + "\n".join(
"\t".join(str(v) for v in r) for r in rows)
def _fmt_csv(self, cols, rows, _t):
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(cols)
for r in rows:
w.writerow(r)
return buf.getvalue()
def _fmt_tsv(self, cols, rows, _t):
lines = ["\t".join(cols)]
for r in rows:
lines.append("\t".join(str(v) for v in r))
return "\n".join(lines)
def _fmt_html(self, cols, rows, _t):
h = "<table>\n<tr>" + "".join(f"<th>{c}</th>" for c in cols) + "</tr>\n"
for r in rows:
h += "<tr>" + "".join(f"<td>{v}</td>" for v in r) + "</tr>\n"
h += "</table>"
return h
def _fmt_xml(self, cols, rows, _t):
lines = ['<?xml version="1.0" encoding="UTF-8"?>', "<results>"]
for r in rows:
lines.append(" <row>")
for c, v in zip(cols, r):
tag = re.sub(r'[^a-zA-Z0-9_]', '_', c)
lines.append(f" <{tag}>{v}</{tag}>")
lines.append(" </row>")
lines.append("</results>")
return "\n".join(lines)
def _fmt_sql_insert(self, cols, rows, tbl):
cl = ", ".join(f"`{c}`" for c in cols)
return "\n".join(
f"INSERT INTO `{tbl}` ({cl}) VALUES ({', '.join(self._sql_val(v) for v in r)});"
for r in rows)
def _fmt_sql_insert_ignore(self, cols, rows, tbl):
cl = ", ".join(f"`{c}`" for c in cols)
return "\n".join(
f"INSERT IGNORE INTO `{tbl}` ({cl}) VALUES ({', '.join(self._sql_val(v) for v in r)});"
for r in rows)
def _fmt_sql_replace(self, cols, rows, tbl):
cl = ", ".join(f"`{c}`" for c in cols)
return "\n".join(
f"REPLACE INTO `{tbl}` ({cl}) VALUES ({', '.join(self._sql_val(v) for v in r)});"
for r in rows)
def _fmt_sql_delete_insert(self, cols, rows, tbl):
cl = ", ".join(f"`{c}`" for c in cols)
lines = []
for r in rows:
where = f"`{cols[0]}` = {self._sql_val(r[0])}"
lines.append(f"DELETE FROM `{tbl}` WHERE {where};")
lines.append(f"INSERT INTO `{tbl}` ({cl}) VALUES ({', '.join(self._sql_val(v) for v in r)});")
return "\n".join(lines)
def _fmt_sql_update(self, cols, rows, tbl):
lines = []
for r in rows:
sets = ", ".join(f"`{c}` = {self._sql_val(v)}" for c, v in zip(cols[1:], r[1:]))
where = f"`{cols[0]}` = {self._sql_val(r[0])}"
lines.append(f"UPDATE `{tbl}` SET {sets} WHERE {where};")
return "\n".join(lines)
def _sql_val(self, v) -> str:
if v is None or str(v) == "NULL":
return "NULL"
s = str(v)
try:
float(s)
return s
except ValueError:
return "'" + s.replace("'", "''") + "'"
def _fmt_latex(self, cols, rows, _t):
align = "|".join("l" * len(cols))
lines = [f"\\begin{{tabular}}{{|{align}|}}", "\\hline"]
lines.append(" & ".join(cols) + " \\\\")
lines.append("\\hline")
for r in rows:
lines.append(" & ".join(str(v) for v in r) + " \\\\")
lines.append("\\hline")
lines.append("\\end{tabular}")
return "\n".join(lines)
def _fmt_textile(self, cols, rows, _t):
lines = ["|_. " + " |_. ".join(cols) + " |"]
for r in rows:
lines.append("| " + " | ".join(str(v) for v in r) + " |")
return "\n".join(lines)
def _fmt_jira(self, cols, rows, _t):
lines = ["|| " + " || ".join(cols) + " ||"]
for r in rows:
lines.append("| " + " | ".join(str(v) for v in r) + " |")
return "\n".join(lines)
def _fmt_php(self, cols, rows, _t):
lines = ["["]
for r in rows:
pairs = ", ".join(f"'{c}' => {self._php_val(v)}" for c, v in zip(cols, r))
lines.append(f" [{pairs}],")
lines.append("]")
return "\n".join(lines)
def _php_val(self, v) -> str:
if v is None or str(v) == "NULL":
return "null"
s = str(v)
try:
float(s)
return s
except ValueError:
return "'" + s.replace("'", "\\'") + "'"
def _fmt_markdown(self, cols, rows, _t):
lines = ["| " + " | ".join(cols) + " |"]
lines.append("| " + " | ".join("---" for _ in cols) + " |")
for r in rows:
lines.append("| " + " | ".join(str(v) for v in r) + " |")
return "\n".join(lines)
def _fmt_json(self, cols, rows, _t):
data = [dict(zip(cols, [str(v) for v in r])) for r in rows]
return json.dumps(data, indent=2, ensure_ascii=False)
def _fmt_jsonl(self, cols, rows, _t):
lines = []
for r in rows:
obj = dict(zip(cols, [str(v) for v in r]))
lines.append(json.dumps(obj, ensure_ascii=False))
return "\n".join(lines)
# ── 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)