- 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>
1010 lines
37 KiB
Python
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)
|