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>
This commit is contained in:
2026-05-21 17:00:31 -04:00
co-authored by Claude Sonnet 4.6
parent b003976d39
commit 972166e62a
5 changed files with 398 additions and 53 deletions
+88
View File
@@ -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)
+48
View File
@@ -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
+8 -1
View File
@@ -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)