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_connected": "Connected to {alias} ({db})",
|
||||||
"query_connecting": "Connecting...",
|
"query_connecting": "Connecting...",
|
||||||
"query_disconnected": "Not connected",
|
"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}",
|
"query_exported": "Exported to {path}",
|
||||||
|
|
||||||
# Redis tab
|
# Redis tab
|
||||||
@@ -741,6 +754,19 @@ _RU = {
|
|||||||
"query_disconnected": "Не подключено",
|
"query_disconnected": "Не подключено",
|
||||||
"query_exported": "Экспортировано в {path}",
|
"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 tab
|
||||||
"redis_clear": "Очистить",
|
"redis_clear": "Очистить",
|
||||||
"redis_execute": "Выполнить",
|
"redis_execute": "Выполнить",
|
||||||
@@ -1153,6 +1179,19 @@ _ZH = {
|
|||||||
"query_disconnected": "未连接",
|
"query_disconnected": "未连接",
|
||||||
"query_exported": "已导出到 {path}",
|
"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 tab
|
||||||
"redis_clear": "清除",
|
"redis_clear": "清除",
|
||||||
"redis_execute": "执行",
|
"redis_execute": "执行",
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ class SQLClient:
|
|||||||
log.info("sql_client: disconnected")
|
log.info("sql_client: disconnected")
|
||||||
|
|
||||||
def check_connection(self) -> bool:
|
def check_connection(self) -> bool:
|
||||||
|
if self._conn is None:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
cur = self._conn.cursor()
|
cur = self._conn.cursor()
|
||||||
cur.execute("SELECT 1")
|
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 csv
|
||||||
import io
|
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
import tkinter as tk
|
||||||
from tkinter import ttk, filedialog
|
from tkinter import ttk, filedialog
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
@@ -14,6 +14,67 @@ from core.i18n import t
|
|||||||
from core.icons import icon_text
|
from core.icons import icon_text
|
||||||
from core.sql_client import SQLClient
|
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):
|
class QueryTab(ctk.CTkFrame):
|
||||||
def __init__(self, master, store):
|
def __init__(self, master, store):
|
||||||
@@ -21,56 +82,91 @@ class QueryTab(ctk.CTkFrame):
|
|||||||
self._current_alias: str | None = None
|
self._current_alias: str | None = None
|
||||||
self.store = store
|
self.store = store
|
||||||
self._client: SQLClient | None = None
|
self._client: SQLClient | None = None
|
||||||
|
self._db_lock = threading.Lock()
|
||||||
self._results: list[list] = []
|
self._results: list[list] = []
|
||||||
self._columns: list[str] = []
|
self._columns: list[str] = []
|
||||||
self._executing = False
|
self._executing = False
|
||||||
|
self._current_db: str = ""
|
||||||
|
|
||||||
|
_apply_db_tree_theme()
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
# ── UI construction ────────────────────────────────────────────
|
# ── UI construction ────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
# === Database selector row ===
|
# === Main PanedWindow: tree | editor+results ===
|
||||||
db_row = ctk.CTkFrame(self, fg_color="transparent")
|
self._paned = ttk.PanedWindow(self, orient="horizontal")
|
||||||
db_row.pack(fill="x", padx=10, pady=(10, 5))
|
self._paned.pack(fill="both", expand=True, padx=5, pady=5)
|
||||||
|
|
||||||
ctk.CTkLabel(db_row, text=t("query_database"), anchor="w").pack(
|
# ── Left panel: Database tree ──
|
||||||
side="left", padx=(0, 8)
|
left_frame = tk.Frame(self._paned, bg="#1a1a1a", width=260)
|
||||||
)
|
self._paned.add(left_frame, weight=0)
|
||||||
|
|
||||||
self._db_var = ctk.StringVar(value="")
|
# Tree toolbar
|
||||||
self._db_combo = ctk.CTkComboBox(
|
toolbar = tk.Frame(left_frame, bg="#1a1a1a")
|
||||||
db_row,
|
toolbar.pack(fill="x", padx=2, pady=(4, 2))
|
||||||
variable=self._db_var,
|
|
||||||
values=[],
|
self._tree_title = tk.Label(
|
||||||
width=220,
|
toolbar, text=t("tree_databases"), bg="#1a1a1a", fg="#9ca3af",
|
||||||
command=self._on_db_selected,
|
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 ===
|
# === SQL Editor ===
|
||||||
editor_frame = ctk.CTkFrame(self, fg_color="transparent")
|
editor_frame = ctk.CTkFrame(right_ctk, fg_color="transparent")
|
||||||
editor_frame.pack(fill="both", expand=True, padx=10, pady=5, side="top")
|
editor_frame.pack(fill="x", padx=8, pady=(8, 4))
|
||||||
# Give editor roughly 1/3 of space
|
|
||||||
editor_frame.pack_configure(expand=False)
|
|
||||||
|
|
||||||
self._editor = ctk.CTkTextbox(
|
self._editor = ctk.CTkTextbox(
|
||||||
editor_frame,
|
editor_frame,
|
||||||
font=ctk.CTkFont(family="Consolas", size=13),
|
font=ctk.CTkFont(family="Consolas", size=13),
|
||||||
height=160,
|
height=140,
|
||||||
wrap="none",
|
wrap="none",
|
||||||
)
|
)
|
||||||
self._editor.pack(fill="both", expand=True)
|
self._editor.pack(fill="both", expand=True)
|
||||||
self._editor.insert("0.0", t("query_editor_placeholder"))
|
self._editor.insert("0.0", t("query_editor_placeholder"))
|
||||||
self._editor.bind("<FocusIn>", self._on_editor_focus)
|
self._editor.bind("<FocusIn>", self._on_editor_focus)
|
||||||
|
|
||||||
# Bind keyboard shortcuts
|
|
||||||
self._editor.bind("<F5>", lambda e: self._execute_query())
|
self._editor.bind("<F5>", lambda e: self._execute_query())
|
||||||
self._editor.bind("<Control-Return>", lambda e: self._execute_query())
|
self._editor.bind("<Control-Return>", lambda e: self._execute_query())
|
||||||
|
|
||||||
# === Button row ===
|
# === Button row ===
|
||||||
btn_row = ctk.CTkFrame(self, fg_color="transparent")
|
btn_row = ctk.CTkFrame(right_ctk, fg_color="transparent")
|
||||||
btn_row.pack(fill="x", padx=10, pady=5)
|
btn_row.pack(fill="x", padx=8, pady=4)
|
||||||
|
|
||||||
self._exec_btn = ctk.CTkButton(
|
self._exec_btn = ctk.CTkButton(
|
||||||
btn_row,
|
btn_row,
|
||||||
@@ -103,46 +199,65 @@ class QueryTab(ctk.CTkFrame):
|
|||||||
self._export_btn.pack(side="left")
|
self._export_btn.pack(side="left")
|
||||||
|
|
||||||
# === Results area (Treeview) ===
|
# === Results area (Treeview) ===
|
||||||
results_frame = ctk.CTkFrame(self, fg_color="transparent")
|
results_frame = ctk.CTkFrame(right_ctk, fg_color="transparent")
|
||||||
results_frame.pack(fill="both", expand=True, padx=10, pady=(5, 5))
|
results_frame.pack(fill="both", expand=True, padx=8, pady=(4, 4))
|
||||||
|
|
||||||
# Horizontal scrollbar
|
self._res_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
|
||||||
self._tree_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
|
self._res_xscroll.pack(side="bottom", fill="x")
|
||||||
self._tree_xscroll.pack(side="bottom", fill="x")
|
|
||||||
|
|
||||||
# Vertical scrollbar
|
self._res_yscroll = ttk.Scrollbar(results_frame, orient="vertical")
|
||||||
self._tree_yscroll = ttk.Scrollbar(results_frame, orient="vertical")
|
self._res_yscroll.pack(side="right", fill="y")
|
||||||
self._tree_yscroll.pack(side="right", fill="y")
|
|
||||||
|
|
||||||
self._tree = ttk.Treeview(
|
self._results_tree = ttk.Treeview(
|
||||||
results_frame,
|
results_frame,
|
||||||
show="headings",
|
show="headings",
|
||||||
xscrollcommand=self._tree_xscroll.set,
|
style="Results.Treeview",
|
||||||
yscrollcommand=self._tree_yscroll.set,
|
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._res_xscroll.config(command=self._results_tree.xview)
|
||||||
self._tree_yscroll.config(command=self._tree.yview)
|
self._res_yscroll.config(command=self._results_tree.yview)
|
||||||
|
|
||||||
# === Status bar ===
|
# === Status bar ===
|
||||||
self._status_label = ctk.CTkLabel(
|
self._status_label = ctk.CTkLabel(
|
||||||
self,
|
right_ctk, text="", anchor="w",
|
||||||
text="",
|
font=ctk.CTkFont(size=12), text_color="#9ca3af",
|
||||||
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 ───────────────────────────────────
|
# ── Editor placeholder logic ───────────────────────────────────
|
||||||
|
|
||||||
def _on_editor_focus(self, event=None):
|
def _on_editor_focus(self, event=None):
|
||||||
content = self._editor.get("0.0", "end").strip()
|
content = self._editor.get("0.0", "end").strip()
|
||||||
placeholder = t("query_editor_placeholder")
|
if content == t("query_editor_placeholder"):
|
||||||
if content == placeholder:
|
|
||||||
self._editor.delete("0.0", "end")
|
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 ───────────────────────────────
|
# ── Server / database connection ───────────────────────────────
|
||||||
|
|
||||||
def set_server(self, alias: str | None):
|
def set_server(self, alias: str | None):
|
||||||
@@ -150,69 +265,326 @@ class QueryTab(ctk.CTkFrame):
|
|||||||
self._current_alias = alias
|
self._current_alias = alias
|
||||||
self._disconnect()
|
self._disconnect()
|
||||||
self._clear_results()
|
self._clear_results()
|
||||||
|
self._clear_tree()
|
||||||
self._set_status("")
|
self._set_status("")
|
||||||
|
self._current_db = ""
|
||||||
|
|
||||||
if not alias:
|
if not alias:
|
||||||
self._db_combo.configure(values=[])
|
|
||||||
self._db_var.set("")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._set_status(f"Connecting to {alias}...")
|
self._set_status(f"Connecting to {alias}...")
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._connect_and_list_dbs,
|
target=self._connect_and_load_tree,
|
||||||
args=(alias,),
|
args=(alias,),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _connect_and_list_dbs(self, alias: str):
|
def _connect_and_load_tree(self, alias: str):
|
||||||
"""Background: create SQLClient, fetch database list."""
|
"""Background: create SQLClient, fetch databases, populate tree."""
|
||||||
try:
|
try:
|
||||||
server = self.store.get_server(alias)
|
server = self.store.get_server(alias)
|
||||||
if not server:
|
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
|
return
|
||||||
|
|
||||||
client = SQLClient(server)
|
client = SQLClient(server)
|
||||||
if not client.connect():
|
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
|
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():
|
def _update():
|
||||||
if self._current_alias != alias:
|
if self._current_alias != alias:
|
||||||
return # switched away
|
return
|
||||||
self._client = client
|
self._populate_tree_databases(databases)
|
||||||
self._db_combo.configure(values=databases)
|
|
||||||
if databases:
|
if databases:
|
||||||
self._db_var.set(databases[0])
|
self._current_db = databases[0]
|
||||||
self._switch_database(databases[0])
|
self._set_status(t("tree_connected"))
|
||||||
self._set_status("OK")
|
|
||||||
|
|
||||||
self._schedule(_update)
|
self.after(0, _update)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._schedule(self._set_status, str(exc), error=True)
|
self.after(0, lambda: 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)
|
|
||||||
|
|
||||||
def _disconnect(self):
|
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:
|
try:
|
||||||
self._client.disconnect()
|
if not self._db_lock.acquire(timeout=10):
|
||||||
except Exception:
|
return
|
||||||
pass
|
try:
|
||||||
self._client = None
|
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 ────────────────────────────────────────────
|
# ── Query execution ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -236,10 +608,21 @@ class QueryTab(ctk.CTkFrame):
|
|||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _run_query(self, sql: str):
|
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()
|
start = time.perf_counter()
|
||||||
try:
|
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"]
|
columns = result["columns"]
|
||||||
rows = result["rows"]
|
rows = result["rows"]
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
@@ -247,65 +630,56 @@ class QueryTab(ctk.CTkFrame):
|
|||||||
def _update():
|
def _update():
|
||||||
self._columns = columns
|
self._columns = columns
|
||||||
self._results = rows
|
self._results = rows
|
||||||
self._populate_tree(columns, rows)
|
self._populate_results(columns, rows)
|
||||||
row_count = len(rows)
|
|
||||||
self._set_status(
|
self._set_status(
|
||||||
t("query_status_rows").format(
|
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:
|
except Exception as exc:
|
||||||
elapsed = time.perf_counter() - start
|
self.after(0, lambda: self._set_status(
|
||||||
|
f"{t('query_error')}: {exc}", error=True))
|
||||||
def _update_err():
|
finally:
|
||||||
self._set_status(
|
def _reset():
|
||||||
f"{t('query_error')}: {exc}", error=True
|
|
||||||
)
|
|
||||||
self._executing = False
|
self._executing = False
|
||||||
self._exec_btn.configure(state="normal")
|
self._exec_btn.configure(state="normal")
|
||||||
|
self.after(0, _reset)
|
||||||
|
|
||||||
self._schedule(_update_err)
|
# ── Results Treeview population ────────────────────────────────
|
||||||
|
|
||||||
# ── Treeview population ────────────────────────────────────────
|
def _populate_results(self, columns: list[str], rows: list[list]):
|
||||||
|
"""Clear and populate the results Treeview."""
|
||||||
def _populate_tree(self, columns: list[str], rows: list[list]):
|
self._results_tree.delete(*self._results_tree.get_children())
|
||||||
"""Clear and populate the Treeview with query results."""
|
|
||||||
self._tree.delete(*self._tree.get_children())
|
|
||||||
|
|
||||||
if not columns:
|
if not columns:
|
||||||
self._tree["columns"] = ()
|
self._results_tree["columns"] = ()
|
||||||
return
|
return
|
||||||
|
|
||||||
self._tree["columns"] = columns
|
self._results_tree["columns"] = columns
|
||||||
for col in columns:
|
for col in columns:
|
||||||
self._tree.heading(col, text=col, anchor="w")
|
self._results_tree.heading(col, text=col, anchor="w")
|
||||||
self._tree.column(col, width=120, minwidth=60, anchor="w")
|
self._results_tree.column(col, width=120, minwidth=60, anchor="w")
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
display = [str(v) if v is not None else "NULL" for v in row]
|
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):
|
def _clear_results(self):
|
||||||
"""Remove all rows and columns from the Treeview."""
|
self._results_tree.delete(*self._results_tree.get_children())
|
||||||
self._tree.delete(*self._tree.get_children())
|
self._results_tree["columns"] = ()
|
||||||
self._tree["columns"] = ()
|
|
||||||
self._columns = []
|
self._columns = []
|
||||||
self._results = []
|
self._results = []
|
||||||
|
|
||||||
# ── Button actions ─────────────────────────────────────────────
|
# ── Button actions ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _clear_all(self):
|
def _clear_all(self):
|
||||||
"""Clear editor content and results."""
|
|
||||||
self._editor.delete("0.0", "end")
|
self._editor.delete("0.0", "end")
|
||||||
self._clear_results()
|
self._clear_results()
|
||||||
self._set_status("")
|
self._set_status("")
|
||||||
|
|
||||||
def _export_csv(self):
|
def _export_csv(self):
|
||||||
"""Export current results to a CSV file via save dialog."""
|
|
||||||
if not self._columns or not self._results:
|
if not self._columns or not self._results:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -334,9 +708,3 @@ class QueryTab(ctk.CTkFrame):
|
|||||||
def _set_status(self, text: str, error: bool = False):
|
def _set_status(self, text: str, error: bool = False):
|
||||||
color = "#ef4444" if error else "#9ca3af"
|
color = "#ef4444" if error else "#9ca3af"
|
||||||
self._status_label.configure(text=text, text_color=color)
|
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 info for ServerManager."""
|
||||||
|
|
||||||
__version__ = "1.8.53"
|
__version__ = "1.8.56"
|
||||||
__app_name__ = "ServerManager"
|
__app_name__ = "ServerManager"
|
||||||
__author__ = "aibot777"
|
__author__ = "aibot777"
|
||||||
__description__ = "Desktop GUI for managing remote servers"
|
__description__ = "Desktop GUI for managing remote servers"
|
||||||
|
|||||||
Reference in New Issue
Block a user