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,150 @@
1
+ """Clickable breadcrumb path bar with edit mode."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from PyQt6.QtCore import Qt, pyqtSignal
7
+ from PyQt6.QtWidgets import (
8
+ QHBoxLayout,
9
+ QLabel,
10
+ QLineEdit,
11
+ QSizePolicy,
12
+ QToolButton,
13
+ QWidget,
14
+ )
15
+
16
+
17
+ class BreadcrumbBar(QWidget):
18
+ """Breadcrumb navigation bar.
19
+
20
+ Shows path segments as clickable buttons. Clicking whitespace to the right
21
+ enters edit mode with a QLineEdit for typing a path directly.
22
+ """
23
+
24
+ path_clicked = pyqtSignal(str) # emitted when a segment is clicked
25
+ path_edited = pyqtSignal(str) # emitted when path is typed and Enter pressed
26
+
27
+ def __init__(self, separator: str = "/", parent: QWidget | None = None) -> None:
28
+ super().__init__(parent)
29
+ self._separator = separator
30
+ self._current_path = ""
31
+ self._editing = False
32
+
33
+ self._layout = QHBoxLayout(self)
34
+ self._layout.setContentsMargins(2, 0, 2, 0)
35
+ self._layout.setSpacing(0)
36
+
37
+ # Edit line (hidden by default)
38
+ self._edit = QLineEdit(self)
39
+ self._edit.setVisible(False)
40
+ self._edit.returnPressed.connect(self._on_edit_accepted)
41
+ self._edit.installEventFilter(self)
42
+
43
+ # Clickable area to enter edit mode
44
+ self.setMouseTracking(True)
45
+ self.setCursor(Qt.CursorShape.IBeamCursor)
46
+
47
+ def set_path(self, path: str) -> None:
48
+ """Set the displayed path, rebuilding segment buttons."""
49
+ self._current_path = path
50
+ if not self._editing:
51
+ self._rebuild_segments()
52
+
53
+ def current_path(self) -> str:
54
+ return self._current_path
55
+
56
+ def _rebuild_segments(self) -> None:
57
+ # Clear existing widgets (except the edit line)
58
+ while self._layout.count():
59
+ item = self._layout.takeAt(0)
60
+ widget = item.widget()
61
+ if widget and widget is not self._edit:
62
+ widget.deleteLater()
63
+
64
+ if not self._current_path:
65
+ return
66
+
67
+ # Parse path into segments
68
+ if self._separator == "/":
69
+ parts = Path(self._current_path).parts
70
+ else:
71
+ parts = self._current_path.split(self._separator)
72
+ parts = [p for p in parts if p]
73
+
74
+ # Build segment buttons
75
+ for i, part in enumerate(parts):
76
+ if i > 0:
77
+ sep = QLabel(self._separator)
78
+ sep.setStyleSheet("color: gray; padding: 0 2px;")
79
+ self._layout.addWidget(sep)
80
+
81
+ btn = QToolButton()
82
+ btn.setText(part)
83
+ btn.setAutoRaise(True)
84
+ btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
85
+
86
+ # Build the path up to this segment
87
+ if self._separator == "/":
88
+ segment_path = str(Path(*parts[: i + 1]))
89
+ # On Unix, ensure root starts with /
90
+ if sys.platform != "win32" and not segment_path.startswith("/"):
91
+ segment_path = "/" + segment_path
92
+ else:
93
+ segment_path = self._separator.join(parts[: i + 1])
94
+ if not segment_path.endswith(self._separator):
95
+ segment_path += self._separator
96
+
97
+ btn.clicked.connect(lambda checked, p=segment_path: self.path_clicked.emit(p))
98
+ self._layout.addWidget(btn)
99
+
100
+ # Spacer to fill remaining width (clicking it enters edit mode)
101
+ spacer = QWidget()
102
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
103
+ self._layout.addWidget(spacer)
104
+
105
+ def _enter_edit_mode(self) -> None:
106
+ self._editing = True
107
+ # Hide all segment widgets
108
+ for i in range(self._layout.count()):
109
+ w = self._layout.itemAt(i).widget()
110
+ if w:
111
+ w.setVisible(False)
112
+
113
+ self._edit.setText(self._current_path)
114
+ self._edit.setVisible(True)
115
+ self._edit.selectAll()
116
+ self._edit.setFocus()
117
+ self._layout.addWidget(self._edit)
118
+
119
+ def _exit_edit_mode(self) -> None:
120
+ self._editing = False
121
+ self._edit.setVisible(False)
122
+ self._rebuild_segments()
123
+
124
+ def _on_edit_accepted(self) -> None:
125
+ text = self._edit.text().strip()
126
+ self._exit_edit_mode()
127
+ if text:
128
+ self.path_edited.emit(text)
129
+
130
+ def mousePressEvent(self, event) -> None:
131
+ # Click on empty area enters edit mode
132
+ if not self._editing:
133
+ child = self.childAt(event.pos())
134
+ if child is None or child.objectName() == "":
135
+ self._enter_edit_mode()
136
+ return
137
+ super().mousePressEvent(event)
138
+
139
+ def eventFilter(self, obj, event) -> bool:
140
+ if obj is self._edit:
141
+ from PyQt6.QtCore import QEvent
142
+
143
+ if event.type() == QEvent.Type.KeyPress:
144
+ if event.key() == Qt.Key.Key_Escape:
145
+ self._exit_edit_mode()
146
+ return True
147
+ elif event.type() == QEvent.Type.FocusOut:
148
+ self._exit_edit_mode()
149
+ return True
150
+ return super().eventFilter(obj, event)
@@ -0,0 +1,60 @@
1
+ """Delete confirmation dialog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PyQt6.QtWidgets import (
6
+ QDialog,
7
+ QDialogButtonBox,
8
+ QLabel,
9
+ QListWidget,
10
+ QVBoxLayout,
11
+ )
12
+
13
+ from s3ui.models.s3_objects import _format_size
14
+
15
+
16
+ class DeleteConfirmDialog(QDialog):
17
+ """Confirm deletion of S3 objects."""
18
+
19
+ def __init__(
20
+ self,
21
+ keys: list[str],
22
+ total_size: int = 0,
23
+ parent=None,
24
+ ) -> None:
25
+ super().__init__(parent)
26
+ count = len(keys)
27
+ self.setWindowTitle(f"Delete {count} file{'s' if count != 1 else ''}?")
28
+ self.setMinimumWidth(400)
29
+
30
+ layout = QVBoxLayout(self)
31
+
32
+ # Warning
33
+ layout.addWidget(
34
+ QLabel(f"Are you sure you want to delete {count} item{'s' if count != 1 else ''}?")
35
+ )
36
+
37
+ # File list (first 10)
38
+ file_list = QListWidget()
39
+ for key in keys[:10]:
40
+ file_list.addItem(key)
41
+ if count > 10:
42
+ file_list.addItem(f"...and {count - 10} more")
43
+ file_list.setMaximumHeight(200)
44
+ layout.addWidget(file_list)
45
+
46
+ # Total size
47
+ if total_size > 0:
48
+ layout.addWidget(QLabel(f"Total size: {_format_size(total_size)}"))
49
+
50
+ # Warning
51
+ layout.addWidget(QLabel("This action cannot be undone."))
52
+
53
+ # Buttons
54
+ buttons = QDialogButtonBox(
55
+ QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok
56
+ )
57
+ buttons.button(QDialogButtonBox.StandardButton.Ok).setText("Delete")
58
+ buttons.accepted.connect(self.accept)
59
+ buttons.rejected.connect(self.reject)
60
+ layout.addWidget(buttons)
s3ui/ui/cost_dialog.py ADDED
@@ -0,0 +1,163 @@
1
+ """Cost dashboard dialog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, timedelta
6
+ from typing import TYPE_CHECKING
7
+
8
+ from PyQt6.QtWidgets import (
9
+ QDialog,
10
+ QDialogButtonBox,
11
+ QFileDialog,
12
+ QLabel,
13
+ QPushButton,
14
+ QTableWidget,
15
+ QTableWidgetItem,
16
+ QVBoxLayout,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from s3ui.core.cost import CostTracker
21
+
22
+
23
+ def _fmt_bytes(n: int) -> str:
24
+ """Human-readable byte size."""
25
+ if n < 1024:
26
+ return f"{n} B"
27
+ if n < 1024**2:
28
+ return f"{n / 1024:.1f} KB"
29
+ if n < 1024**3:
30
+ return f"{n / 1024**2:.1f} MB"
31
+ return f"{n / 1024**3:.2f} GB"
32
+
33
+
34
+ class CostDialog(QDialog):
35
+ """Shows monthly cost estimate and daily breakdown."""
36
+
37
+ def __init__(self, cost_tracker: CostTracker | None = None, parent=None) -> None:
38
+ super().__init__(parent)
39
+ self._cost = cost_tracker
40
+ self.setWindowTitle("Cost Dashboard")
41
+ self.setMinimumSize(500, 400)
42
+
43
+ layout = QVBoxLayout(self)
44
+
45
+ # Monthly estimate
46
+ self._estimate_label = QLabel("Estimated cost this month: —")
47
+ self._estimate_label.setStyleSheet("font-size: 18px; font-weight: bold;")
48
+ layout.addWidget(self._estimate_label)
49
+
50
+ # Daily breakdown table
51
+ layout.addWidget(QLabel("Daily Breakdown (last 30 days):"))
52
+ self._daily_table = QTableWidget()
53
+ self._daily_table.setColumnCount(5)
54
+ self._daily_table.setHorizontalHeaderLabels(
55
+ ["Date", "Requests", "Upload", "Download", "Est. Cost"]
56
+ )
57
+ layout.addWidget(self._daily_table)
58
+
59
+ # Buttons
60
+ btn_layout = QDialogButtonBox()
61
+
62
+ export_btn = QPushButton("Export CSV")
63
+ export_btn.clicked.connect(self._export_csv)
64
+ btn_layout.addButton(export_btn, QDialogButtonBox.ButtonRole.ActionRole)
65
+
66
+ close_btn = btn_layout.addButton(QDialogButtonBox.StandardButton.Close)
67
+ close_btn.clicked.connect(self.accept)
68
+ layout.addWidget(btn_layout)
69
+
70
+ self._load_data()
71
+
72
+ def _load_data(self) -> None:
73
+ if not self._cost:
74
+ return
75
+
76
+ estimate = self._cost.get_monthly_estimate()
77
+ self._estimate_label.setText(f"Estimated cost this month: ${estimate:.4f}")
78
+
79
+ today = date.today()
80
+ start = (today - timedelta(days=29)).isoformat()
81
+ end = today.isoformat()
82
+ days = self._cost.get_daily_costs(start, end)
83
+
84
+ # Get raw usage rows for request counts and byte totals
85
+ usage_map = self._build_usage_map(start, end)
86
+
87
+ self._daily_table.setRowCount(len(days))
88
+ for i, day in enumerate(days):
89
+ self._daily_table.setItem(i, 0, QTableWidgetItem(day.date))
90
+
91
+ usage = usage_map.get(day.date)
92
+ if usage:
93
+ total_reqs = (
94
+ (usage["put_requests"] or 0)
95
+ + (usage["get_requests"] or 0)
96
+ + (usage["list_requests"] or 0)
97
+ + (usage["delete_requests"] or 0)
98
+ + (usage["head_requests"] or 0)
99
+ + (usage["copy_requests"] or 0)
100
+ )
101
+ upload = usage["bytes_uploaded"] or 0
102
+ download = usage["bytes_downloaded"] or 0
103
+ else:
104
+ total_reqs = 0
105
+ upload = 0
106
+ download = 0
107
+
108
+ self._daily_table.setItem(i, 1, QTableWidgetItem(f"{total_reqs:,}"))
109
+ self._daily_table.setItem(i, 2, QTableWidgetItem(_fmt_bytes(upload)))
110
+ self._daily_table.setItem(i, 3, QTableWidgetItem(_fmt_bytes(download)))
111
+ self._daily_table.setItem(i, 4, QTableWidgetItem(f"${day.total:.4f}"))
112
+
113
+ def _build_usage_map(self, start: str, end: str) -> dict:
114
+ """Query raw daily_usage rows and index by date."""
115
+ if not self._cost or not self._cost._db:
116
+ return {}
117
+ rows = self._cost._db.fetchall(
118
+ "SELECT * FROM daily_usage WHERE bucket_id = ? "
119
+ "AND usage_date >= ? AND usage_date <= ? ORDER BY usage_date",
120
+ (self._cost._bucket_id, start, end),
121
+ )
122
+ return {row["usage_date"]: row for row in rows}
123
+
124
+ def _export_csv(self) -> None:
125
+ path, _ = QFileDialog.getSaveFileName(
126
+ self, "Export Cost Data", "s3ui-costs.csv", "CSV Files (*.csv)"
127
+ )
128
+ if not path or not self._cost:
129
+ return
130
+
131
+ today = date.today()
132
+ start = (today - timedelta(days=364)).isoformat()
133
+ end = today.isoformat()
134
+ days = self._cost.get_daily_costs(start, end)
135
+ usage_map = self._build_usage_map(start, end)
136
+
137
+ with open(path, "w") as f:
138
+ f.write(
139
+ "date,put_requests,get_requests,list_requests,delete_requests,"
140
+ "head_requests,copy_requests,bytes_uploaded,bytes_downloaded,"
141
+ "storage_cost,request_cost,transfer_cost,total_cost\n"
142
+ )
143
+ for day in days:
144
+ usage = usage_map.get(day.date)
145
+ if usage:
146
+ f.write(
147
+ f"{day.date},{usage['put_requests'] or 0},"
148
+ f"{usage['get_requests'] or 0},"
149
+ f"{usage['list_requests'] or 0},"
150
+ f"{usage['delete_requests'] or 0},"
151
+ f"{usage['head_requests'] or 0},"
152
+ f"{usage['copy_requests'] or 0},"
153
+ f"{usage['bytes_uploaded'] or 0},"
154
+ f"{usage['bytes_downloaded'] or 0},"
155
+ f"{day.storage:.6f},{day.requests:.6f},"
156
+ f"{day.transfer:.6f},{day.total:.6f}\n"
157
+ )
158
+ else:
159
+ f.write(
160
+ f"{day.date},0,0,0,0,0,0,0,0,"
161
+ f"{day.storage:.6f},0.000000,0.000000,"
162
+ f"{day.storage:.6f}\n"
163
+ )
s3ui/ui/get_info.py ADDED
@@ -0,0 +1,50 @@
1
+ """Get Info dialog showing S3 object metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PyQt6.QtWidgets import (
6
+ QDialog,
7
+ QDialogButtonBox,
8
+ QFormLayout,
9
+ QLabel,
10
+ QVBoxLayout,
11
+ )
12
+
13
+ from s3ui.models.s3_objects import S3Item, _format_date, _format_size
14
+
15
+
16
+ class GetInfoDialog(QDialog):
17
+ """Shows detailed metadata for an S3 object or prefix."""
18
+
19
+ def __init__(self, item: S3Item, parent=None) -> None:
20
+ super().__init__(parent)
21
+ self.setWindowTitle("Get Info")
22
+ self.setMinimumWidth(400)
23
+
24
+ layout = QVBoxLayout(self)
25
+
26
+ # File name (large)
27
+ name_label = QLabel(item.name)
28
+ name_label.setStyleSheet("font-size: 16px; font-weight: bold;")
29
+ layout.addWidget(name_label)
30
+
31
+ # Details
32
+ form = QFormLayout()
33
+
34
+ form.addRow("S3 Key:", QLabel(item.key))
35
+
36
+ if not item.is_prefix:
37
+ form.addRow("Size:", QLabel(_format_size(item.size)))
38
+ form.addRow("Last Modified:", QLabel(_format_date(item.last_modified)))
39
+ if item.storage_class:
40
+ form.addRow("Storage Class:", QLabel(item.storage_class))
41
+ if item.etag:
42
+ form.addRow("ETag:", QLabel(item.etag))
43
+ else:
44
+ form.addRow("Type:", QLabel("Folder (prefix)"))
45
+
46
+ layout.addLayout(form)
47
+
48
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
49
+ buttons.rejected.connect(self.reject)
50
+ layout.addWidget(buttons)
s3ui/ui/local_pane.py ADDED
@@ -0,0 +1,226 @@
1
+ """Local file system pane — left side of the dual-pane browser."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from PyQt6.QtCore import QDir, QModelIndex, Qt, QUrl, pyqtSignal
7
+ from PyQt6.QtGui import QDesktopServices, QFileSystemModel
8
+ from PyQt6.QtWidgets import (
9
+ QAbstractItemView,
10
+ QHBoxLayout,
11
+ QLabel,
12
+ QToolButton,
13
+ QTreeView,
14
+ QVBoxLayout,
15
+ QWidget,
16
+ )
17
+
18
+ from s3ui.constants import NAV_HISTORY_MAX
19
+ from s3ui.ui.breadcrumb_bar import BreadcrumbBar
20
+
21
+ logger = logging.getLogger("s3ui.local_pane")
22
+
23
+
24
+ class LocalPaneWidget(QWidget):
25
+ """Pane for browsing local files with QFileSystemModel."""
26
+
27
+ directory_changed = pyqtSignal(str) # current directory path
28
+ upload_requested = pyqtSignal(list) # list of local file paths
29
+
30
+ def __init__(self, parent: QWidget | None = None) -> None:
31
+ super().__init__(parent)
32
+ self._history_back: list[str] = []
33
+ self._history_forward: list[str] = []
34
+ self._current_path = str(Path.home())
35
+ self._show_hidden = False
36
+
37
+ self._setup_ui()
38
+ self._setup_model()
39
+ self.navigate_to(self._current_path, record_history=False)
40
+
41
+ def _setup_ui(self) -> None:
42
+ layout = QVBoxLayout(self)
43
+ layout.setContentsMargins(0, 0, 0, 0)
44
+ layout.setSpacing(0)
45
+
46
+ # Mini toolbar
47
+ toolbar = QHBoxLayout()
48
+ toolbar.setContentsMargins(4, 2, 4, 2)
49
+ toolbar.setSpacing(2)
50
+
51
+ self._back_btn = QToolButton()
52
+ self._back_btn.setText("◀")
53
+ self._back_btn.setToolTip("Back")
54
+ self._back_btn.setAutoRaise(True)
55
+ self._back_btn.clicked.connect(self.go_back)
56
+ self._back_btn.setEnabled(False)
57
+ toolbar.addWidget(self._back_btn)
58
+
59
+ self._forward_btn = QToolButton()
60
+ self._forward_btn.setText("▶")
61
+ self._forward_btn.setToolTip("Forward")
62
+ self._forward_btn.setAutoRaise(True)
63
+ self._forward_btn.clicked.connect(self.go_forward)
64
+ self._forward_btn.setEnabled(False)
65
+ toolbar.addWidget(self._forward_btn)
66
+
67
+ self._up_btn = QToolButton()
68
+ self._up_btn.setText("▲")
69
+ self._up_btn.setToolTip("Enclosing Folder")
70
+ self._up_btn.setAutoRaise(True)
71
+ self._up_btn.clicked.connect(self.go_up)
72
+ toolbar.addWidget(self._up_btn)
73
+
74
+ self._breadcrumb = BreadcrumbBar(separator="/")
75
+ self._breadcrumb.path_clicked.connect(self._on_breadcrumb_clicked)
76
+ self._breadcrumb.path_edited.connect(self._on_breadcrumb_edited)
77
+ toolbar.addWidget(self._breadcrumb, 1)
78
+
79
+ toolbar_widget = QWidget()
80
+ toolbar_widget.setLayout(toolbar)
81
+ layout.addWidget(toolbar_widget)
82
+
83
+ # Tree view
84
+ self._tree = QTreeView()
85
+ self._tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
86
+ self._tree.setUniformRowHeights(True)
87
+ self._tree.setSortingEnabled(True)
88
+ self._tree.setAnimated(False)
89
+ self._tree.setDragEnabled(True)
90
+ self._tree.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
91
+ self._tree.doubleClicked.connect(self._on_double_click)
92
+ self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
93
+ self._tree.customContextMenuRequested.connect(self._on_context_menu)
94
+ layout.addWidget(self._tree, 1)
95
+
96
+ # Footer
97
+ self._footer = QLabel("0 items")
98
+ self._footer.setContentsMargins(8, 2, 8, 2)
99
+ self._footer.setStyleSheet("color: gray; font-size: 11px;")
100
+ layout.addWidget(self._footer)
101
+
102
+ def _setup_model(self) -> None:
103
+ self._model = QFileSystemModel()
104
+ self._model.setRootPath("")
105
+ self._model.setFilter(QDir.Filter.AllDirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot)
106
+ self._tree.setModel(self._model)
107
+
108
+ # Show only Name, Size, Date Modified
109
+ self._tree.setColumnHidden(2, True) # Hide Type column
110
+
111
+ # Sort dirs before files, then alphabetical
112
+ self._tree.sortByColumn(0, Qt.SortOrder.AscendingOrder)
113
+
114
+ # Connect directory loaded signal for footer updates
115
+ self._model.directoryLoaded.connect(self._update_footer)
116
+
117
+ def navigate_to(self, path: str, record_history: bool = True) -> None:
118
+ """Navigate to a directory path."""
119
+ p = Path(path)
120
+ if not p.is_dir():
121
+ return
122
+
123
+ if record_history and self._current_path != path:
124
+ self._history_back.append(self._current_path)
125
+ if len(self._history_back) > NAV_HISTORY_MAX:
126
+ self._history_back = self._history_back[-NAV_HISTORY_MAX:]
127
+ self._history_forward.clear()
128
+
129
+ self._current_path = str(p)
130
+ index = self._model.index(self._current_path)
131
+ self._tree.setRootIndex(index)
132
+ self._breadcrumb.set_path(self._current_path)
133
+ self._update_nav_buttons()
134
+ self._update_footer(self._current_path)
135
+ self.directory_changed.emit(self._current_path)
136
+ logger.debug("Navigated to %s", self._current_path)
137
+
138
+ def go_back(self) -> None:
139
+ if not self._history_back:
140
+ return
141
+ self._history_forward.append(self._current_path)
142
+ path = self._history_back.pop()
143
+ self.navigate_to(path, record_history=False)
144
+
145
+ def go_forward(self) -> None:
146
+ if not self._history_forward:
147
+ return
148
+ self._history_back.append(self._current_path)
149
+ path = self._history_forward.pop()
150
+ self.navigate_to(path, record_history=False)
151
+
152
+ def go_up(self) -> None:
153
+ parent = str(Path(self._current_path).parent)
154
+ if parent != self._current_path:
155
+ self.navigate_to(parent)
156
+
157
+ def set_show_hidden(self, show: bool) -> None:
158
+ self._show_hidden = show
159
+ filters = QDir.Filter.AllDirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot
160
+ if show:
161
+ filters |= QDir.Filter.Hidden
162
+ self._model.setFilter(filters)
163
+
164
+ def current_path(self) -> str:
165
+ return self._current_path
166
+
167
+ def selected_paths(self) -> list[str]:
168
+ """Return full paths of selected items."""
169
+ paths = []
170
+ for idx in self._tree.selectionModel().selectedRows():
171
+ paths.append(self._model.filePath(idx))
172
+ return paths
173
+
174
+ def _on_double_click(self, index: QModelIndex) -> None:
175
+ path = self._model.filePath(index)
176
+ if self._model.isDir(index):
177
+ self.navigate_to(path)
178
+ else:
179
+ QDesktopServices.openUrl(QUrl.fromLocalFile(path))
180
+
181
+ def _on_breadcrumb_clicked(self, path: str) -> None:
182
+ self.navigate_to(path)
183
+
184
+ def _on_breadcrumb_edited(self, path: str) -> None:
185
+ if Path(path).is_dir():
186
+ self.navigate_to(path)
187
+
188
+ def _update_nav_buttons(self) -> None:
189
+ self._back_btn.setEnabled(len(self._history_back) > 0)
190
+ self._forward_btn.setEnabled(len(self._history_forward) > 0)
191
+
192
+ def _update_footer(self, path: str = "") -> None:
193
+ index = self._model.index(self._current_path)
194
+ count = self._model.rowCount(index)
195
+ total_size = 0
196
+ for i in range(count):
197
+ child = self._model.index(i, 0, index)
198
+ if not self._model.isDir(child):
199
+ total_size += self._model.size(child)
200
+
201
+ size_str = _format_size(total_size)
202
+ self._footer.setText(f"{count} items, {size_str}")
203
+
204
+ def _on_context_menu(self, pos) -> None:
205
+ from PyQt6.QtWidgets import QMenu
206
+
207
+ selected = self.selected_paths()
208
+ if not selected:
209
+ return
210
+
211
+ menu = QMenu(self)
212
+ upload_action = menu.addAction("Upload to S3")
213
+ upload_action.triggered.connect(lambda: self.upload_requested.emit(selected))
214
+ menu.exec(self._tree.viewport().mapToGlobal(pos))
215
+
216
+
217
+ def _format_size(size_bytes: int) -> str:
218
+ """Format bytes into human-readable string."""
219
+ if size_bytes < 1024:
220
+ return f"{size_bytes} B"
221
+ elif size_bytes < 1024 * 1024:
222
+ return f"{size_bytes / 1024:.1f} KB"
223
+ elif size_bytes < 1024 * 1024 * 1024:
224
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
225
+ else:
226
+ return f"{size_bytes / (1024**3):.1f} GB"