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
|
@@ -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"
|