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:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user