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/models/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""S3 object data structures and Qt table model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import bisect
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt
|
|
11
|
+
from PyQt6.QtWidgets import QFileIconProvider
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("s3ui.s3_objects")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class S3Item:
|
|
18
|
+
"""Represents a single S3 object or prefix (folder) in a listing."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
key: str
|
|
22
|
+
is_prefix: bool
|
|
23
|
+
size: int | None = None
|
|
24
|
+
last_modified: datetime | None = None
|
|
25
|
+
storage_class: str | None = None
|
|
26
|
+
etag: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sort_key(item: S3Item) -> tuple[int, str]:
|
|
30
|
+
"""Sort key: prefixes first (0), then objects (1), alphabetical by name."""
|
|
31
|
+
return (0 if item.is_prefix else 1, item.name.lower())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _format_size(size_bytes: int | None) -> str:
|
|
35
|
+
"""Format bytes into human-readable string."""
|
|
36
|
+
if size_bytes is None:
|
|
37
|
+
return ""
|
|
38
|
+
if size_bytes < 1024:
|
|
39
|
+
return f"{size_bytes} B"
|
|
40
|
+
elif size_bytes < 1024 * 1024:
|
|
41
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
42
|
+
elif size_bytes < 1024 * 1024 * 1024:
|
|
43
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
44
|
+
else:
|
|
45
|
+
return f"{size_bytes / (1024**3):.1f} GB"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _format_date(dt: datetime | None) -> str:
|
|
49
|
+
"""Format datetime into human-readable string."""
|
|
50
|
+
if dt is None:
|
|
51
|
+
return ""
|
|
52
|
+
now = datetime.now(UTC)
|
|
53
|
+
if dt.tzinfo is None:
|
|
54
|
+
dt = dt.replace(tzinfo=UTC)
|
|
55
|
+
delta = now - dt
|
|
56
|
+
if delta.total_seconds() < 0:
|
|
57
|
+
return dt.strftime("%b %d, %Y")
|
|
58
|
+
if delta.total_seconds() < 60:
|
|
59
|
+
return "Just now"
|
|
60
|
+
if delta.total_seconds() < 3600:
|
|
61
|
+
mins = int(delta.total_seconds() / 60)
|
|
62
|
+
return f"{mins} minute{'s' if mins != 1 else ''} ago"
|
|
63
|
+
if delta.total_seconds() < 86400:
|
|
64
|
+
hours = int(delta.total_seconds() / 3600)
|
|
65
|
+
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
|
66
|
+
if dt.year == now.year:
|
|
67
|
+
return dt.strftime("%b %d")
|
|
68
|
+
return dt.strftime("%b %d, %Y")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Column indices
|
|
72
|
+
COL_NAME = 0
|
|
73
|
+
COL_SIZE = 1
|
|
74
|
+
COL_MODIFIED = 2
|
|
75
|
+
|
|
76
|
+
_COLUMN_HEADERS = ["Name", "Size", "Date Modified"]
|
|
77
|
+
_COLUMN_COUNT = len(_COLUMN_HEADERS)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class S3ObjectModel(QAbstractTableModel):
|
|
81
|
+
"""Table model for S3 objects with granular mutation API."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, parent=None) -> None:
|
|
84
|
+
super().__init__(parent)
|
|
85
|
+
self._items: list[S3Item] = []
|
|
86
|
+
self._icon_provider = QFileIconProvider()
|
|
87
|
+
|
|
88
|
+
# --- Qt model interface ---
|
|
89
|
+
|
|
90
|
+
_EMPTY_INDEX = QModelIndex()
|
|
91
|
+
|
|
92
|
+
def rowCount(self, parent: QModelIndex = _EMPTY_INDEX) -> int:
|
|
93
|
+
if parent.isValid():
|
|
94
|
+
return 0
|
|
95
|
+
return len(self._items)
|
|
96
|
+
|
|
97
|
+
def columnCount(self, parent: QModelIndex = _EMPTY_INDEX) -> int:
|
|
98
|
+
if parent.isValid():
|
|
99
|
+
return 0
|
|
100
|
+
return _COLUMN_COUNT
|
|
101
|
+
|
|
102
|
+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
|
103
|
+
if not index.isValid() or index.row() >= len(self._items):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
item = self._items[index.row()]
|
|
107
|
+
col = index.column()
|
|
108
|
+
|
|
109
|
+
if role == Qt.ItemDataRole.DisplayRole:
|
|
110
|
+
if col == COL_NAME:
|
|
111
|
+
return item.name
|
|
112
|
+
elif col == COL_SIZE:
|
|
113
|
+
if item.is_prefix:
|
|
114
|
+
return ""
|
|
115
|
+
return _format_size(item.size)
|
|
116
|
+
elif col == COL_MODIFIED:
|
|
117
|
+
return _format_date(item.last_modified)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
if role == Qt.ItemDataRole.DecorationRole and col == COL_NAME:
|
|
121
|
+
if item.is_prefix:
|
|
122
|
+
return self._icon_provider.icon(QFileIconProvider.IconType.Folder)
|
|
123
|
+
return self._icon_provider.icon(QFileIconProvider.IconType.File)
|
|
124
|
+
|
|
125
|
+
if role == Qt.ItemDataRole.TextAlignmentRole:
|
|
126
|
+
if col == COL_SIZE:
|
|
127
|
+
return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
|
128
|
+
return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
|
129
|
+
|
|
130
|
+
if role == Qt.ItemDataRole.UserRole:
|
|
131
|
+
return item
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
def headerData(
|
|
136
|
+
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole
|
|
137
|
+
):
|
|
138
|
+
if (
|
|
139
|
+
orientation == Qt.Orientation.Horizontal
|
|
140
|
+
and role == Qt.ItemDataRole.DisplayRole
|
|
141
|
+
and 0 <= section < _COLUMN_COUNT
|
|
142
|
+
):
|
|
143
|
+
return _COLUMN_HEADERS[section]
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
|
147
|
+
base = super().flags(index)
|
|
148
|
+
if index.isValid():
|
|
149
|
+
return base | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled
|
|
150
|
+
return base
|
|
151
|
+
|
|
152
|
+
# --- Data access ---
|
|
153
|
+
|
|
154
|
+
def get_item(self, row: int) -> S3Item | None:
|
|
155
|
+
"""Get item at row index."""
|
|
156
|
+
if 0 <= row < len(self._items):
|
|
157
|
+
return self._items[row]
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def total_size(self) -> int:
|
|
161
|
+
"""Sum of all item sizes."""
|
|
162
|
+
return sum(item.size or 0 for item in self._items if not item.is_prefix)
|
|
163
|
+
|
|
164
|
+
def item_count(self) -> int:
|
|
165
|
+
return len(self._items)
|
|
166
|
+
|
|
167
|
+
def items(self) -> list[S3Item]:
|
|
168
|
+
"""Return a copy of the current items list."""
|
|
169
|
+
return list(self._items)
|
|
170
|
+
|
|
171
|
+
# --- Bulk operations ---
|
|
172
|
+
|
|
173
|
+
def set_items(self, items: list[S3Item]) -> None:
|
|
174
|
+
"""Replace all items. Only for initial load."""
|
|
175
|
+
self.beginResetModel()
|
|
176
|
+
self._items = sorted(items, key=_sort_key)
|
|
177
|
+
self.endResetModel()
|
|
178
|
+
|
|
179
|
+
def clear(self) -> None:
|
|
180
|
+
"""Remove all items."""
|
|
181
|
+
if not self._items:
|
|
182
|
+
return
|
|
183
|
+
self.beginResetModel()
|
|
184
|
+
self._items.clear()
|
|
185
|
+
self.endResetModel()
|
|
186
|
+
|
|
187
|
+
# --- Granular mutation methods ---
|
|
188
|
+
|
|
189
|
+
def insert_item(self, item: S3Item) -> int:
|
|
190
|
+
"""Insert item in sorted position. Returns the row index."""
|
|
191
|
+
key = _sort_key(item)
|
|
192
|
+
keys = [_sort_key(i) for i in self._items]
|
|
193
|
+
row = bisect.bisect_left(keys, key)
|
|
194
|
+
self.beginInsertRows(QModelIndex(), row, row)
|
|
195
|
+
self._items.insert(row, item)
|
|
196
|
+
self.endInsertRows()
|
|
197
|
+
return row
|
|
198
|
+
|
|
199
|
+
def remove_item(self, key: str) -> bool:
|
|
200
|
+
"""Remove item by key. Returns True if found."""
|
|
201
|
+
for row, item in enumerate(self._items):
|
|
202
|
+
if item.key == key:
|
|
203
|
+
self.beginRemoveRows(QModelIndex(), row, row)
|
|
204
|
+
self._items.pop(row)
|
|
205
|
+
self.endRemoveRows()
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def remove_items(self, keys: set[str]) -> int:
|
|
210
|
+
"""Batch remove items by keys. Removes highest index first. Returns count removed."""
|
|
211
|
+
rows_to_remove = []
|
|
212
|
+
for row, item in enumerate(self._items):
|
|
213
|
+
if item.key in keys:
|
|
214
|
+
rows_to_remove.append(row)
|
|
215
|
+
if not rows_to_remove:
|
|
216
|
+
return 0
|
|
217
|
+
# Remove from highest index first to avoid shifting
|
|
218
|
+
for row in reversed(rows_to_remove):
|
|
219
|
+
self.beginRemoveRows(QModelIndex(), row, row)
|
|
220
|
+
self._items.pop(row)
|
|
221
|
+
self.endRemoveRows()
|
|
222
|
+
return len(rows_to_remove)
|
|
223
|
+
|
|
224
|
+
def update_item(self, item_key: str, **fields) -> bool:
|
|
225
|
+
"""Update fields on an existing item. Emits dataChanged for that row only."""
|
|
226
|
+
for row, item in enumerate(self._items):
|
|
227
|
+
if item.key == item_key:
|
|
228
|
+
for field, value in fields.items():
|
|
229
|
+
if hasattr(item, field):
|
|
230
|
+
setattr(item, field, value)
|
|
231
|
+
top_left = self.index(row, 0)
|
|
232
|
+
bottom_right = self.index(row, _COLUMN_COUNT - 1)
|
|
233
|
+
self.dataChanged.emit(top_left, bottom_right)
|
|
234
|
+
return True
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
def append_items(self, items: list[S3Item]) -> None:
|
|
238
|
+
"""Append items at end (for incremental page loading)."""
|
|
239
|
+
if not items:
|
|
240
|
+
return
|
|
241
|
+
start = len(self._items)
|
|
242
|
+
end = start + len(items) - 1
|
|
243
|
+
self.beginInsertRows(QModelIndex(), start, end)
|
|
244
|
+
self._items.extend(items)
|
|
245
|
+
self.endInsertRows()
|
|
246
|
+
|
|
247
|
+
def diff_apply(self, new_items: list[S3Item]) -> bool:
|
|
248
|
+
"""Compute diff against current data and apply changes.
|
|
249
|
+
|
|
250
|
+
Returns True if any changes were made.
|
|
251
|
+
Used by background revalidation to minimize UI disruption.
|
|
252
|
+
"""
|
|
253
|
+
new_sorted = sorted(new_items, key=_sort_key)
|
|
254
|
+
old_by_key = {item.key: item for item in self._items}
|
|
255
|
+
new_by_key = {item.key: item for item in new_sorted}
|
|
256
|
+
|
|
257
|
+
old_keys = set(old_by_key.keys())
|
|
258
|
+
new_keys = set(new_by_key.keys())
|
|
259
|
+
|
|
260
|
+
removed_keys = old_keys - new_keys
|
|
261
|
+
added_keys = new_keys - old_keys
|
|
262
|
+
common_keys = old_keys & new_keys
|
|
263
|
+
|
|
264
|
+
changed = False
|
|
265
|
+
|
|
266
|
+
# Remove missing items
|
|
267
|
+
if removed_keys:
|
|
268
|
+
self.remove_items(removed_keys)
|
|
269
|
+
changed = True
|
|
270
|
+
|
|
271
|
+
# Update changed items
|
|
272
|
+
for key in common_keys:
|
|
273
|
+
old_item = old_by_key[key]
|
|
274
|
+
new_item = new_by_key[key]
|
|
275
|
+
updates = {}
|
|
276
|
+
if old_item.size != new_item.size:
|
|
277
|
+
updates["size"] = new_item.size
|
|
278
|
+
if old_item.last_modified != new_item.last_modified:
|
|
279
|
+
updates["last_modified"] = new_item.last_modified
|
|
280
|
+
if old_item.storage_class != new_item.storage_class:
|
|
281
|
+
updates["storage_class"] = new_item.storage_class
|
|
282
|
+
if old_item.etag != new_item.etag:
|
|
283
|
+
updates["etag"] = new_item.etag
|
|
284
|
+
if old_item.name != new_item.name:
|
|
285
|
+
updates["name"] = new_item.name
|
|
286
|
+
if updates:
|
|
287
|
+
self.update_item(key, **updates)
|
|
288
|
+
changed = True
|
|
289
|
+
|
|
290
|
+
# Insert new items
|
|
291
|
+
for key in added_keys:
|
|
292
|
+
self.insert_item(new_by_key[key])
|
|
293
|
+
changed = True
|
|
294
|
+
|
|
295
|
+
return changed
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Transfer model for the transfer panel with signal coalescing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from s3ui.db.database import Database
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("s3ui.transfer_model")
|
|
15
|
+
|
|
16
|
+
# Column indices
|
|
17
|
+
COL_DIRECTION = 0
|
|
18
|
+
COL_FILE = 1
|
|
19
|
+
COL_PROGRESS = 2
|
|
20
|
+
COL_SPEED = 3
|
|
21
|
+
COL_ETA = 4
|
|
22
|
+
COL_STATUS = 5
|
|
23
|
+
|
|
24
|
+
_COLUMN_HEADERS = ["", "File", "Progress", "Speed", "ETA", "Status"]
|
|
25
|
+
_COLUMN_COUNT = len(_COLUMN_HEADERS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class TransferRow:
|
|
30
|
+
"""In-memory representation of a transfer for display."""
|
|
31
|
+
|
|
32
|
+
transfer_id: int
|
|
33
|
+
direction: str # "upload" or "download"
|
|
34
|
+
filename: str
|
|
35
|
+
local_path: str
|
|
36
|
+
s3_key: str
|
|
37
|
+
total_bytes: int
|
|
38
|
+
transferred_bytes: int = 0
|
|
39
|
+
speed_bps: float = 0.0
|
|
40
|
+
eta_seconds: float = 0.0
|
|
41
|
+
_smoothed_eta: float = 0.0
|
|
42
|
+
status: str = "queued"
|
|
43
|
+
error_message: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _format_speed(bps: float) -> str:
|
|
47
|
+
if bps <= 0:
|
|
48
|
+
return "—"
|
|
49
|
+
if bps < 1024:
|
|
50
|
+
return f"{bps:.0f} B/s"
|
|
51
|
+
if bps < 1024 * 1024:
|
|
52
|
+
return f"{bps / 1024:.1f} KB/s"
|
|
53
|
+
return f"{bps / (1024 * 1024):.1f} MB/s"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_eta(seconds: float) -> str:
|
|
57
|
+
if seconds <= 0:
|
|
58
|
+
return "—"
|
|
59
|
+
if seconds < 60:
|
|
60
|
+
return f"~{int(seconds)} sec"
|
|
61
|
+
if seconds < 3600:
|
|
62
|
+
return f"~{int(seconds / 60)} min"
|
|
63
|
+
return f"~{seconds / 3600:.1f} hr"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_progress(transferred: int, total: int) -> str:
|
|
67
|
+
if total <= 0:
|
|
68
|
+
return "0%"
|
|
69
|
+
pct = (transferred / total) * 100
|
|
70
|
+
return f"{pct:.0f}%"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _format_status(row: TransferRow) -> str:
|
|
74
|
+
if row.status == "completed":
|
|
75
|
+
return "Complete"
|
|
76
|
+
if row.status == "failed":
|
|
77
|
+
return "Failed"
|
|
78
|
+
if row.status == "cancelled":
|
|
79
|
+
return "Cancelled"
|
|
80
|
+
if row.status == "paused":
|
|
81
|
+
return "Paused"
|
|
82
|
+
if row.status == "in_progress":
|
|
83
|
+
return _format_progress(row.transferred_bytes, row.total_bytes)
|
|
84
|
+
return "Queued"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TransferModel(QAbstractTableModel):
|
|
88
|
+
"""Table model for transfers with 100ms coalesced updates."""
|
|
89
|
+
|
|
90
|
+
_EMPTY_INDEX = QModelIndex()
|
|
91
|
+
|
|
92
|
+
def __init__(self, db: Database | None = None, parent=None) -> None:
|
|
93
|
+
super().__init__(parent)
|
|
94
|
+
self._db = db
|
|
95
|
+
self._rows: list[TransferRow] = []
|
|
96
|
+
self._id_to_row: dict[int, int] = {}
|
|
97
|
+
self._pending_updates: dict[int, dict] = {}
|
|
98
|
+
self._dirty_rows: set[int] = set()
|
|
99
|
+
|
|
100
|
+
# Coalescing timer
|
|
101
|
+
self._timer = QTimer(self)
|
|
102
|
+
self._timer.setInterval(100)
|
|
103
|
+
self._timer.timeout.connect(self._flush_updates)
|
|
104
|
+
|
|
105
|
+
# --- Qt model interface ---
|
|
106
|
+
|
|
107
|
+
def rowCount(self, parent: QModelIndex = _EMPTY_INDEX) -> int:
|
|
108
|
+
if parent.isValid():
|
|
109
|
+
return 0
|
|
110
|
+
return len(self._rows)
|
|
111
|
+
|
|
112
|
+
def columnCount(self, parent: QModelIndex = _EMPTY_INDEX) -> int:
|
|
113
|
+
if parent.isValid():
|
|
114
|
+
return 0
|
|
115
|
+
return _COLUMN_COUNT
|
|
116
|
+
|
|
117
|
+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
|
118
|
+
if not index.isValid() or index.row() >= len(self._rows):
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
row = self._rows[index.row()]
|
|
122
|
+
col = index.column()
|
|
123
|
+
|
|
124
|
+
if role == Qt.ItemDataRole.DisplayRole:
|
|
125
|
+
if col == COL_DIRECTION:
|
|
126
|
+
return "↑" if row.direction == "upload" else "↓"
|
|
127
|
+
if col == COL_FILE:
|
|
128
|
+
return row.filename
|
|
129
|
+
if col == COL_PROGRESS:
|
|
130
|
+
return _format_progress(row.transferred_bytes, row.total_bytes)
|
|
131
|
+
if col == COL_SPEED:
|
|
132
|
+
if row.status == "in_progress":
|
|
133
|
+
return _format_speed(row.speed_bps)
|
|
134
|
+
return "—"
|
|
135
|
+
if col == COL_ETA:
|
|
136
|
+
if row.status == "in_progress":
|
|
137
|
+
return _format_eta(row.eta_seconds)
|
|
138
|
+
return "—"
|
|
139
|
+
if col == COL_STATUS:
|
|
140
|
+
return _format_status(row)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
if role == Qt.ItemDataRole.TextAlignmentRole:
|
|
144
|
+
if col in (COL_PROGRESS, COL_SPEED, COL_ETA):
|
|
145
|
+
return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
|
146
|
+
if col == COL_DIRECTION:
|
|
147
|
+
return Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter
|
|
148
|
+
return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
|
149
|
+
|
|
150
|
+
if role == Qt.ItemDataRole.UserRole:
|
|
151
|
+
return row
|
|
152
|
+
|
|
153
|
+
if role == Qt.ItemDataRole.ToolTipRole and col == COL_STATUS:
|
|
154
|
+
if row.status == "failed":
|
|
155
|
+
return row.error_message
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def headerData(
|
|
161
|
+
self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole
|
|
162
|
+
):
|
|
163
|
+
if (
|
|
164
|
+
orientation == Qt.Orientation.Horizontal
|
|
165
|
+
and role == Qt.ItemDataRole.DisplayRole
|
|
166
|
+
and 0 <= section < _COLUMN_COUNT
|
|
167
|
+
):
|
|
168
|
+
return _COLUMN_HEADERS[section]
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
# --- Public API ---
|
|
172
|
+
|
|
173
|
+
def add_transfer(self, transfer_id: int) -> None:
|
|
174
|
+
"""Add a transfer from the database."""
|
|
175
|
+
if self._db is None:
|
|
176
|
+
return
|
|
177
|
+
db_row = self._db.fetchone("SELECT * FROM transfers WHERE id = ?", (transfer_id,))
|
|
178
|
+
if not db_row:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
from pathlib import Path
|
|
182
|
+
|
|
183
|
+
filename = Path(db_row["local_path"]).name
|
|
184
|
+
|
|
185
|
+
row = TransferRow(
|
|
186
|
+
transfer_id=transfer_id,
|
|
187
|
+
direction=db_row["direction"],
|
|
188
|
+
filename=filename,
|
|
189
|
+
local_path=db_row["local_path"],
|
|
190
|
+
s3_key=db_row["object_key"],
|
|
191
|
+
total_bytes=db_row["total_bytes"] or 0,
|
|
192
|
+
transferred_bytes=db_row["transferred"] or 0,
|
|
193
|
+
status=db_row["status"],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
idx = len(self._rows)
|
|
197
|
+
self.beginInsertRows(QModelIndex(), idx, idx)
|
|
198
|
+
self._rows.append(row)
|
|
199
|
+
self._id_to_row[transfer_id] = idx
|
|
200
|
+
self.endInsertRows()
|
|
201
|
+
|
|
202
|
+
if not self._timer.isActive():
|
|
203
|
+
self._timer.start()
|
|
204
|
+
|
|
205
|
+
def get_transfer_row(self, transfer_id: int) -> TransferRow | None:
|
|
206
|
+
idx = self._id_to_row.get(transfer_id)
|
|
207
|
+
if idx is not None and idx < len(self._rows):
|
|
208
|
+
return self._rows[idx]
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
# --- Signal handlers (buffer into pending updates) ---
|
|
212
|
+
|
|
213
|
+
def on_progress(self, transfer_id: int, bytes_done: int, total: int) -> None:
|
|
214
|
+
self._buffer_update(transfer_id, transferred_bytes=bytes_done, total_bytes=total)
|
|
215
|
+
|
|
216
|
+
def on_speed(self, transfer_id: int, bps: float) -> None:
|
|
217
|
+
idx = self._id_to_row.get(transfer_id)
|
|
218
|
+
if idx is not None:
|
|
219
|
+
row = self._rows[idx]
|
|
220
|
+
remaining = row.total_bytes - row.transferred_bytes
|
|
221
|
+
eta = remaining / bps if bps > 0 else 0
|
|
222
|
+
# ETA smoothing
|
|
223
|
+
smoothed = 0.7 * eta + 0.3 * row._smoothed_eta if row._smoothed_eta > 0 else eta
|
|
224
|
+
self._buffer_update(
|
|
225
|
+
transfer_id, speed_bps=bps, eta_seconds=smoothed, _smoothed_eta=smoothed
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def on_status_changed(self, transfer_id: int, new_status: str) -> None:
|
|
229
|
+
self._buffer_update(transfer_id, status=new_status)
|
|
230
|
+
|
|
231
|
+
def on_error(self, transfer_id: int, user_msg: str, detail: str) -> None:
|
|
232
|
+
self._buffer_update(transfer_id, status="failed", error_message=user_msg)
|
|
233
|
+
|
|
234
|
+
def on_finished(self, transfer_id: int) -> None:
|
|
235
|
+
self._buffer_update(
|
|
236
|
+
transfer_id, status="completed", speed_bps=0, eta_seconds=0, _smoothed_eta=0
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# --- Internal ---
|
|
240
|
+
|
|
241
|
+
def _buffer_update(self, transfer_id: int, **fields) -> None:
|
|
242
|
+
if transfer_id not in self._pending_updates:
|
|
243
|
+
self._pending_updates[transfer_id] = {}
|
|
244
|
+
self._pending_updates[transfer_id].update(fields)
|
|
245
|
+
|
|
246
|
+
idx = self._id_to_row.get(transfer_id)
|
|
247
|
+
if idx is not None:
|
|
248
|
+
self._dirty_rows.add(idx)
|
|
249
|
+
|
|
250
|
+
def _flush_updates(self) -> None:
|
|
251
|
+
if not self._pending_updates:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
for transfer_id, fields in self._pending_updates.items():
|
|
255
|
+
idx = self._id_to_row.get(transfer_id)
|
|
256
|
+
if idx is None or idx >= len(self._rows):
|
|
257
|
+
continue
|
|
258
|
+
row = self._rows[idx]
|
|
259
|
+
for field_name, value in fields.items():
|
|
260
|
+
if hasattr(row, field_name):
|
|
261
|
+
setattr(row, field_name, value)
|
|
262
|
+
|
|
263
|
+
if self._dirty_rows:
|
|
264
|
+
min_row = min(self._dirty_rows)
|
|
265
|
+
max_row = max(self._dirty_rows)
|
|
266
|
+
top_left = self.index(min_row, 0)
|
|
267
|
+
bottom_right = self.index(max_row, _COLUMN_COUNT - 1)
|
|
268
|
+
self.dataChanged.emit(top_left, bottom_right)
|
|
269
|
+
|
|
270
|
+
self._pending_updates.clear()
|
|
271
|
+
self._dirty_rows.clear()
|
|
272
|
+
|
|
273
|
+
# Stop timer if no active transfers
|
|
274
|
+
has_active = any(r.status in ("queued", "in_progress", "paused") for r in self._rows)
|
|
275
|
+
if not has_active:
|
|
276
|
+
self._timer.stop()
|
|
277
|
+
|
|
278
|
+
def active_count(self) -> int:
|
|
279
|
+
return sum(1 for r in self._rows if r.status == "in_progress")
|
|
280
|
+
|
|
281
|
+
def queued_count(self) -> int:
|
|
282
|
+
return sum(1 for r in self._rows if r.status == "queued")
|
|
File without changes
|
s3ui/resources/s3ui.png
ADDED
|
Binary file
|
s3ui/ui/__init__.py
ADDED
|
File without changes
|