From 972166e62a349dfc725f4862b18614871305cc64 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 21 May 2026 17:00:31 -0400 Subject: [PATCH] feat: shortcuts dialog, tab context menus, smart status bar, recent files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/config/recent_files.py | 39 ++++++ app/main_window.py | 267 +++++++++++++++++++++++++++++-------- app/ui/shortcuts_dialog.py | 88 ++++++++++++ app/ui/sql_editor.py | 48 +++++++ app/ui/table_viewer.py | 9 +- 5 files changed, 398 insertions(+), 53 deletions(-) create mode 100644 app/config/recent_files.py create mode 100644 app/ui/shortcuts_dialog.py diff --git a/app/config/recent_files.py b/app/config/recent_files.py new file mode 100644 index 0000000..6114501 --- /dev/null +++ b/app/config/recent_files.py @@ -0,0 +1,39 @@ +"""Persistent list of recently opened SQL files (~/.dbclient/recent_files.json).""" +import json +from pathlib import Path + +_RECENT_FILE = Path.home() / ".dbclient" / "recent_files.json" +_MAX = 10 + + +def load_recent() -> list[str]: + """Return up to _MAX recent file paths that still exist on disk.""" + if not _RECENT_FILE.exists(): + return [] + try: + with open(_RECENT_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return [p for p in data if isinstance(p, str) and Path(p).exists()][:_MAX] + except Exception: + return [] + + +def add_recent(path: str) -> None: + """Prepend path to the recent list, deduplicating and capping at _MAX.""" + recent = load_recent() + recent = [p for p in recent if p != path] + recent.insert(0, path) + _save(recent[:_MAX]) + + +def clear_recent() -> None: + _save([]) + + +def _save(data: list) -> None: + _RECENT_FILE.parent.mkdir(parents=True, exist_ok=True) + try: + with open(_RECENT_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + except Exception: + pass diff --git a/app/main_window.py b/app/main_window.py index bebdd65..2553077 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -8,10 +8,12 @@ On startup: β€’ 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, + QPushButton, QApplication, QMenu, QInputDialog, QFileDialog, ) from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QAction, QColor, QKeySequence @@ -28,7 +30,9 @@ 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 @@ -99,12 +103,10 @@ class MainWindow(QMainWindow): ll.addWidget(hdr) self._schema_browser = SchemaBrowser() - # Tree β†’ workspace wiring 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) - # Saved-profile management signals 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) @@ -121,6 +123,15 @@ class MainWindow(QMainWindow): 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" @@ -153,15 +164,25 @@ class MainWindow(QMainWindow): 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() @@ -170,14 +191,14 @@ class MainWindow(QMainWindow): 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)) + 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: - """Create a QAction parented to this window (prevents GC from killing it).""" a = QAction(text, self) a.triggered.connect(slot) if shortcut: @@ -189,19 +210,98 @@ class MainWindow(QMainWindow): 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): - """ - Load all saved connection profiles from disk and add them to the - sidebar as disconnected nodes. Called once after the window shows. - """ profiles = load_profiles() for profile in profiles: self._all_profiles[profile.id] = profile @@ -216,18 +316,14 @@ class MainWindow(QMainWindow): # ── Connection management ───────────────────────────────────────────────── def _new_connection(self): - """Open the New Connection dialog, save the profile, and connect.""" dlg = ConnectionDialog(parent=self) if dlg.exec(): profile = dlg.profile self._all_profiles[profile.id] = profile - # Dialog already persisted it via save_profile() - # Show it as saved first, then auto-connect self._schema_browser.add_saved_profile(profile) self._do_connect(profile) def _connect_by_id(self, profile_id: str): - """Called when user double-clicks / right-clicks Connect on a saved node.""" if self._schema_browser.is_connected(profile_id): self._set_status("Already connected.") return @@ -237,7 +333,6 @@ class MainWindow(QMainWindow): self._do_connect(profile) def _do_connect(self, profile: ConnectionProfile): - """Build the driver, connect, and upgrade the sidebar node.""" pid = profile.id config = { "host": profile.host, @@ -268,37 +363,27 @@ class MainWindow(QMainWindow): f"Could not connect to '{profile.name}':\n\n{e}") def _edit_connection(self, profile_id: str): - """Open edit dialog for a saved or connected profile.""" 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 - # save_profile() was already called inside the dialog - if was_connected: - # Disconnect first, then reconnect with new credentials 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): - """Delete a profile entirely from memory and disk.""" 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" @@ -309,14 +394,11 @@ class MainWindow(QMainWindow): ) if btn != QMessageBox.StandardButton.Yes: return - - # Disconnect if active 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.") @@ -341,6 +423,7 @@ class MainWindow(QMainWindow): 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) @@ -384,6 +467,108 @@ class MainWindow(QMainWindow): 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): @@ -397,30 +582,16 @@ class MainWindow(QMainWindow): "Built with Python + PyQt6.") def _show_shortcuts(self): - QMessageBox.information(self, "Keyboard Shortcuts", - "F5 / Ctrl+Enter β€” Run query\n" - "Ctrl+/ β€” Toggle comment\n" - "Ctrl+Space β€” Force auto-complete\n" - "Ctrl+F β€” Find in SQL editor\n" - "Ctrl+H β€” Find & Replace in SQL editor\n" - "Ctrl+N β€” New connection\n" - "Ctrl+T β€” New SQL tab\n" - "Ctrl+Shift+H β€” Toggle query history\n" - "Ctrl+U β€” User management\n" - "Ctrl+L β€” View app logs\n" - "Delete β€” Delete selected row (table viewer)\n" - "Ins β€” Add new row (table viewer)\n" - "Ctrl+Q β€” Quit") + dlg = ShortcutsDialog(parent=self) + dlg.exec() def _open_log_viewer(self): - """Open the application log viewer as a workspace tab.""" viewer = LogViewer(parent=self) idx = self._workspace.addTab(viewer, "πŸ“‹ App Logs") self._workspace.setCurrentIndex(idx) self._show_workspace() def _open_process_list(self): - """Open a Process List tab for the currently active connection.""" driver, name = self._active_driver_for_tools() if driver is None: return @@ -432,11 +603,9 @@ class MainWindow(QMainWindow): self._show_workspace() def _open_import_dialog(self): - """Open the Import CSV/JSON dialog targeting the active connection.""" driver, name = self._active_driver_for_tools() if driver is None: return - # Determine current database from the active workspace tab if possible database = "" current = self._workspace.currentWidget() if hasattr(current, "_database"): @@ -445,11 +614,9 @@ class MainWindow(QMainWindow): dlg.exec() def _open_dump_dialog(self): - """Open the Export Database Dump dialog targeting the active connection.""" driver, name = self._active_driver_for_tools() if driver is None: return - # Pre-select the database visible in the current workspace tab database = "" current = self._workspace.currentWidget() if hasattr(current, "_database"): @@ -458,7 +625,6 @@ class MainWindow(QMainWindow): dlg.exec() def _open_user_manager(self): - """Open the User & Privilege Management tab.""" driver, name = self._active_driver_for_tools() if driver is None: return @@ -469,18 +635,15 @@ class MainWindow(QMainWindow): self._show_workspace() def open_explain_tab(self, driver, database: str, sql: str): - """Open an EXPLAIN plan tab (called from SQLEditorWidget).""" 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, f"πŸ”Ž EXPLAIN") + 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): - """Return (driver, connection_name) for the first active connection, - or show a warning and return (None, '') if none are connected.""" if not self._active_drivers: QMessageBox.information( self, "No Active Connection", diff --git a/app/ui/shortcuts_dialog.py b/app/ui/shortcuts_dialog.py new file mode 100644 index 0000000..99f02c2 --- /dev/null +++ b/app/ui/shortcuts_dialog.py @@ -0,0 +1,88 @@ +"""Keyboard Shortcuts reference dialog.""" +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QDialogButtonBox, +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +_SHORTCUTS = [ + ("SQL Editor", [ + ("F5 / Ctrl+Enter", "Run query"), + ("Ctrl+/", "Toggle line comment"), + ("Ctrl+Space", "Force auto-complete"), + ("Ctrl+F", "Find in editor"), + ("Ctrl+H", "Find & Replace in editor"), + ("Ctrl+Z / Ctrl+Y", "Undo / Redo"), + ("Ctrl+A", "Select all"), + ("Ctrl+O", "Open SQL file"), + ("Ctrl+S", "Save SQL file"), + ]), + ("Navigation", [ + ("Ctrl+N", "New connection"), + ("Ctrl+T", "New SQL tab"), + ("Ctrl+W", "Close current tab"), + ("Ctrl+Tab", "Next tab"), + ]), + ("Tools & Panels", [ + ("Ctrl+P", "Process list"), + ("Ctrl+U", "User management"), + ("Ctrl+Shift+H", "Toggle query history"), + ("Ctrl+L", "View app logs"), + ]), + ("Table Viewer", [ + ("Ins", "Add new row"), + ("Delete", "Delete selected row(s)"), + ("Double-click", "Edit row"), + ]), + ("General", [ + ("Ctrl+Q", "Quit"), + ]), +] + + +class ShortcutsDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Keyboard Shortcuts") + self.setMinimumSize(500, 540) + self.resize(540, 580) + self._build_ui() + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(8) + + tree = QTreeWidget() + tree.setColumnCount(2) + tree.setHeaderLabels(["Shortcut", "Action"]) + tree.setRootIsDecorated(True) + tree.setAlternatingRowColors(True) + tree.setEditTriggers(QTreeWidget.EditTrigger.NoEditTriggers) + tree.setSelectionMode(QTreeWidget.SelectionMode.NoSelection) + tree.header().setStretchLastSection(True) + tree.setIndentation(20) + + bold = QFont() + bold.setBold(True) + + for category, items in _SHORTCUTS: + cat_item = QTreeWidgetItem([category, ""]) + cat_item.setFont(0, bold) + cat_item.setFlags(cat_item.flags() & ~Qt.ItemFlag.ItemIsSelectable) + for key, desc in items: + child = QTreeWidgetItem([key, desc]) + child.setTextAlignment( + 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + cat_item.addChild(child) + tree.addTopLevelItem(cat_item) + cat_item.setExpanded(True) + + tree.resizeColumnToContents(0) + tree.setColumnWidth(0, max(200, tree.columnWidth(0) + 20)) + root.addWidget(tree, 1) + + bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + bb.rejected.connect(self.reject) + root.addWidget(bb) diff --git a/app/ui/sql_editor.py b/app/ui/sql_editor.py index 984499f..38fc54e 100644 --- a/app/ui/sql_editor.py +++ b/app/ui/sql_editor.py @@ -6,6 +6,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit, QTabWidget, QPushButton, QLabel, QSplitter, QTabBar, QSizePolicy, QFileDialog, QMessageBox, QToolButton, QComboBox, QLineEdit, QCheckBox, + QMenu, QInputDialog, ) from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer from PyQt6.QtGui import ( @@ -404,6 +405,7 @@ class EditorTab(QWidget): self._driver = driver self._database = database self._worker: QueryWorker | None = None + self._filepath: str | None = None self._build_ui() def _build_ui(self): @@ -582,6 +584,14 @@ class SQLEditorWidget(QWidget): new_btn.clicked.connect(lambda: self.new_tab()) self._tabs.setCornerWidget(new_btn, Qt.Corner.TopRightCorner) + # Right-click context menu on query tab bar + self._tabs.tabBar().setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + self._tabs.tabBar().customContextMenuRequested.connect( + self._tab_context_menu + ) + root.addWidget(self._tabs) def new_tab(self, driver=None, database: str = "", @@ -599,6 +609,44 @@ class SQLEditorWidget(QWidget): if self._tabs.count() > 1: self._tabs.removeTab(idx) + def _tab_context_menu(self, pos) -> None: + idx = self._tabs.tabBar().tabAt(pos) + if idx < 0: + return + menu = QMenu(self) + rename_act = menu.addAction("Rename…") + dup_act = menu.addAction("Duplicate") + menu.addSeparator() + close_act = menu.addAction("Close") + close_others = menu.addAction("Close Others") + + close_act.setEnabled(self._tabs.count() > 1) + close_others.setEnabled(self._tabs.count() > 1) + + act = menu.exec(self._tabs.tabBar().mapToGlobal(pos)) + if act == rename_act: + text, ok = QInputDialog.getText( + self, "Rename Tab", "Tab name:", + text=self._tabs.tabText(idx), + ) + if ok and text.strip(): + self._tabs.setTabText(idx, text.strip()) + elif act == dup_act: + tab = self._tabs.widget(idx) + if isinstance(tab, EditorTab): + new = self.new_tab( + tab._driver, tab._database, + sql=tab.get_sql(), + title=self._tabs.tabText(idx) + " (copy)", + ) + new._filepath = tab._filepath + elif act == close_act: + self._close_tab(idx) + elif act == close_others: + for i in range(self._tabs.count() - 1, -1, -1): + if i != idx: + self._close_tab(i) + def current_tab(self) -> EditorTab | None: w = self._tabs.currentWidget() return w if isinstance(w, EditorTab) else None diff --git a/app/ui/table_viewer.py b/app/ui/table_viewer.py index c66953a..2083101 100644 --- a/app/ui/table_viewer.py +++ b/app/ui/table_viewer.py @@ -301,6 +301,7 @@ _PAGE_OPTIONS = [("50", 50), ("100", 100), ("All", 0)] class TableViewer(QWidget): status_message = pyqtSignal(str) + cell_selected = pyqtSignal(str) # emits display value of clicked cell def __init__(self, driver, database: str, table: str, parent=None): super().__init__(parent) @@ -398,7 +399,7 @@ class TableViewer(QWidget): self._table_view.doubleClicked.connect(self._on_double_click) # Track selection to enable/disable Edit/Delete buttons. # Use both signals: clicked covers mouse, selectionChanged covers keyboard. - self._table_view.clicked.connect(lambda _: self._refresh_action_states()) + self._table_view.clicked.connect(self._on_cell_click) self._table_view.selectionModel().selectionChanged.connect( self._on_selection_changed) @@ -584,6 +585,12 @@ class TableViewer(QWidget): # ── Selection tracking ───────────────────────────────────────────────────── + def _on_cell_click(self, index: "QModelIndex") -> None: + self._refresh_action_states() + if index.isValid(): + val = self._model.data(index, Qt.ItemDataRole.DisplayRole) + self.cell_selected.emit(str(val) if val is not None else "NULL") + def _on_selection_changed(self, *_): has_sel = bool(self._table_view.selectionModel().selectedRows()) self._edit_btn.setEnabled(has_sel)