v1.8.56: Database Tree Explorer + thread-safe SQL operations
- Add HeidiSQL-style database tree panel (Databases → Tables → Columns) - Lazy loading with ttk.Treeview, context menus, double-click SELECT TOP 1000 - Fix pymysql thread safety: serialize all DB ops with threading.Lock - Use lock.acquire(timeout=10) to prevent deadlocks between tree and query threads - Always reset _executing flag in finally block to prevent stuck queries - Add _ensure_connected() auto-reconnect on broken connections - Add sql_client check_connection() null safety - Add 12 tree-related i18n keys (EN/RU/ZH) - Clean up old releases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
39
core/i18n.py
39
core/i18n.py
@@ -327,6 +327,19 @@ _EN = {
|
||||
"query_connected": "Connected to {alias} ({db})",
|
||||
"query_connecting": "Connecting...",
|
||||
"query_disconnected": "Not connected",
|
||||
|
||||
# Database tree
|
||||
"tree_databases": "Databases",
|
||||
"tree_refresh": "Refresh",
|
||||
"tree_use_db": "Use database",
|
||||
"tree_select_top": "SELECT TOP 1000",
|
||||
"tree_describe": "Describe table",
|
||||
"tree_copy_name": "Copy name",
|
||||
"tree_select_column": "Insert column",
|
||||
"tree_loading": "Loading...",
|
||||
"tree_no_tables": "(no tables)",
|
||||
"tree_no_columns": "(no columns)",
|
||||
"tree_connected": "Connected",
|
||||
"query_exported": "Exported to {path}",
|
||||
|
||||
# Redis tab
|
||||
@@ -741,6 +754,19 @@ _RU = {
|
||||
"query_disconnected": "Не подключено",
|
||||
"query_exported": "Экспортировано в {path}",
|
||||
|
||||
# Database tree
|
||||
"tree_databases": "Базы данных",
|
||||
"tree_refresh": "Обновить",
|
||||
"tree_use_db": "Выбрать базу",
|
||||
"tree_select_top": "SELECT TOP 1000",
|
||||
"tree_describe": "Описание таблицы",
|
||||
"tree_copy_name": "Копировать имя",
|
||||
"tree_select_column": "Вставить колонку",
|
||||
"tree_loading": "Загрузка...",
|
||||
"tree_no_tables": "(нет таблиц)",
|
||||
"tree_no_columns": "(нет колонок)",
|
||||
"tree_connected": "Подключено",
|
||||
|
||||
# Redis tab
|
||||
"redis_clear": "Очистить",
|
||||
"redis_execute": "Выполнить",
|
||||
@@ -1153,6 +1179,19 @@ _ZH = {
|
||||
"query_disconnected": "未连接",
|
||||
"query_exported": "已导出到 {path}",
|
||||
|
||||
# Database tree
|
||||
"tree_databases": "数据库",
|
||||
"tree_refresh": "刷新",
|
||||
"tree_use_db": "选择数据库",
|
||||
"tree_select_top": "SELECT TOP 1000",
|
||||
"tree_describe": "表描述",
|
||||
"tree_copy_name": "复制名称",
|
||||
"tree_select_column": "插入列",
|
||||
"tree_loading": "加载中...",
|
||||
"tree_no_tables": "(无表)",
|
||||
"tree_no_columns": "(无列)",
|
||||
"tree_connected": "已连接",
|
||||
|
||||
# Redis tab
|
||||
"redis_clear": "清除",
|
||||
"redis_execute": "执行",
|
||||
|
||||
@@ -70,6 +70,8 @@ class SQLClient:
|
||||
log.info("sql_client: disconnected")
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
if self._conn is None:
|
||||
return False
|
||||
try:
|
||||
cur = self._conn.cursor()
|
||||
cur.execute("SELECT 1")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
Query tab — SQL database interaction with editor, results grid, and export.
|
||||
Query tab — SQL database interaction with tree explorer, editor, results grid, and export.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog
|
||||
|
||||
import customtkinter as ctk
|
||||
@@ -14,6 +14,67 @@ 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):
|
||||
@@ -21,56 +82,91 @@ class QueryTab(ctk.CTkFrame):
|
||||
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):
|
||||
# === Database selector row ===
|
||||
db_row = ctk.CTkFrame(self, fg_color="transparent")
|
||||
db_row.pack(fill="x", padx=10, pady=(10, 5))
|
||||
# === Main PanedWindow: tree | editor+results ===
|
||||
self._paned = ttk.PanedWindow(self, orient="horizontal")
|
||||
self._paned.pack(fill="both", expand=True, padx=5, pady=5)
|
||||
|
||||
ctk.CTkLabel(db_row, text=t("query_database"), anchor="w").pack(
|
||||
side="left", padx=(0, 8)
|
||||
)
|
||||
# ── Left panel: Database tree ──
|
||||
left_frame = tk.Frame(self._paned, bg="#1a1a1a", width=260)
|
||||
self._paned.add(left_frame, weight=0)
|
||||
|
||||
self._db_var = ctk.StringVar(value="")
|
||||
self._db_combo = ctk.CTkComboBox(
|
||||
db_row,
|
||||
variable=self._db_var,
|
||||
values=[],
|
||||
width=220,
|
||||
command=self._on_db_selected,
|
||||
# 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._db_combo.pack(side="left")
|
||||
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(self, fg_color="transparent")
|
||||
editor_frame.pack(fill="both", expand=True, padx=10, pady=5, side="top")
|
||||
# Give editor roughly 1/3 of space
|
||||
editor_frame.pack_configure(expand=False)
|
||||
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=160,
|
||||
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)
|
||||
|
||||
# Bind keyboard shortcuts
|
||||
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(self, fg_color="transparent")
|
||||
btn_row.pack(fill="x", padx=10, pady=5)
|
||||
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,
|
||||
@@ -103,46 +199,65 @@ class QueryTab(ctk.CTkFrame):
|
||||
self._export_btn.pack(side="left")
|
||||
|
||||
# === Results area (Treeview) ===
|
||||
results_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
results_frame.pack(fill="both", expand=True, padx=10, pady=(5, 5))
|
||||
results_frame = ctk.CTkFrame(right_ctk, fg_color="transparent")
|
||||
results_frame.pack(fill="both", expand=True, padx=8, pady=(4, 4))
|
||||
|
||||
# Horizontal scrollbar
|
||||
self._tree_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
|
||||
self._tree_xscroll.pack(side="bottom", fill="x")
|
||||
self._res_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
|
||||
self._res_xscroll.pack(side="bottom", fill="x")
|
||||
|
||||
# Vertical scrollbar
|
||||
self._tree_yscroll = ttk.Scrollbar(results_frame, orient="vertical")
|
||||
self._tree_yscroll.pack(side="right", fill="y")
|
||||
self._res_yscroll = ttk.Scrollbar(results_frame, orient="vertical")
|
||||
self._res_yscroll.pack(side="right", fill="y")
|
||||
|
||||
self._tree = ttk.Treeview(
|
||||
self._results_tree = ttk.Treeview(
|
||||
results_frame,
|
||||
show="headings",
|
||||
xscrollcommand=self._tree_xscroll.set,
|
||||
yscrollcommand=self._tree_yscroll.set,
|
||||
style="Results.Treeview",
|
||||
xscrollcommand=self._res_xscroll.set,
|
||||
yscrollcommand=self._res_yscroll.set,
|
||||
)
|
||||
self._tree.pack(fill="both", expand=True)
|
||||
self._results_tree.pack(fill="both", expand=True)
|
||||
|
||||
self._tree_xscroll.config(command=self._tree.xview)
|
||||
self._tree_yscroll.config(command=self._tree.yview)
|
||||
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(
|
||||
self,
|
||||
text="",
|
||||
anchor="w",
|
||||
font=ctk.CTkFont(size=12),
|
||||
text_color="#9ca3af",
|
||||
right_ctk, text="", anchor="w",
|
||||
font=ctk.CTkFont(size=12), text_color="#9ca3af",
|
||||
)
|
||||
self._status_label.pack(fill="x", padx=12, pady=(0, 8))
|
||||
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()
|
||||
placeholder = t("query_editor_placeholder")
|
||||
if content == placeholder:
|
||||
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):
|
||||
@@ -150,69 +265,326 @@ class QueryTab(ctk.CTkFrame):
|
||||
self._current_alias = alias
|
||||
self._disconnect()
|
||||
self._clear_results()
|
||||
self._clear_tree()
|
||||
self._set_status("")
|
||||
self._current_db = ""
|
||||
|
||||
if not alias:
|
||||
self._db_combo.configure(values=[])
|
||||
self._db_var.set("")
|
||||
return
|
||||
|
||||
self._set_status(f"Connecting to {alias}...")
|
||||
threading.Thread(
|
||||
target=self._connect_and_list_dbs,
|
||||
target=self._connect_and_load_tree,
|
||||
args=(alias,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _connect_and_list_dbs(self, alias: str):
|
||||
"""Background: create SQLClient, fetch database list."""
|
||||
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._schedule(self._set_status, t("query_error"), error=True)
|
||||
self.after(0, lambda: self._set_status(t("query_error"), error=True))
|
||||
return
|
||||
|
||||
client = SQLClient(server)
|
||||
if not client.connect():
|
||||
self._schedule(self._set_status, t("query_error") + ": connection failed", error=True)
|
||||
self.after(0, lambda: self._set_status(
|
||||
t("query_error") + ": connection failed", error=True))
|
||||
return
|
||||
databases = client.list_databases()
|
||||
|
||||
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 # switched away
|
||||
self._client = client
|
||||
self._db_combo.configure(values=databases)
|
||||
return
|
||||
self._populate_tree_databases(databases)
|
||||
if databases:
|
||||
self._db_var.set(databases[0])
|
||||
self._switch_database(databases[0])
|
||||
self._set_status("OK")
|
||||
self._current_db = databases[0]
|
||||
self._set_status(t("tree_connected"))
|
||||
|
||||
self._schedule(_update)
|
||||
self.after(0, _update)
|
||||
except Exception as exc:
|
||||
self._schedule(self._set_status, str(exc), error=True)
|
||||
|
||||
def _on_db_selected(self, value: str):
|
||||
if value:
|
||||
self._switch_database(value)
|
||||
|
||||
def _switch_database(self, db_name: str):
|
||||
"""Switch active database on the current client."""
|
||||
if not self._client:
|
||||
return
|
||||
try:
|
||||
self._client.switch_database(db_name)
|
||||
self._set_status(f"Database: {db_name}")
|
||||
except Exception as exc:
|
||||
self._set_status(str(exc), error=True)
|
||||
self.after(0, lambda: self._set_status(str(exc), error=True))
|
||||
|
||||
def _disconnect(self):
|
||||
if self._client:
|
||||
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:
|
||||
self._client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._client = None
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
@@ -236,10 +608,21 @@ class QueryTab(ctk.CTkFrame):
|
||||
).start()
|
||||
|
||||
def _run_query(self, sql: str):
|
||||
"""Background thread: execute SQL, measure time, post results."""
|
||||
"""Background thread: execute SQL with lock, auto-reconnect, auto-switch DB."""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = self._client.execute_query(sql)
|
||||
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
|
||||
@@ -247,65 +630,56 @@ class QueryTab(ctk.CTkFrame):
|
||||
def _update():
|
||||
self._columns = columns
|
||||
self._results = rows
|
||||
self._populate_tree(columns, rows)
|
||||
row_count = len(rows)
|
||||
self._populate_results(columns, rows)
|
||||
self._set_status(
|
||||
t("query_status_rows").format(
|
||||
rows=row_count, time=f"{elapsed:.3f}"
|
||||
rows=len(rows), time=f"{elapsed:.3f}"
|
||||
)
|
||||
)
|
||||
self._executing = False
|
||||
self._exec_btn.configure(state="normal")
|
||||
|
||||
self._schedule(_update)
|
||||
self.after(0, _update)
|
||||
except Exception as exc:
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
def _update_err():
|
||||
self._set_status(
|
||||
f"{t('query_error')}: {exc}", error=True
|
||||
)
|
||||
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)
|
||||
|
||||
self._schedule(_update_err)
|
||||
# ── Results Treeview population ────────────────────────────────
|
||||
|
||||
# ── Treeview population ────────────────────────────────────────
|
||||
|
||||
def _populate_tree(self, columns: list[str], rows: list[list]):
|
||||
"""Clear and populate the Treeview with query results."""
|
||||
self._tree.delete(*self._tree.get_children())
|
||||
def _populate_results(self, columns: list[str], rows: list[list]):
|
||||
"""Clear and populate the results Treeview."""
|
||||
self._results_tree.delete(*self._results_tree.get_children())
|
||||
|
||||
if not columns:
|
||||
self._tree["columns"] = ()
|
||||
self._results_tree["columns"] = ()
|
||||
return
|
||||
|
||||
self._tree["columns"] = columns
|
||||
self._results_tree["columns"] = columns
|
||||
for col in columns:
|
||||
self._tree.heading(col, text=col, anchor="w")
|
||||
self._tree.column(col, width=120, minwidth=60, anchor="w")
|
||||
self._results_tree.heading(col, text=col, anchor="w")
|
||||
self._results_tree.column(col, width=120, minwidth=60, anchor="w")
|
||||
|
||||
for row in rows:
|
||||
display = [str(v) if v is not None else "NULL" for v in row]
|
||||
self._tree.insert("", "end", values=display)
|
||||
self._results_tree.insert("", "end", values=display)
|
||||
|
||||
def _clear_results(self):
|
||||
"""Remove all rows and columns from the Treeview."""
|
||||
self._tree.delete(*self._tree.get_children())
|
||||
self._tree["columns"] = ()
|
||||
self._results_tree.delete(*self._results_tree.get_children())
|
||||
self._results_tree["columns"] = ()
|
||||
self._columns = []
|
||||
self._results = []
|
||||
|
||||
# ── Button actions ─────────────────────────────────────────────
|
||||
|
||||
def _clear_all(self):
|
||||
"""Clear editor content and results."""
|
||||
self._editor.delete("0.0", "end")
|
||||
self._clear_results()
|
||||
self._set_status("")
|
||||
|
||||
def _export_csv(self):
|
||||
"""Export current results to a CSV file via save dialog."""
|
||||
if not self._columns or not self._results:
|
||||
return
|
||||
|
||||
@@ -334,9 +708,3 @@ class QueryTab(ctk.CTkFrame):
|
||||
def _set_status(self, text: str, error: bool = False):
|
||||
color = "#ef4444" if error else "#9ca3af"
|
||||
self._status_label.configure(text=text, text_color=color)
|
||||
|
||||
# ── Thread-safe scheduling ─────────────────────────────────────
|
||||
|
||||
def _schedule(self, func, *args, **kwargs):
|
||||
"""Schedule a function to run on the main (tkinter) thread."""
|
||||
self.after(0, lambda: func(*args, **kwargs))
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
"""Version info for ServerManager."""
|
||||
|
||||
__version__ = "1.8.53"
|
||||
__version__ = "1.8.56"
|
||||
__app_name__ = "ServerManager"
|
||||
__author__ = "aibot777"
|
||||
__description__ = "Desktop GUI for managing remote servers"
|
||||
|
||||
Reference in New Issue
Block a user