From 893132f1101ec34c04ca77f3c5c5e190375178c5 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 12 Feb 2026 13:55:37 +0100 Subject: [PATCH] Add multi-select delete to Restore Session dialog - Session list now supports extended selection (Shift+click for range, Ctrl+click for individual) - "Delete Selected" button removes chosen sessions with confirmation - List refreshes in-place after deletion so you can keep cleaning up - Added delete_sessions() batch method to database (single transaction) - Simplified delete_session() to rely on ON DELETE CASCADE Co-Authored-By: Claude Opus 4.6 --- core/database.py | 24 ++++++++++++++-- ui/main_window.py | 70 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/core/database.py b/core/database.py index 750cd91..a65ee15 100644 --- a/core/database.py +++ b/core/database.py @@ -331,7 +331,7 @@ class DatabaseManager: ] def delete_session(self, session_id: int) -> None: - """Delete a session and all its symlink records. + """Delete a session and all its related data (CASCADE handles child tables). Args: session_id: The session ID to delete. @@ -341,11 +341,31 @@ class DatabaseManager: """ try: with self._connect() as conn: - conn.execute("DELETE FROM symlinks WHERE session_id = ?", (session_id,)) conn.execute("DELETE FROM symlink_sessions WHERE id = ?", (session_id,)) except sqlite3.Error as e: raise DatabaseError(f"Failed to delete session: {e}") from e + def delete_sessions(self, session_ids: list[int]) -> None: + """Delete multiple sessions in a single transaction. + + Args: + session_ids: List of session IDs to delete. + + Raises: + DatabaseError: If deletion fails. + """ + if not session_ids: + return + try: + with self._connect() as conn: + placeholders = ','.join('?' for _ in session_ids) + conn.execute( + f"DELETE FROM symlink_sessions WHERE id IN ({placeholders})", + session_ids + ) + except sqlite3.Error as e: + raise DatabaseError(f"Failed to delete sessions: {e}") from e + def get_sessions_by_destination(self, dest: str) -> list[SessionRecord]: """Get all sessions for a destination directory. diff --git a/ui/main_window.py b/ui/main_window.py index a604083..8510ee4 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -3285,7 +3285,10 @@ class SequenceLinkerUI(QWidget): QMessageBox.information(self, "No Sessions", "No saved sessions found.") return - from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView + from PyQt6.QtWidgets import ( + QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, + QTreeWidget, QTreeWidgetItem, QHeaderView, QPushButton, + ) dlg = QDialog(self) dlg.setWindowTitle("Restore Session") @@ -3297,37 +3300,72 @@ class SequenceLinkerUI(QWidget): tree.setHeaderLabels(["Date", "Destination", "Files"]) tree.setRootIsDecorated(False) tree.setAlternatingRowColors(True) + tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) tree.header().setStretchLastSection(False) tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(tree) - for s in sessions: - date_str = s.created_at.strftime('%Y-%m-%d %H:%M') - dest_short = s.destination - # Shorten long paths - if len(dest_short) > 60: - dest_short = '...' + dest_short[-57:] - item = QTreeWidgetItem([date_str, dest_short, str(s.link_count)]) - item.setData(0, Qt.ItemDataRole.UserRole, s) - item.setToolTip(1, s.destination) - tree.addTopLevelItem(item) + def populate_tree(): + tree.clear() + for s in self.db.get_sessions(): + date_str = s.created_at.strftime('%Y-%m-%d %H:%M') + dest_short = s.destination + if len(dest_short) > 60: + dest_short = '...' + dest_short[-57:] + item = QTreeWidgetItem([date_str, dest_short, str(s.link_count)]) + item.setData(0, Qt.ItemDataRole.UserRole, s) + item.setToolTip(1, s.destination) + tree.addTopLevelItem(item) + if tree.topLevelItemCount() > 0: + tree.setCurrentItem(tree.topLevelItem(0)) + + def delete_selected(): + selected_items = tree.selectedItems() + if not selected_items: + return + count = len(selected_items) + label = "session" if count == 1 else "sessions" + reply = QMessageBox.question( + dlg, "Delete Sessions", + f"Delete {count} {label}? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + ids = [] + for item in selected_items: + s = item.data(0, Qt.ItemDataRole.UserRole) + if s is not None: + ids.append(s.id) + if ids: + try: + self.db.delete_sessions(ids) + except Exception as e: + QMessageBox.critical(dlg, "Error", f"Failed to delete:\n{e}") + return + populate_tree() + + populate_tree() + + btn_layout = QHBoxLayout() + delete_btn = QPushButton("Delete Selected") + delete_btn.clicked.connect(delete_selected) + btn_layout.addWidget(delete_btn) + btn_layout.addStretch() buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(dlg.accept) buttons.rejected.connect(dlg.reject) - layout.addWidget(buttons) + btn_layout.addWidget(buttons) + layout.addLayout(btn_layout) # Double-click to accept tree.itemDoubleClicked.connect(dlg.accept) - # Select first item - if tree.topLevelItemCount() > 0: - tree.setCurrentItem(tree.topLevelItem(0)) - if dlg.exec() != QDialog.DialogCode.Accepted: return