- Add core/icons.py — centralized icon text helper with emoji/symbol support - Add Windows SSH command sanitization in ssh.py (Linux→Windows auto-translation) - Improve embedded RDP: launch tab connect/disconnect, fullscreen toggle - Refactor sidebar: cleaner server type badges - Update server_dialog: adaptive fields per server type - Add setup_openssh.bat tool - Update skill-ssh.md and CLAUDE.md docs for Windows SSH support - Cleanup old releases, add v1.8.48-v1.8.52 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""
|
|
Query tab — SQL database interaction with editor, results grid, and export.
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
import time
|
|
import threading
|
|
from tkinter import ttk, filedialog
|
|
|
|
import customtkinter as ctk
|
|
|
|
from core.i18n import t
|
|
from core.icons import icon_text
|
|
from core.sql_client import SQLClient
|
|
|
|
|
|
class QueryTab(ctk.CTkFrame):
|
|
def __init__(self, master, store):
|
|
super().__init__(master, fg_color="transparent")
|
|
self._current_alias: str | None = None
|
|
self.store = store
|
|
self._client: SQLClient | None = None
|
|
self._results: list[list] = []
|
|
self._columns: list[str] = []
|
|
self._executing = False
|
|
|
|
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))
|
|
|
|
ctk.CTkLabel(db_row, text=t("query_database"), anchor="w").pack(
|
|
side="left", padx=(0, 8)
|
|
)
|
|
|
|
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,
|
|
)
|
|
self._db_combo.pack(side="left")
|
|
|
|
# === 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)
|
|
|
|
self._editor = ctk.CTkTextbox(
|
|
editor_frame,
|
|
font=ctk.CTkFont(family="Consolas", size=13),
|
|
height=160,
|
|
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)
|
|
|
|
self._exec_btn = ctk.CTkButton(
|
|
btn_row,
|
|
text=icon_text("execute", t("query_execute")),
|
|
command=self._execute_query,
|
|
width=130,
|
|
fg_color="#2563eb",
|
|
hover_color="#1d4ed8",
|
|
)
|
|
self._exec_btn.pack(side="left", padx=(0, 6))
|
|
|
|
self._clear_btn = ctk.CTkButton(
|
|
btn_row,
|
|
text=icon_text("clear", t("query_clear")),
|
|
command=self._clear_all,
|
|
width=80,
|
|
fg_color="#6b7280",
|
|
hover_color="#4b5563",
|
|
)
|
|
self._clear_btn.pack(side="left", padx=(0, 6))
|
|
|
|
self._export_btn = ctk.CTkButton(
|
|
btn_row,
|
|
text=icon_text("save", t("query_export_csv")),
|
|
command=self._export_csv,
|
|
width=110,
|
|
fg_color="#059669",
|
|
hover_color="#047857",
|
|
)
|
|
self._export_btn.pack(side="left")
|
|
|
|
# === Results area (Treeview) ===
|
|
results_frame = ctk.CTkFrame(self, fg_color="transparent")
|
|
results_frame.pack(fill="both", expand=True, padx=10, pady=(5, 5))
|
|
|
|
# Horizontal scrollbar
|
|
self._tree_xscroll = ttk.Scrollbar(results_frame, orient="horizontal")
|
|
self._tree_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._tree = ttk.Treeview(
|
|
results_frame,
|
|
show="headings",
|
|
xscrollcommand=self._tree_xscroll.set,
|
|
yscrollcommand=self._tree_yscroll.set,
|
|
)
|
|
self._tree.pack(fill="both", expand=True)
|
|
|
|
self._tree_xscroll.config(command=self._tree.xview)
|
|
self._tree_yscroll.config(command=self._tree.yview)
|
|
|
|
# === Status bar ===
|
|
self._status_label = ctk.CTkLabel(
|
|
self,
|
|
text="",
|
|
anchor="w",
|
|
font=ctk.CTkFont(size=12),
|
|
text_color="#9ca3af",
|
|
)
|
|
self._status_label.pack(fill="x", padx=12, pady=(0, 8))
|
|
|
|
# ── 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:
|
|
self._editor.delete("0.0", "end")
|
|
|
|
# ── 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._set_status("")
|
|
|
|
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,
|
|
args=(alias,),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _connect_and_list_dbs(self, alias: str):
|
|
"""Background: create SQLClient, fetch database list."""
|
|
try:
|
|
server = self.store.get_server(alias)
|
|
if not server:
|
|
self._schedule(self._set_status, t("query_error"), error=True)
|
|
return
|
|
|
|
client = SQLClient(server)
|
|
databases = client.list_databases()
|
|
|
|
def _update():
|
|
if self._current_alias != alias:
|
|
return # switched away
|
|
self._client = client
|
|
self._db_combo.configure(values=databases)
|
|
if databases:
|
|
self._db_var.set(databases[0])
|
|
self._switch_database(databases[0])
|
|
self._set_status("OK")
|
|
|
|
self._schedule(_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.use_database(db_name)
|
|
self._set_status(f"Database: {db_name}")
|
|
except Exception as exc:
|
|
self._set_status(str(exc), error=True)
|
|
|
|
def _disconnect(self):
|
|
if self._client:
|
|
try:
|
|
self._client.close()
|
|
except Exception:
|
|
pass
|
|
self._client = None
|
|
|
|
# ── 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, measure time, post results."""
|
|
start = time.perf_counter()
|
|
try:
|
|
columns, rows = self._client.execute(sql)
|
|
elapsed = time.perf_counter() - start
|
|
|
|
def _update():
|
|
self._columns = columns
|
|
self._results = rows
|
|
self._populate_tree(columns, rows)
|
|
row_count = len(rows)
|
|
self._set_status(
|
|
t("query_status_rows").format(
|
|
rows=row_count, time=f"{elapsed:.3f}"
|
|
)
|
|
)
|
|
self._executing = False
|
|
self._exec_btn.configure(state="normal")
|
|
|
|
self._schedule(_update)
|
|
except Exception as exc:
|
|
elapsed = time.perf_counter() - start
|
|
|
|
def _update_err():
|
|
self._set_status(
|
|
f"{t('query_error')}: {exc}", error=True
|
|
)
|
|
self._executing = False
|
|
self._exec_btn.configure(state="normal")
|
|
|
|
self._schedule(_update_err)
|
|
|
|
# ── 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())
|
|
|
|
if not columns:
|
|
self._tree["columns"] = ()
|
|
return
|
|
|
|
self._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")
|
|
|
|
for row in rows:
|
|
display = [str(v) if v is not None else "NULL" for v in row]
|
|
self._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._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
|
|
|
|
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)
|
|
|
|
# ── 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))
|