"""
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.ui.preferences_dialog import PreferencesDialog
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"))
view_menu.addAction(self._act("Close Tab", self._close_current_tab, "Ctrl+W"))
# ── Tools ─────────────────────────────────────────────────────────────
tools_menu = mb.addMenu("&Tools")
tools_menu.addAction(self._act("Preferences…", self._open_preferences, "Ctrl+,"))
tools_menu.addSeparator()
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 _close_current_tab(self):
idx = self._workspace.currentIndex()
if idx >= 0:
self._close_tab(idx)
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",
"DBClient v1.0.0
"
"Cross-platform desktop database client.
"
"MySQL · PostgreSQL · SQLite · SQL Server
"
"Built with Python + PyQt6.")
def _open_preferences(self):
dlg = PreferencesDialog(parent=self)
dlg.settings_applied.connect(self._apply_settings_to_open_tabs)
dlg.exec()
def _apply_settings_to_open_tabs(self):
"""Push updated settings to all currently open workspace tabs."""
for i in range(self._workspace.count()):
widget = self._workspace.widget(i)
if hasattr(widget, "apply_settings"):
widget.apply_settings()
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()