Files
server-manager/gui/tabs/query_tab.py
chrome-storm-c442 4959004a3f v1.8.52: icons module, Windows SSH sanitization, embedded RDP improvements, UI polish
- 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>
2026-02-24 14:37:37 -05:00

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))