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.
- s3ui/__init__.py +1 -0
- s3ui/app.py +56 -0
- s3ui/constants.py +39 -0
- s3ui/core/__init__.py +0 -0
- s3ui/core/cost.py +218 -0
- s3ui/core/credentials.py +165 -0
- s3ui/core/download_worker.py +260 -0
- s3ui/core/errors.py +104 -0
- s3ui/core/listing_cache.py +178 -0
- s3ui/core/s3_client.py +358 -0
- s3ui/core/stats.py +128 -0
- s3ui/core/transfers.py +281 -0
- s3ui/core/upload_worker.py +311 -0
- s3ui/db/__init__.py +0 -0
- s3ui/db/database.py +143 -0
- s3ui/db/migrations/001_initial.sql +114 -0
- s3ui/logging_setup.py +18 -0
- s3ui/main_window.py +969 -0
- s3ui/models/__init__.py +0 -0
- s3ui/models/s3_objects.py +295 -0
- s3ui/models/transfer_model.py +282 -0
- s3ui/resources/__init__.py +0 -0
- s3ui/resources/s3ui.png +0 -0
- s3ui/ui/__init__.py +0 -0
- s3ui/ui/breadcrumb_bar.py +150 -0
- s3ui/ui/confirm_delete.py +60 -0
- s3ui/ui/cost_dialog.py +163 -0
- s3ui/ui/get_info.py +50 -0
- s3ui/ui/local_pane.py +226 -0
- s3ui/ui/name_conflict.py +68 -0
- s3ui/ui/s3_pane.py +547 -0
- s3ui/ui/settings_dialog.py +328 -0
- s3ui/ui/setup_wizard.py +462 -0
- s3ui/ui/stats_dialog.py +162 -0
- s3ui/ui/transfer_panel.py +153 -0
- s3ui-1.0.0.dist-info/METADATA +118 -0
- s3ui-1.0.0.dist-info/RECORD +40 -0
- s3ui-1.0.0.dist-info/WHEEL +4 -0
- s3ui-1.0.0.dist-info/entry_points.txt +2 -0
- s3ui-1.0.0.dist-info/licenses/LICENSE +21 -0
s3ui/ui/name_conflict.py
ADDED
|
@@ -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
|