voidremote 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.
Files changed (46) hide show
  1. voidremote/__init__.py +31 -0
  2. voidremote/adb/__init__.py +28 -0
  3. voidremote/adb/client.py +385 -0
  4. voidremote/adb/device_parser.py +286 -0
  5. voidremote/cli/__init__.py +5 -0
  6. voidremote/cli/main.py +987 -0
  7. voidremote/config/__init__.py +37 -0
  8. voidremote/config/settings.py +203 -0
  9. voidremote/controllers/__init__.py +5 -0
  10. voidremote/controllers/app_controller.py +222 -0
  11. voidremote/core/__init__.py +1 -0
  12. voidremote/core/automation.py +184 -0
  13. voidremote/models/__init__.py +31 -0
  14. voidremote/models/device.py +263 -0
  15. voidremote/network/__init__.py +1 -0
  16. voidremote/network/discovery.py +116 -0
  17. voidremote/services/__init__.py +13 -0
  18. voidremote/services/device_service.py +340 -0
  19. voidremote/services/input_service.py +181 -0
  20. voidremote/services/monitor_service.py +198 -0
  21. voidremote/ui/__init__.py +1 -0
  22. voidremote/ui/app.py +70 -0
  23. voidremote/ui/dialogs/__init__.py +6 -0
  24. voidremote/ui/dialogs/connect_dialog.py +149 -0
  25. voidremote/ui/dialogs/pair_dialog.py +189 -0
  26. voidremote/ui/main_window.py +371 -0
  27. voidremote/ui/theme.py +529 -0
  28. voidremote/ui/views/__init__.py +15 -0
  29. voidremote/ui/views/dashboard_view.py +233 -0
  30. voidremote/ui/views/files_view.py +305 -0
  31. voidremote/ui/views/monitor_view.py +236 -0
  32. voidremote/ui/views/settings_view.py +257 -0
  33. voidremote/ui/views/shell_view.py +234 -0
  34. voidremote/ui/widgets/__init__.py +14 -0
  35. voidremote/ui/widgets/device_card.py +259 -0
  36. voidremote/ui/widgets/log_view.py +159 -0
  37. voidremote/ui/widgets/metric_gauge.py +90 -0
  38. voidremote/utils/__init__.py +28 -0
  39. voidremote/utils/logging.py +120 -0
  40. voidremote/utils/security.py +216 -0
  41. voidremote-1.0.0.dist-info/METADATA +578 -0
  42. voidremote-1.0.0.dist-info/RECORD +46 -0
  43. voidremote-1.0.0.dist-info/WHEEL +5 -0
  44. voidremote-1.0.0.dist-info/entry_points.txt +5 -0
  45. voidremote-1.0.0.dist-info/licenses/LICENSE +21 -0
  46. voidremote-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,233 @@
1
+ """Dashboard view — device grid with refresh and quick actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from PySide6.QtCore import Qt, QThread, Signal, Slot, QTimer
8
+ from PySide6.QtWidgets import (
9
+ QFrame,
10
+ QGridLayout,
11
+ QHBoxLayout,
12
+ QLabel,
13
+ QPushButton,
14
+ QScrollArea,
15
+ QSizePolicy,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+
20
+ from voidremote.models.device import Device
21
+ from voidremote.ui.theme import Colors
22
+ from voidremote.ui.widgets.device_card import DeviceCard
23
+
24
+
25
+ class RefreshWorker(QThread):
26
+ finished = Signal(list)
27
+ error = Signal(str)
28
+
29
+ def __init__(self, controller: object, parent: Optional[object] = None) -> None:
30
+ super().__init__(parent)
31
+ self._controller = controller
32
+
33
+ def run(self) -> None:
34
+ try:
35
+ devices = self._controller.list_devices(refresh=True)
36
+ self.finished.emit(devices)
37
+ except Exception as exc:
38
+ self.error.emit(str(exc))
39
+
40
+
41
+ class DashboardView(QWidget):
42
+ """
43
+ Main dashboard showing device cards in a responsive grid.
44
+
45
+ Signals:
46
+ connect_device(host, port): Request connection to device.
47
+ disconnect_device(serial): Request disconnect.
48
+ mirror_device(serial): Request screen mirror.
49
+ show_device_info(serial): Request info panel.
50
+ pair_device(): Open pair dialog.
51
+ """
52
+
53
+ connect_device: Signal = Signal(str, int)
54
+ disconnect_device: Signal = Signal(str)
55
+ mirror_device: Signal = Signal(str)
56
+ show_device_info: Signal = Signal(str)
57
+ pair_device: Signal = Signal()
58
+
59
+ def __init__(self, controller: object, parent: Optional[QWidget] = None) -> None:
60
+ super().__init__(parent)
61
+ self._controller = controller
62
+ self._cards: dict[str, DeviceCard] = {}
63
+ self._worker: Optional[RefreshWorker] = None
64
+ self._build_ui()
65
+ self._auto_refresh = QTimer(self)
66
+ self._auto_refresh.setInterval(5000)
67
+ self._auto_refresh.timeout.connect(self.refresh)
68
+
69
+ def _build_ui(self) -> None:
70
+ root = QVBoxLayout(self)
71
+ root.setContentsMargins(0, 0, 0, 0)
72
+ root.setSpacing(0)
73
+
74
+ # ── Header bar ──────────────────────────────────
75
+ header = QHBoxLayout()
76
+ header.setContentsMargins(24, 20, 24, 16)
77
+ header.setSpacing(12)
78
+
79
+ title = QLabel("Devices")
80
+ title.setStyleSheet(
81
+ f"font-size: 22px; font-weight: 700; color: {Colors.TEXT_PRIMARY};"
82
+ )
83
+ header.addWidget(title)
84
+ header.addStretch()
85
+
86
+ self._count_label = QLabel("0 devices")
87
+ self._count_label.setStyleSheet(f"color: {Colors.TEXT_MUTED}; font-size: 13px;")
88
+ header.addWidget(self._count_label)
89
+
90
+ btn_pair = QPushButton("+ Pair")
91
+ btn_pair.setObjectName("accent")
92
+ btn_pair.setFixedWidth(80)
93
+ btn_pair.clicked.connect(self.pair_device)
94
+ header.addWidget(btn_pair)
95
+
96
+ self._btn_refresh = QPushButton("↻ Refresh")
97
+ self._btn_refresh.setFixedWidth(90)
98
+ self._btn_refresh.clicked.connect(self.refresh)
99
+ header.addWidget(self._btn_refresh)
100
+
101
+ header_widget = QWidget()
102
+ header_widget.setStyleSheet(
103
+ f"background: {Colors.BG_SURFACE}; border-bottom: 1px solid {Colors.BORDER};"
104
+ )
105
+ header_widget.setLayout(header)
106
+ root.addWidget(header_widget)
107
+
108
+ # ── Scroll area with device grid ─────────────────
109
+ scroll = QScrollArea()
110
+ scroll.setWidgetResizable(True)
111
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
112
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
113
+
114
+ self._grid_container = QWidget()
115
+ self._grid_container.setStyleSheet(f"background: {Colors.BG_BASE};")
116
+ self._grid = QGridLayout(self._grid_container)
117
+ self._grid.setContentsMargins(24, 20, 24, 20)
118
+ self._grid.setSpacing(16)
119
+ self._grid.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
120
+
121
+ scroll.setWidget(self._grid_container)
122
+ root.addWidget(scroll, stretch=1)
123
+
124
+ # ── Empty state ──────────────────────────────────
125
+ self._empty_state = QWidget()
126
+ empty_layout = QVBoxLayout(self._empty_state)
127
+ empty_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
128
+
129
+ empty_icon = QLabel("📱")
130
+ empty_icon.setStyleSheet("font-size: 56px;")
131
+ empty_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
132
+ empty_layout.addWidget(empty_icon)
133
+
134
+ empty_title = QLabel("No devices connected")
135
+ empty_title.setStyleSheet(
136
+ f"font-size: 18px; font-weight: 600; color: {Colors.TEXT_SECONDARY}; margin-top: 12px;"
137
+ )
138
+ empty_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
139
+ empty_layout.addWidget(empty_title)
140
+
141
+ empty_hint = QLabel(
142
+ "Enable Wireless Debugging on your Android device,\n"
143
+ "then click '+ Pair' to get started."
144
+ )
145
+ empty_hint.setStyleSheet(f"color: {Colors.TEXT_MUTED}; font-size: 13px; margin-top: 6px;")
146
+ empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
147
+ empty_layout.addWidget(empty_hint)
148
+
149
+ btn_pair2 = QPushButton("Pair New Device")
150
+ btn_pair2.setObjectName("accent")
151
+ btn_pair2.setFixedWidth(160)
152
+ btn_pair2.clicked.connect(self.pair_device)
153
+ btn_pair2_wrap = QHBoxLayout()
154
+ btn_pair2_wrap.addStretch()
155
+ btn_pair2_wrap.addWidget(btn_pair2)
156
+ btn_pair2_wrap.addStretch()
157
+ empty_layout.addLayout(btn_pair2_wrap)
158
+
159
+ scroll.setWidget(self._grid_container)
160
+
161
+ # Stack grid + empty state inside scroll
162
+ self._stack_widget = QWidget()
163
+ self._stack_widget.setStyleSheet(f"background: {Colors.BG_BASE};")
164
+ stack_layout = QVBoxLayout(self._stack_widget)
165
+ stack_layout.setContentsMargins(0, 0, 0, 0)
166
+ stack_layout.addWidget(self._grid_container)
167
+ stack_layout.addWidget(self._empty_state)
168
+
169
+ scroll.setWidget(self._stack_widget)
170
+ self._empty_state.hide()
171
+
172
+ def start_auto_refresh(self) -> None:
173
+ self._auto_refresh.start()
174
+
175
+ def stop_auto_refresh(self) -> None:
176
+ self._auto_refresh.stop()
177
+
178
+ def refresh(self) -> None:
179
+ if self._worker and self._worker.isRunning():
180
+ return
181
+ self._btn_refresh.setEnabled(False)
182
+ self._btn_refresh.setText("Refreshing…")
183
+ self._worker = RefreshWorker(self._controller, self)
184
+ self._worker.finished.connect(self._on_devices_loaded)
185
+ self._worker.error.connect(self._on_refresh_error)
186
+ self._worker.start()
187
+
188
+ @Slot(list)
189
+ def _on_devices_loaded(self, devices: list) -> None:
190
+ self._btn_refresh.setEnabled(True)
191
+ self._btn_refresh.setText("↻ Refresh")
192
+ self._update_grid(devices)
193
+ count = len(devices)
194
+ self._count_label.setText(f"{count} device{'s' if count != 1 else ''}")
195
+
196
+ @Slot(str)
197
+ def _on_refresh_error(self, error: str) -> None:
198
+ self._btn_refresh.setEnabled(True)
199
+ self._btn_refresh.setText("↻ Refresh")
200
+ self._count_label.setText(f"Error: {error[:40]}")
201
+
202
+ def _update_grid(self, devices: list) -> None:
203
+ existing_serials = set(self._cards.keys())
204
+ new_serials = {d.serial for d in devices}
205
+
206
+ # Remove stale cards
207
+ for serial in existing_serials - new_serials:
208
+ card = self._cards.pop(serial)
209
+ self._grid.removeWidget(card)
210
+ card.deleteLater()
211
+
212
+ # Add or update cards
213
+ for device in devices:
214
+ if device.serial in self._cards:
215
+ self._cards[device.serial].refresh(device)
216
+ else:
217
+ card = DeviceCard(device)
218
+ card.connect_requested.connect(
219
+ lambda s: self.connect_device.emit(s.split(":")[0], int(s.split(":")[1]) if ":" in s else 5555)
220
+ )
221
+ card.disconnect_requested.connect(self.disconnect_device)
222
+ card.mirror_requested.connect(self.mirror_device)
223
+ card.info_requested.connect(self.show_device_info)
224
+ self._cards[device.serial] = card
225
+
226
+ # Re-layout grid (2 columns)
227
+ for i, (serial, card) in enumerate(self._cards.items()):
228
+ self._grid.addWidget(card, i // 2, i % 2)
229
+ card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
230
+
231
+ has_devices = bool(self._cards)
232
+ self._grid_container.setVisible(has_devices)
233
+ self._empty_state.setVisible(not has_devices)
@@ -0,0 +1,305 @@
1
+ """File manager view for browsing and transferring device files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from PySide6.QtCore import Qt, QThread, Signal, Slot
10
+ from PySide6.QtWidgets import (
11
+ QAbstractItemView,
12
+ QComboBox,
13
+ QFileDialog,
14
+ QHBoxLayout,
15
+ QHeaderView,
16
+ QLabel,
17
+ QLineEdit,
18
+ QProgressBar,
19
+ QPushButton,
20
+ QSplitter,
21
+ QTableWidget,
22
+ QTableWidgetItem,
23
+ QVBoxLayout,
24
+ QWidget,
25
+ )
26
+
27
+ from voidremote.ui.theme import Colors
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class ListDirWorker(QThread):
33
+ finished = Signal(list)
34
+ error = Signal(str)
35
+
36
+ def __init__(self, controller: object, serial: str, path: str, parent: Optional[object] = None) -> None:
37
+ super().__init__(parent)
38
+ self._controller = controller
39
+ self._serial = serial
40
+ self._path = path
41
+
42
+ def run(self) -> None:
43
+ try:
44
+ raw = self._controller.shell(self._serial, f"ls -la '{self._path}'")
45
+ entries = self._parse_ls(raw)
46
+ self.finished.emit(entries)
47
+ except Exception as exc:
48
+ self.error.emit(str(exc))
49
+
50
+ def _parse_ls(self, raw: str) -> list[dict]:
51
+ entries: list[dict] = []
52
+ for line in raw.splitlines():
53
+ parts = line.split(None, 8)
54
+ if len(parts) < 8:
55
+ continue
56
+ perms = parts[0]
57
+ size = parts[4] if len(parts) > 4 else "0"
58
+ name = parts[8] if len(parts) > 8 else parts[-1]
59
+ if name in (".", ".."):
60
+ continue
61
+ entries.append({
62
+ "name": name,
63
+ "is_dir": perms.startswith("d"),
64
+ "size": size,
65
+ "permissions": perms,
66
+ })
67
+ return entries
68
+
69
+
70
+ class TransferWorker(QThread):
71
+ progress = Signal(int)
72
+ finished = Signal()
73
+ error = Signal(str)
74
+
75
+ def __init__(self, controller: object, serial: str, mode: str,
76
+ local: str, remote: str, parent: Optional[object] = None) -> None:
77
+ super().__init__(parent)
78
+ self._controller = controller
79
+ self._serial = serial
80
+ self._mode = mode
81
+ self._local = local
82
+ self._remote = remote
83
+
84
+ def run(self) -> None:
85
+ try:
86
+ if self._mode == "push":
87
+ self._controller.push_file(self._serial, Path(self._local), self._remote)
88
+ else:
89
+ self._controller.pull_file(self._serial, self._remote, Path(self._local))
90
+ self.progress.emit(100)
91
+ self.finished.emit()
92
+ except Exception as exc:
93
+ self.error.emit(str(exc))
94
+
95
+
96
+ class FilesView(QWidget):
97
+ """ADB file manager with navigation, upload, and download."""
98
+
99
+ def __init__(self, controller: object, parent: Optional[QWidget] = None) -> None:
100
+ super().__init__(parent)
101
+ self._controller = controller
102
+ self._serial: Optional[str] = None
103
+ self._current_path = "/sdcard"
104
+ self._path_history: list[str] = []
105
+ self._worker: Optional[QThread] = None
106
+ self._build_ui()
107
+
108
+ def _build_ui(self) -> None:
109
+ root = QVBoxLayout(self)
110
+ root.setContentsMargins(0, 0, 0, 0)
111
+ root.setSpacing(0)
112
+
113
+ # ── Header ──────────────────────────────────────
114
+ header = QHBoxLayout()
115
+ header.setContentsMargins(24, 16, 24, 12)
116
+ header.setSpacing(10)
117
+
118
+ title = QLabel("Files")
119
+ title.setStyleSheet(f"font-size: 22px; font-weight: 700; color: {Colors.TEXT_PRIMARY};")
120
+ header.addWidget(title)
121
+ header.addStretch()
122
+
123
+ header.addWidget(QLabel("Device:"))
124
+ self._device_combo = QComboBox()
125
+ self._device_combo.setMinimumWidth(200)
126
+ self._device_combo.currentTextChanged.connect(self._on_device_changed)
127
+ header.addWidget(self._device_combo)
128
+
129
+ header_w = QWidget()
130
+ header_w.setStyleSheet(f"background: {Colors.BG_SURFACE}; border-bottom: 1px solid {Colors.BORDER};")
131
+ header_w.setLayout(header)
132
+ root.addWidget(header_w)
133
+
134
+ # ── Path bar ─────────────────────────────────────
135
+ path_bar = QHBoxLayout()
136
+ path_bar.setContentsMargins(16, 8, 16, 8)
137
+ path_bar.setSpacing(8)
138
+
139
+ self._back_btn = QPushButton("←")
140
+ self._back_btn.setFixedWidth(36)
141
+ self._back_btn.clicked.connect(self._go_back)
142
+ path_bar.addWidget(self._back_btn)
143
+
144
+ self._path_edit = QLineEdit(self._current_path)
145
+ self._path_edit.returnPressed.connect(self._navigate_to_path)
146
+ path_bar.addWidget(self._path_edit, stretch=1)
147
+
148
+ btn_go = QPushButton("Go")
149
+ btn_go.setFixedWidth(40)
150
+ btn_go.clicked.connect(self._navigate_to_path)
151
+ path_bar.addWidget(btn_go)
152
+
153
+ btn_upload = QPushButton("↑ Upload")
154
+ btn_upload.clicked.connect(self._upload_file)
155
+ path_bar.addWidget(btn_upload)
156
+
157
+ btn_download = QPushButton("↓ Download")
158
+ btn_download.clicked.connect(self._download_selected)
159
+ path_bar.addWidget(btn_download)
160
+
161
+ btn_refresh = QPushButton("↻")
162
+ btn_refresh.setFixedWidth(36)
163
+ btn_refresh.clicked.connect(self._load_directory)
164
+ path_bar.addWidget(btn_refresh)
165
+
166
+ path_bar_w = QWidget()
167
+ path_bar_w.setStyleSheet(f"background: {Colors.BG_ELEVATED}; border-bottom: 1px solid {Colors.BORDER};")
168
+ path_bar_w.setLayout(path_bar)
169
+ root.addWidget(path_bar_w)
170
+
171
+ # ── File table ───────────────────────────────────
172
+ self._table = QTableWidget(0, 4)
173
+ self._table.setHorizontalHeaderLabels(["Name", "Type", "Size", "Permissions"])
174
+ self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
175
+ self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
176
+ self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
177
+ self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
178
+ self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
179
+ self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
180
+ self._table.setAlternatingRowColors(True)
181
+ self._table.doubleClicked.connect(self._on_double_click)
182
+ root.addWidget(self._table, stretch=1)
183
+
184
+ # ── Progress bar ─────────────────────────────────
185
+ self._progress = QProgressBar()
186
+ self._progress.hide()
187
+ root.addWidget(self._progress)
188
+
189
+ # ── Status ───────────────────────────────────────
190
+ self._status = QLabel("Select a device to browse files")
191
+ self._status.setStyleSheet(
192
+ f"color: {Colors.TEXT_MUTED}; padding: 6px 24px; "
193
+ f"background: {Colors.BG_SURFACE}; border-top: 1px solid {Colors.BORDER};"
194
+ )
195
+ root.addWidget(self._status)
196
+
197
+ def set_devices(self, devices: list) -> None:
198
+ current = self._device_combo.currentData()
199
+ self._device_combo.blockSignals(True)
200
+ self._device_combo.clear()
201
+ for d in devices:
202
+ self._device_combo.addItem(f"{d.info.display_name} ({d.serial})", userData=d.serial)
203
+ idx = self._device_combo.findData(current)
204
+ if idx >= 0:
205
+ self._device_combo.setCurrentIndex(idx)
206
+ self._device_combo.blockSignals(False)
207
+
208
+ def _on_device_changed(self) -> None:
209
+ self._serial = self._device_combo.currentData()
210
+ if self._serial:
211
+ self._current_path = "/sdcard"
212
+ self._path_edit.setText(self._current_path)
213
+ self._load_directory()
214
+
215
+ def _navigate_to_path(self) -> None:
216
+ path = self._path_edit.text().strip()
217
+ if path:
218
+ self._path_history.append(self._current_path)
219
+ self._current_path = path
220
+ self._load_directory()
221
+
222
+ def _go_back(self) -> None:
223
+ if self._path_history:
224
+ self._current_path = self._path_history.pop()
225
+ self._path_edit.setText(self._current_path)
226
+ self._load_directory()
227
+
228
+ def _load_directory(self) -> None:
229
+ if not self._serial:
230
+ return
231
+ self._table.setRowCount(0)
232
+ self._status.setText(f"Loading {self._current_path}…")
233
+ w = ListDirWorker(self._controller, self._serial, self._current_path, self)
234
+ w.finished.connect(self._on_entries_loaded)
235
+ w.error.connect(lambda e: self._status.setText(f"Error: {e}"))
236
+ self._worker = w
237
+ w.start()
238
+
239
+ @Slot(list)
240
+ def _on_entries_loaded(self, entries: list) -> None:
241
+ self._table.setRowCount(len(entries))
242
+ for row, entry in enumerate(entries):
243
+ icon = "📁" if entry["is_dir"] else "📄"
244
+ self._table.setItem(row, 0, QTableWidgetItem(f"{icon} {entry['name']}"))
245
+ self._table.setItem(row, 1, QTableWidgetItem("Directory" if entry["is_dir"] else "File"))
246
+ self._table.setItem(row, 2, QTableWidgetItem(entry["size"]))
247
+ self._table.setItem(row, 3, QTableWidgetItem(entry["permissions"]))
248
+ # Store full entry data
249
+ self._table.item(row, 0).setData(Qt.ItemDataRole.UserRole, entry) # type: ignore[attr-defined]
250
+ self._status.setText(f"{len(entries)} items in {self._current_path}")
251
+
252
+ def _on_double_click(self, index: object) -> None:
253
+ row = self._table.currentRow()
254
+ if row < 0:
255
+ return
256
+ item = self._table.item(row, 0)
257
+ if item is None:
258
+ return
259
+ entry = item.data(Qt.ItemDataRole.UserRole)
260
+ if entry and entry.get("is_dir"):
261
+ self._path_history.append(self._current_path)
262
+ self._current_path = f"{self._current_path.rstrip('/')}/{entry['name']}"
263
+ self._path_edit.setText(self._current_path)
264
+ self._load_directory()
265
+
266
+ def _upload_file(self) -> None:
267
+ if not self._serial:
268
+ return
269
+ path, _ = QFileDialog.getOpenFileName(self, "Select file to upload")
270
+ if not path:
271
+ return
272
+ remote = f"{self._current_path.rstrip('/')}/{Path(path).name}"
273
+ self._progress.setRange(0, 0)
274
+ self._progress.show()
275
+ w = TransferWorker(self._controller, self._serial, "push", path, remote, self)
276
+ w.finished.connect(lambda: (self._progress.hide(), self._load_directory()))
277
+ w.error.connect(lambda e: (self._progress.hide(), self._status.setText(f"Upload error: {e}")))
278
+ self._worker = w
279
+ w.start()
280
+
281
+ def _download_selected(self) -> None:
282
+ if not self._serial:
283
+ return
284
+ row = self._table.currentRow()
285
+ if row < 0:
286
+ return
287
+ item = self._table.item(row, 0)
288
+ if item is None:
289
+ return
290
+ entry = item.data(Qt.ItemDataRole.UserRole)
291
+ if not entry or entry.get("is_dir"):
292
+ self._status.setText("Select a file to download (not a directory)")
293
+ return
294
+ remote = f"{self._current_path.rstrip('/')}/{entry['name']}"
295
+ local_dir = QFileDialog.getExistingDirectory(self, "Save to folder")
296
+ if not local_dir:
297
+ return
298
+ local = str(Path(local_dir) / entry["name"])
299
+ self._progress.setRange(0, 0)
300
+ self._progress.show()
301
+ w = TransferWorker(self._controller, self._serial, "pull", local, remote, self)
302
+ w.finished.connect(lambda: (self._progress.hide(), self._status.setText(f"Downloaded to {local}")))
303
+ w.error.connect(lambda e: (self._progress.hide(), self._status.setText(f"Download error: {e}")))
304
+ self._worker = w
305
+ w.start()