""" 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, QThread, pyqtSignal 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 _PingWorker(QThread): """Background thread that pings one connection and emits the result.""" pinged = pyqtSignal(str, bool) # (profile_id, ok) def __init__(self, pid: str, driver, parent=None): super().__init__(parent) self._pid = pid self._driver = driver def run(self): try: ok, _ = self._driver.test_connection() self.pinged.emit(self._pid, ok) except Exception: self.pinged.emit(self._pid, False) 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 = {} # ping state self._ping_results: dict = {} # profile_id → bool self._ping_workers: list = [] # keep QThread refs alive self._build_ui() self._build_menus() self._build_status_bar() # Periodic connection heartbeat (every 30 s) self._ping_timer = QTimer(self) self._ping_timer.timeout.connect(self._ping_all_connections) self._ping_timer.start(30_000) # 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) self._ping_lbl = QLabel("") self._ping_lbl.setFixedWidth(16) self._ping_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) self._ping_lbl.setToolTip("Connection heartbeat") sb.addPermanentWidget(self._ping_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) self._ping_results.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}") # ── Connection heartbeat ────────────────────────────────────────────────── def _ping_all_connections(self): # Drop finished workers self._ping_workers = [w for w in self._ping_workers if w.isRunning()] if not self._active_drivers: self._ping_lbl.setText("") return for pid, driver in list(self._active_drivers.items()): # Skip if a ping for this connection is already in flight if any(getattr(w, "_pid", None) == pid for w in self._ping_workers): continue worker = _PingWorker(pid, driver) worker.pinged.connect(self._on_ping_result) worker.start() self._ping_workers.append(worker) def _on_ping_result(self, pid: str, ok: bool): if pid not in self._active_drivers: return self._ping_results[pid] = ok all_ok = all(self._ping_results.get(p, True) for p in self._active_drivers) color = "#a6d189" if all_ok else "#e78284" self._ping_lbl.setText("●") self._ping_lbl.setStyleSheet(f"color: {color}; font-size: 11pt;") lines = [] for p_id in self._active_drivers: profile = self._all_profiles.get(p_id) name = profile.name if profile else p_id icon = "✅" if self._ping_results.get(p_id, True) else "❌" lines.append(f"{icon} {name}") self._ping_lbl.setToolTip("Connections:\n" + "\n".join(lines)) if not ok: profile = self._all_profiles.get(pid) name = profile.name if profile else pid _log.warning("Heartbeat failed for '%s'", name) self._set_status(f"⚠️ Connection heartbeat failed: {name}") # ── 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()