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/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)