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/main_window.py
ADDED
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
"""Main application window — dual-pane layout with toolbar, menus, status bar."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from PyQt6.QtCore import QByteArray, QObject, Qt, QThread, QTimer, QUrl, pyqtSignal
|
|
7
|
+
from PyQt6.QtGui import QAction, QDesktopServices, QKeySequence
|
|
8
|
+
from PyQt6.QtWidgets import (
|
|
9
|
+
QComboBox,
|
|
10
|
+
QDockWidget,
|
|
11
|
+
QInputDialog,
|
|
12
|
+
QLabel,
|
|
13
|
+
QMainWindow,
|
|
14
|
+
QMessageBox,
|
|
15
|
+
QPushButton,
|
|
16
|
+
QSplitter,
|
|
17
|
+
QSystemTrayIcon,
|
|
18
|
+
QToolBar,
|
|
19
|
+
QWidget,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from s3ui.constants import (
|
|
23
|
+
LOG_DIR,
|
|
24
|
+
MIN_WINDOW_HEIGHT,
|
|
25
|
+
MIN_WINDOW_WIDTH,
|
|
26
|
+
NOTIFY_SIZE_THRESHOLD,
|
|
27
|
+
QUICK_OPEN_THRESHOLD,
|
|
28
|
+
TEMP_DIR,
|
|
29
|
+
)
|
|
30
|
+
from s3ui.core.cost import CostTracker
|
|
31
|
+
from s3ui.core.credentials import CredentialStore, Profile, discover_aws_profiles
|
|
32
|
+
from s3ui.core.s3_client import S3Client, S3ClientError
|
|
33
|
+
from s3ui.core.transfers import TransferEngine
|
|
34
|
+
from s3ui.ui.local_pane import LocalPaneWidget
|
|
35
|
+
from s3ui.ui.s3_pane import S3PaneWidget
|
|
36
|
+
from s3ui.ui.settings_dialog import SettingsDialog
|
|
37
|
+
from s3ui.ui.setup_wizard import SetupWizard
|
|
38
|
+
from s3ui.ui.transfer_panel import TransferPanelWidget
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("s3ui.main_window")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _ConnectSignals(QObject):
|
|
44
|
+
connected = pyqtSignal(object, list) # S3Client, bucket_names
|
|
45
|
+
failed = pyqtSignal(str) # error message
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _ConnectWorker(QThread):
|
|
49
|
+
"""Background thread for connecting to an AWS profile and listing buckets."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, profile: Profile, parent=None) -> None:
|
|
52
|
+
super().__init__(parent)
|
|
53
|
+
self.signals = _ConnectSignals()
|
|
54
|
+
self._profile = profile
|
|
55
|
+
|
|
56
|
+
def run(self) -> None:
|
|
57
|
+
try:
|
|
58
|
+
client = S3Client(self._profile)
|
|
59
|
+
buckets = client.list_buckets()
|
|
60
|
+
self.signals.connected.emit(client, buckets)
|
|
61
|
+
except S3ClientError as e:
|
|
62
|
+
self.signals.failed.emit(e.user_message)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
self.signals.failed.emit(str(e))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _DeleteSignals(QObject):
|
|
68
|
+
finished = pyqtSignal(list) # list of deleted keys
|
|
69
|
+
failed = pyqtSignal(str) # error message
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _DeleteWorker(QThread):
|
|
73
|
+
"""Background thread for deleting S3 objects."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, s3_client: S3Client, bucket: str, keys: list[str], parent=None) -> None:
|
|
76
|
+
super().__init__(parent)
|
|
77
|
+
self.signals = _DeleteSignals()
|
|
78
|
+
self._s3 = s3_client
|
|
79
|
+
self._bucket = bucket
|
|
80
|
+
self._keys = keys
|
|
81
|
+
|
|
82
|
+
def run(self) -> None:
|
|
83
|
+
try:
|
|
84
|
+
failed = self._s3.delete_objects(self._bucket, self._keys)
|
|
85
|
+
deleted = [k for k in self._keys if k not in failed]
|
|
86
|
+
self.signals.finished.emit(deleted)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
self.signals.failed.emit(str(e))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class MainWindow(QMainWindow):
|
|
92
|
+
def __init__(self, db=None) -> None:
|
|
93
|
+
super().__init__()
|
|
94
|
+
self._db = db
|
|
95
|
+
self._transfer_engine = None
|
|
96
|
+
self._tray_icon: QSystemTrayIcon | None = None
|
|
97
|
+
self._temp_files: list[str] = []
|
|
98
|
+
self._store = CredentialStore()
|
|
99
|
+
self._s3_client: S3Client | None = None
|
|
100
|
+
self._connect_worker: _ConnectWorker | None = None
|
|
101
|
+
self._wizard: SetupWizard | None = None
|
|
102
|
+
self._delete_worker: _DeleteWorker | None = None
|
|
103
|
+
self._cost_tracker: CostTracker | None = None
|
|
104
|
+
self._aws_profile_names: set[str] = set()
|
|
105
|
+
|
|
106
|
+
self.setWindowTitle("S3UI")
|
|
107
|
+
self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
|
|
108
|
+
|
|
109
|
+
self._setup_toolbar()
|
|
110
|
+
self._setup_central()
|
|
111
|
+
self._setup_transfer_dock()
|
|
112
|
+
self._setup_status_bar()
|
|
113
|
+
self._setup_menus()
|
|
114
|
+
self._setup_keyboard_shortcuts()
|
|
115
|
+
self._setup_tray_icon()
|
|
116
|
+
self._restore_state()
|
|
117
|
+
|
|
118
|
+
# Wire combo signals
|
|
119
|
+
self._profile_combo.currentIndexChanged.connect(self._on_profile_selected)
|
|
120
|
+
self._bucket_combo.currentIndexChanged.connect(self._on_bucket_selected)
|
|
121
|
+
|
|
122
|
+
# Wire double-click-to-open
|
|
123
|
+
self._s3_pane.quick_open_requested.connect(self._on_quick_open)
|
|
124
|
+
|
|
125
|
+
# Wire upload / download / drop / delete signals
|
|
126
|
+
self._local_pane.upload_requested.connect(self._on_upload_requested)
|
|
127
|
+
self._s3_pane.files_dropped.connect(self._on_files_dropped)
|
|
128
|
+
self._s3_pane.download_requested.connect(self._on_download_requested)
|
|
129
|
+
self._s3_pane.delete_requested.connect(self._on_delete_requested)
|
|
130
|
+
self._s3_pane.new_folder_requested.connect(self._on_new_folder_requested)
|
|
131
|
+
|
|
132
|
+
# Wire transfer panel control signals
|
|
133
|
+
self._transfer_panel.pause_requested.connect(self._on_pause_transfer)
|
|
134
|
+
self._transfer_panel.resume_requested.connect(self._on_resume_transfer)
|
|
135
|
+
self._transfer_panel.cancel_requested.connect(self._on_cancel_transfer)
|
|
136
|
+
self._transfer_panel.retry_requested.connect(self._on_retry_transfer)
|
|
137
|
+
|
|
138
|
+
logger.info("Main window initialized")
|
|
139
|
+
|
|
140
|
+
# Discover profiles and connect after event loop starts
|
|
141
|
+
QTimer.singleShot(0, self._init_connection)
|
|
142
|
+
|
|
143
|
+
# --- Toolbar ---
|
|
144
|
+
|
|
145
|
+
def _setup_toolbar(self) -> None:
|
|
146
|
+
toolbar = QToolBar("Main")
|
|
147
|
+
toolbar.setMovable(False)
|
|
148
|
+
toolbar.setFloatable(False)
|
|
149
|
+
self.addToolBar(toolbar)
|
|
150
|
+
|
|
151
|
+
self._profile_combo = QComboBox()
|
|
152
|
+
self._profile_combo.setToolTip("AWS Profile")
|
|
153
|
+
self._profile_combo.setMinimumWidth(100)
|
|
154
|
+
toolbar.addWidget(self._profile_combo)
|
|
155
|
+
|
|
156
|
+
toolbar.addSeparator()
|
|
157
|
+
|
|
158
|
+
self._bucket_combo = QComboBox()
|
|
159
|
+
self._bucket_combo.setToolTip("S3 Bucket")
|
|
160
|
+
self._bucket_combo.setMinimumWidth(150)
|
|
161
|
+
toolbar.addWidget(self._bucket_combo)
|
|
162
|
+
|
|
163
|
+
spacer = QWidget()
|
|
164
|
+
spacer.setSizePolicy(
|
|
165
|
+
spacer.sizePolicy().horizontalPolicy().Expanding,
|
|
166
|
+
spacer.sizePolicy().verticalPolicy().Preferred,
|
|
167
|
+
)
|
|
168
|
+
toolbar.addWidget(spacer)
|
|
169
|
+
|
|
170
|
+
self._settings_btn = QPushButton("Settings")
|
|
171
|
+
self._settings_btn.setToolTip("Settings")
|
|
172
|
+
self._settings_btn.setFlat(True)
|
|
173
|
+
self._settings_btn.clicked.connect(self._open_settings)
|
|
174
|
+
toolbar.addWidget(self._settings_btn)
|
|
175
|
+
|
|
176
|
+
# --- Connection flow ---
|
|
177
|
+
|
|
178
|
+
def _init_connection(self) -> None:
|
|
179
|
+
"""Discover profiles and connect to the last-used or first available."""
|
|
180
|
+
self._populate_profiles()
|
|
181
|
+
|
|
182
|
+
if self._profile_combo.count() == 0:
|
|
183
|
+
self._show_setup_wizard()
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Restore last-used profile or default to first
|
|
187
|
+
target_idx = 0
|
|
188
|
+
if self._db:
|
|
189
|
+
from s3ui.db.database import get_pref
|
|
190
|
+
|
|
191
|
+
last_profile = get_pref(self._db, "last_profile")
|
|
192
|
+
if last_profile:
|
|
193
|
+
idx = self._profile_combo.findData(last_profile)
|
|
194
|
+
if idx >= 0:
|
|
195
|
+
target_idx = idx
|
|
196
|
+
|
|
197
|
+
self._profile_combo.blockSignals(True)
|
|
198
|
+
self._profile_combo.setCurrentIndex(target_idx)
|
|
199
|
+
self._profile_combo.blockSignals(False)
|
|
200
|
+
self._on_profile_selected(target_idx)
|
|
201
|
+
|
|
202
|
+
def _populate_profiles(self) -> None:
|
|
203
|
+
"""Discover AWS CLI profiles and custom keyring profiles."""
|
|
204
|
+
self._profile_combo.blockSignals(True)
|
|
205
|
+
self._profile_combo.clear()
|
|
206
|
+
self._aws_profile_names = set()
|
|
207
|
+
|
|
208
|
+
# AWS CLI profiles
|
|
209
|
+
aws_profiles = discover_aws_profiles()
|
|
210
|
+
for name in aws_profiles:
|
|
211
|
+
self._profile_combo.addItem(f"{name} (AWS)", name)
|
|
212
|
+
self._aws_profile_names.add(name)
|
|
213
|
+
|
|
214
|
+
# Custom keyring profiles
|
|
215
|
+
for name in self._store.list_profiles():
|
|
216
|
+
if name not in self._aws_profile_names:
|
|
217
|
+
self._profile_combo.addItem(name, name)
|
|
218
|
+
|
|
219
|
+
self._profile_combo.blockSignals(False)
|
|
220
|
+
|
|
221
|
+
def _on_profile_selected(self, index: int) -> None:
|
|
222
|
+
"""Handle profile combo selection — connect to the chosen profile."""
|
|
223
|
+
if index < 0:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
profile_name = self._profile_combo.currentData()
|
|
227
|
+
if not profile_name:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if profile_name in self._aws_profile_names:
|
|
231
|
+
profile = Profile(name=profile_name, is_aws_profile=True)
|
|
232
|
+
else:
|
|
233
|
+
profile = self._store.get_profile(profile_name)
|
|
234
|
+
if not profile:
|
|
235
|
+
self.set_status(f"Profile '{profile_name}' not found")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
self._connect_to_profile(profile)
|
|
239
|
+
|
|
240
|
+
def _connect_to_profile(self, profile: Profile) -> None:
|
|
241
|
+
"""Create an S3 client and list buckets in a background thread."""
|
|
242
|
+
if self._connect_worker is not None:
|
|
243
|
+
self._connect_worker.quit()
|
|
244
|
+
self._connect_worker.wait(1000)
|
|
245
|
+
|
|
246
|
+
self.set_status(f"Connecting to '{profile.name}'...")
|
|
247
|
+
self._bucket_combo.blockSignals(True)
|
|
248
|
+
self._bucket_combo.clear()
|
|
249
|
+
self._bucket_combo.blockSignals(False)
|
|
250
|
+
|
|
251
|
+
self._connect_worker = _ConnectWorker(profile, self)
|
|
252
|
+
self._connect_worker.signals.connected.connect(self._on_connected)
|
|
253
|
+
self._connect_worker.signals.failed.connect(self._on_connect_failed)
|
|
254
|
+
self._connect_worker.finished.connect(self._on_connect_worker_done)
|
|
255
|
+
self._connect_worker.start()
|
|
256
|
+
|
|
257
|
+
def _on_connect_worker_done(self) -> None:
|
|
258
|
+
"""Clean up the connect worker after it finishes."""
|
|
259
|
+
worker = self._connect_worker
|
|
260
|
+
self._connect_worker = None
|
|
261
|
+
if worker is not None:
|
|
262
|
+
worker.deleteLater()
|
|
263
|
+
|
|
264
|
+
def _on_connected(self, client: S3Client, buckets: list[str]) -> None:
|
|
265
|
+
"""Handle successful connection — populate bucket combo."""
|
|
266
|
+
self._s3_client = client
|
|
267
|
+
self._s3_pane.set_client(client)
|
|
268
|
+
|
|
269
|
+
self._bucket_combo.blockSignals(True)
|
|
270
|
+
self._bucket_combo.clear()
|
|
271
|
+
for name in sorted(buckets):
|
|
272
|
+
self._bucket_combo.addItem(name, name)
|
|
273
|
+
self._bucket_combo.blockSignals(False)
|
|
274
|
+
|
|
275
|
+
profile_name = self._profile_combo.currentData()
|
|
276
|
+
self.set_status(f"Connected — {len(buckets)} bucket(s)")
|
|
277
|
+
|
|
278
|
+
# Save last-used profile
|
|
279
|
+
if self._db and profile_name:
|
|
280
|
+
from s3ui.db.database import set_pref
|
|
281
|
+
|
|
282
|
+
set_pref(self._db, "last_profile", profile_name)
|
|
283
|
+
|
|
284
|
+
# Select last-used bucket or first available
|
|
285
|
+
if self._bucket_combo.count() > 0:
|
|
286
|
+
target_idx = 0
|
|
287
|
+
if self._db:
|
|
288
|
+
from s3ui.db.database import get_pref
|
|
289
|
+
|
|
290
|
+
last_bucket = get_pref(self._db, "last_bucket")
|
|
291
|
+
if last_bucket:
|
|
292
|
+
idx = self._bucket_combo.findData(last_bucket)
|
|
293
|
+
if idx >= 0:
|
|
294
|
+
target_idx = idx
|
|
295
|
+
self._bucket_combo.blockSignals(True)
|
|
296
|
+
self._bucket_combo.setCurrentIndex(target_idx)
|
|
297
|
+
self._bucket_combo.blockSignals(False)
|
|
298
|
+
self._on_bucket_selected(target_idx)
|
|
299
|
+
|
|
300
|
+
def _on_connect_failed(self, error_message: str) -> None:
|
|
301
|
+
"""Handle connection failure."""
|
|
302
|
+
self.set_status(f"Connection failed: {error_message}")
|
|
303
|
+
logger.warning("Connection failed: %s", error_message)
|
|
304
|
+
|
|
305
|
+
def _on_bucket_selected(self, index: int) -> None:
|
|
306
|
+
"""Handle bucket combo selection — switch the S3 pane to this bucket."""
|
|
307
|
+
if index < 0:
|
|
308
|
+
return
|
|
309
|
+
bucket_name = self._bucket_combo.currentData()
|
|
310
|
+
if not bucket_name:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
self._s3_pane.set_bucket(bucket_name)
|
|
314
|
+
self.set_status(f"Browsing {bucket_name}")
|
|
315
|
+
|
|
316
|
+
if self._db:
|
|
317
|
+
from s3ui.db.database import set_pref
|
|
318
|
+
|
|
319
|
+
set_pref(self._db, "last_bucket", bucket_name)
|
|
320
|
+
|
|
321
|
+
self._create_cost_tracker()
|
|
322
|
+
self._create_transfer_engine()
|
|
323
|
+
|
|
324
|
+
def _show_setup_wizard(self) -> None:
|
|
325
|
+
"""Show the setup wizard, passing already-discovered profiles."""
|
|
326
|
+
aws_profiles = list(self._aws_profile_names) if self._aws_profile_names else None
|
|
327
|
+
self._wizard = SetupWizard(self._store, self, aws_profiles=aws_profiles)
|
|
328
|
+
self._wizard.finished.connect(self._on_wizard_finished)
|
|
329
|
+
self._wizard.open() # Window-modal, non-blocking
|
|
330
|
+
|
|
331
|
+
def _on_wizard_finished(self, result: int) -> None:
|
|
332
|
+
"""Handle wizard close — defer work to run outside QDialog::done()."""
|
|
333
|
+
wizard = self._wizard
|
|
334
|
+
self._wizard = None
|
|
335
|
+
if result != 1: # Not QDialog.Accepted
|
|
336
|
+
return
|
|
337
|
+
# Defer to avoid running inside done() stack which causes SIGABRT on exception
|
|
338
|
+
QTimer.singleShot(0, lambda: self._apply_wizard_result(wizard))
|
|
339
|
+
|
|
340
|
+
def _apply_wizard_result(self, wizard: SetupWizard) -> None:
|
|
341
|
+
"""Apply the wizard result after the dialog has fully closed."""
|
|
342
|
+
profile = wizard.get_profile()
|
|
343
|
+
bucket_name = wizard.get_bucket()
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
self._store.save_profile(profile)
|
|
347
|
+
except Exception:
|
|
348
|
+
logger.exception("Failed to save profile from wizard")
|
|
349
|
+
|
|
350
|
+
logger.info("Setup complete: profile='%s', bucket='%s'", profile.name, bucket_name)
|
|
351
|
+
|
|
352
|
+
if self._db and bucket_name:
|
|
353
|
+
from s3ui.db.database import set_pref
|
|
354
|
+
|
|
355
|
+
set_pref(self._db, "last_bucket", bucket_name)
|
|
356
|
+
|
|
357
|
+
self._populate_profiles()
|
|
358
|
+
idx = self._profile_combo.findData(profile.name)
|
|
359
|
+
if idx >= 0:
|
|
360
|
+
self._profile_combo.setCurrentIndex(idx)
|
|
361
|
+
self._on_profile_selected(idx)
|
|
362
|
+
|
|
363
|
+
# --- Cost tracking ---
|
|
364
|
+
|
|
365
|
+
def _create_cost_tracker(self) -> None:
|
|
366
|
+
"""Create a CostTracker for the current bucket and attach to S3Client."""
|
|
367
|
+
bucket_id = self._ensure_bucket_id()
|
|
368
|
+
if bucket_id is None or not self._db:
|
|
369
|
+
self._cost_tracker = None
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
self._cost_tracker = CostTracker(self._db, bucket_id)
|
|
373
|
+
if self._s3_client:
|
|
374
|
+
self._s3_client.set_cost_tracker(self._cost_tracker)
|
|
375
|
+
self._cost_action.setEnabled(True)
|
|
376
|
+
self._update_cost_label()
|
|
377
|
+
|
|
378
|
+
def _update_cost_label(self) -> None:
|
|
379
|
+
"""Refresh the status bar cost estimate."""
|
|
380
|
+
if not self._cost_tracker:
|
|
381
|
+
self._cost_label.setText("")
|
|
382
|
+
return
|
|
383
|
+
estimate = self._cost_tracker.get_monthly_estimate()
|
|
384
|
+
self._cost_label.setText(f"Est. ${estimate:.4f}/mo")
|
|
385
|
+
|
|
386
|
+
def _open_cost_dashboard(self) -> None:
|
|
387
|
+
"""Open the cost dashboard dialog."""
|
|
388
|
+
from s3ui.ui.cost_dialog import CostDialog
|
|
389
|
+
|
|
390
|
+
dialog = CostDialog(cost_tracker=self._cost_tracker, parent=self)
|
|
391
|
+
dialog.exec()
|
|
392
|
+
self._update_cost_label()
|
|
393
|
+
|
|
394
|
+
# --- Upload / Download / Transfer wiring ---
|
|
395
|
+
|
|
396
|
+
def _create_transfer_engine(self) -> None:
|
|
397
|
+
"""Create a TransferEngine for the current bucket + client."""
|
|
398
|
+
bucket_name = self._bucket_combo.currentData()
|
|
399
|
+
if not self._s3_client or not self._db or not bucket_name:
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
engine = TransferEngine(self._s3_client, self._db, bucket_name)
|
|
403
|
+
self.set_transfer_engine(engine)
|
|
404
|
+
engine.restore_pending()
|
|
405
|
+
|
|
406
|
+
def _ensure_bucket_id(self) -> int | None:
|
|
407
|
+
"""Get or create the bucket record in the database, return its ID."""
|
|
408
|
+
if self._db is None:
|
|
409
|
+
return None
|
|
410
|
+
bucket_name = self._bucket_combo.currentData()
|
|
411
|
+
if not bucket_name:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
row = self._db.fetchone(
|
|
415
|
+
"SELECT id FROM buckets WHERE name = ? ORDER BY id DESC LIMIT 1",
|
|
416
|
+
(bucket_name,),
|
|
417
|
+
)
|
|
418
|
+
if row:
|
|
419
|
+
return row["id"]
|
|
420
|
+
|
|
421
|
+
profile_name = self._profile_combo.currentData() or ""
|
|
422
|
+
cursor = self._db.execute(
|
|
423
|
+
"INSERT INTO buckets (name, region, profile) VALUES (?, ?, ?)",
|
|
424
|
+
(bucket_name, "", profile_name),
|
|
425
|
+
)
|
|
426
|
+
return cursor.lastrowid
|
|
427
|
+
|
|
428
|
+
def _on_upload_requested(self, paths: list[str]) -> None:
|
|
429
|
+
"""Handle upload request from local pane context menu."""
|
|
430
|
+
self._enqueue_uploads(paths)
|
|
431
|
+
|
|
432
|
+
def _on_files_dropped(self, paths: list[str]) -> None:
|
|
433
|
+
"""Handle files dropped onto S3 pane."""
|
|
434
|
+
self._enqueue_uploads(paths)
|
|
435
|
+
|
|
436
|
+
def _enqueue_uploads(self, paths: list[str]) -> None:
|
|
437
|
+
"""Create transfer records and enqueue uploads."""
|
|
438
|
+
from pathlib import Path
|
|
439
|
+
|
|
440
|
+
if not self._transfer_engine or not self._db:
|
|
441
|
+
self.set_status("Not connected — cannot upload")
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
bucket_id = self._ensure_bucket_id()
|
|
445
|
+
if bucket_id is None:
|
|
446
|
+
self.set_status("No bucket selected")
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
prefix = self._s3_pane.current_prefix()
|
|
450
|
+
count = 0
|
|
451
|
+
|
|
452
|
+
for path_str in paths:
|
|
453
|
+
path = Path(path_str)
|
|
454
|
+
if path.is_dir():
|
|
455
|
+
for file_path in path.rglob("*"):
|
|
456
|
+
if file_path.is_file():
|
|
457
|
+
rel = file_path.relative_to(path.parent)
|
|
458
|
+
key = prefix + str(rel).replace("\\", "/")
|
|
459
|
+
self._create_upload_transfer(bucket_id, key, file_path)
|
|
460
|
+
count += 1
|
|
461
|
+
elif path.is_file():
|
|
462
|
+
key = prefix + path.name
|
|
463
|
+
self._create_upload_transfer(bucket_id, key, path)
|
|
464
|
+
count += 1
|
|
465
|
+
|
|
466
|
+
if count:
|
|
467
|
+
self.set_status(f"Uploading {count} file(s)...")
|
|
468
|
+
|
|
469
|
+
def _create_upload_transfer(self, bucket_id: int, key: str, local_path) -> None:
|
|
470
|
+
"""Insert a single upload transfer record and enqueue it."""
|
|
471
|
+
size = local_path.stat().st_size
|
|
472
|
+
tid = self._db.execute(
|
|
473
|
+
"INSERT INTO transfers "
|
|
474
|
+
"(bucket_id, object_key, direction, local_path, status, total_bytes, transferred) "
|
|
475
|
+
"VALUES (?, ?, 'upload', ?, 'queued', ?, 0)",
|
|
476
|
+
(bucket_id, key, str(local_path), size),
|
|
477
|
+
).lastrowid
|
|
478
|
+
|
|
479
|
+
self._transfer_panel.add_transfer(tid)
|
|
480
|
+
self._transfer_engine.enqueue(tid)
|
|
481
|
+
|
|
482
|
+
def _on_download_requested(self, items: list) -> None:
|
|
483
|
+
"""Handle download request from S3 pane context menu."""
|
|
484
|
+
from pathlib import Path
|
|
485
|
+
|
|
486
|
+
if not self._transfer_engine or not self._db:
|
|
487
|
+
self.set_status("Not connected — cannot download")
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
bucket_id = self._ensure_bucket_id()
|
|
491
|
+
if bucket_id is None:
|
|
492
|
+
self.set_status("No bucket selected")
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
dest_dir = Path(self._local_pane.current_path())
|
|
496
|
+
count = 0
|
|
497
|
+
|
|
498
|
+
for item in items:
|
|
499
|
+
if item.is_prefix:
|
|
500
|
+
continue
|
|
501
|
+
filename = item.name or item.key.rsplit("/", 1)[-1]
|
|
502
|
+
local_path = dest_dir / filename
|
|
503
|
+
size = item.size or 0
|
|
504
|
+
|
|
505
|
+
tid = self._db.execute(
|
|
506
|
+
"INSERT INTO transfers "
|
|
507
|
+
"(bucket_id, object_key, direction, local_path, status, total_bytes, transferred) "
|
|
508
|
+
"VALUES (?, ?, 'download', ?, 'queued', ?, 0)",
|
|
509
|
+
(bucket_id, item.key, str(local_path), size),
|
|
510
|
+
).lastrowid
|
|
511
|
+
|
|
512
|
+
self._transfer_panel.add_transfer(tid)
|
|
513
|
+
self._transfer_engine.enqueue(tid)
|
|
514
|
+
count += 1
|
|
515
|
+
|
|
516
|
+
if count:
|
|
517
|
+
self.set_status(f"Downloading {count} file(s)...")
|
|
518
|
+
|
|
519
|
+
def _on_delete_requested(self, items: list) -> None:
|
|
520
|
+
"""Handle delete request from S3 pane context menu."""
|
|
521
|
+
if not self._s3_client:
|
|
522
|
+
self.set_status("Not connected — cannot delete")
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
bucket = self._bucket_combo.currentData()
|
|
526
|
+
if not bucket:
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
if not items:
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
names = [i.name for i in items[:5]]
|
|
533
|
+
if len(items) > 5:
|
|
534
|
+
names.append(f"... and {len(items) - 5} more")
|
|
535
|
+
listing = "\n".join(names)
|
|
536
|
+
|
|
537
|
+
reply = QMessageBox.question(
|
|
538
|
+
self,
|
|
539
|
+
"Delete Objects",
|
|
540
|
+
f"Delete {len(items)} item(s)?\n\n{listing}",
|
|
541
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
542
|
+
)
|
|
543
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
keys = [i.key for i in items]
|
|
547
|
+
self.set_status(f"Deleting {len(keys)} item(s)...")
|
|
548
|
+
|
|
549
|
+
worker = _DeleteWorker(self._s3_client, bucket, keys, self)
|
|
550
|
+
worker.signals.finished.connect(self._on_delete_finished)
|
|
551
|
+
worker.signals.failed.connect(lambda msg: self.set_status(f"Delete failed: {msg}"))
|
|
552
|
+
worker.finished.connect(self._on_delete_worker_done)
|
|
553
|
+
self._delete_worker = worker
|
|
554
|
+
worker.start()
|
|
555
|
+
|
|
556
|
+
def _on_delete_worker_done(self) -> None:
|
|
557
|
+
"""Clean up the delete worker after it finishes."""
|
|
558
|
+
worker = self._delete_worker
|
|
559
|
+
self._delete_worker = None
|
|
560
|
+
if worker is not None:
|
|
561
|
+
worker.deleteLater()
|
|
562
|
+
|
|
563
|
+
def _on_delete_finished(self, deleted_keys: list[str]) -> None:
|
|
564
|
+
"""Handle completed deletion — update S3 pane and status."""
|
|
565
|
+
self._s3_pane.notify_delete_complete(deleted_keys)
|
|
566
|
+
self.set_status(f"Deleted {len(deleted_keys)} object(s)")
|
|
567
|
+
|
|
568
|
+
def _on_new_folder_requested(self) -> None:
|
|
569
|
+
"""Prompt for folder name and create it as an empty S3 object."""
|
|
570
|
+
if not self._s3_client:
|
|
571
|
+
self.set_status("Not connected — cannot create folder")
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
bucket = self._bucket_combo.currentData()
|
|
575
|
+
if not bucket:
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
|
579
|
+
if not ok or not name.strip():
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
name = name.strip().rstrip("/")
|
|
583
|
+
prefix = self._s3_pane.current_prefix()
|
|
584
|
+
key = f"{prefix}{name}/"
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
self._s3_client.put_object(bucket, key, b"")
|
|
588
|
+
self._s3_pane.notify_new_folder(key, name)
|
|
589
|
+
self.set_status(f"Created folder '{name}'")
|
|
590
|
+
except Exception as e:
|
|
591
|
+
logger.warning("Failed to create folder '%s': %s", key, e)
|
|
592
|
+
self.set_status(f"Failed to create folder: {e}")
|
|
593
|
+
|
|
594
|
+
def _on_pause_transfer(self, tid: int) -> None:
|
|
595
|
+
if self._transfer_engine:
|
|
596
|
+
self._transfer_engine.pause(tid)
|
|
597
|
+
|
|
598
|
+
def _on_resume_transfer(self, tid: int) -> None:
|
|
599
|
+
if self._transfer_engine:
|
|
600
|
+
self._transfer_engine.resume(tid)
|
|
601
|
+
|
|
602
|
+
def _on_cancel_transfer(self, tid: int) -> None:
|
|
603
|
+
if self._transfer_engine:
|
|
604
|
+
self._transfer_engine.cancel(tid)
|
|
605
|
+
|
|
606
|
+
def _on_retry_transfer(self, tid: int) -> None:
|
|
607
|
+
if self._transfer_engine:
|
|
608
|
+
self._transfer_engine.retry(tid)
|
|
609
|
+
|
|
610
|
+
# --- Central widget: splitter with local + S3 panes ---
|
|
611
|
+
|
|
612
|
+
def _setup_central(self) -> None:
|
|
613
|
+
self._splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
614
|
+
|
|
615
|
+
# Left pane: local files
|
|
616
|
+
self._local_pane = LocalPaneWidget()
|
|
617
|
+
self._splitter.addWidget(self._local_pane)
|
|
618
|
+
|
|
619
|
+
# Right pane: S3
|
|
620
|
+
self._s3_pane = S3PaneWidget()
|
|
621
|
+
self._s3_pane.status_message.connect(self.set_status)
|
|
622
|
+
self._splitter.addWidget(self._s3_pane)
|
|
623
|
+
|
|
624
|
+
self._splitter.setSizes([450, 450])
|
|
625
|
+
self.setCentralWidget(self._splitter)
|
|
626
|
+
|
|
627
|
+
# --- Transfer panel dock ---
|
|
628
|
+
|
|
629
|
+
def _setup_transfer_dock(self) -> None:
|
|
630
|
+
self._transfer_dock = QDockWidget("Transfers", self)
|
|
631
|
+
self._transfer_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea)
|
|
632
|
+
self._transfer_dock.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable)
|
|
633
|
+
self._transfer_panel = TransferPanelWidget(db=self._db)
|
|
634
|
+
self._transfer_panel.setMinimumHeight(80)
|
|
635
|
+
self._transfer_dock.setWidget(self._transfer_panel)
|
|
636
|
+
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self._transfer_dock)
|
|
637
|
+
|
|
638
|
+
@property
|
|
639
|
+
def transfer_panel(self) -> TransferPanelWidget:
|
|
640
|
+
return self._transfer_panel
|
|
641
|
+
|
|
642
|
+
# --- System tray icon (for notifications) ---
|
|
643
|
+
|
|
644
|
+
def _setup_tray_icon(self) -> None:
|
|
645
|
+
if QSystemTrayIcon.isSystemTrayAvailable():
|
|
646
|
+
self._tray_icon = QSystemTrayIcon(self)
|
|
647
|
+
self._tray_icon.setIcon(self.windowIcon())
|
|
648
|
+
self._tray_icon.setToolTip("S3UI")
|
|
649
|
+
# Don't show in tray by default — just use it for notifications
|
|
650
|
+
else:
|
|
651
|
+
self._tray_icon = None
|
|
652
|
+
|
|
653
|
+
def _notify(self, title: str, message: str) -> None:
|
|
654
|
+
"""Show a system notification if the app is not in the foreground."""
|
|
655
|
+
if self._tray_icon is None:
|
|
656
|
+
return
|
|
657
|
+
if self.isActiveWindow():
|
|
658
|
+
return
|
|
659
|
+
# Temporarily show to deliver the message, then hide
|
|
660
|
+
self._tray_icon.show()
|
|
661
|
+
self._tray_icon.showMessage(title, message, QSystemTrayIcon.MessageIcon.Information, 5000)
|
|
662
|
+
|
|
663
|
+
# --- Transfer engine integration ---
|
|
664
|
+
|
|
665
|
+
def set_transfer_engine(self, engine) -> None:
|
|
666
|
+
"""Wire a TransferEngine to the panel and optimistic update signals."""
|
|
667
|
+
self._transfer_engine = engine
|
|
668
|
+
self._transfer_panel.set_engine(engine)
|
|
669
|
+
|
|
670
|
+
# Wire transfer completion → optimistic S3 pane updates + notifications
|
|
671
|
+
engine.transfer_finished.connect(self._on_transfer_finished)
|
|
672
|
+
|
|
673
|
+
def _on_transfer_finished(self, transfer_id: int) -> None:
|
|
674
|
+
"""Handle transfer completion: optimistic update + notification."""
|
|
675
|
+
if self._db is None:
|
|
676
|
+
return
|
|
677
|
+
|
|
678
|
+
row = self._db.fetchone("SELECT * FROM transfers WHERE id = ?", (transfer_id,))
|
|
679
|
+
if not row:
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
if row["direction"] == "upload":
|
|
683
|
+
key = row["object_key"]
|
|
684
|
+
size = row["total_bytes"] or 0
|
|
685
|
+
self._s3_pane.notify_upload_complete(key, size)
|
|
686
|
+
|
|
687
|
+
# Refresh cost estimate after transfer
|
|
688
|
+
self._update_cost_label()
|
|
689
|
+
|
|
690
|
+
# Notification for large transfers when app is in background
|
|
691
|
+
total = row["total_bytes"] or 0
|
|
692
|
+
if total >= NOTIFY_SIZE_THRESHOLD:
|
|
693
|
+
direction = "Upload" if row["direction"] == "upload" else "Download"
|
|
694
|
+
from pathlib import Path
|
|
695
|
+
|
|
696
|
+
filename = Path(row["local_path"]).name
|
|
697
|
+
self._notify(f"{direction} complete", filename)
|
|
698
|
+
|
|
699
|
+
# --- Quick-open (double-click file in S3 pane) ---
|
|
700
|
+
|
|
701
|
+
def _on_quick_open(self, item) -> None:
|
|
702
|
+
"""Download an S3 file to temp and open with system default app."""
|
|
703
|
+
if not self._s3_pane._s3_client or not self._s3_pane._bucket:
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
size = item.size or 0
|
|
707
|
+
if size > QUICK_OPEN_THRESHOLD:
|
|
708
|
+
# Large file — emit download_requested for normal transfer queue
|
|
709
|
+
self._s3_pane.download_requested.emit([item])
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
# Small file — download inline to temp dir
|
|
713
|
+
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
|
714
|
+
filename = item.name or item.key.rsplit("/", 1)[-1]
|
|
715
|
+
local_path = TEMP_DIR / filename
|
|
716
|
+
|
|
717
|
+
try:
|
|
718
|
+
body = self._s3_pane._s3_client.get_object(self._s3_pane._bucket, item.key)
|
|
719
|
+
data = body.read()
|
|
720
|
+
local_path.write_bytes(data)
|
|
721
|
+
self._temp_files.append(str(local_path))
|
|
722
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(local_path)))
|
|
723
|
+
except Exception as e:
|
|
724
|
+
logger.warning("Quick-open failed for %s: %s", item.key, e)
|
|
725
|
+
self.set_status(f"Failed to open: {e}")
|
|
726
|
+
|
|
727
|
+
# --- Status bar ---
|
|
728
|
+
|
|
729
|
+
def _setup_status_bar(self) -> None:
|
|
730
|
+
sb = self.statusBar()
|
|
731
|
+
self._status_label = QLabel("Ready")
|
|
732
|
+
self._object_count_label = QLabel("")
|
|
733
|
+
self._total_size_label = QLabel("")
|
|
734
|
+
self._cost_label = QLabel("")
|
|
735
|
+
|
|
736
|
+
sb.addWidget(self._status_label, 1)
|
|
737
|
+
sb.addPermanentWidget(self._object_count_label)
|
|
738
|
+
sb.addPermanentWidget(self._total_size_label)
|
|
739
|
+
sb.addPermanentWidget(self._cost_label)
|
|
740
|
+
|
|
741
|
+
def set_status(self, text: str) -> None:
|
|
742
|
+
self._status_label.setText(text)
|
|
743
|
+
|
|
744
|
+
@property
|
|
745
|
+
def s3_pane(self) -> S3PaneWidget:
|
|
746
|
+
return self._s3_pane
|
|
747
|
+
|
|
748
|
+
@property
|
|
749
|
+
def local_pane(self) -> LocalPaneWidget:
|
|
750
|
+
return self._local_pane
|
|
751
|
+
|
|
752
|
+
@property
|
|
753
|
+
def profile_combo(self) -> QComboBox:
|
|
754
|
+
return self._profile_combo
|
|
755
|
+
|
|
756
|
+
@property
|
|
757
|
+
def bucket_combo(self) -> QComboBox:
|
|
758
|
+
return self._bucket_combo
|
|
759
|
+
|
|
760
|
+
def _open_settings(self) -> None:
|
|
761
|
+
current_profile = self._profile_combo.currentData()
|
|
762
|
+
dialog = SettingsDialog(store=self._store, db=self._db, parent=self)
|
|
763
|
+
dialog.exec()
|
|
764
|
+
# Refresh profiles in case credentials were changed
|
|
765
|
+
self._populate_profiles()
|
|
766
|
+
if current_profile:
|
|
767
|
+
idx = self._profile_combo.findData(current_profile)
|
|
768
|
+
if idx >= 0:
|
|
769
|
+
self._profile_combo.blockSignals(True)
|
|
770
|
+
self._profile_combo.setCurrentIndex(idx)
|
|
771
|
+
self._profile_combo.blockSignals(False)
|
|
772
|
+
|
|
773
|
+
# --- Keyboard shortcuts ---
|
|
774
|
+
|
|
775
|
+
def _setup_keyboard_shortcuts(self) -> None:
|
|
776
|
+
# Focus switching: Ctrl+1 → local pane, Ctrl+2 → S3 pane
|
|
777
|
+
focus_local = QAction("Focus Local Pane", self)
|
|
778
|
+
focus_local.setShortcut(QKeySequence("Ctrl+1"))
|
|
779
|
+
focus_local.triggered.connect(self._focus_local_pane)
|
|
780
|
+
self.addAction(focus_local)
|
|
781
|
+
|
|
782
|
+
focus_s3 = QAction("Focus S3 Pane", self)
|
|
783
|
+
focus_s3.setShortcut(QKeySequence("Ctrl+2"))
|
|
784
|
+
focus_s3.triggered.connect(self._focus_s3_pane)
|
|
785
|
+
self.addAction(focus_s3)
|
|
786
|
+
|
|
787
|
+
def _focus_local_pane(self) -> None:
|
|
788
|
+
self._local_pane.setFocus()
|
|
789
|
+
|
|
790
|
+
def _focus_s3_pane(self) -> None:
|
|
791
|
+
self._s3_pane.setFocus()
|
|
792
|
+
|
|
793
|
+
# --- Window state save/restore ---
|
|
794
|
+
|
|
795
|
+
def _save_state(self) -> None:
|
|
796
|
+
"""Save window geometry, splitter position, and dock state to preferences."""
|
|
797
|
+
if self._db is None:
|
|
798
|
+
return
|
|
799
|
+
|
|
800
|
+
from s3ui.db.database import set_pref
|
|
801
|
+
|
|
802
|
+
set_pref(self._db, "window_geometry", self.saveGeometry().toBase64().data().decode())
|
|
803
|
+
set_pref(self._db, "window_state", self.saveState().toBase64().data().decode())
|
|
804
|
+
set_pref(self._db, "splitter_state", self._splitter.saveState().toBase64().data().decode())
|
|
805
|
+
set_pref(
|
|
806
|
+
self._db,
|
|
807
|
+
"transfer_dock_visible",
|
|
808
|
+
"true" if self._transfer_dock.isVisible() else "false",
|
|
809
|
+
)
|
|
810
|
+
set_pref(self._db, "local_pane_path", self._local_pane.current_path())
|
|
811
|
+
|
|
812
|
+
def _restore_state(self) -> None:
|
|
813
|
+
"""Restore window geometry, splitter position, and dock state."""
|
|
814
|
+
if self._db is None:
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
from s3ui.db.database import get_bool_pref, get_pref
|
|
818
|
+
|
|
819
|
+
geom = get_pref(self._db, "window_geometry")
|
|
820
|
+
if geom:
|
|
821
|
+
self.restoreGeometry(QByteArray.fromBase64(geom.encode()))
|
|
822
|
+
|
|
823
|
+
state = get_pref(self._db, "window_state")
|
|
824
|
+
if state:
|
|
825
|
+
self.restoreState(QByteArray.fromBase64(state.encode()))
|
|
826
|
+
|
|
827
|
+
splitter = get_pref(self._db, "splitter_state")
|
|
828
|
+
if splitter:
|
|
829
|
+
self._splitter.restoreState(QByteArray.fromBase64(splitter.encode()))
|
|
830
|
+
|
|
831
|
+
dock_vis = get_bool_pref(self._db, "transfer_dock_visible", default=True)
|
|
832
|
+
self._transfer_dock.setVisible(dock_vis)
|
|
833
|
+
|
|
834
|
+
local_path = get_pref(self._db, "local_pane_path")
|
|
835
|
+
if local_path:
|
|
836
|
+
from pathlib import Path
|
|
837
|
+
|
|
838
|
+
if Path(local_path).is_dir():
|
|
839
|
+
self._local_pane.navigate_to(local_path, record_history=False)
|
|
840
|
+
|
|
841
|
+
def closeEvent(self, event) -> None:
|
|
842
|
+
self._save_state()
|
|
843
|
+
self._cleanup_temp_files()
|
|
844
|
+
if self._tray_icon:
|
|
845
|
+
self._tray_icon.hide()
|
|
846
|
+
super().closeEvent(event)
|
|
847
|
+
|
|
848
|
+
def _cleanup_temp_files(self) -> None:
|
|
849
|
+
"""Remove any temp files downloaded for quick-open."""
|
|
850
|
+
import contextlib
|
|
851
|
+
from pathlib import Path
|
|
852
|
+
|
|
853
|
+
for path_str in self._temp_files:
|
|
854
|
+
with contextlib.suppress(OSError):
|
|
855
|
+
Path(path_str).unlink()
|
|
856
|
+
self._temp_files.clear()
|
|
857
|
+
|
|
858
|
+
# --- Show Log File ---
|
|
859
|
+
|
|
860
|
+
def _open_log_directory(self) -> None:
|
|
861
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
862
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(LOG_DIR)))
|
|
863
|
+
|
|
864
|
+
# --- Menus ---
|
|
865
|
+
|
|
866
|
+
def _setup_menus(self) -> None:
|
|
867
|
+
menu_bar = self.menuBar()
|
|
868
|
+
|
|
869
|
+
# File menu
|
|
870
|
+
file_menu = menu_bar.addMenu("&File")
|
|
871
|
+
|
|
872
|
+
settings_action = QAction("&Settings...", self)
|
|
873
|
+
settings_action.triggered.connect(self._open_settings)
|
|
874
|
+
if sys.platform == "darwin":
|
|
875
|
+
settings_action.setShortcut(QKeySequence("Ctrl+,"))
|
|
876
|
+
settings_action.setMenuRole(QAction.MenuRole.PreferencesRole)
|
|
877
|
+
file_menu.addAction(settings_action)
|
|
878
|
+
|
|
879
|
+
wizard_action = QAction("Setup &Wizard...", self)
|
|
880
|
+
wizard_action.triggered.connect(self._show_setup_wizard)
|
|
881
|
+
file_menu.addAction(wizard_action)
|
|
882
|
+
|
|
883
|
+
file_menu.addSeparator()
|
|
884
|
+
|
|
885
|
+
quit_action = QAction("&Quit", self)
|
|
886
|
+
quit_action.setShortcut(QKeySequence.StandardKey.Quit)
|
|
887
|
+
quit_action.triggered.connect(self.close)
|
|
888
|
+
if sys.platform == "darwin":
|
|
889
|
+
quit_action.setMenuRole(QAction.MenuRole.QuitRole)
|
|
890
|
+
file_menu.addAction(quit_action)
|
|
891
|
+
|
|
892
|
+
# Edit menu
|
|
893
|
+
edit_menu = menu_bar.addMenu("&Edit")
|
|
894
|
+
self._copy_action = QAction("&Copy", self)
|
|
895
|
+
self._copy_action.setShortcut(QKeySequence.StandardKey.Copy)
|
|
896
|
+
self._copy_action.setEnabled(False)
|
|
897
|
+
edit_menu.addAction(self._copy_action)
|
|
898
|
+
|
|
899
|
+
self._paste_action = QAction("&Paste", self)
|
|
900
|
+
self._paste_action.setShortcut(QKeySequence.StandardKey.Paste)
|
|
901
|
+
self._paste_action.setEnabled(False)
|
|
902
|
+
edit_menu.addAction(self._paste_action)
|
|
903
|
+
|
|
904
|
+
edit_menu.addSeparator()
|
|
905
|
+
|
|
906
|
+
self._delete_action = QAction("&Delete", self)
|
|
907
|
+
self._delete_action.setShortcut(QKeySequence.StandardKey.Delete)
|
|
908
|
+
self._delete_action.setEnabled(False)
|
|
909
|
+
edit_menu.addAction(self._delete_action)
|
|
910
|
+
|
|
911
|
+
self._rename_action = QAction("&Rename", self)
|
|
912
|
+
self._rename_action.setEnabled(False)
|
|
913
|
+
edit_menu.addAction(self._rename_action)
|
|
914
|
+
|
|
915
|
+
# View menu
|
|
916
|
+
view_menu = menu_bar.addMenu("&View")
|
|
917
|
+
self._hidden_files_action = QAction("Show &Hidden Files", self)
|
|
918
|
+
self._hidden_files_action.setCheckable(True)
|
|
919
|
+
self._hidden_files_action.toggled.connect(
|
|
920
|
+
lambda checked: self._local_pane.set_show_hidden(checked)
|
|
921
|
+
)
|
|
922
|
+
view_menu.addAction(self._hidden_files_action)
|
|
923
|
+
|
|
924
|
+
self._toggle_transfers_action = QAction("Show &Transfers", self)
|
|
925
|
+
self._toggle_transfers_action.setCheckable(True)
|
|
926
|
+
self._toggle_transfers_action.setChecked(True)
|
|
927
|
+
self._toggle_transfers_action.toggled.connect(self._transfer_dock.setVisible)
|
|
928
|
+
view_menu.addAction(self._toggle_transfers_action)
|
|
929
|
+
|
|
930
|
+
# Go menu
|
|
931
|
+
go_menu = menu_bar.addMenu("&Go")
|
|
932
|
+
back_action = QAction("&Back", self)
|
|
933
|
+
back_action.setShortcut(QKeySequence("Alt+Left"))
|
|
934
|
+
back_action.triggered.connect(self._local_pane.go_back)
|
|
935
|
+
go_menu.addAction(back_action)
|
|
936
|
+
|
|
937
|
+
forward_action = QAction("&Forward", self)
|
|
938
|
+
forward_action.setShortcut(QKeySequence("Alt+Right"))
|
|
939
|
+
forward_action.triggered.connect(self._local_pane.go_forward)
|
|
940
|
+
go_menu.addAction(forward_action)
|
|
941
|
+
|
|
942
|
+
up_action = QAction("Enclosing &Folder", self)
|
|
943
|
+
up_action.setShortcut(QKeySequence("Alt+Up"))
|
|
944
|
+
up_action.triggered.connect(self._local_pane.go_up)
|
|
945
|
+
go_menu.addAction(up_action)
|
|
946
|
+
|
|
947
|
+
# Bucket menu
|
|
948
|
+
bucket_menu = menu_bar.addMenu("&Bucket")
|
|
949
|
+
self._refresh_action = QAction("&Refresh", self)
|
|
950
|
+
self._refresh_action.setShortcut(QKeySequence("Ctrl+R"))
|
|
951
|
+
self._refresh_action.triggered.connect(self._s3_pane.refresh)
|
|
952
|
+
bucket_menu.addAction(self._refresh_action)
|
|
953
|
+
|
|
954
|
+
bucket_menu.addSeparator()
|
|
955
|
+
|
|
956
|
+
self._stats_action = QAction("Bucket &Stats...", self)
|
|
957
|
+
self._stats_action.setEnabled(False)
|
|
958
|
+
bucket_menu.addAction(self._stats_action)
|
|
959
|
+
|
|
960
|
+
self._cost_action = QAction("&Cost Dashboard...", self)
|
|
961
|
+
self._cost_action.setEnabled(False)
|
|
962
|
+
self._cost_action.triggered.connect(self._open_cost_dashboard)
|
|
963
|
+
bucket_menu.addAction(self._cost_action)
|
|
964
|
+
|
|
965
|
+
# Help menu
|
|
966
|
+
help_menu = menu_bar.addMenu("&Help")
|
|
967
|
+
self._show_log_action = QAction("Show &Log File", self)
|
|
968
|
+
self._show_log_action.triggered.connect(self._open_log_directory)
|
|
969
|
+
help_menu.addAction(self._show_log_action)
|