s3ui 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,462 @@
1
+ """First-run setup wizard for configuring AWS credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from PyQt6.QtCore import QObject, QThread, pyqtSignal
8
+ from PyQt6.QtWidgets import (
9
+ QComboBox,
10
+ QHBoxLayout,
11
+ QLabel,
12
+ QLineEdit,
13
+ QListWidget,
14
+ QPushButton,
15
+ QRadioButton,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ QWizard,
19
+ QWizardPage,
20
+ )
21
+
22
+ from s3ui.core.credentials import (
23
+ CredentialStore,
24
+ Profile,
25
+ TestResult,
26
+ discover_aws_profiles,
27
+ get_aws_profile_region,
28
+ )
29
+
30
+ logger = logging.getLogger("s3ui.setup_wizard")
31
+
32
+ # AWS regions (common subset)
33
+ AWS_REGIONS = [
34
+ ("US East (N. Virginia)", "us-east-1"),
35
+ ("US East (Ohio)", "us-east-2"),
36
+ ("US West (N. California)", "us-west-1"),
37
+ ("US West (Oregon)", "us-west-2"),
38
+ ("Europe (Ireland)", "eu-west-1"),
39
+ ("Europe (London)", "eu-west-2"),
40
+ ("Europe (Frankfurt)", "eu-central-1"),
41
+ ("Europe (Paris)", "eu-west-3"),
42
+ ("Europe (Stockholm)", "eu-north-1"),
43
+ ("Asia Pacific (Tokyo)", "ap-northeast-1"),
44
+ ("Asia Pacific (Seoul)", "ap-northeast-2"),
45
+ ("Asia Pacific (Singapore)", "ap-southeast-1"),
46
+ ("Asia Pacific (Sydney)", "ap-southeast-2"),
47
+ ("Asia Pacific (Mumbai)", "ap-south-1"),
48
+ ("Canada (Central)", "ca-central-1"),
49
+ ("South America (Sao Paulo)", "sa-east-1"),
50
+ ]
51
+
52
+
53
+ class _TestWorkerSignals(QObject):
54
+ finished = pyqtSignal(TestResult)
55
+
56
+
57
+ class _TestWorker(QThread):
58
+ """Background thread for testing AWS credentials."""
59
+
60
+ def __init__(self, store: CredentialStore, profile: Profile) -> None:
61
+ super().__init__()
62
+ self.signals = _TestWorkerSignals()
63
+ self._store = store
64
+ self._profile = profile
65
+
66
+ def run(self) -> None:
67
+ result = self._store.test_connection(self._profile)
68
+ self.signals.finished.emit(result)
69
+
70
+
71
+ class WelcomePage(QWizardPage):
72
+ """Page 1: Welcome text."""
73
+
74
+ def __init__(self, parent=None) -> None:
75
+ super().__init__(parent)
76
+ self.setTitle("Welcome to S3UI")
77
+
78
+ layout = QVBoxLayout(self)
79
+ label = QLabel(
80
+ "S3UI is a native file manager for Amazon S3\n"
81
+ "and S3-compatible services like MinIO.\n\n"
82
+ "You can browse, upload, download, and manage files\n"
83
+ "in your buckets with a familiar dual-pane interface.\n\n"
84
+ "Let's get started by connecting your account."
85
+ )
86
+ label.setWordWrap(True)
87
+ layout.addWidget(label)
88
+ layout.addStretch()
89
+
90
+
91
+ class CredentialPage(QWizardPage):
92
+ """Page 2: Choose an AWS CLI profile or enter credentials manually."""
93
+
94
+ def __init__(
95
+ self,
96
+ store: CredentialStore,
97
+ aws_profiles: list[str] | None = None,
98
+ parent=None,
99
+ ) -> None:
100
+ super().__init__(parent)
101
+ self._store = store
102
+ self._test_result: TestResult | None = None
103
+ self._worker: _TestWorker | None = None
104
+ self._aws_profiles: list[str] = aws_profiles if aws_profiles is not None else []
105
+
106
+ self.setTitle("AWS Credentials")
107
+ self.setSubTitle("Use an existing AWS CLI profile or enter credentials manually.")
108
+
109
+ layout = QVBoxLayout(self)
110
+ layout.setSpacing(6)
111
+
112
+ # --- Option 1: AWS CLI profile ---
113
+ self._aws_radio = QRadioButton("Use AWS CLI profile")
114
+ layout.addWidget(self._aws_radio)
115
+
116
+ self._aws_profile_combo = QComboBox()
117
+ self._aws_profile_combo.setMinimumWidth(200)
118
+ layout.addWidget(self._aws_profile_combo)
119
+
120
+ self._aws_info_label = QLabel("")
121
+ self._aws_info_label.setStyleSheet("color: gray; font-size: 11px; margin-left: 20px;")
122
+ self._aws_info_label.setWordWrap(True)
123
+ layout.addWidget(self._aws_info_label)
124
+
125
+ # Region override (for AWS profiles)
126
+ self._region_label = QLabel("Region (optional override):")
127
+ layout.addWidget(self._region_label)
128
+
129
+ self._region_combo = QComboBox()
130
+ self._region_combo.addItem("Auto-detect from profile", "")
131
+ for display_name, region_code in AWS_REGIONS:
132
+ self._region_combo.addItem(f"{display_name} ({region_code})", region_code)
133
+ layout.addWidget(self._region_combo)
134
+
135
+ # --- Option 2: Manual credentials ---
136
+ self._manual_radio = QRadioButton("Enter credentials manually")
137
+ layout.addWidget(self._manual_radio)
138
+
139
+ # Manual fields container — visibility controlled by radio
140
+ self._manual_widget = _ManualCredentialWidget()
141
+ self._manual_widget.fields_changed.connect(self.completeChanged)
142
+ layout.addWidget(self._manual_widget)
143
+
144
+ # Test connection
145
+ layout.addSpacing(8)
146
+ test_row = QHBoxLayout()
147
+ self._test_btn = QPushButton("Test Connection")
148
+ self._test_btn.clicked.connect(self._on_test_clicked)
149
+ test_row.addWidget(self._test_btn)
150
+ self._test_status = QLabel("")
151
+ test_row.addWidget(self._test_status, 1)
152
+ layout.addLayout(test_row)
153
+
154
+ # Error detail
155
+ self._error_label = QLabel("")
156
+ self._error_label.setWordWrap(True)
157
+ self._error_label.setStyleSheet("color: red;")
158
+ self._error_label.setVisible(False)
159
+ layout.addWidget(self._error_label)
160
+
161
+ # Now connect radio signals and populate — AFTER all widgets are created
162
+ self._aws_radio.toggled.connect(self._on_mode_changed)
163
+ self._manual_radio.toggled.connect(self._on_mode_changed)
164
+
165
+ # Populate AWS profiles (uses pre-discovered list or discovers fresh)
166
+ self._populate_profiles()
167
+
168
+ def _populate_profiles(self) -> None:
169
+ """Populate the AWS profile combo with already-discovered profiles."""
170
+ # If no profiles were passed in, try discovering now
171
+ if not self._aws_profiles:
172
+ self._aws_profiles = discover_aws_profiles()
173
+ logger.debug("Wizard discovered %d AWS profiles", len(self._aws_profiles))
174
+
175
+ self._aws_profile_combo.clear()
176
+
177
+ if self._aws_profiles:
178
+ for name in self._aws_profiles:
179
+ region = get_aws_profile_region(name)
180
+ display = f"{name} ({region})" if region else name
181
+ self._aws_profile_combo.addItem(display, name)
182
+ self._aws_info_label.setText(
183
+ f"Found {len(self._aws_profiles)} profile(s) in ~/.aws/config"
184
+ )
185
+ self._aws_radio.setChecked(True)
186
+ else:
187
+ self._aws_profile_combo.addItem("(no profiles found)")
188
+ self._aws_profile_combo.setEnabled(False)
189
+ self._aws_radio.setEnabled(False)
190
+ self._aws_info_label.setText(
191
+ "No AWS CLI profiles found. Run 'aws configure' to create one,\n"
192
+ "or enter credentials manually below."
193
+ )
194
+ self._manual_radio.setChecked(True)
195
+
196
+ # Apply initial visibility
197
+ self._on_mode_changed()
198
+
199
+ def _on_mode_changed(self) -> None:
200
+ is_aws = self._aws_radio.isChecked()
201
+ has_profiles = bool(self._aws_profiles)
202
+ # AWS section
203
+ self._aws_profile_combo.setVisible(is_aws and has_profiles)
204
+ self._aws_info_label.setVisible(is_aws)
205
+ self._region_label.setVisible(is_aws and has_profiles)
206
+ self._region_combo.setVisible(is_aws and has_profiles)
207
+ # Manual section
208
+ self._manual_widget.setVisible(not is_aws)
209
+ # Reset test state
210
+ self._test_result = None
211
+ self._test_status.setText("")
212
+ self._error_label.setVisible(False)
213
+ self.completeChanged.emit()
214
+
215
+ def _on_test_clicked(self) -> None:
216
+ profile = self._build_profile()
217
+ if profile is None:
218
+ self._test_status.setText("Please fill in all required fields.")
219
+ self._test_status.setStyleSheet("color: orange;")
220
+ return
221
+
222
+ self._test_btn.setEnabled(False)
223
+ self._test_status.setText("Testing...")
224
+ self._test_status.setStyleSheet("color: gray;")
225
+ self._error_label.setVisible(False)
226
+
227
+ self._worker = _TestWorker(self._store, profile)
228
+ self._worker.signals.finished.connect(self._on_test_result)
229
+ self._worker.finished.connect(self._worker.deleteLater)
230
+ self._worker.start()
231
+
232
+ def _build_profile(self) -> Profile | None:
233
+ """Build a Profile from the current UI state."""
234
+ if self._aws_radio.isChecked():
235
+ if not self._aws_profiles:
236
+ return None
237
+ name = self._aws_profile_combo.currentData()
238
+ region = self._region_combo.currentData() or ""
239
+ return Profile(name=name, region=region, is_aws_profile=True)
240
+ else:
241
+ name = self._manual_widget.profile_name()
242
+ access_key = self._manual_widget.access_key()
243
+ secret_key = self._manual_widget.secret_key()
244
+ region = self._manual_widget.region()
245
+ endpoint_url = self._manual_widget.endpoint_url()
246
+ if not name or not access_key or not secret_key:
247
+ return None
248
+ return Profile(
249
+ name=name,
250
+ access_key_id=access_key,
251
+ secret_access_key=secret_key,
252
+ region=region,
253
+ endpoint_url=endpoint_url,
254
+ )
255
+
256
+ def _on_test_result(self, result: TestResult) -> None:
257
+ self._test_btn.setEnabled(True)
258
+ self._test_result = result
259
+
260
+ if result.success:
261
+ self._test_status.setText(f"Connected! Found {len(result.buckets)} bucket(s).")
262
+ self._test_status.setStyleSheet("color: green;")
263
+ self._error_label.setVisible(False)
264
+ else:
265
+ self._test_status.setText("Connection failed.")
266
+ self._test_status.setStyleSheet("color: red;")
267
+ detail = result.error_detail if result.error_detail else result.error_message
268
+ self._error_label.setText(f"{result.error_message}\n\nDetail: {detail}")
269
+ self._error_label.setVisible(True)
270
+
271
+ self.completeChanged.emit()
272
+
273
+ def isComplete(self) -> bool:
274
+ """Page is complete when credentials are filled in (test is optional)."""
275
+ if self._aws_radio.isChecked():
276
+ return bool(self._aws_profiles)
277
+ # Manual mode: need name, access key, and secret key
278
+ return bool(
279
+ self._manual_widget.profile_name()
280
+ and self._manual_widget.access_key()
281
+ and self._manual_widget.secret_key()
282
+ )
283
+
284
+ def get_profile(self) -> Profile:
285
+ return self._build_profile() or Profile(name="default")
286
+
287
+ def get_buckets(self) -> list[str]:
288
+ if self._test_result:
289
+ return self._test_result.buckets
290
+ return []
291
+
292
+
293
+ class _ManualCredentialWidget(QWidget):
294
+ """Sub-widget with manual credential entry fields."""
295
+
296
+ fields_changed = pyqtSignal()
297
+
298
+ def __init__(self, parent=None) -> None:
299
+ super().__init__(parent)
300
+ layout = QVBoxLayout(self)
301
+ layout.setContentsMargins(20, 8, 0, 8)
302
+ layout.setSpacing(4)
303
+
304
+ # Profile name
305
+ layout.addWidget(QLabel("Profile Name:"))
306
+ self._name_edit = QLineEdit()
307
+ self._name_edit.setText("default")
308
+ self._name_edit.setPlaceholderText("e.g., default, work, personal")
309
+ self._name_edit.textChanged.connect(self.fields_changed)
310
+ layout.addWidget(self._name_edit)
311
+
312
+ # Access Key ID
313
+ layout.addWidget(QLabel("Access Key ID:"))
314
+ self._access_key_edit = QLineEdit()
315
+ self._access_key_edit.setPlaceholderText("AKIA...")
316
+ self._access_key_edit.textChanged.connect(self.fields_changed)
317
+ layout.addWidget(self._access_key_edit)
318
+
319
+ # Secret Access Key
320
+ layout.addWidget(QLabel("Secret Access Key:"))
321
+ secret_row = QHBoxLayout()
322
+ self._secret_key_edit = QLineEdit()
323
+ self._secret_key_edit.setEchoMode(QLineEdit.EchoMode.Password)
324
+ self._secret_key_edit.setPlaceholderText("Your secret key")
325
+ self._secret_key_edit.textChanged.connect(self.fields_changed)
326
+ secret_row.addWidget(self._secret_key_edit)
327
+ self._toggle_btn = QPushButton("Show")
328
+ self._toggle_btn.setFixedWidth(50)
329
+ self._toggle_btn.clicked.connect(self._toggle_visibility)
330
+ secret_row.addWidget(self._toggle_btn)
331
+ layout.addLayout(secret_row)
332
+
333
+ # Region
334
+ layout.addWidget(QLabel("Region:"))
335
+ self._region_combo = QComboBox()
336
+ for display_name, region_code in AWS_REGIONS:
337
+ self._region_combo.addItem(f"{display_name} ({region_code})", region_code)
338
+ layout.addWidget(self._region_combo)
339
+
340
+ # Endpoint URL (for MinIO, LocalStack, etc.)
341
+ layout.addWidget(QLabel("Endpoint URL (optional, for S3-compatible services):"))
342
+ self._endpoint_edit = QLineEdit()
343
+ self._endpoint_edit.setPlaceholderText("e.g., http://localhost:9000")
344
+ layout.addWidget(self._endpoint_edit)
345
+
346
+ def _toggle_visibility(self) -> None:
347
+ if self._secret_key_edit.echoMode() == QLineEdit.EchoMode.Password:
348
+ self._secret_key_edit.setEchoMode(QLineEdit.EchoMode.Normal)
349
+ self._toggle_btn.setText("Hide")
350
+ else:
351
+ self._secret_key_edit.setEchoMode(QLineEdit.EchoMode.Password)
352
+ self._toggle_btn.setText("Show")
353
+
354
+ def profile_name(self) -> str:
355
+ return self._name_edit.text().strip()
356
+
357
+ def access_key(self) -> str:
358
+ return self._access_key_edit.text().strip()
359
+
360
+ def secret_key(self) -> str:
361
+ return self._secret_key_edit.text().strip()
362
+
363
+ def region(self) -> str:
364
+ return self._region_combo.currentData()
365
+
366
+ def endpoint_url(self) -> str:
367
+ return self._endpoint_edit.text().strip()
368
+
369
+
370
+ class BucketPage(QWizardPage):
371
+ """Page 3: Pick a bucket."""
372
+
373
+ def __init__(self, parent=None) -> None:
374
+ super().__init__(parent)
375
+ self.setTitle("Select a Bucket")
376
+ self.setSubTitle("Choose which S3 bucket to open, or type a name.")
377
+
378
+ layout = QVBoxLayout(self)
379
+
380
+ self._bucket_list = QListWidget()
381
+ self._bucket_list.currentItemChanged.connect(lambda: self.completeChanged.emit())
382
+ layout.addWidget(self._bucket_list, 1)
383
+
384
+ # Manual entry for when bucket listing wasn't available
385
+ self._manual_label = QLabel("Or enter a bucket name:")
386
+ layout.addWidget(self._manual_label)
387
+ self._manual_edit = QLineEdit()
388
+ self._manual_edit.setPlaceholderText("my-bucket-name")
389
+ self._manual_edit.textChanged.connect(self.completeChanged)
390
+ layout.addWidget(self._manual_edit)
391
+
392
+ # Info label when no buckets are available
393
+ self._info_label = QLabel(
394
+ "No buckets were discovered. You can enter a bucket name manually,\n"
395
+ "or skip this step and select a bucket later from the toolbar."
396
+ )
397
+ self._info_label.setWordWrap(True)
398
+ self._info_label.setStyleSheet("color: gray;")
399
+ self._info_label.setVisible(False)
400
+ layout.addWidget(self._info_label)
401
+
402
+ def initializePage(self) -> None:
403
+ wizard = self.wizard()
404
+ cred_page = wizard.page(1)
405
+ buckets = cred_page.get_buckets()
406
+ self._bucket_list.clear()
407
+ self._manual_edit.clear()
408
+
409
+ if buckets:
410
+ self._bucket_list.addItems(sorted(buckets))
411
+ self._bucket_list.setCurrentRow(0)
412
+ self._bucket_list.setVisible(True)
413
+ self._manual_label.setVisible(False)
414
+ self._manual_edit.setVisible(False)
415
+ self._info_label.setVisible(False)
416
+ else:
417
+ self._bucket_list.setVisible(False)
418
+ self._manual_label.setVisible(True)
419
+ self._manual_edit.setVisible(True)
420
+ self._info_label.setVisible(True)
421
+
422
+ def isComplete(self) -> bool:
423
+ # Complete if a bucket is selected from list OR entered manually OR left empty (skip)
424
+ return True
425
+
426
+ def selected_bucket(self) -> str:
427
+ item = self._bucket_list.currentItem()
428
+ if item:
429
+ return item.text()
430
+ return self._manual_edit.text().strip()
431
+
432
+
433
+ class SetupWizard(QWizard):
434
+ """First-run wizard for setting up AWS credentials."""
435
+
436
+ def __init__(
437
+ self,
438
+ store: CredentialStore | None = None,
439
+ parent=None,
440
+ aws_profiles: list[str] | None = None,
441
+ ) -> None:
442
+ super().__init__(parent)
443
+ self._store = store or CredentialStore()
444
+ self.setWindowTitle("S3UI Setup")
445
+ self.setMinimumSize(600, 550)
446
+ self.setWizardStyle(QWizard.WizardStyle.ModernStyle)
447
+
448
+ self._welcome = WelcomePage()
449
+ self._cred_page = CredentialPage(self._store, aws_profiles=aws_profiles)
450
+ self._bucket_page = BucketPage()
451
+
452
+ self.addPage(self._welcome)
453
+ self.addPage(self._cred_page)
454
+ self.addPage(self._bucket_page)
455
+
456
+ def get_profile(self) -> Profile:
457
+ """Return the profile configured by the user."""
458
+ return self._cred_page.get_profile()
459
+
460
+ def get_bucket(self) -> str:
461
+ """Return the bucket selected (or entered) by the user."""
462
+ return self._bucket_page.selected_bucket()
@@ -0,0 +1,162 @@
1
+ """Bucket statistics dialog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from PyQt6.QtWidgets import (
8
+ QDialog,
9
+ QDialogButtonBox,
10
+ QLabel,
11
+ QProgressBar,
12
+ QPushButton,
13
+ QTableWidget,
14
+ QTableWidgetItem,
15
+ QVBoxLayout,
16
+ )
17
+
18
+ from s3ui.core.stats import BucketSnapshot, StatsCollector
19
+ from s3ui.models.s3_objects import _format_size
20
+
21
+ if TYPE_CHECKING:
22
+ from s3ui.core.s3_client import S3Client
23
+ from s3ui.db.database import Database
24
+
25
+
26
+ class StatsDialog(QDialog):
27
+ """Shows bucket statistics and storage breakdown."""
28
+
29
+ def __init__(
30
+ self,
31
+ s3_client: S3Client | None = None,
32
+ bucket: str = "",
33
+ db: Database | None = None,
34
+ parent=None,
35
+ ) -> None:
36
+ super().__init__(parent)
37
+ self._s3 = s3_client
38
+ self._bucket = bucket
39
+ self._db = db
40
+ self._collector: StatsCollector | None = None
41
+
42
+ self.setWindowTitle(f"Bucket Stats — {bucket}" if bucket else "Bucket Stats")
43
+ self.setMinimumSize(500, 400)
44
+
45
+ layout = QVBoxLayout(self)
46
+
47
+ # Summary
48
+ self._summary = QLabel("No data yet. Click Scan to begin.")
49
+ self._summary.setStyleSheet("font-size: 14px;")
50
+ self._summary.setWordWrap(True)
51
+ layout.addWidget(self._summary)
52
+
53
+ # Progress
54
+ self._progress_bar = QProgressBar()
55
+ self._progress_bar.setRange(0, 0) # indeterminate
56
+ self._progress_bar.setVisible(False)
57
+ layout.addWidget(self._progress_bar)
58
+
59
+ self._progress_label = QLabel("")
60
+ self._progress_label.setVisible(False)
61
+ layout.addWidget(self._progress_label)
62
+
63
+ # Storage breakdown table
64
+ self._breakdown_table = QTableWidget()
65
+ self._breakdown_table.setColumnCount(3)
66
+ self._breakdown_table.setHorizontalHeaderLabels(["Storage Class", "Size", "Objects"])
67
+ self._breakdown_table.setVisible(False)
68
+ layout.addWidget(self._breakdown_table)
69
+
70
+ # Top 10 largest
71
+ self._largest_label = QLabel("Largest Files:")
72
+ self._largest_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
73
+ self._largest_label.setVisible(False)
74
+ layout.addWidget(self._largest_label)
75
+
76
+ self._largest_table = QTableWidget()
77
+ self._largest_table.setColumnCount(2)
78
+ self._largest_table.setHorizontalHeaderLabels(["Key", "Size"])
79
+ self._largest_table.setVisible(False)
80
+ layout.addWidget(self._largest_table)
81
+
82
+ # Buttons
83
+ btn_layout = QDialogButtonBox()
84
+ self._scan_btn = QPushButton("Scan")
85
+ self._scan_btn.clicked.connect(self._start_scan)
86
+ btn_layout.addButton(self._scan_btn, QDialogButtonBox.ButtonRole.ActionRole)
87
+
88
+ self._cancel_btn = QPushButton("Cancel Scan")
89
+ self._cancel_btn.clicked.connect(self._cancel_scan)
90
+ self._cancel_btn.setVisible(False)
91
+ btn_layout.addButton(self._cancel_btn, QDialogButtonBox.ButtonRole.ActionRole)
92
+
93
+ close_btn = btn_layout.addButton(QDialogButtonBox.StandardButton.Close)
94
+ close_btn.clicked.connect(self.accept)
95
+ layout.addWidget(btn_layout)
96
+
97
+ def _start_scan(self) -> None:
98
+ if not self._s3 or not self._bucket:
99
+ return
100
+
101
+ self._scan_btn.setVisible(False)
102
+ self._cancel_btn.setVisible(True)
103
+ self._progress_bar.setVisible(True)
104
+ self._progress_label.setVisible(True)
105
+ self._summary.setText("Scanning...")
106
+
107
+ self._collector = StatsCollector(self._s3, self._bucket, self._db, self)
108
+ self._collector.signals.progress.connect(self._on_progress)
109
+ self._collector.signals.complete.connect(self._on_complete)
110
+ self._collector.signals.error.connect(self._on_error)
111
+ self._collector.finished.connect(self._collector.deleteLater)
112
+ self._collector.start()
113
+
114
+ def _cancel_scan(self) -> None:
115
+ if self._collector:
116
+ self._collector.cancel()
117
+ self._scan_btn.setVisible(True)
118
+ self._cancel_btn.setVisible(False)
119
+ self._progress_bar.setVisible(False)
120
+ self._progress_label.setVisible(False)
121
+ self._summary.setText("Scan cancelled.")
122
+
123
+ def _on_progress(self, count: int) -> None:
124
+ self._progress_label.setText(f"Scanned {count:,} objects...")
125
+
126
+ def _on_complete(self, snapshot: BucketSnapshot) -> None:
127
+ self._scan_btn.setVisible(True)
128
+ self._scan_btn.setText("Rescan")
129
+ self._cancel_btn.setVisible(False)
130
+ self._progress_bar.setVisible(False)
131
+ self._progress_label.setVisible(False)
132
+
133
+ self._summary.setText(
134
+ f"Total: {snapshot.total_count:,} objects, {_format_size(snapshot.total_bytes)}"
135
+ )
136
+
137
+ # Breakdown table
138
+ self._breakdown_table.setVisible(True)
139
+ self._breakdown_table.setRowCount(len(snapshot.bytes_by_class))
140
+ for i, (cls, size) in enumerate(
141
+ sorted(snapshot.bytes_by_class.items(), key=lambda x: -x[1])
142
+ ):
143
+ self._breakdown_table.setItem(i, 0, QTableWidgetItem(cls))
144
+ self._breakdown_table.setItem(i, 1, QTableWidgetItem(_format_size(size)))
145
+ count = snapshot.count_by_class.get(cls, 0)
146
+ self._breakdown_table.setItem(i, 2, QTableWidgetItem(f"{count:,}"))
147
+
148
+ # Top 10 largest
149
+ if snapshot.top_largest:
150
+ self._largest_label.setVisible(True)
151
+ self._largest_table.setVisible(True)
152
+ self._largest_table.setRowCount(len(snapshot.top_largest))
153
+ for i, entry in enumerate(snapshot.top_largest):
154
+ self._largest_table.setItem(i, 0, QTableWidgetItem(entry["key"]))
155
+ self._largest_table.setItem(i, 1, QTableWidgetItem(_format_size(entry["size"])))
156
+
157
+ def _on_error(self, msg: str) -> None:
158
+ self._scan_btn.setVisible(True)
159
+ self._cancel_btn.setVisible(False)
160
+ self._progress_bar.setVisible(False)
161
+ self._progress_label.setVisible(False)
162
+ self._summary.setText(f"Error: {msg}")