s3ui 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ """Name conflict resolution dialog for downloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from PyQt6.QtWidgets import (
8
+ QButtonGroup,
9
+ QCheckBox,
10
+ QDialog,
11
+ QDialogButtonBox,
12
+ QLabel,
13
+ QRadioButton,
14
+ QVBoxLayout,
15
+ )
16
+
17
+
18
+ class ConflictResolution(Enum):
19
+ REPLACE = "replace"
20
+ KEEP_BOTH = "keep_both"
21
+ SKIP = "skip"
22
+
23
+
24
+ class NameConflictDialog(QDialog):
25
+ """Dialog for resolving file name conflicts during download."""
26
+
27
+ def __init__(self, filename: str, parent=None) -> None:
28
+ super().__init__(parent)
29
+ self.setWindowTitle("File Already Exists")
30
+ self.setMinimumWidth(350)
31
+
32
+ layout = QVBoxLayout(self)
33
+ layout.addWidget(QLabel(f'"{filename}" already exists in the destination.'))
34
+ layout.addWidget(QLabel("What would you like to do?"))
35
+
36
+ self._replace_radio = QRadioButton("Replace existing file")
37
+ self._keep_both_radio = QRadioButton("Keep both (rename new file)")
38
+ self._skip_radio = QRadioButton("Skip this file")
39
+ self._replace_radio.setChecked(True)
40
+
41
+ group = QButtonGroup(self)
42
+ group.addButton(self._replace_radio)
43
+ group.addButton(self._keep_both_radio)
44
+ group.addButton(self._skip_radio)
45
+
46
+ layout.addWidget(self._replace_radio)
47
+ layout.addWidget(self._keep_both_radio)
48
+ layout.addWidget(self._skip_radio)
49
+
50
+ self._apply_all = QCheckBox("Apply to all remaining conflicts")
51
+ layout.addWidget(self._apply_all)
52
+
53
+ buttons = QDialogButtonBox(
54
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
55
+ )
56
+ buttons.accepted.connect(self.accept)
57
+ buttons.rejected.connect(self.reject)
58
+ layout.addWidget(buttons)
59
+
60
+ def resolution(self) -> ConflictResolution:
61
+ if self._replace_radio.isChecked():
62
+ return ConflictResolution.REPLACE
63
+ if self._keep_both_radio.isChecked():
64
+ return ConflictResolution.KEEP_BOTH
65
+ return ConflictResolution.SKIP
66
+
67
+ def apply_to_all(self) -> bool:
68
+ return self._apply_all.isChecked()
s3ui/ui/s3_pane.py ADDED
@@ -0,0 +1,547 @@
1
+ """S3 file browser pane — right side of the dual-pane browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PyQt6.QtCore import (
9
+ QEvent,
10
+ QModelIndex,
11
+ QObject,
12
+ QSortFilterProxyModel,
13
+ Qt,
14
+ QThread,
15
+ pyqtSignal,
16
+ )
17
+ from PyQt6.QtWidgets import (
18
+ QAbstractItemView,
19
+ QHBoxLayout,
20
+ QHeaderView,
21
+ QLabel,
22
+ QLineEdit,
23
+ QTableView,
24
+ QToolButton,
25
+ QVBoxLayout,
26
+ QWidget,
27
+ )
28
+
29
+ from s3ui.constants import NAV_HISTORY_MAX
30
+ from s3ui.core.listing_cache import ListingCache
31
+ from s3ui.models.s3_objects import S3Item, S3ObjectModel, _format_size
32
+ from s3ui.ui.breadcrumb_bar import BreadcrumbBar
33
+
34
+ if TYPE_CHECKING:
35
+ from s3ui.core.s3_client import S3Client
36
+
37
+ logger = logging.getLogger("s3ui.s3_pane")
38
+
39
+
40
+ class _FetchSignals(QObject):
41
+ """Signals emitted by the fetch worker."""
42
+
43
+ page_ready = pyqtSignal(str, list, bool, int) # prefix, items, is_first_page, fetch_id
44
+ listing_complete = pyqtSignal(str, list, int) # prefix, all_items, fetch_id
45
+ error = pyqtSignal(str, str, int) # prefix, error_message, fetch_id
46
+
47
+
48
+ class _FetchWorker(QThread):
49
+ """Background thread for fetching S3 listings."""
50
+
51
+ def __init__(
52
+ self,
53
+ s3_client: S3Client,
54
+ bucket: str,
55
+ prefix: str,
56
+ fetch_id: int,
57
+ parent: QObject | None = None,
58
+ ) -> None:
59
+ super().__init__(parent)
60
+ self.signals = _FetchSignals()
61
+ self._s3 = s3_client
62
+ self._bucket = bucket
63
+ self._prefix = prefix
64
+ self._fetch_id = fetch_id
65
+
66
+ def run(self) -> None:
67
+ try:
68
+ items, _ = self._s3.list_objects(self._bucket, self._prefix)
69
+ self.signals.listing_complete.emit(self._prefix, items, self._fetch_id)
70
+ except Exception as e:
71
+ logger.error("Fetch failed for prefix '%s': %s", self._prefix, e)
72
+ self.signals.error.emit(self._prefix, str(e), self._fetch_id)
73
+
74
+
75
+ class S3PaneWidget(QWidget):
76
+ """Pane for browsing S3 bucket contents."""
77
+
78
+ directory_changed = pyqtSignal(str) # current prefix
79
+ status_message = pyqtSignal(str) # for status bar updates
80
+ download_requested = pyqtSignal(list) # list of S3Item
81
+ delete_requested = pyqtSignal(list) # list of S3Item
82
+ new_folder_requested = pyqtSignal()
83
+ get_info_requested = pyqtSignal(object) # S3Item
84
+ files_dropped = pyqtSignal(list) # list of local file paths (str) dropped onto S3 pane
85
+ quick_open_requested = pyqtSignal(object) # S3Item — double-click file opens it
86
+
87
+ def __init__(self, parent: QWidget | None = None) -> None:
88
+ super().__init__(parent)
89
+ self._s3_client: S3Client | None = None
90
+ self._bucket: str = ""
91
+ self._current_prefix: str = ""
92
+ self._history_back: list[str] = []
93
+ self._history_forward: list[str] = []
94
+ self._fetch_id: int = 0
95
+ self._fetch_worker: _FetchWorker | None = None
96
+ self._cache = ListingCache()
97
+ self._connected = False
98
+ self._operation_locks: dict[str, str] = {}
99
+
100
+ self._setup_ui()
101
+
102
+ def _setup_ui(self) -> None:
103
+ layout = QVBoxLayout(self)
104
+ layout.setContentsMargins(0, 0, 0, 0)
105
+ layout.setSpacing(0)
106
+
107
+ # Mini toolbar
108
+ toolbar = QHBoxLayout()
109
+ toolbar.setContentsMargins(4, 2, 4, 2)
110
+ toolbar.setSpacing(2)
111
+
112
+ self._back_btn = QToolButton()
113
+ self._back_btn.setText("◀")
114
+ self._back_btn.setToolTip("Back")
115
+ self._back_btn.setAutoRaise(True)
116
+ self._back_btn.clicked.connect(self.go_back)
117
+ self._back_btn.setEnabled(False)
118
+ toolbar.addWidget(self._back_btn)
119
+
120
+ self._forward_btn = QToolButton()
121
+ self._forward_btn.setText("▶")
122
+ self._forward_btn.setToolTip("Forward")
123
+ self._forward_btn.setAutoRaise(True)
124
+ self._forward_btn.clicked.connect(self.go_forward)
125
+ self._forward_btn.setEnabled(False)
126
+ toolbar.addWidget(self._forward_btn)
127
+
128
+ self._new_folder_btn = QToolButton()
129
+ self._new_folder_btn.setText("+")
130
+ self._new_folder_btn.setToolTip("New Folder")
131
+ self._new_folder_btn.setAutoRaise(True)
132
+ self._new_folder_btn.clicked.connect(self.new_folder_requested.emit)
133
+ toolbar.addWidget(self._new_folder_btn)
134
+
135
+ self._search_btn = QToolButton()
136
+ self._search_btn.setText("🔍")
137
+ self._search_btn.setToolTip("Filter (Ctrl+F)")
138
+ self._search_btn.setAutoRaise(True)
139
+ self._search_btn.setCheckable(True)
140
+ self._search_btn.toggled.connect(self._toggle_filter)
141
+ toolbar.addWidget(self._search_btn)
142
+
143
+ self._breadcrumb = BreadcrumbBar(separator="/")
144
+ self._breadcrumb.path_clicked.connect(self._on_breadcrumb_clicked)
145
+ self._breadcrumb.path_edited.connect(self._on_breadcrumb_edited)
146
+ toolbar.addWidget(self._breadcrumb, 1)
147
+
148
+ toolbar_widget = QWidget()
149
+ toolbar_widget.setLayout(toolbar)
150
+ layout.addWidget(toolbar_widget)
151
+
152
+ # Filter bar (hidden by default)
153
+ self._filter_bar = QLineEdit()
154
+ self._filter_bar.setPlaceholderText("Filter by name...")
155
+ self._filter_bar.setClearButtonEnabled(True)
156
+ self._filter_bar.setVisible(False)
157
+ self._filter_bar.textChanged.connect(self._on_filter_changed)
158
+ layout.addWidget(self._filter_bar)
159
+
160
+ # Table view
161
+ self._model = S3ObjectModel()
162
+ self._proxy = QSortFilterProxyModel()
163
+ self._proxy.setSourceModel(self._model)
164
+ self._proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
165
+ self._proxy.setFilterKeyColumn(0) # Filter on Name column
166
+
167
+ self._table = QTableView()
168
+ self._table.setModel(self._proxy)
169
+ self._table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
170
+ self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
171
+ self._table.setShowGrid(False)
172
+ self._table.setAlternatingRowColors(True)
173
+ self._table.verticalHeader().setVisible(False)
174
+ self._table.setSortingEnabled(True)
175
+ self._table.horizontalHeader().setStretchLastSection(True)
176
+ self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
177
+ self._table.doubleClicked.connect(self._on_double_click)
178
+ self._table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
179
+ self._table.customContextMenuRequested.connect(self._on_context_menu)
180
+
181
+ # Accept drops on the viewport; handled via event filter
182
+ self._table.setAcceptDrops(True)
183
+ self._table.viewport().setAcceptDrops(True)
184
+ self._table.viewport().installEventFilter(self)
185
+
186
+ layout.addWidget(self._table, 1)
187
+
188
+ # Status/error label (hidden by default)
189
+ self._status_label = QLabel()
190
+ self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
191
+ self._status_label.setStyleSheet("color: gray; padding: 20px;")
192
+ self._status_label.setVisible(False)
193
+ layout.addWidget(self._status_label)
194
+
195
+ # Placeholder (shown when not connected)
196
+ self._placeholder = QLabel("Connect to S3 to browse files")
197
+ self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
198
+ self._placeholder.setStyleSheet("color: gray;")
199
+ layout.addWidget(self._placeholder)
200
+ self._table.setVisible(False)
201
+
202
+ # Footer
203
+ self._footer = QLabel("0 items")
204
+ self._footer.setContentsMargins(8, 2, 8, 2)
205
+ self._footer.setStyleSheet("color: gray; font-size: 11px;")
206
+ layout.addWidget(self._footer)
207
+
208
+ # --- Public API ---
209
+
210
+ def set_client(self, s3_client: S3Client) -> None:
211
+ """Set the S3 client to use for fetching."""
212
+ self._s3_client = s3_client
213
+ self._connected = True
214
+ self._placeholder.setVisible(False)
215
+ self._table.setVisible(True)
216
+
217
+ def set_bucket(self, bucket_name: str) -> None:
218
+ """Switch to a different bucket."""
219
+ self._bucket = bucket_name
220
+ self._cache.invalidate_all()
221
+ self._history_back.clear()
222
+ self._history_forward.clear()
223
+ self.navigate_to("")
224
+
225
+ def navigate_to(self, prefix: str, record_history: bool = True) -> None:
226
+ """Navigate to an S3 prefix."""
227
+ if not self._s3_client or not self._bucket:
228
+ return
229
+
230
+ if record_history and self._current_prefix != prefix:
231
+ self._history_back.append(self._current_prefix)
232
+ if len(self._history_back) > NAV_HISTORY_MAX:
233
+ self._history_back = self._history_back[-NAV_HISTORY_MAX:]
234
+ self._history_forward.clear()
235
+
236
+ self._current_prefix = prefix
237
+ self._update_breadcrumb()
238
+ self._update_nav_buttons()
239
+
240
+ # Check cache
241
+ cached = self._cache.get(prefix)
242
+ if cached is not None:
243
+ self._model.set_items(cached.items)
244
+ self._update_footer()
245
+ self._status_label.setVisible(False)
246
+ self.directory_changed.emit(prefix)
247
+
248
+ # Launch background revalidation if stale
249
+ if self._cache.is_stale(prefix):
250
+ counter = self._cache.get_mutation_counter(prefix)
251
+ self._launch_fetch(prefix, revalidate=True, counter=counter)
252
+ return
253
+
254
+ # Cache miss — show loading state and fetch
255
+ self._model.clear()
256
+ self._show_loading()
257
+ self._launch_fetch(prefix)
258
+ self.directory_changed.emit(prefix)
259
+
260
+ def go_back(self) -> None:
261
+ if not self._history_back:
262
+ return
263
+ self._history_forward.append(self._current_prefix)
264
+ prefix = self._history_back.pop()
265
+ self.navigate_to(prefix, record_history=False)
266
+
267
+ def go_forward(self) -> None:
268
+ if not self._history_forward:
269
+ return
270
+ self._history_back.append(self._current_prefix)
271
+ prefix = self._history_forward.pop()
272
+ self.navigate_to(prefix, record_history=False)
273
+
274
+ def refresh(self) -> None:
275
+ """Force refresh current prefix."""
276
+ self._cache.invalidate(self._current_prefix)
277
+ self.navigate_to(self._current_prefix, record_history=False)
278
+
279
+ def current_prefix(self) -> str:
280
+ return self._current_prefix
281
+
282
+ def selected_items(self) -> list[S3Item]:
283
+ """Return S3Items for selected rows."""
284
+ items = []
285
+ for idx in self._table.selectionModel().selectedRows():
286
+ source_idx = self._proxy.mapToSource(idx)
287
+ item = self._model.get_item(source_idx.row())
288
+ if item:
289
+ items.append(item)
290
+ return items
291
+
292
+ # --- Optimistic mutation interface ---
293
+
294
+ def notify_upload_complete(self, key: str, size: int) -> None:
295
+ """Optimistic: insert uploaded object into current listing."""
296
+ prefix = self._current_prefix
297
+ name = key[len(prefix) :] if prefix else key
298
+ if "/" in name:
299
+ return # Not in current directory level
300
+ item = S3Item(name=name, key=key, is_prefix=False, size=size)
301
+ self._model.insert_item(item)
302
+ self._cache.apply_mutation(prefix, lambda items: items.append(item))
303
+ self._update_footer()
304
+
305
+ def notify_delete_complete(self, keys: list[str]) -> None:
306
+ """Optimistic: remove deleted objects from current listing."""
307
+ key_set = set(keys)
308
+ self._model.remove_items(key_set)
309
+ self._cache.apply_mutation(
310
+ self._current_prefix,
311
+ lambda items: self._remove_from_list(items, key_set),
312
+ )
313
+ self._update_footer()
314
+
315
+ def notify_rename_complete(self, old_key: str, new_key: str, new_name: str) -> None:
316
+ """Optimistic: update a renamed item."""
317
+ self._model.update_item(old_key, key=new_key, name=new_name)
318
+ self._cache.apply_mutation(
319
+ self._current_prefix,
320
+ lambda items: self._rename_in_list(items, old_key, new_key, new_name),
321
+ )
322
+
323
+ def notify_new_folder(self, key: str, name: str) -> None:
324
+ """Optimistic: insert a new prefix (folder)."""
325
+ item = S3Item(name=name, key=key, is_prefix=True)
326
+ self._model.insert_item(item)
327
+ self._cache.apply_mutation(self._current_prefix, lambda items: items.append(item))
328
+ self._update_footer()
329
+
330
+ def notify_copy_complete(self, key: str, size: int) -> None:
331
+ """Optimistic: insert a copied object."""
332
+ self.notify_upload_complete(key, size)
333
+
334
+ # --- Filter ---
335
+
336
+ def _toggle_filter(self, checked: bool) -> None:
337
+ self._filter_bar.setVisible(checked)
338
+ if checked:
339
+ self._filter_bar.setFocus()
340
+ else:
341
+ self._filter_bar.clear()
342
+
343
+ def _on_filter_changed(self, text: str) -> None:
344
+ self._proxy.setFilterFixedString(text)
345
+ self._update_footer()
346
+
347
+ # --- Internal ---
348
+
349
+ def _launch_fetch(self, prefix: str, revalidate: bool = False, counter: int = 0) -> None:
350
+ """Launch a background fetch for the given prefix."""
351
+ self._fetch_id += 1
352
+ fetch_id = self._fetch_id
353
+
354
+ worker = _FetchWorker(self._s3_client, self._bucket, prefix, fetch_id, self)
355
+
356
+ if revalidate:
357
+ worker.signals.listing_complete.connect(
358
+ lambda p, items, fid: self._on_revalidation_complete(p, items, fid, counter)
359
+ )
360
+ else:
361
+ worker.signals.listing_complete.connect(self._on_listing_complete)
362
+
363
+ worker.signals.error.connect(self._on_fetch_error)
364
+ worker.finished.connect(worker.deleteLater)
365
+ self._fetch_worker = worker
366
+ worker.start()
367
+
368
+ def _on_listing_complete(self, prefix: str, items: list[S3Item], fetch_id: int) -> None:
369
+ """Handle completion of a fresh fetch."""
370
+ if fetch_id != self._fetch_id:
371
+ # Stale fetch — cache the result but don't update UI
372
+ self._cache.put(prefix, items)
373
+ return
374
+
375
+ self._cache.put(prefix, items)
376
+ self._model.set_items(items)
377
+ self._status_label.setVisible(False)
378
+ self._update_footer()
379
+ self.status_message.emit(f"Loaded {len(items)} items")
380
+
381
+ def _on_revalidation_complete(
382
+ self, prefix: str, items: list[S3Item], fetch_id: int, counter: int
383
+ ) -> None:
384
+ """Handle completion of a background revalidation."""
385
+ self._cache.safe_revalidate(prefix, items, counter)
386
+
387
+ if fetch_id != self._fetch_id:
388
+ return # User navigated away
389
+
390
+ if prefix == self._current_prefix:
391
+ cached = self._cache.get(prefix)
392
+ if cached:
393
+ self._model.diff_apply(cached.items)
394
+ self._update_footer()
395
+
396
+ def _on_fetch_error(self, prefix: str, error_msg: str, fetch_id: int) -> None:
397
+ """Handle fetch failure."""
398
+ if fetch_id != self._fetch_id:
399
+ return
400
+ self._status_label.setText(f"Error loading: {error_msg}\nClick Refresh to retry.")
401
+ self._status_label.setVisible(True)
402
+ self.status_message.emit(f"Error: {error_msg}")
403
+
404
+ def _show_loading(self) -> None:
405
+ self._status_label.setText("Loading...")
406
+ self._status_label.setVisible(True)
407
+
408
+ def _update_breadcrumb(self) -> None:
409
+ display_path = f"{self._bucket}/{self._current_prefix}" if self._bucket else ""
410
+ self._breadcrumb.set_path(display_path)
411
+
412
+ def _on_breadcrumb_clicked(self, path: str) -> None:
413
+ # Strip bucket name from the front to get the prefix
414
+ if self._bucket and path.startswith(self._bucket):
415
+ prefix = path[len(self._bucket) :]
416
+ if prefix.startswith("/"):
417
+ prefix = prefix[1:]
418
+ if prefix and not prefix.endswith("/"):
419
+ prefix += "/"
420
+ # Root is empty string, not "/"
421
+ if prefix == "/":
422
+ prefix = ""
423
+ self.navigate_to(prefix)
424
+
425
+ def _on_breadcrumb_edited(self, path: str) -> None:
426
+ # User typed a path — interpret as prefix
427
+ if self._bucket and path.startswith(self._bucket):
428
+ prefix = path[len(self._bucket) :]
429
+ if prefix.startswith("/"):
430
+ prefix = prefix[1:]
431
+ else:
432
+ prefix = path
433
+ if prefix and not prefix.endswith("/"):
434
+ prefix += "/"
435
+ self.navigate_to(prefix)
436
+
437
+ def _on_double_click(self, index: QModelIndex) -> None:
438
+ source_idx = self._proxy.mapToSource(index)
439
+ item = self._model.get_item(source_idx.row())
440
+ if not item:
441
+ return
442
+ if item.is_prefix:
443
+ self.navigate_to(item.key)
444
+ else:
445
+ self.quick_open_requested.emit(item)
446
+
447
+ def _update_nav_buttons(self) -> None:
448
+ self._back_btn.setEnabled(len(self._history_back) > 0)
449
+ self._forward_btn.setEnabled(len(self._history_forward) > 0)
450
+
451
+ def _update_footer(self) -> None:
452
+ total = self._model.item_count()
453
+ visible = self._proxy.rowCount()
454
+ size_str = _format_size(self._model.total_size())
455
+
456
+ if self._filter_bar.isVisible() and self._filter_bar.text():
457
+ self._footer.setText(f"{visible} of {total} items, {size_str}")
458
+ else:
459
+ self._footer.setText(f"{total} items, {size_str}")
460
+
461
+ @staticmethod
462
+ def _remove_from_list(items: list[S3Item], keys: set[str]) -> None:
463
+ items[:] = [i for i in items if i.key not in keys]
464
+
465
+ def _on_context_menu(self, pos) -> None:
466
+ from PyQt6.QtWidgets import QMenu
467
+
468
+ menu = QMenu(self)
469
+ selected = self.selected_items()
470
+
471
+ if selected:
472
+ download_action = menu.addAction("Download")
473
+ download_action.triggered.connect(lambda: self.download_requested.emit(selected))
474
+
475
+ menu.addSeparator()
476
+
477
+ delete_action = menu.addAction("Delete")
478
+ delete_action.triggered.connect(lambda: self.delete_requested.emit(selected))
479
+
480
+ if len(selected) == 1:
481
+ menu.addSeparator()
482
+ info_action = menu.addAction("Get Info")
483
+ info_action.triggered.connect(lambda: self.get_info_requested.emit(selected[0]))
484
+ else:
485
+ new_folder_action = menu.addAction("New Folder")
486
+ new_folder_action.triggered.connect(self.new_folder_requested.emit)
487
+
488
+ refresh_action = menu.addAction("Refresh")
489
+ refresh_action.triggered.connect(self.refresh)
490
+
491
+ menu.exec(self._table.viewport().mapToGlobal(pos))
492
+
493
+ # --- Operation lock manager ---
494
+
495
+ def acquire_lock(self, keys: list[str], description: str) -> bool:
496
+ """Attempt to lock keys for an operation. Returns False if conflict."""
497
+ for key in keys:
498
+ for locked_key, locked_desc in self._operation_locks.items():
499
+ if key.startswith(locked_key) or locked_key.startswith(key):
500
+ logger.warning(
501
+ "Lock conflict: '%s' blocked by '%s' (%s)",
502
+ key,
503
+ locked_key,
504
+ locked_desc,
505
+ )
506
+ return False
507
+ for key in keys:
508
+ self._operation_locks[key] = description
509
+ return True
510
+
511
+ def release_lock(self, keys: list[str]) -> None:
512
+ """Release locks for the given keys."""
513
+ for key in keys:
514
+ self._operation_locks.pop(key, None)
515
+
516
+ # --- Drag and drop (via event filter on table viewport) ---
517
+
518
+ def eventFilter(self, obj: QObject, event: QEvent) -> bool:
519
+ if obj is not self._table.viewport():
520
+ return super().eventFilter(obj, event)
521
+
522
+ etype = event.type()
523
+
524
+ if etype in (QEvent.Type.DragEnter, QEvent.Type.DragMove) and event.mimeData().hasUrls():
525
+ event.setDropAction(Qt.DropAction.CopyAction)
526
+ event.accept()
527
+ return True
528
+
529
+ if etype == QEvent.Type.Drop and event.mimeData().hasUrls():
530
+ paths = []
531
+ for url in event.mimeData().urls():
532
+ if url.isLocalFile():
533
+ paths.append(url.toLocalFile())
534
+ if paths:
535
+ self.files_dropped.emit(paths)
536
+ event.accept()
537
+ return True
538
+
539
+ return super().eventFilter(obj, event)
540
+
541
+ @staticmethod
542
+ def _rename_in_list(items: list[S3Item], old_key: str, new_key: str, new_name: str) -> None:
543
+ for item in items:
544
+ if item.key == old_key:
545
+ item.key = new_key
546
+ item.name = new_name
547
+ break