Files
DBClient/app/main_window.py
T
nngoandClaude Sonnet 4.6 972166e62a feat: shortcuts dialog, tab context menus, smart status bar, recent files
- ShortcutsDialog: grouped tree of all keyboard shortcuts (Help menu)
- Workspace tab context menu: Rename, Close, Close Others, Close to Right
- SQL query tab context menu: Rename, Duplicate, Close, Close Others
- Smart status bar: right-side connection indicator + selected cell preview
- File → Open SQL File (Ctrl+O), Save (Ctrl+S), Save As, Open Recent
- Recent files persisted to ~/.dbclient/recent_files.json (last 10)
- TableViewer.cell_selected signal wired to status bar cell preview
- EditorTab._filepath tracks the file associated with each query tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 17:00:31 -04:00

666 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Main application window.
On startup:
• All saved connection profiles are loaded from ~/.dbclient/connections.json
and shown in the sidebar in "disconnected" state.
• Double-clicking or right-click → Connect instantly re-connects.
• New Connection dialog saves the profile AND connects immediately.
• Edit / Delete work on both connected and saved-only profiles.
"""
from pathlib import Path
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter,
QTabWidget, QStatusBar, QLabel, QMessageBox, QDockWidget,
QPushButton, QApplication, QMenu, QInputDialog, QFileDialog,
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QAction, QColor, QKeySequence
from app.ui.schema_browser import SchemaBrowser
from app.ui.sql_editor import SQLEditorWidget
from app.ui.table_viewer import TableViewer
from app.ui.table_structure import TableStructureView
from app.ui.query_history import QueryHistoryPanel
from app.ui.process_list import ProcessListPanel
from app.ui.import_dialog import ImportDialog
from app.ui.dump_dialog import DumpDialog
from app.ui.explain_view import ExplainPanel
from app.ui.user_manager import UserManagerPanel
from app.ui.log_viewer import LogViewer
from app.ui.connection_dialog import ConnectionDialog
from app.ui.shortcuts_dialog import ShortcutsDialog
from app.config.connections import load_profiles, delete_profile
from app.config.recent_files import load_recent, add_recent, clear_recent
from app.models.connection_model import ConnectionProfile
from app.drivers import get_driver
from app.utils.logger import get_logger
_log = get_logger(__name__)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("DBClient")
self.resize(1400, 860)
self.setMinimumSize(1024, 640)
# profile_id → driver (only currently connected ones)
self._active_drivers: dict = {}
# profile_id → ConnectionProfile (all loaded profiles, connected or not)
self._all_profiles: dict = {}
self._build_ui()
self._build_menus()
self._build_status_bar()
# Load saved profiles after the window is shown
QTimer.singleShot(0, self._load_saved_profiles)
# ── Layout ────────────────────────────────────────────────────────────────
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
h = QHBoxLayout(central)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(0)
self._h_splitter = QSplitter(Qt.Orientation.Horizontal)
self._h_splitter.setHandleWidth(2)
# ── Left sidebar ──────────────────────────────────────────────────────
left = QWidget()
left.setMinimumWidth(220)
left.setMaximumWidth(420)
ll = QVBoxLayout(left)
ll.setContentsMargins(0, 0, 0, 0)
ll.setSpacing(0)
hdr = QWidget()
hdr.setObjectName("sidebarHeader")
hdr_lay = QHBoxLayout(hdr)
hdr_lay.setContentsMargins(8, 6, 8, 6)
hdr_lay.setSpacing(4)
title = QLabel(" Connections")
title.setObjectName("sidebarTitle")
f = title.font()
f.setBold(True)
title.setFont(f)
self._new_conn_btn = QPushButton("")
self._new_conn_btn.setObjectName("newConnBtn")
self._new_conn_btn.setFixedSize(28, 28)
self._new_conn_btn.setToolTip("New Connection (Ctrl+N)")
self._new_conn_btn.clicked.connect(self._new_connection)
hdr_lay.addWidget(title, 1)
hdr_lay.addWidget(self._new_conn_btn)
ll.addWidget(hdr)
self._schema_browser = SchemaBrowser()
self._schema_browser.open_table_viewer.connect(self._open_table_viewer)
self._schema_browser.open_table_structure.connect(self._open_table_structure)
self._schema_browser.open_sql_editor.connect(self._open_sql_editor)
self._schema_browser.run_query_requested.connect(self._paste_query)
self._schema_browser.connect_requested.connect(self._connect_by_id)
self._schema_browser.edit_requested.connect(self._edit_connection)
self._schema_browser.delete_requested.connect(self._delete_connection)
ll.addWidget(self._schema_browser, 1)
# ── Right workspace ───────────────────────────────────────────────────
right = QWidget()
rl = QVBoxLayout(right)
rl.setContentsMargins(0, 0, 0, 0)
rl.setSpacing(0)
self._workspace = QTabWidget()
self._workspace.setTabsClosable(True)
self._workspace.setMovable(True)
self._workspace.tabCloseRequested.connect(self._close_tab)
self._workspace.setObjectName("workspace")
self._workspace.currentChanged.connect(self._on_workspace_tab_changed)
# Right-click context menu on workspace tab bar
self._workspace.tabBar().setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self._workspace.tabBar().customContextMenuRequested.connect(
self._workspace_tab_context_menu
)
self._empty_label = QLabel(
"🔌 Double-click a saved connection to connect\n\n"
"Use to add a new connection profile."
)
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._empty_label.setObjectName("emptyLabel")
rl.addWidget(self._empty_label)
rl.addWidget(self._workspace)
self._workspace.setVisible(False)
# ── History dock ──────────────────────────────────────────────────────
self._history_panel = QueryHistoryPanel()
self._history_panel.run_query.connect(self._paste_query)
history_dock = QDockWidget("Query History", self)
history_dock.setWidget(self._history_panel)
history_dock.setMinimumHeight(120)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, history_dock)
history_dock.setVisible(False)
self._history_dock = history_dock
self._h_splitter.addWidget(left)
self._h_splitter.addWidget(right)
self._h_splitter.setSizes([260, 1100])
h.addWidget(self._h_splitter)
# ── Menus ─────────────────────────────────────────────────────────────────
def _build_menus(self):
mb = self.menuBar()
# ── File ──────────────────────────────────────────────────────────────
file_menu = mb.addMenu("&File")
file_menu.addAction(self._act(" New Connection…", self._new_connection, "Ctrl+N"))
file_menu.addSeparator()
file_menu.addAction(self._act("📂 Open SQL File…", self._open_sql_file, "Ctrl+O"))
file_menu.addAction(self._act("💾 Save SQL File", self._save_sql_file, "Ctrl+S"))
file_menu.addAction(self._act("💾 Save SQL File As…", self._save_sql_file_as))
file_menu.addSeparator()
self._recent_menu = file_menu.addMenu("Open Recent")
self._rebuild_recent_menu()
file_menu.addSeparator()
file_menu.addAction(self._act("Exit", QApplication.quit, "Ctrl+Q"))
# ── View ──────────────────────────────────────────────────────────────
view_menu = mb.addMenu("&View")
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+Shift+H"))
view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
# ── Tools ─────────────────────────────────────────────────────────────
tools_menu = mb.addMenu("&Tools")
tools_menu.addAction(self._act("Process List…", self._open_process_list, "Ctrl+P"))
tools_menu.addSeparator()
tools_menu.addAction(self._act("Import CSV / JSON…", self._open_import_dialog))
tools_menu.addAction(self._act("Export Database Dump…", self._open_dump_dialog))
tools_menu.addSeparator()
tools_menu.addAction(self._act("User & Privilege Management…", self._open_user_manager, "Ctrl+U"))
# ── Help ──────────────────────────────────────────────────────────────
help_menu = mb.addMenu("&Help")
help_menu.addAction(self._act("Keyboard Shortcuts…", self._show_shortcuts, "Ctrl+?"))
help_menu.addAction(self._act("View App Logs", self._open_log_viewer, "Ctrl+L"))
help_menu.addSeparator()
help_menu.addAction(self._act("About DBClient", self._show_about))
def _act(self, text: str, slot, shortcut: str = None) -> QAction:
a = QAction(text, self)
a.triggered.connect(slot)
if shortcut:
a.setShortcut(QKeySequence(shortcut))
return a
# ── Status bar ────────────────────────────────────────────────────────────
def _build_status_bar(self):
sb = QStatusBar()
self.setStatusBar(sb)
self._status_lbl = QLabel("Ready")
sb.addWidget(self._status_lbl, 1)
# ── Permanent right-side widgets ──────────────────────────────────────
self._cell_lbl = QLabel("")
self._cell_lbl.setObjectName("statusLabel")
self._cell_lbl.setMaximumWidth(340)
self._cell_lbl.setToolTip("Selected cell value")
sb.addPermanentWidget(self._cell_lbl)
_sep = QLabel("│")
_sep.setStyleSheet("color: #51576d; padding: 0 4px;")
sb.addPermanentWidget(_sep)
self._conn_lbl = QLabel("")
self._conn_lbl.setObjectName("statusLabel")
self._conn_lbl.setMinimumWidth(160)
sb.addPermanentWidget(self._conn_lbl)
def _set_status(self, msg: str):
self._status_lbl.setText(msg)
def _update_conn_label(self, widget):
"""Refresh the connection indicator for the given workspace widget."""
driver = None
if isinstance(widget, SQLEditorWidget):
tab = widget.current_tab()
driver = getattr(tab, "_driver", None) if tab else None
else:
driver = getattr(widget, "_driver", None)
profile = self._profile_for_driver(driver) if driver else None
if profile:
db = ""
if isinstance(widget, SQLEditorWidget):
tab = widget.current_tab()
db = getattr(tab, "_database", "") if tab else ""
else:
db = getattr(widget, "_database", "")
self._conn_lbl.setText(
f"🔌 {profile.name}" + (f" / {db}" if db else "")
)
else:
self._conn_lbl.setText("")
def _on_workspace_tab_changed(self, _idx: int):
self._cell_lbl.setText("")
widget = self._workspace.currentWidget()
if widget:
self._update_conn_label(widget)
# ── Tab context menu (workspace) ──────────────────────────────────────────
def _workspace_tab_context_menu(self, pos):
idx = self._workspace.tabBar().tabAt(pos)
if idx < 0:
return
menu = QMenu(self)
rename_act = menu.addAction("Rename Tab…")
menu.addSeparator()
close_act = menu.addAction("Close Tab")
close_others = menu.addAction("Close Others")
close_right = menu.addAction("Close to the Right")
close_others.setEnabled(self._workspace.count() > 1)
close_right.setEnabled(idx < self._workspace.count() - 1)
act = menu.exec(self._workspace.tabBar().mapToGlobal(pos))
if act == rename_act:
text, ok = QInputDialog.getText(
self, "Rename Tab", "Tab name:",
text=self._workspace.tabText(idx),
)
if ok and text.strip():
self._workspace.setTabText(idx, text.strip())
elif act == close_act:
self._close_tab(idx)
elif act == close_others:
for i in range(self._workspace.count() - 1, -1, -1):
if i != idx:
self._workspace.removeTab(i)
if self._workspace.count() == 0:
self._workspace.setVisible(False)
self._empty_label.setVisible(True)
elif act == close_right:
for i in range(self._workspace.count() - 1, idx, -1):
self._workspace.removeTab(i)
# ── Startup: load saved profiles ──────────────────────────────────────────
def _load_saved_profiles(self):
profiles = load_profiles()
for profile in profiles:
self._all_profiles[profile.id] = profile
self._schema_browser.add_saved_profile(profile)
if profiles:
self._set_status(
f"Loaded {len(profiles)} saved connection(s). "
"Double-click to connect."
)
# ── Connection management ─────────────────────────────────────────────────
def _new_connection(self):
dlg = ConnectionDialog(parent=self)
if dlg.exec():
profile = dlg.profile
self._all_profiles[profile.id] = profile
self._schema_browser.add_saved_profile(profile)
self._do_connect(profile)
def _connect_by_id(self, profile_id: str):
if self._schema_browser.is_connected(profile_id):
self._set_status("Already connected.")
return
profile = self._all_profiles.get(profile_id)
if not profile:
return
self._do_connect(profile)
def _do_connect(self, profile: ConnectionProfile):
pid = profile.id
config = {
"host": profile.host,
"port": profile.port,
"database": profile.database,
"user": profile.username,
"password": profile.password,
"connection_timeout": profile.connection_timeout,
"ssl": profile.ssl,
"ssl_ca": profile.ssl_ca,
"ssl_cert": profile.ssl_cert,
"ssl_key": profile.ssl_key,
}
_log.info("Connecting to '%s' type=%s host=%s",
profile.name, profile.db_type, profile.host)
try:
driver = get_driver(profile.db_type, config)
driver.connect()
self._active_drivers[pid] = driver
self._schema_browser.add_connection(profile, driver)
self._show_workspace()
self._set_status(f"✅ Connected: {profile.name}")
_log.info("Connected to '%s' successfully", profile.name)
except Exception as e:
_log.error("Connection failed for '%s': %s", profile.name, e,
exc_info=True)
QMessageBox.critical(self, "Connection Error",
f"Could not connect to '{profile.name}':\n\n{e}")
def _edit_connection(self, profile_id: str):
profile = self._all_profiles.get(profile_id)
if not profile:
return
was_connected = self._schema_browser.is_connected(profile_id)
dlg = ConnectionDialog(profile=profile, parent=self)
if not dlg.exec():
return
updated = dlg.profile
self._all_profiles[profile_id] = updated
if was_connected:
self._schema_browser.remove_connection(profile_id, keep_saved=True)
self._active_drivers.pop(profile_id, None)
self._do_connect(updated)
else:
self._schema_browser.update_saved_profile(updated)
self._set_status(f"Connection '{updated.name}' updated.")
def _delete_connection(self, profile_id: str):
profile = self._all_profiles.get(profile_id)
if not profile:
return
btn = QMessageBox.warning(
self, "Delete Connection",
f"Delete the connection profile '{profile.name}'?\n\n"
"This removes it from the saved list. "
"The database itself will NOT be affected.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if btn != QMessageBox.StandardButton.Yes:
return
if self._schema_browser.is_connected(profile_id):
self._schema_browser.remove_connection(profile_id, keep_saved=False)
self._active_drivers.pop(profile_id, None)
else:
self._schema_browser.remove_connection(profile_id, keep_saved=False)
self._all_profiles.pop(profile_id, None)
delete_profile(profile_id)
self._set_status(f"Connection '{profile.name}' deleted.")
# ── Workspace helpers ─────────────────────────────────────────────────────
def _show_workspace(self):
self._empty_label.setVisible(False)
self._workspace.setVisible(True)
def _profile_for_driver(self, driver):
for pid, d in self._active_drivers.items():
if d is driver:
return self._all_profiles.get(pid)
return None
def _apply_tab_color(self, idx: int, driver) -> None:
profile = self._profile_for_driver(driver)
if profile:
self._workspace.tabBar().setTabTextColor(idx, QColor(profile.color))
def _open_table_viewer(self, driver, database: str, table: str):
tab = TableViewer(driver, database, table)
tab.status_message.connect(self._set_status)
tab.cell_selected.connect(self._on_cell_selected)
idx = self._workspace.addTab(tab, f"📋 {table}")
self._workspace.setCurrentIndex(idx)
self._apply_tab_color(idx, driver)
def _open_table_structure(self, driver, database: str, table: str):
tab = TableStructureView(driver, database, table)
tab.status_message.connect(self._set_status)
idx = self._workspace.addTab(tab, f"🏗️ {table}")
self._workspace.setCurrentIndex(idx)
self._apply_tab_color(idx, driver)
def _open_sql_editor(self, driver, database: str):
sql_widget = SQLEditorWidget()
sql_widget.status_message.connect(self._set_status)
sql_widget.new_tab(driver, database)
label = f"✏️ SQL — {database}" if database else "✏️ SQL"
idx = self._workspace.addTab(sql_widget, label)
self._workspace.setCurrentIndex(idx)
self._apply_tab_color(idx, driver)
def _new_sql_tab(self):
sql_widget = SQLEditorWidget()
sql_widget.status_message.connect(self._set_status)
sql_widget.new_tab()
idx = self._workspace.addTab(sql_widget, "✏️ SQL")
self._workspace.setCurrentIndex(idx)
def _paste_query(self, sql: str):
current = self._workspace.currentWidget()
if isinstance(current, SQLEditorWidget):
tab = current.current_tab()
if tab:
tab.set_sql(sql)
return
self._new_sql_tab()
self._paste_query(sql)
def _close_tab(self, idx: int):
self._workspace.removeTab(idx)
if self._workspace.count() == 0:
self._workspace.setVisible(False)
self._empty_label.setVisible(True)
def _on_cell_selected(self, value: str):
"""Show a truncated cell value in the status bar."""
if not value or value == "NULL":
self._cell_lbl.setText("")
else:
preview = value[:60] + ("…" if len(value) > 60 else "")
self._cell_lbl.setText(f" {preview}")
# ── File open / save ──────────────────────────────────────────────────────
def _open_sql_file(self, filepath: str = None):
if not filepath:
filepath, _ = QFileDialog.getOpenFileName(
self, "Open SQL File", "",
"SQL Files (*.sql);;Text Files (*.txt);;All Files (*)",
)
if not filepath:
return
try:
with open(filepath, "r", encoding="utf-8") as f:
sql = f.read()
except Exception as e:
QMessageBox.warning(self, "Open Failed", str(e))
return
name = Path(filepath).name
sql_widget = SQLEditorWidget()
sql_widget.status_message.connect(self._set_status)
tab = sql_widget.new_tab(sql=sql, title=name)
tab._filepath = filepath
idx = self._workspace.addTab(sql_widget, f"✏️ {name}")
self._workspace.setCurrentIndex(idx)
self._show_workspace()
add_recent(filepath)
self._rebuild_recent_menu()
self._set_status(f"Opened: {filepath}")
def _save_sql_file(self):
current = self._workspace.currentWidget()
if not isinstance(current, SQLEditorWidget):
return
tab = current.current_tab()
if tab is None:
return
filepath = getattr(tab, "_filepath", None)
if not filepath:
self._save_sql_file_as()
return
self._write_sql_file(tab, filepath)
def _save_sql_file_as(self):
current = self._workspace.currentWidget()
if not isinstance(current, SQLEditorWidget):
return
tab = current.current_tab()
if tab is None:
return
filepath, _ = QFileDialog.getSaveFileName(
self, "Save SQL File", "",
"SQL Files (*.sql);;Text Files (*.txt);;All Files (*)",
)
if filepath:
self._write_sql_file(tab, filepath)
def _write_sql_file(self, tab, filepath: str):
try:
with open(filepath, "w", encoding="utf-8") as f:
f.write(tab.get_sql())
tab._filepath = filepath
# Update workspace tab label
idx = self._workspace.currentIndex()
self._workspace.setTabText(idx, f"✏️ {Path(filepath).name}")
add_recent(filepath)
self._rebuild_recent_menu()
self._set_status(f"Saved: {filepath}")
except Exception as e:
QMessageBox.warning(self, "Save Failed", str(e))
# ── Recent files ──────────────────────────────────────────────────────────
def _rebuild_recent_menu(self):
self._recent_menu.clear()
recent = load_recent()
if not recent:
no_act = self._recent_menu.addAction("(no recent files)")
no_act.setEnabled(False)
return
for path in recent:
act = self._recent_menu.addAction(Path(path).name)
act.setToolTip(path)
act.triggered.connect(lambda checked, p=path: self._open_sql_file(p))
self._recent_menu.addSeparator()
self._recent_menu.addAction("Clear Recent Files").triggered.connect(
self._clear_recent_files
)
def _clear_recent_files(self):
clear_recent()
self._rebuild_recent_menu()
# ── Misc ──────────────────────────────────────────────────────────────────
def _toggle_history(self):
self._history_dock.setVisible(not self._history_dock.isVisible())
def _show_about(self):
QMessageBox.about(self, "About DBClient",
"<b>DBClient</b> v1.0.0<br><br>"
"Cross-platform desktop database client.<br>"
"MySQL · PostgreSQL · SQLite · SQL Server<br><br>"
"Built with Python + PyQt6.")
def _show_shortcuts(self):
dlg = ShortcutsDialog(parent=self)
dlg.exec()
def _open_log_viewer(self):
viewer = LogViewer(parent=self)
idx = self._workspace.addTab(viewer, "📋 App Logs")
self._workspace.setCurrentIndex(idx)
self._show_workspace()
def _open_process_list(self):
driver, name = self._active_driver_for_tools()
if driver is None:
return
panel = ProcessListPanel(driver, connection_name=name)
panel.status_message.connect(self._set_status)
idx = self._workspace.addTab(panel, f"⚙️ Processes — {name}")
self._workspace.setCurrentIndex(idx)
self._apply_tab_color(idx, driver)
self._show_workspace()
def _open_import_dialog(self):
driver, name = self._active_driver_for_tools()
if driver is None:
return
database = ""
current = self._workspace.currentWidget()
if hasattr(current, "_database"):
database = current._database or ""
dlg = ImportDialog(driver, database=database, table="", parent=self)
dlg.exec()
def _open_dump_dialog(self):
driver, name = self._active_driver_for_tools()
if driver is None:
return
database = ""
current = self._workspace.currentWidget()
if hasattr(current, "_database"):
database = current._database or ""
dlg = DumpDialog(driver, database=database, parent=self)
dlg.exec()
def _open_user_manager(self):
driver, name = self._active_driver_for_tools()
if driver is None:
return
panel = UserManagerPanel(driver, parent=self)
panel.status_message.connect(self._set_status)
idx = self._workspace.addTab(panel, f"👤 Users — {name}")
self._workspace.setCurrentIndex(idx)
self._show_workspace()
def open_explain_tab(self, driver, database: str, sql: str):
panel = ExplainPanel(driver, sql, parent=self)
panel.status_message.connect(self._set_status)
short_sql = sql[:40].replace("\n", " ") + ("…" if len(sql) > 40 else "")
idx = self._workspace.addTab(panel, "🔎 EXPLAIN")
self._workspace.setTabToolTip(idx, short_sql)
self._workspace.setCurrentIndex(idx)
self._show_workspace()
def _active_driver_for_tools(self):
if not self._active_drivers:
QMessageBox.information(
self, "No Active Connection",
"Connect to a database first."
)
return None, ""
pid = next(iter(self._active_drivers))
driver = self._active_drivers[pid]
profile = self._all_profiles.get(pid)
name = profile.name if profile else pid
return driver, name
def closeEvent(self, event):
for driver in self._active_drivers.values():
try:
driver.disconnect()
except Exception:
pass
event.accept()