adbsshdeck 0.1.1__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,3680 @@
1
+ import hashlib
2
+ import io
3
+ import json
4
+ import os
5
+ import posixpath
6
+ import re
7
+ import time
8
+ import shutil
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from ftplib import error_perm
13
+ from pathlib import Path
14
+ from stat import S_ISDIR, S_ISREG
15
+ from typing import Callable, Dict, List, Optional, Tuple
16
+
17
+ from PyQt5.QtCore import (
18
+ QByteArray,
19
+ QFileInfo,
20
+ QFileSystemWatcher,
21
+ QMimeData,
22
+ QSize,
23
+ Qt,
24
+ QThread,
25
+ QTimer,
26
+ QUrl,
27
+ pyqtSignal,
28
+ )
29
+ from PyQt5.QtGui import QDesktopServices, QDrag, QFont, QIcon, QKeySequence
30
+
31
+ from ... import APP_TITLE
32
+ from ...session import ConnectionKind, SessionProfile
33
+ from ...config import AppConfig
34
+ from ..combo_utils import ExpandAllComboBox
35
+ from ..icon_utils import icon_home_folder, icon_nav_up, icon_root_drive
36
+ from ..session_login_dialog import SessionLoginDialog, SessionLoginOutcome
37
+ from PyQt5.QtWidgets import (
38
+ QAbstractItemView,
39
+ QApplication,
40
+ QDialog,
41
+ QDialogButtonBox,
42
+ QFileDialog,
43
+ QFileIconProvider,
44
+ QFormLayout,
45
+ QFrame,
46
+ QGridLayout,
47
+ QHBoxLayout,
48
+ QHeaderView,
49
+ QInputDialog,
50
+ QLabel,
51
+ QLineEdit,
52
+ QListWidget,
53
+ QListWidgetItem,
54
+ QMenu,
55
+ QMessageBox,
56
+ QPlainTextEdit,
57
+ QProgressDialog,
58
+ QPushButton,
59
+ QShortcut,
60
+ QSizePolicy,
61
+ QSplitter,
62
+ QStyle,
63
+ QTabWidget,
64
+ QTableWidget,
65
+ QTableWidgetItem,
66
+ QStackedWidget,
67
+ QToolButton,
68
+ QVBoxLayout,
69
+ QWidget,
70
+ )
71
+
72
+ from ...services.commands import run_adb, run_adb_with_line_callback
73
+ from ...services.remote_clients import disconnect_ftp, disconnect_sftp
74
+
75
+ # Windows/macOS shell icons by extension for remote listings (no real local path).
76
+ _remote_ext_icon_cache: Dict[str, QIcon] = {}
77
+ _MAX_INLINE_EDITOR_BYTES = 8 * 1024 * 1024
78
+ _MAX_FIND_FOLDER_HISTORY = 24
79
+
80
+ # Remote table → local table drag (pull); custom MIME, not file:// URLs.
81
+ MIME_REMOTE_PULL = "application/x-devicedeck-remote-pull"
82
+
83
+
84
+ def _push_find_folder_history(cfg: Optional[AppConfig], side: str, folder: str) -> None:
85
+ if not cfg:
86
+ return
87
+ p = (folder or "").strip()
88
+ if not p:
89
+ return
90
+ key = "find_folder_history_local" if side == "local" else "find_folder_history_remote"
91
+ lst = getattr(cfg, key, None)
92
+ if not isinstance(lst, list):
93
+ lst = []
94
+ if p in lst:
95
+ lst.remove(p)
96
+ lst.insert(0, p)
97
+ setattr(cfg, key, lst[:_MAX_FIND_FOLDER_HISTORY])
98
+ cfg.save()
99
+
100
+
101
+ def _first_serial_token(raw: Optional[str]) -> str:
102
+ parts = (raw or "").split()
103
+ return parts[0].strip() if parts else ""
104
+
105
+
106
+ def _icon_for_remote_name(name: str, is_dir: bool, icon_provider: QFileIconProvider, style) -> QIcon:
107
+ if name == "..":
108
+ return style.standardIcon(QStyle.SP_FileDialogToParent)
109
+ if is_dir:
110
+ icd = icon_provider.icon(QFileIconProvider.Folder)
111
+ if icd.isNull():
112
+ icd = style.standardIcon(QStyle.SP_DirIcon)
113
+ return icd
114
+ ext = Path(name).suffix.lower()
115
+ cache_key = ext if ext else "<noext>"
116
+ if cache_key in _remote_ext_icon_cache:
117
+ return _remote_ext_icon_cache[cache_key]
118
+ td = Path(tempfile.gettempdir())
119
+ # Extensionless names: use .txt so Windows shows a normal document association instead of a blank shell.
120
+ probe = td / (f"_device_deck_icon{ext}" if ext else "_device_deck_generic.txt")
121
+ ic: QIcon
122
+ try:
123
+ if probe.exists():
124
+ try:
125
+ probe.unlink()
126
+ except OSError:
127
+ pass
128
+ probe.write_bytes(b"")
129
+ ic = icon_provider.icon(QFileInfo(str(probe)))
130
+ except OSError:
131
+ ic = QIcon()
132
+ finally:
133
+ try:
134
+ if probe.exists():
135
+ probe.unlink()
136
+ except OSError:
137
+ pass
138
+ if ic.isNull():
139
+ ic = style.standardIcon(QStyle.SP_FileIcon)
140
+ _remote_ext_icon_cache[cache_key] = ic
141
+ return ic
142
+
143
+
144
+ def _icon_for_local_path(path: Path, icon_provider: QFileIconProvider, style) -> QIcon:
145
+ if path.is_dir():
146
+ ic = icon_provider.icon(QFileIconProvider.Folder)
147
+ if not ic.isNull():
148
+ return ic
149
+ return style.standardIcon(QStyle.SP_DirIcon)
150
+ fi = QFileInfo(str(path))
151
+ ic = icon_provider.icon(fi)
152
+ if not ic.isNull():
153
+ return ic
154
+ return _icon_for_remote_name(path.name, False, icon_provider, style)
155
+
156
+
157
+ @dataclass
158
+ class RemoteItem:
159
+ name: str
160
+ is_dir: bool
161
+ permissions: str
162
+ owner: str
163
+ group: str
164
+ size: str
165
+ modified: str
166
+
167
+
168
+ def _fmt_local_listing_size(path: Path) -> str:
169
+ """Files: size on disk. Directories: placeholder (not computed — keeps browsing fast)."""
170
+ try:
171
+ if path.is_file():
172
+ return _human_bytes(path.stat().st_size)
173
+ except OSError:
174
+ return ""
175
+ if path.is_dir():
176
+ return "…"
177
+ return ""
178
+
179
+
180
+ def _fmt_mtime(path: Path) -> str:
181
+ try:
182
+ from datetime import datetime
183
+
184
+ return datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
185
+ except OSError:
186
+ return ""
187
+
188
+
189
+ def _local_type_label(path: Path) -> str:
190
+ if path.is_dir():
191
+ return "Folder"
192
+ ext = path.suffix.lower().lstrip(".")
193
+ return f"{ext.upper()} file" if ext else "File"
194
+
195
+
196
+ def _remote_type_label(name: str, is_dir: bool) -> str:
197
+ if is_dir:
198
+ return "Folder"
199
+ ext = Path(name).suffix.lower().lstrip(".")
200
+ return f"{ext.upper()} file" if ext else "File"
201
+
202
+
203
+ def _local_name_sort_key(path: Path) -> tuple:
204
+ return (not path.is_dir(), path.name.lower())
205
+
206
+
207
+ def _local_size_sort_key(path: Path) -> tuple:
208
+ if path.is_dir():
209
+ try:
210
+ n = sum(1 for _ in path.iterdir())
211
+ except OSError:
212
+ n = 0
213
+ return (0, n)
214
+ try:
215
+ return (1, path.stat().st_size)
216
+ except OSError:
217
+ return (1, 0)
218
+
219
+
220
+ def _local_mtime_sort_key(path: Path) -> float:
221
+ try:
222
+ return path.stat().st_mtime
223
+ except OSError:
224
+ return 0.0
225
+
226
+
227
+ def _local_type_sort_key(path: Path) -> tuple:
228
+ return (not path.is_dir(), "folder" if path.is_dir() else "file")
229
+
230
+
231
+ def _remote_name_sort_key(parsed: RemoteItem) -> tuple:
232
+ if parsed.name == "..":
233
+ return (-2,)
234
+ if parsed.is_dir:
235
+ return (0, parsed.name.lower())
236
+ return (1, parsed.name.lower())
237
+
238
+
239
+ def _remote_size_sort_key(parsed: RemoteItem) -> tuple:
240
+ if parsed.name == "..":
241
+ return (-1, 0)
242
+ s = (parsed.size or "").strip()
243
+ if s in ("", "<DIR>", "?"):
244
+ return (1 if not parsed.is_dir else 0, 0)
245
+ try:
246
+ n = int(s)
247
+ except ValueError:
248
+ return (1 if not parsed.is_dir else 0, 0)
249
+ return (1 if not parsed.is_dir else 0, n)
250
+
251
+
252
+ def _remote_mtime_sort_key(parsed: RemoteItem) -> tuple:
253
+ if parsed.name == "..":
254
+ return (-1, "")
255
+ return (0, parsed.modified or "")
256
+
257
+
258
+ def _human_bytes(n: int) -> str:
259
+ if n < 0:
260
+ return "—"
261
+ if n < 1024:
262
+ return f"{n} bytes"
263
+ units = ("KB", "MB", "GB", "TB")
264
+ v = float(n)
265
+ for u in units:
266
+ v /= 1024.0
267
+ if v < 1024.0 or u == "TB":
268
+ return f"{v:.2f} {u} ({n} bytes)"
269
+ return f"{n} bytes"
270
+
271
+
272
+ # Safety cap so a pathological tree cannot run unbounded on the background thread.
273
+ _DIR_SIZE_MAX_FILES = 2_000_000
274
+
275
+
276
+ def _dir_size_walk(path: Path, limit_files: int = _DIR_SIZE_MAX_FILES) -> Optional[int]:
277
+ """Recursive sum of regular-file sizes under path. None on error or if file count exceeds limit."""
278
+ total = 0
279
+ n = 0
280
+ root = str(path)
281
+ try:
282
+ for dirpath, _dirnames, filenames in os.walk(root, followlinks=False):
283
+ for fn in filenames:
284
+ if n >= limit_files:
285
+ return None
286
+ fp = os.path.join(dirpath, fn)
287
+ try:
288
+ st = os.stat(fp)
289
+ if not S_ISREG(st.st_mode):
290
+ continue
291
+ total += st.st_size
292
+ n += 1
293
+ except OSError:
294
+ pass
295
+ return total
296
+ except OSError:
297
+ return None
298
+
299
+
300
+ def _show_properties_dialog(parent: QWidget, title: str, rows: List[Tuple[str, str]]) -> None:
301
+ dlg = QDialog(parent)
302
+ dlg.setWindowTitle(title)
303
+ n = max(len(rows), 1)
304
+ dlg.resize(580, min(520, 100 + n * 26))
305
+ lay = QVBoxLayout(dlg)
306
+ t = QTableWidget(len(rows), 2)
307
+ t.setObjectName("WinScpTable")
308
+ t.setHorizontalHeaderLabels(["Property", "Value"])
309
+ t.verticalHeader().setVisible(False)
310
+ t.setShowGrid(True)
311
+ t.setEditTriggers(QAbstractItemView.NoEditTriggers)
312
+ t.setAlternatingRowColors(True)
313
+ t.setWordWrap(True)
314
+ h = t.horizontalHeader()
315
+ h.setSectionResizeMode(0, QHeaderView.ResizeToContents)
316
+ h.setSectionResizeMode(1, QHeaderView.Stretch)
317
+ for i, (k, v) in enumerate(rows):
318
+ ki = QTableWidgetItem(k)
319
+ ki.setFlags(ki.flags() & ~Qt.ItemIsEditable)
320
+ vi = QTableWidgetItem(v)
321
+ vi.setFlags(vi.flags() & ~Qt.ItemIsEditable)
322
+ if len(v) > 120:
323
+ vi.setToolTip(v)
324
+ t.setItem(i, 0, ki)
325
+ t.setItem(i, 1, vi)
326
+ lay.addWidget(t, 1)
327
+ bb = QDialogButtonBox(QDialogButtonBox.Ok)
328
+ bb.accepted.connect(dlg.accept)
329
+ lay.addWidget(bb)
330
+ dlg.exec_()
331
+
332
+
333
+ class PlainTextEditorDialog(QDialog):
334
+ """Plain text editor: Save writes to disk; optional after_save (e.g. upload). Done = save + close (Accepted)."""
335
+
336
+ def __init__(
337
+ self,
338
+ parent: Optional[QWidget],
339
+ path: Path,
340
+ *,
341
+ after_save: Optional[Callable[[Path], bool]] = None,
342
+ ):
343
+ super().__init__(parent)
344
+ self._path = path
345
+ self._after_save = after_save
346
+ self.setWindowTitle(f"Edit — {path.name}")
347
+ self.resize(780, 560)
348
+ lay = QVBoxLayout(self)
349
+ self._edit = QPlainTextEdit()
350
+ self._edit.setFont(QFont("Consolas", 10))
351
+ self._edit.setLineWrapMode(QPlainTextEdit.NoWrap)
352
+ try:
353
+ if path.is_file():
354
+ sz = path.stat().st_size
355
+ if sz > _MAX_INLINE_EDITOR_BYTES:
356
+ self._edit.setPlainText(
357
+ f"File is too large for the built-in editor ({_human_bytes(sz)}).\n"
358
+ "Use Open with default application for better performance."
359
+ )
360
+ self._edit.setReadOnly(True)
361
+ else:
362
+ self._edit.setPlainText(path.read_text(encoding="utf-8-sig", errors="replace"))
363
+ except OSError:
364
+ pass
365
+ lay.addWidget(self._edit)
366
+ btn_row = QHBoxLayout()
367
+ btn_save = QPushButton("Save")
368
+ if after_save:
369
+ btn_save.setToolTip("Save to disk and sync to remote (Ctrl+S)")
370
+ else:
371
+ btn_save.setToolTip("Save to disk (Ctrl+S) — editor stays open")
372
+ btn_save.clicked.connect(self._save_only)
373
+ btn_done = QPushButton("Done")
374
+ btn_done.setDefault(True)
375
+ btn_done.setToolTip("Save, sync if remote edit, and close")
376
+ btn_done.clicked.connect(self._save_and_close)
377
+ btn_cancel = QPushButton("Cancel")
378
+ btn_cancel.clicked.connect(self.reject)
379
+ btn_row.addWidget(btn_save)
380
+ btn_row.addWidget(btn_done)
381
+ btn_row.addStretch(1)
382
+ btn_row.addWidget(btn_cancel)
383
+ lay.addLayout(btn_row)
384
+ QShortcut(QKeySequence.Save, self, self._save_only)
385
+
386
+ def _save_only(self) -> bool:
387
+ tmp = self._path.with_name(self._path.name + ".~tmp")
388
+ try:
389
+ self._path.parent.mkdir(parents=True, exist_ok=True)
390
+ text = self._edit.toPlainText()
391
+ tmp.write_text(text, encoding="utf-8", newline="\n")
392
+ tmp.replace(self._path)
393
+ except OSError as exc:
394
+ try:
395
+ if tmp.exists():
396
+ tmp.unlink()
397
+ except OSError:
398
+ pass
399
+ QMessageBox.warning(self, "Save", str(exc))
400
+ return False
401
+ if self._after_save:
402
+ if not self._after_save(self._path):
403
+ return False
404
+ self.setWindowTitle(f"Edit — {self._path.name} (saved & synced)")
405
+ else:
406
+ self.setWindowTitle(f"Edit — {self._path.name} (saved)")
407
+ return True
408
+
409
+ def _save_and_close(self) -> None:
410
+ if not self._save_only():
411
+ return
412
+ self.accept()
413
+
414
+
415
+ def _apply_table_chrome(t: QTableWidget, stretch_first: bool = True) -> None:
416
+ t.setObjectName("WinScpTable")
417
+ t.setEditTriggers(QAbstractItemView.NoEditTriggers)
418
+ t.verticalHeader().setVisible(False)
419
+ t.setShowGrid(False)
420
+ t.verticalHeader().setDefaultSectionSize(22)
421
+ t.setSelectionBehavior(QAbstractItemView.SelectRows)
422
+ t.setSelectionMode(QAbstractItemView.ExtendedSelection)
423
+ t.setAlternatingRowColors(True)
424
+ t.setFocusPolicy(Qt.StrongFocus)
425
+ t.setSortingEnabled(True)
426
+ h = t.horizontalHeader()
427
+ h.setHighlightSections(False)
428
+ h.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
429
+ h.setMinimumSectionSize(72)
430
+ h.setSectionsClickable(True)
431
+ if stretch_first:
432
+ h.setSectionResizeMode(0, QHeaderView.Stretch)
433
+ for c in range(1, t.columnCount()):
434
+ h.setSectionResizeMode(c, QHeaderView.Interactive)
435
+ else:
436
+ h.setStretchLastSection(True)
437
+
438
+
439
+ class _SortTableItem(QTableWidgetItem):
440
+ """Sort by ``sort_key`` (tuple/number); default is visible text."""
441
+
442
+ def __init__(self, text: str = "", icon: Optional[QIcon] = None, sort_key=None):
443
+ if icon is not None:
444
+ super().__init__(icon, text)
445
+ else:
446
+ super().__init__(text)
447
+ self._sk = sort_key if sort_key is not None else (text or "")
448
+
449
+ def set_sort_key(self, sk) -> None:
450
+ self._sk = sk
451
+
452
+ def __lt__(self, other):
453
+ if isinstance(other, _SortTableItem):
454
+ a, b = self._sk, other._sk
455
+ try:
456
+ return a < b
457
+ except Exception:
458
+ pass
459
+ return super().__lt__(other)
460
+
461
+
462
+ def _read_file_bytes_with_retry(path: str, attempts: int = 15, delay: float = 0.35) -> Optional[bytes]:
463
+ """Read file bytes (retries while Word/PDF viewers hold a lock)."""
464
+ last_err: Optional[BaseException] = None
465
+ for _ in range(attempts):
466
+ try:
467
+ with open(path, "rb") as f:
468
+ return f.read()
469
+ except (PermissionError, OSError) as exc:
470
+ last_err = exc
471
+ time.sleep(delay)
472
+ return None
473
+
474
+
475
+ class LocalFileTable(QTableWidget):
476
+ """Local files: drag file:// URLs to remote (push); accept remote drags here (pull)."""
477
+
478
+ def __init__(
479
+ self,
480
+ on_paste_paths: Optional[Callable[[List[str]], None]] = None,
481
+ on_drop_remote_pull: Optional[Callable[[List[dict]], None]] = None,
482
+ ):
483
+ super().__init__()
484
+ self._on_paste_paths = on_paste_paths
485
+ self._on_drop_remote_pull = on_drop_remote_pull
486
+ self.setAcceptDrops(True)
487
+
488
+ def dragEnterEvent(self, e):
489
+ if self._on_drop_remote_pull and e.mimeData().hasFormat(MIME_REMOTE_PULL):
490
+ e.acceptProposedAction()
491
+ else:
492
+ super().dragEnterEvent(e)
493
+
494
+ def dragMoveEvent(self, e):
495
+ if self._on_drop_remote_pull and e.mimeData().hasFormat(MIME_REMOTE_PULL):
496
+ e.acceptProposedAction()
497
+ else:
498
+ super().dragMoveEvent(e)
499
+
500
+ def dropEvent(self, e):
501
+ if self._on_drop_remote_pull and e.mimeData().hasFormat(MIME_REMOTE_PULL):
502
+ try:
503
+ raw = e.mimeData().data(MIME_REMOTE_PULL)
504
+ payload = bytes(raw).decode("utf-8")
505
+ infos = json.loads(payload)
506
+ if isinstance(infos, list):
507
+ self._on_drop_remote_pull(infos)
508
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
509
+ pass
510
+ e.acceptProposedAction()
511
+ return
512
+ super().dropEvent(e)
513
+
514
+ def keyPressEvent(self, ev):
515
+ if (ev.modifiers() & Qt.ControlModifier) and ev.key() in (Qt.Key_C, Qt.Key_Insert):
516
+ paths: List[str] = []
517
+ for r in sorted({i.row() for i in self.selectedItems()}):
518
+ it = self.item(r, 0)
519
+ if it:
520
+ p = it.data(Qt.UserRole)
521
+ if p:
522
+ paths.append(str(p))
523
+ if paths:
524
+ QApplication.clipboard().setText("\n".join(paths))
525
+ ev.accept()
526
+ return
527
+ if ev.key() == Qt.Key_V and (ev.modifiers() & Qt.ControlModifier) and self._on_paste_paths:
528
+ raw = QApplication.clipboard().text()
529
+ lines = [ln.strip() for ln in raw.replace("\r\n", "\n").split("\n") if ln.strip()]
530
+ good: List[str] = []
531
+ for ln in lines:
532
+ pp = Path(ln)
533
+ if pp.exists() and (pp.is_file() or pp.is_dir()):
534
+ good.append(str(pp.resolve()))
535
+ if good:
536
+ self._on_paste_paths(good)
537
+ ev.accept()
538
+ return
539
+ if ev.key() == Qt.Key_A and (ev.modifiers() & Qt.ControlModifier):
540
+ self.selectAll()
541
+ ev.accept()
542
+ return
543
+ super().keyPressEvent(ev)
544
+
545
+ def startDrag(self, supportedActions):
546
+ rows = sorted({i.row() for i in self.selectedItems()})
547
+ urls: List[QUrl] = []
548
+ for r in rows:
549
+ it = self.item(r, 0)
550
+ if not it:
551
+ continue
552
+ p = it.data(Qt.UserRole)
553
+ if not p:
554
+ continue
555
+ lp = Path(p)
556
+ if lp.exists():
557
+ urls.append(QUrl.fromLocalFile(str(lp.resolve())))
558
+ if not urls:
559
+ return
560
+ m = QMimeData()
561
+ m.setUrls(urls)
562
+ drag = QDrag(self)
563
+ drag.setMimeData(m)
564
+ drag.exec_(Qt.CopyAction)
565
+
566
+
567
+ class RemoteFileTable(QTableWidget):
568
+ """Remote listing: accept local file drops to Push into current remote folder."""
569
+
570
+ def __init__(
571
+ self,
572
+ activated_slot,
573
+ on_drop_local_paths: Callable[[List[str]], None],
574
+ on_paste_paths: Optional[Callable[[List[str]], None]] = None,
575
+ ):
576
+ super().__init__()
577
+ self._on_drop_local_paths = on_drop_local_paths
578
+ self._on_paste_paths = on_paste_paths
579
+ self.setColumnCount(5)
580
+ self.setHorizontalHeaderLabels(["Name", "Size", "Permissions", "Date modified", "Type"])
581
+ _apply_table_chrome(self, stretch_first=True)
582
+ self.setIconSize(QSize(24, 24))
583
+ self.itemActivated.connect(activated_slot)
584
+ self.setAcceptDrops(True)
585
+ self.setDragEnabled(True)
586
+ self.setDragDropMode(QAbstractItemView.DragDrop)
587
+ self.setDefaultDropAction(Qt.CopyAction)
588
+ self.setDropIndicatorShown(True)
589
+
590
+ def startDrag(self, supportedActions):
591
+ rows = sorted({i.row() for i in self.selectedItems()})
592
+ infos: List[dict] = []
593
+ for r in rows:
594
+ it = self.item(r, 0)
595
+ if not it or it.text() == "..":
596
+ continue
597
+ data = it.data(Qt.UserRole) or {}
598
+ p = data.get("path")
599
+ if p:
600
+ infos.append({"path": str(p), "is_dir": bool(data.get("is_dir"))})
601
+ if not infos:
602
+ return
603
+ m = QMimeData()
604
+ m.setData(MIME_REMOTE_PULL, QByteArray(json.dumps(infos).encode("utf-8")))
605
+ drag = QDrag(self)
606
+ drag.setMimeData(m)
607
+ drag.exec_(Qt.CopyAction)
608
+
609
+ def dragEnterEvent(self, e):
610
+ if e.mimeData().hasUrls():
611
+ e.acceptProposedAction()
612
+ else:
613
+ super().dragEnterEvent(e)
614
+
615
+ def dragMoveEvent(self, e):
616
+ if e.mimeData().hasUrls():
617
+ e.acceptProposedAction()
618
+ else:
619
+ super().dragMoveEvent(e)
620
+
621
+ def dropEvent(self, e):
622
+ if e.mimeData().hasUrls():
623
+ paths = [u.toLocalFile() for u in e.mimeData().urls() if u.isLocalFile()]
624
+ if paths:
625
+ self._on_drop_local_paths(paths)
626
+ e.acceptProposedAction()
627
+ return
628
+ super().dropEvent(e)
629
+
630
+ def keyPressEvent(self, ev):
631
+ if (ev.modifiers() & Qt.ControlModifier) and ev.key() in (Qt.Key_C, Qt.Key_Insert):
632
+ paths: List[str] = []
633
+ for r in sorted({i.row() for i in self.selectedItems()}):
634
+ it = self.item(r, 0)
635
+ if it:
636
+ info = it.data(Qt.UserRole) or {}
637
+ p = info.get("path")
638
+ if p:
639
+ paths.append(str(p))
640
+ if paths:
641
+ QApplication.clipboard().setText("\n".join(paths))
642
+ ev.accept()
643
+ return
644
+ if ev.key() == Qt.Key_V and (ev.modifiers() & Qt.ControlModifier) and self._on_paste_paths:
645
+ raw = QApplication.clipboard().text()
646
+ lines = [ln.strip() for ln in raw.replace("\r\n", "\n").split("\n") if ln.strip()]
647
+ good: List[str] = []
648
+ for ln in lines:
649
+ pp = Path(ln)
650
+ if pp.exists() and (pp.is_file() or pp.is_dir()):
651
+ good.append(str(pp.resolve()))
652
+ if good:
653
+ self._on_paste_paths(good)
654
+ ev.accept()
655
+ return
656
+ if ev.key() == Qt.Key_A and (ev.modifiers() & Qt.ControlModifier):
657
+ self.selectAll()
658
+ ev.accept()
659
+ return
660
+ super().keyPressEvent(ev)
661
+
662
+
663
+ _MONTHS = (
664
+ "Jan",
665
+ "Feb",
666
+ "Mar",
667
+ "Apr",
668
+ "May",
669
+ "Jun",
670
+ "Jul",
671
+ "Aug",
672
+ "Sep",
673
+ "Oct",
674
+ "Nov",
675
+ "Dec",
676
+ )
677
+
678
+
679
+ def _strip_ls_datetime_name_tokens(rtoks: List[str]) -> List[str]:
680
+ """Remove leading ls date/time tokens; remainder is the file name."""
681
+ if not rtoks:
682
+ return []
683
+ t = list(rtoks)
684
+ if len(t[0]) >= 10 and t[0][4:5] == "-":
685
+ t.pop(0)
686
+ if t and re.match(r"^\d{1,2}:\d{2}$", t[0]):
687
+ t.pop(0)
688
+ return t
689
+ if len(t[0]) == 3 and t[0] in _MONTHS:
690
+ t.pop(0)
691
+ if t and t[0].isdigit():
692
+ t.pop(0)
693
+ if t and (len(t[0]) == 4 and t[0].isdigit()):
694
+ t.pop(0)
695
+ elif t and re.match(r"^\d{1,2}:\d{2}$", t[0]):
696
+ t.pop(0)
697
+ return t
698
+ return t
699
+
700
+
701
+ def _parse_ls_line(line: str) -> Optional[RemoteItem]:
702
+ line = line.strip()
703
+ if not line or line.startswith("total "):
704
+ return None
705
+ m = re.match(
706
+ r"^([drwxlsStT\-\+]{10})\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(.+)$",
707
+ line,
708
+ )
709
+ if not m:
710
+ return None
711
+ perm, _nlink, user, group, size_s, rest = m.groups()
712
+ is_dir = perm.startswith("d")
713
+ sym = ""
714
+ rest_main = rest.strip()
715
+ if " -> " in rest_main:
716
+ rest_main, sym = rest_main.split(" -> ", 1)
717
+ sym = " -> " + sym.strip()
718
+ rtoks = rest_main.split()
719
+ name_toks = _strip_ls_datetime_name_tokens(rtoks)
720
+ name = (" ".join(name_toks) if name_toks else "") + sym
721
+ if not name.strip():
722
+ return None
723
+ first_tok = name.split(None, 1)[0]
724
+ if first_tok in (".", "..") and " -> " not in name:
725
+ return None
726
+ mtime = rest_main[: min(48, len(rest_main))]
727
+ return RemoteItem(name.strip(), is_dir, perm, user, group, size_s, mtime)
728
+
729
+
730
+ def _fill_remote_table(
731
+ table: QTableWidget, rows: List[tuple], style, icon_provider: QFileIconProvider
732
+ ) -> None:
733
+ table.setSortingEnabled(False)
734
+ table.setIconSize(QSize(24, 24))
735
+ table.setRowCount(len(rows))
736
+ for i, (parsed, full_path) in enumerate(rows):
737
+ icon = _icon_for_remote_name(parsed.name, parsed.is_dir, icon_provider, style)
738
+ name_item = _SortTableItem(parsed.name, icon, sort_key=_remote_name_sort_key(parsed))
739
+ name_item.setTextAlignment(Qt.AlignVCenter | Qt.AlignLeft)
740
+ table.setItem(i, 0, name_item)
741
+ if parsed.name == "..":
742
+ size_cell = "—"
743
+ elif parsed.is_dir:
744
+ size_cell = "…"
745
+ else:
746
+ raw = (parsed.size or "").strip()
747
+ try:
748
+ size_cell = _human_bytes(int(raw))
749
+ except ValueError:
750
+ size_cell = raw if raw else "—"
751
+ table.setItem(i, 1, _SortTableItem(size_cell, sort_key=_remote_size_sort_key(parsed)))
752
+ table.setItem(i, 2, _SortTableItem(parsed.permissions, sort_key=parsed.permissions))
753
+ table.setItem(
754
+ i,
755
+ 3,
756
+ _SortTableItem(parsed.modified, sort_key=_remote_mtime_sort_key(parsed)),
757
+ )
758
+ typ = _remote_type_label(parsed.name, parsed.is_dir)
759
+ table.setItem(i, 4, _SortTableItem(typ, sort_key=typ.lower()))
760
+ table.item(i, 0).setData(Qt.UserRole, {"path": full_path, "is_dir": parsed.is_dir})
761
+ table.setSortingEnabled(True)
762
+
763
+
764
+ class _FindFilesSearchThread(QThread):
765
+ """Runs search off the UI thread so large trees do not freeze the window."""
766
+
767
+ finished_ok = pyqtSignal(list)
768
+ finished_err = pyqtSignal(str)
769
+
770
+ def __init__(
771
+ self,
772
+ page: "ExplorerSessionPage",
773
+ side: str,
774
+ folder: str,
775
+ needle: str,
776
+ parent: Optional[QWidget] = None,
777
+ ):
778
+ # Parent to the dialog so the thread is torn down after cancel/wait in closeEvent,
779
+ # not when the explorer page is destroyed while run() is still busy.
780
+ super().__init__(parent)
781
+ self._page = page
782
+ self._side = side
783
+ self._folder = folder
784
+ self._needle = needle
785
+
786
+ def run(self) -> None:
787
+ try:
788
+ if self._side == "local":
789
+ paths = self._page.find_local_matches(
790
+ self._folder, self._needle, interrupt_check=self.isInterruptionRequested
791
+ )
792
+ self.finished_ok.emit([str(p) for p in paths])
793
+ else:
794
+ paths = self._page.find_remote_matches(
795
+ self._folder, self._needle, interrupt_check=self.isInterruptionRequested
796
+ )
797
+ self.finished_ok.emit(list(paths))
798
+ except Exception as exc:
799
+ self.finished_err.emit(str(exc))
800
+
801
+
802
+ _FIND_FILES_DIALOG_QSS_DARK = """
803
+ QDialog#FindFilesDialog {
804
+ background-color: #0f172a;
805
+ }
806
+ QDialog#FindFilesDialog QLabel {
807
+ color: #e2e8f0;
808
+ }
809
+ QDialog#FindFilesDialog QLineEdit, QDialog#FindFilesDialog QComboBox {
810
+ background-color: #1e293b;
811
+ color: #f1f5f9;
812
+ border: 1px solid #334155;
813
+ border-radius: 4px;
814
+ padding: 4px 8px;
815
+ }
816
+ QDialog#FindFilesDialog QListWidget#FindFilesResultList {
817
+ background-color: #1e293b;
818
+ color: #e2e8f0;
819
+ alternate-background-color: #334155;
820
+ selection-background-color: #2563eb;
821
+ selection-color: #ffffff;
822
+ }
823
+ QDialog#FindFilesDialog QListWidget#FindFilesResultList::item {
824
+ color: #e2e8f0;
825
+ padding: 2px 4px;
826
+ }
827
+ QDialog#FindFilesDialog QListWidget#FindFilesResultList::item:alternate {
828
+ background-color: #334155;
829
+ color: #e2e8f0;
830
+ }
831
+ """
832
+
833
+
834
+ class FindFilesDialog(QDialog):
835
+ """WinSCP-style: folder + name pattern + result list in one dialog."""
836
+
837
+ def __init__(self, page: "ExplorerSessionPage", side: str, parent=None):
838
+ super().__init__(parent)
839
+ self.setObjectName("FindFilesDialog")
840
+ self._page = page
841
+ self._side = side # "local" | "remote"
842
+ self._cfg: Optional[AppConfig] = getattr(page, "_app_config", None)
843
+ self._search_thread: Optional[_FindFilesSearchThread] = None
844
+ self._last_search_folder = ""
845
+ self.setWindowTitle("Find files" if side == "local" else "Find files on device")
846
+ self.resize(560, 420)
847
+ lay = QVBoxLayout(self)
848
+ form = QFormLayout()
849
+ self.folder_combo = ExpandAllComboBox()
850
+ self.folder_combo.setEditable(True)
851
+ self.folder_combo.setMaxVisibleItems(16)
852
+ self.folder_combo.setMinimumWidth(360)
853
+ hist_key = "find_folder_history_local" if side == "local" else "find_folder_history_remote"
854
+ hist: List[str] = []
855
+ if self._cfg and isinstance(getattr(self._cfg, hist_key, None), list):
856
+ hist = [str(x) for x in getattr(self._cfg, hist_key) if str(x).strip()]
857
+ cur = (page.local_path if side == "local" else page.remote_path).strip()
858
+ seen = set()
859
+ if cur:
860
+ self.folder_combo.addItem(cur)
861
+ seen.add(cur)
862
+ for p in hist:
863
+ ps = str(p).strip()
864
+ if ps and ps not in seen:
865
+ self.folder_combo.addItem(ps)
866
+ seen.add(ps)
867
+ self.folder_combo.setCurrentIndex(0)
868
+ self.pattern_edit = QLineEdit()
869
+ self.pattern_edit.setPlaceholderText("Name contains…")
870
+ form.addRow("Folder:", self.folder_combo)
871
+ form.addRow("Name contains:", self.pattern_edit)
872
+ lay.addLayout(form)
873
+ row = QHBoxLayout()
874
+ self._btn_browse = QPushButton("Browse…")
875
+ self._btn_browse.clicked.connect(self._browse_folder)
876
+ if side != "local":
877
+ self._btn_browse.setVisible(False)
878
+ row.addWidget(self._btn_browse)
879
+ self._btn_search = QPushButton("Search")
880
+ self._btn_search.setIcon(self.style().standardIcon(QStyle.SP_FileDialogContentsView))
881
+ self._btn_search.clicked.connect(self._run_search)
882
+ row.addWidget(self._btn_search)
883
+ row.addStretch(1)
884
+ lay.addLayout(row)
885
+ self._list = QListWidget()
886
+ self._list.setObjectName("FindFilesResultList")
887
+ self._list.setAlternatingRowColors(True)
888
+ self._list.itemDoubleClicked.connect(self._go_selected)
889
+ lay.addWidget(self._list, 1)
890
+ bb = QDialogButtonBox(QDialogButtonBox.Close)
891
+ self._btn_go = bb.addButton("Go to selected", QDialogButtonBox.ActionRole)
892
+ self._btn_go.clicked.connect(self._go_selected)
893
+ bb.rejected.connect(self.reject)
894
+ lay.addWidget(bb)
895
+ dark = bool(self._cfg and getattr(self._cfg, "dark_theme", False))
896
+ if dark:
897
+ self.setStyleSheet(_FIND_FILES_DIALOG_QSS_DARK)
898
+
899
+ def closeEvent(self, event) -> None:
900
+ self._cancel_search_thread()
901
+ super().closeEvent(event)
902
+
903
+ def reject(self) -> None:
904
+ self._cancel_search_thread()
905
+ super().reject()
906
+
907
+ def _cancel_search_thread(self) -> None:
908
+ t = self._search_thread
909
+ if t is not None and t.isRunning():
910
+ t.requestInterruption()
911
+ # ADB remote find can block inside run_adb for up to ~120s before checking interruption.
912
+ if not t.wait(130000):
913
+ t.terminate()
914
+ t.wait(5000)
915
+ self._search_thread = None
916
+
917
+ def _browse_folder(self) -> None:
918
+ le = self.folder_combo.lineEdit()
919
+ start = (le.text() if le else self.folder_combo.currentText()) or str(Path.home())
920
+ d = QFileDialog.getExistingDirectory(self, "Search folder", start)
921
+ if d:
922
+ if le:
923
+ le.setText(d)
924
+ else:
925
+ self.folder_combo.setEditText(d)
926
+
927
+ def _run_search(self) -> None:
928
+ le = self.folder_combo.lineEdit()
929
+ folder = (le.text() if le else self.folder_combo.currentText()).strip()
930
+ needle = (self.pattern_edit.text() or "").strip().lower()
931
+ self._list.clear()
932
+ if not needle:
933
+ QMessageBox.information(self, "Find", "Enter text to search for in the file or folder name.")
934
+ return
935
+ self._cancel_search_thread()
936
+ self._last_search_folder = folder
937
+ self._btn_search.setEnabled(False)
938
+ self._btn_search.setText("Searching…")
939
+ th = _FindFilesSearchThread(self._page, self._side, folder, needle, self)
940
+ self._search_thread = th
941
+ th.finished_ok.connect(self._on_search_finished)
942
+ th.finished_err.connect(self._on_search_failed)
943
+ th.finished.connect(self._on_search_thread_done)
944
+ th.start()
945
+
946
+ def _on_search_finished(self, paths: List[str]) -> None:
947
+ for p in paths:
948
+ if self._side == "local":
949
+ it = QListWidgetItem(p)
950
+ it.setData(Qt.UserRole, ("local", p))
951
+ else:
952
+ it = QListWidgetItem(p)
953
+ it.setData(Qt.UserRole, ("remote", p))
954
+ self._list.addItem(it)
955
+ _push_find_folder_history(self._cfg, self._side, self._last_search_folder)
956
+ if self._list.count() == 0:
957
+ QMessageBox.information(self, "Find", "No matches.")
958
+
959
+ def _on_search_failed(self, message: str) -> None:
960
+ QMessageBox.warning(self, "Find", message or "Search failed.")
961
+
962
+ def _on_search_thread_done(self) -> None:
963
+ self._btn_search.setEnabled(True)
964
+ self._btn_search.setText("Search")
965
+ self._search_thread = None
966
+
967
+ def _go_selected(self) -> None:
968
+ it = self._list.currentItem()
969
+ if not it:
970
+ return
971
+ data = it.data(Qt.UserRole)
972
+ if not data:
973
+ return
974
+ kind, path = data
975
+ if kind == "local":
976
+ self._page.apply_find_local_result(path)
977
+ else:
978
+ self._page.apply_find_remote_result(path)
979
+ self.accept()
980
+
981
+
982
+ class ExplorerSessionPage(QWidget):
983
+ """One WinSCP-style window: Local | Remote. Connection is established in the Login dialog before this tab opens."""
984
+
985
+ def __init__(
986
+ self,
987
+ kind: str,
988
+ get_adb_path: Callable[[], str],
989
+ get_device_serial: Callable[[], str],
990
+ log: Callable[[str], None],
991
+ session_adb_serial: Optional[str] = None,
992
+ sftp_transport=None,
993
+ sftp_client=None,
994
+ sftp_host: str = "",
995
+ sftp_user: str = "",
996
+ sftp_port: int = 22,
997
+ sftp_password: str = "",
998
+ ftp_client=None,
999
+ ftp_host: str = "",
1000
+ ftp_port: int = 21,
1001
+ ftp_user: str = "",
1002
+ ftp_password: str = "",
1003
+ app_config: Optional[AppConfig] = None,
1004
+ ):
1005
+ super().__init__()
1006
+ self.kind = kind
1007
+ self._app_config = app_config
1008
+ self.get_adb_path = get_adb_path
1009
+ self.get_device_serial = get_device_serial
1010
+ self._log = log
1011
+ self._session_adb_serial = (session_adb_serial or "").strip()
1012
+ self._ssh_host = sftp_host
1013
+ self._ssh_user = sftp_user
1014
+ self._ssh_port = int(sftp_port)
1015
+ self._ssh_password = sftp_password
1016
+ self._ftp_host = ftp_host
1017
+ self._ftp_port = int(ftp_port)
1018
+ self._ftp_user = ftp_user
1019
+ self._ftp_password = ftp_password
1020
+ self.local_path = str(Path.home())
1021
+ self.remote_path = "/sdcard" if kind == "adb" else "/"
1022
+ self.icon_provider = QFileIconProvider()
1023
+ self._adb_last_error = ""
1024
+ self._sftp_last_error = ""
1025
+ self._ftp_last_error = ""
1026
+ self._sftp_transport = sftp_transport
1027
+ self._sftp_client = sftp_client
1028
+ self._ftp_client = ftp_client
1029
+ self._last_refresh_note = ""
1030
+ self._last_error_popup_key = ""
1031
+ # When a file is opened in an external app (Word, PDF reader), sync saves back to the remote path.
1032
+ self._ext_sync_remote: Dict[str, str] = {}
1033
+ self._ext_sync_timers: Dict[str, QTimer] = {}
1034
+ self._ext_last_mtime: Dict[str, float] = {}
1035
+ self._ext_fs_watcher: Optional[QFileSystemWatcher] = None
1036
+ self._ext_poll_timer = QTimer(self)
1037
+ self._ext_poll_timer.setInterval(2000)
1038
+ self._ext_poll_timer.timeout.connect(self._poll_external_mtime)
1039
+ self._ext_poll_timer.start()
1040
+ self._local_history: List[str] = []
1041
+ self._remote_history: List[str] = []
1042
+ self._build_ui()
1043
+ self.refresh_local()
1044
+ self.refresh_remote()
1045
+
1046
+ def disconnect_session(self) -> None:
1047
+ disconnect_sftp(self._sftp_transport, self._sftp_client)
1048
+ self._sftp_transport = None
1049
+ self._sftp_client = None
1050
+ disconnect_ftp(self._ftp_client)
1051
+ self._ftp_client = None
1052
+
1053
+ def get_sftp_profile(self) -> SessionProfile:
1054
+ if self.kind != "sftp":
1055
+ return SessionProfile(ConnectionKind.SSH_SFTP)
1056
+ return SessionProfile(
1057
+ ConnectionKind.SSH_SFTP,
1058
+ ssh_host=self._ssh_host,
1059
+ ssh_user=self._ssh_user,
1060
+ ssh_port=self._ssh_port,
1061
+ ssh_password=self._ssh_password,
1062
+ )
1063
+
1064
+ def _adb_prefix(self) -> List[str]:
1065
+ # Prefer global device (Terminal bar / refresh_devices); fall back to session from Login.
1066
+ serial = _first_serial_token(self.get_device_serial())
1067
+ if not serial:
1068
+ serial = _first_serial_token(self._session_adb_serial)
1069
+ return ["-s", serial] if serial else []
1070
+
1071
+ def _build_ui(self) -> None:
1072
+ root = QVBoxLayout(self)
1073
+ root.setContentsMargins(2, 0, 2, 2)
1074
+ root.setSpacing(2)
1075
+
1076
+ if self.kind == "adb":
1077
+ self._explorer_hdr = QLabel()
1078
+ self._explorer_hdr.setObjectName("ExplorerSessionHint")
1079
+ root.addWidget(self._explorer_hdr)
1080
+ elif self.kind == "sftp":
1081
+ self._explorer_hdr = QLabel(
1082
+ f"SFTP — {self._ssh_user + '@' if self._ssh_user else ''}{self._ssh_host}:{self._ssh_port}"
1083
+ )
1084
+ self._explorer_hdr.setObjectName("ExplorerSessionHint")
1085
+ root.addWidget(self._explorer_hdr)
1086
+ else:
1087
+ self._explorer_hdr = QLabel(f"FTP — {self._ftp_host}:{self._ftp_port}")
1088
+ self._explorer_hdr.setObjectName("ExplorerSessionHint")
1089
+ root.addWidget(self._explorer_hdr)
1090
+ start_hint = QLabel(
1091
+ "How to start: create/login a session, browse folders, then use Pull or Push for transfer."
1092
+ )
1093
+ start_hint.setObjectName("ExplorerSessionHint")
1094
+ start_hint.setWordWrap(True)
1095
+ root.addWidget(start_hint)
1096
+
1097
+ transfer_col = QFrame()
1098
+ transfer_col.setObjectName("ExplorerTransferStrip")
1099
+ tc = QVBoxLayout(transfer_col)
1100
+ tc.setContentsMargins(4, 6, 4, 6)
1101
+ tc.setSpacing(6)
1102
+ tc.addStretch(1)
1103
+ st = self.style()
1104
+ tb_pull = QToolButton()
1105
+ tb_pull.setObjectName("ExplorerTransferBtn")
1106
+ tb_pull.setIcon(st.standardIcon(QStyle.SP_ArrowLeft))
1107
+ tb_pull.setText("Pull")
1108
+ tb_pull.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
1109
+ tb_pull.setToolTip(
1110
+ "Pull into the current local folder (left address bar). "
1111
+ "Or drag remote file(s) onto the local list."
1112
+ )
1113
+ tb_pull.clicked.connect(self.pull_selected)
1114
+ tb_push = QToolButton()
1115
+ tb_push.setObjectName("ExplorerTransferBtn")
1116
+ tb_push.setIcon(st.standardIcon(QStyle.SP_ArrowRight))
1117
+ tb_push.setText("Push")
1118
+ tb_push.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
1119
+ tb_push.setToolTip(
1120
+ "Send selected local file(s) or folder(s) to the current remote folder. "
1121
+ "Or drag from the local list onto the remote list."
1122
+ )
1123
+ tb_push.clicked.connect(self.push_selected)
1124
+ tc.addWidget(tb_pull, alignment=Qt.AlignHCenter)
1125
+ tc.addWidget(tb_push, alignment=Qt.AlignHCenter)
1126
+ tc.addStretch(1)
1127
+ transfer_col.setFixedWidth(88)
1128
+
1129
+ loc_header = QFrame()
1130
+ loc_header.setObjectName("WinScpPane")
1131
+ lov = QVBoxLayout(loc_header)
1132
+ lov.setContentsMargins(2, 0, 2, 2)
1133
+ lov.addWidget(QLabel("Local site"))
1134
+ la = QHBoxLayout()
1135
+ la.addWidget(QLabel())
1136
+ self.local_address = ExpandAllComboBox()
1137
+ self.local_address.setEditable(True)
1138
+ self.local_address.setObjectName("WinScpAddress")
1139
+ self.local_address.setCurrentText(self.local_path)
1140
+ if self.local_address.lineEdit():
1141
+ self.local_address.lineEdit().returnPressed.connect(self.go_local)
1142
+ la.addWidget(self.local_address, 1)
1143
+ st = self.style()
1144
+ self._local_up_btn = QToolButton()
1145
+ self._local_up_btn.setObjectName("WinScpIconBtn")
1146
+ self._local_up_btn.setIcon(self.style().standardIcon(QStyle.SP_FileDialogToParent))
1147
+ self._local_up_btn.setToolButtonStyle(Qt.ToolButtonIconOnly)
1148
+ self._local_up_btn.setIconSize(QSize(16, 16))
1149
+ self._local_up_btn.setFixedSize(28, 28)
1150
+ self._local_up_btn.setToolTip("Up one folder")
1151
+ self._local_up_btn.clicked.connect(self.local_up)
1152
+ la.addWidget(self._local_up_btn)
1153
+ self._local_home_btn = QToolButton()
1154
+ self._local_home_btn.setObjectName("WinScpIconBtn")
1155
+ self._local_home_btn.setIcon(icon_home_folder())
1156
+ self._local_home_btn.setToolButtonStyle(Qt.ToolButtonIconOnly)
1157
+ self._local_home_btn.setIconSize(QSize(16, 16))
1158
+ self._local_home_btn.setFixedSize(28, 28)
1159
+ self._local_home_btn.setToolTip("Home folder")
1160
+ self._local_home_btn.clicked.connect(self.local_go_home)
1161
+ la.addWidget(self._local_home_btn)
1162
+ b_refresh_l = QToolButton()
1163
+ b_refresh_l.setObjectName("WinScpIconBtn")
1164
+ b_refresh_l.setIcon(st.standardIcon(QStyle.SP_BrowserReload))
1165
+ b_refresh_l.setToolButtonStyle(Qt.ToolButtonIconOnly)
1166
+ b_refresh_l.setIconSize(QSize(16, 16))
1167
+ b_refresh_l.setFixedSize(28, 28)
1168
+ b_refresh_l.setToolTip("Refresh")
1169
+ b_refresh_l.clicked.connect(self.refresh_local)
1170
+ la.addWidget(b_refresh_l)
1171
+ lov.addLayout(la)
1172
+ loc_act = QHBoxLayout()
1173
+ loc_act.setSpacing(4)
1174
+ for tip, icon, fn, oname in [
1175
+ ("Refresh listing", QStyle.SP_BrowserReload, self.refresh_local, "WinScpIconBtn"),
1176
+ ("Find in list", QStyle.SP_FileDialogContentsView, self.find_in_local, "WinScpIconBtn"),
1177
+ ("New folder", QStyle.SP_DirIcon, self.new_local_folder, "WinScpIconBtn"),
1178
+ ("Create new empty file here", QStyle.SP_FileDialogStart, self.new_local_file, "ExplorerNewFileBtn"),
1179
+ ("Open with default application", QStyle.SP_DialogOpenButton, self.open_local, "WinScpIconBtn"),
1180
+ ("Edit in text editor", QStyle.SP_FileDialogDetailedView, self.edit_local, "ExplorerEditBtn"),
1181
+ ("Delete", QStyle.SP_TrashIcon, self.delete_local, "WinScpIconBtn"),
1182
+ ("Properties", QStyle.SP_FileDialogInfoView, self.local_properties, "WinScpIconBtn"),
1183
+ ]:
1184
+ b = QToolButton()
1185
+ b.setObjectName(oname)
1186
+ b.setIcon(st.standardIcon(icon))
1187
+ b.setToolTip(tip)
1188
+ b.clicked.connect(fn)
1189
+ loc_act.addWidget(b)
1190
+ loc_act.addStretch()
1191
+ lov.addLayout(loc_act)
1192
+
1193
+ self.local_table = LocalFileTable(
1194
+ on_paste_paths=self._drop_local_paths_push,
1195
+ on_drop_remote_pull=self._drop_remote_infos_pull,
1196
+ )
1197
+ self.local_table.setColumnCount(4)
1198
+ self.local_table.setHorizontalHeaderLabels(["Name", "Size", "Date modified", "Type"])
1199
+ _apply_table_chrome(self.local_table, stretch_first=True)
1200
+ self.local_table.setIconSize(QSize(24, 24))
1201
+ self.local_table.setDragEnabled(True)
1202
+ self.local_table.setDragDropMode(QAbstractItemView.DragDrop)
1203
+ self.local_table.setDefaultDropAction(Qt.CopyAction)
1204
+ self.local_table.itemActivated.connect(self.on_local_activated)
1205
+
1206
+ rem_header = QFrame()
1207
+ rem_header.setObjectName("WinScpPane")
1208
+ rov = QVBoxLayout(rem_header)
1209
+ rov.setContentsMargins(2, 0, 2, 2)
1210
+ rt = "Remote site"
1211
+ if self.kind == "adb":
1212
+ rt = "Remote site (Android)"
1213
+ elif self.kind == "sftp":
1214
+ rt = "Remote site (SFTP)"
1215
+ else:
1216
+ rt = "Remote site (FTP)"
1217
+ rov.addWidget(QLabel(rt))
1218
+ ra = QHBoxLayout()
1219
+ ra.addWidget(QLabel())
1220
+ self.remote_address = ExpandAllComboBox()
1221
+ self.remote_address.setEditable(True)
1222
+ self.remote_address.setObjectName("WinScpAddress")
1223
+ self.remote_address.setCurrentText(self.remote_path)
1224
+ if self.remote_address.lineEdit():
1225
+ self.remote_address.lineEdit().returnPressed.connect(self.go_remote)
1226
+ ra.addWidget(self.remote_address, 1)
1227
+ self._remote_up_btn = QToolButton()
1228
+ self._remote_up_btn.setObjectName("WinScpIconBtn")
1229
+ self._remote_up_btn.setIcon(self.style().standardIcon(QStyle.SP_FileDialogToParent))
1230
+ self._remote_up_btn.setToolButtonStyle(Qt.ToolButtonIconOnly)
1231
+ self._remote_up_btn.setIconSize(QSize(16, 16))
1232
+ self._remote_up_btn.setFixedSize(28, 28)
1233
+ self._remote_up_btn.setToolTip("Up one folder")
1234
+ self._remote_up_btn.clicked.connect(self.remote_up)
1235
+ ra.addWidget(self._remote_up_btn)
1236
+ self._remote_home_btn = QToolButton()
1237
+ self._remote_home_btn.setObjectName("WinScpIconBtn")
1238
+ self._remote_home_btn.setIcon(icon_home_folder())
1239
+ self._remote_home_btn.setToolButtonStyle(Qt.ToolButtonIconOnly)
1240
+ self._remote_home_btn.setIconSize(QSize(16, 16))
1241
+ self._remote_home_btn.setFixedSize(28, 28)
1242
+ if self.kind == "adb":
1243
+ self._remote_home_btn.setToolTip("Android shared storage (/sdcard)")
1244
+ else:
1245
+ self._remote_home_btn.setToolTip("Home folder")
1246
+ self._remote_home_btn.clicked.connect(self.remote_go_home)
1247
+ ra.addWidget(self._remote_home_btn)
1248
+ if self.kind != "adb":
1249
+ b_root = QToolButton()
1250
+ b_root.setObjectName("WinScpIconBtn")
1251
+ b_root.setIcon(icon_root_drive())
1252
+ b_root.setToolTip("Root (/)")
1253
+ b_root.clicked.connect(self.remote_root)
1254
+ ra.addWidget(b_root)
1255
+ b_refresh_r = QToolButton()
1256
+ b_refresh_r.setObjectName("WinScpIconBtn")
1257
+ b_refresh_r.setIcon(st.standardIcon(QStyle.SP_BrowserReload))
1258
+ b_refresh_r.setToolButtonStyle(Qt.ToolButtonIconOnly)
1259
+ b_refresh_r.setIconSize(QSize(16, 16))
1260
+ b_refresh_r.setFixedSize(28, 28)
1261
+ b_refresh_r.setToolTip("Refresh")
1262
+ b_refresh_r.clicked.connect(self.refresh_remote)
1263
+ ra.addWidget(b_refresh_r)
1264
+ rov.addLayout(ra)
1265
+ rem_act = QHBoxLayout()
1266
+ rem_act.setSpacing(4)
1267
+ for tip, icon, fn, oname in [
1268
+ ("Refresh listing", QStyle.SP_BrowserReload, self.refresh_remote, "WinScpIconBtn"),
1269
+ ("Find in list", QStyle.SP_FileDialogContentsView, self.find_in_remote, "WinScpIconBtn"),
1270
+ ("New folder", QStyle.SP_DirIcon, self.make_remote_folder, "WinScpIconBtn"),
1271
+ ("Create new empty file here", QStyle.SP_FileDialogStart, self.new_remote_file, "ExplorerNewFileBtn"),
1272
+ ("Open with default application (download first)", QStyle.SP_DialogOpenButton, self.open_remote, "WinScpIconBtn"),
1273
+ ("Edit in text editor", QStyle.SP_FileDialogDetailedView, self.edit_remote, "ExplorerEditBtn"),
1274
+ ("Delete", QStyle.SP_TrashIcon, self.delete_selected_remote, "WinScpIconBtn"),
1275
+ ("Properties", QStyle.SP_FileDialogInfoView, self.remote_properties, "WinScpIconBtn"),
1276
+ ]:
1277
+ b = QToolButton()
1278
+ b.setObjectName(oname)
1279
+ b.setIcon(st.standardIcon(icon))
1280
+ b.setToolTip(tip)
1281
+ b.clicked.connect(fn)
1282
+ rem_act.addWidget(b)
1283
+ rem_act.addStretch()
1284
+ rov.addLayout(rem_act)
1285
+
1286
+ self.remote_table = RemoteFileTable(
1287
+ self.on_remote_activated,
1288
+ self._drop_local_paths_push,
1289
+ on_paste_paths=self._drop_local_paths_push,
1290
+ )
1291
+
1292
+ mid_wrap = QWidget()
1293
+ mid_wrap.setMinimumWidth(88)
1294
+ mid_lay = QHBoxLayout(mid_wrap)
1295
+ mid_lay.setContentsMargins(6, 0, 6, 0)
1296
+ mid_lay.setSpacing(0)
1297
+ mid_lay.addStretch(1)
1298
+ mid_lay.addWidget(transfer_col, 0, Qt.AlignCenter)
1299
+ mid_lay.addStretch(1)
1300
+
1301
+ self.local_table.setMinimumWidth(140)
1302
+ self.remote_table.setMinimumWidth(140)
1303
+ self.local_table.setContextMenuPolicy(Qt.CustomContextMenu)
1304
+ self.local_table.customContextMenuRequested.connect(self._on_local_context_menu)
1305
+ self.remote_table.setContextMenuPolicy(Qt.CustomContextMenu)
1306
+ self.remote_table.customContextMenuRequested.connect(self._on_remote_context_menu)
1307
+ lv = self.local_table.viewport()
1308
+ lv.setContextMenuPolicy(Qt.CustomContextMenu)
1309
+ lv.customContextMenuRequested.connect(self._on_local_context_menu)
1310
+ rv = self.remote_table.viewport()
1311
+ rv.setContextMenuPolicy(Qt.CustomContextMenu)
1312
+ rv.customContextMenuRequested.connect(self._on_remote_context_menu)
1313
+ QShortcut(QKeySequence(Qt.Key_F5), self.local_table, self.refresh_local)
1314
+ QShortcut(QKeySequence(Qt.Key_Delete), self.local_table, self.delete_local)
1315
+ QShortcut(QKeySequence(Qt.Key_F5), self.remote_table, self.refresh_remote)
1316
+ QShortcut(QKeySequence(Qt.Key_Delete), self.remote_table, self.delete_selected_remote)
1317
+
1318
+ header_grid = QGridLayout()
1319
+ header_grid.setContentsMargins(0, 0, 0, 0)
1320
+ header_grid.setHorizontalSpacing(0)
1321
+ header_grid.addWidget(loc_header, 0, 0)
1322
+ header_mid = QWidget()
1323
+ header_mid.setFixedWidth(112)
1324
+ header_mid.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
1325
+ header_grid.addWidget(header_mid, 0, 1)
1326
+ header_grid.addWidget(rem_header, 0, 2)
1327
+ header_grid.setColumnStretch(0, 1)
1328
+ header_grid.setColumnStretch(2, 1)
1329
+
1330
+ table_split = QSplitter(Qt.Horizontal)
1331
+ table_split.setObjectName("ExplorerSessionSplit")
1332
+ table_split.addWidget(self.local_table)
1333
+ table_split.addWidget(mid_wrap)
1334
+ table_split.addWidget(self.remote_table)
1335
+ table_split.setStretchFactor(0, 1)
1336
+ table_split.setStretchFactor(1, 0)
1337
+ table_split.setStretchFactor(2, 1)
1338
+ table_split.setCollapsible(0, False)
1339
+ table_split.setCollapsible(1, False)
1340
+ table_split.setCollapsible(2, False)
1341
+ table_split.setSizes([520, 112, 520])
1342
+
1343
+ explorer_body = QVBoxLayout()
1344
+ explorer_body.setContentsMargins(0, 0, 0, 0)
1345
+ explorer_body.setSpacing(2)
1346
+ explorer_body.addLayout(header_grid)
1347
+ explorer_body.addWidget(table_split, 1)
1348
+ explorer_wrap = QWidget()
1349
+ explorer_wrap.setLayout(explorer_body)
1350
+ root.addWidget(explorer_wrap, 1)
1351
+ self._update_explorer_header()
1352
+
1353
+ def _update_explorer_header(self) -> None:
1354
+ if not getattr(self, "_explorer_hdr", None):
1355
+ return
1356
+ if self.kind == "adb":
1357
+ ser = _first_serial_token(self.get_device_serial()) or _first_serial_token(self._session_adb_serial)
1358
+ self._explorer_hdr.setText(
1359
+ f"Android · ADB — {ser or 'choose a device in the toolbar (USB debugging on, authorize this PC)'}"
1360
+ )
1361
+
1362
+ def _on_local_context_menu(self, pos) -> None:
1363
+ idx = self.local_table.indexAt(pos)
1364
+ if idx.isValid():
1365
+ self.local_table.selectRow(idx.row())
1366
+ m = QMenu(self)
1367
+ a_copy = m.addAction("Copy path(s)")
1368
+ a_copy.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
1369
+ a_copy.setShortcut(QKeySequence.Copy)
1370
+ a_copy.setShortcutVisibleInContextMenu(True)
1371
+ a_copy.triggered.connect(self._ctx_copy_local_paths)
1372
+ m.addSeparator()
1373
+ a_ref = m.addAction("Refresh")
1374
+ a_ref.setIcon(self.style().standardIcon(QStyle.SP_BrowserReload))
1375
+ a_ref.triggered.connect(self.refresh_local)
1376
+ a_newf = m.addAction("New file")
1377
+ a_newf.setIcon(self.style().standardIcon(QStyle.SP_FileDialogStart))
1378
+ a_newf.triggered.connect(self.new_local_file)
1379
+ a_newd = m.addAction("New folder")
1380
+ a_newd.setIcon(self.style().standardIcon(QStyle.SP_DirIcon))
1381
+ a_newd.triggered.connect(self.new_local_folder)
1382
+ a_push = m.addAction("Push selected")
1383
+ a_push.setIcon(self.style().standardIcon(QStyle.SP_ArrowRight))
1384
+ a_push.triggered.connect(self.push_selected)
1385
+ a_open = m.addAction("Open with default application")
1386
+ a_open.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
1387
+ a_open.triggered.connect(self.open_local)
1388
+ a_edit = m.addAction("Edit in text editor")
1389
+ a_edit.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
1390
+ a_edit.triggered.connect(self.edit_local)
1391
+ a_del = m.addAction("Delete")
1392
+ a_del.setIcon(self.style().standardIcon(QStyle.SP_TrashIcon))
1393
+ a_del.triggered.connect(self.delete_local)
1394
+ a_prop = m.addAction("Properties")
1395
+ a_prop.setIcon(self.style().standardIcon(QStyle.SP_FileDialogInfoView))
1396
+ a_prop.triggered.connect(self.local_properties)
1397
+ m.exec_(self.local_table.viewport().mapToGlobal(pos))
1398
+
1399
+ def _on_remote_context_menu(self, pos) -> None:
1400
+ idx = self.remote_table.indexAt(pos)
1401
+ if idx.isValid():
1402
+ self.remote_table.selectRow(idx.row())
1403
+ m = QMenu(self)
1404
+ a_copy = m.addAction("Copy path(s)")
1405
+ a_copy.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
1406
+ a_copy.setShortcut(QKeySequence.Copy)
1407
+ a_copy.setShortcutVisibleInContextMenu(True)
1408
+ a_copy.triggered.connect(self._ctx_copy_remote_paths)
1409
+ m.addSeparator()
1410
+ a_pull = m.addAction("Pull")
1411
+ a_pull.setIcon(self.style().standardIcon(QStyle.SP_ArrowLeft))
1412
+ a_pull.triggered.connect(self.pull_selected)
1413
+ a_newf = m.addAction("New file")
1414
+ a_newf.setIcon(self.style().standardIcon(QStyle.SP_FileDialogStart))
1415
+ a_newf.triggered.connect(self.new_remote_file)
1416
+ a_newd = m.addAction("New folder")
1417
+ a_newd.setIcon(self.style().standardIcon(QStyle.SP_DirIcon))
1418
+ a_newd.triggered.connect(self.make_remote_folder)
1419
+ m.addSeparator()
1420
+ a_ref = m.addAction("Refresh")
1421
+ a_ref.setIcon(self.style().standardIcon(QStyle.SP_BrowserReload))
1422
+ a_ref.triggered.connect(self.refresh_remote)
1423
+ a_open = m.addAction("Open with default application (download first)")
1424
+ a_open.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
1425
+ a_open.triggered.connect(self.open_remote)
1426
+ a_edit = m.addAction("Edit in text editor")
1427
+ a_edit.setIcon(self.style().standardIcon(QStyle.SP_FileDialogDetailedView))
1428
+ a_edit.triggered.connect(self.edit_remote)
1429
+ a_del = m.addAction("Delete")
1430
+ a_del.setIcon(self.style().standardIcon(QStyle.SP_TrashIcon))
1431
+ a_del.triggered.connect(self.delete_selected_remote)
1432
+ a_prop = m.addAction("Properties")
1433
+ a_prop.setIcon(self.style().standardIcon(QStyle.SP_FileDialogInfoView))
1434
+ a_prop.triggered.connect(self.remote_properties)
1435
+ m.exec_(self.remote_table.viewport().mapToGlobal(pos))
1436
+
1437
+ def _ctx_copy_local_paths(self) -> None:
1438
+ rows = sorted({i.row() for i in self.local_table.selectedItems()})
1439
+ paths: List[str] = []
1440
+ for r in rows:
1441
+ it = self.local_table.item(r, 0)
1442
+ if it and it.data(Qt.UserRole):
1443
+ paths.append(str(it.data(Qt.UserRole)))
1444
+ if paths:
1445
+ QApplication.clipboard().setText("\n".join(paths))
1446
+ self._log(f"Explorer: copied {len(paths)} local path(s).")
1447
+
1448
+ def _ctx_copy_remote_paths(self) -> None:
1449
+ rows = sorted({i.row() for i in self.remote_table.selectedItems()})
1450
+ paths: List[str] = []
1451
+ for r in rows:
1452
+ it = self.remote_table.item(r, 0)
1453
+ if it:
1454
+ info = it.data(Qt.UserRole) or {}
1455
+ p = info.get("path")
1456
+ if p:
1457
+ paths.append(str(p))
1458
+ if paths:
1459
+ QApplication.clipboard().setText("\n".join(paths))
1460
+ self._log(f"Explorer: copied {len(paths)} remote path(s).")
1461
+
1462
+ def _select_local_basename(self, name: str) -> None:
1463
+ name = (name or "").strip()
1464
+ if not name:
1465
+ return
1466
+ for r in range(self.local_table.rowCount()):
1467
+ it = self.local_table.item(r, 0)
1468
+ if it and it.text() == name:
1469
+ self.local_table.clearSelection()
1470
+ self.local_table.selectRow(r)
1471
+ self.local_table.setCurrentCell(r, 0)
1472
+ self.local_table.scrollToItem(it)
1473
+ self.local_table.setFocus(Qt.OtherFocusReason)
1474
+ return
1475
+
1476
+ def _select_remote_basename(self, name: str) -> None:
1477
+ name = (name or "").strip()
1478
+ if not name:
1479
+ return
1480
+ for r in range(self.remote_table.rowCount()):
1481
+ it = self.remote_table.item(r, 0)
1482
+ if it and it.text() == name:
1483
+ self.remote_table.clearSelection()
1484
+ self.remote_table.selectRow(r)
1485
+ self.remote_table.setCurrentCell(r, 0)
1486
+ self.remote_table.scrollToItem(it)
1487
+ self.remote_table.setFocus(Qt.OtherFocusReason)
1488
+ return
1489
+
1490
+ def _addr_local_text(self) -> str:
1491
+ return (self.local_address.currentText() or "").strip()
1492
+
1493
+ def _addr_remote_text(self) -> str:
1494
+ return (self.remote_address.currentText() or "").strip()
1495
+
1496
+ def _set_local_address(self, path: str) -> None:
1497
+ self.local_address.setCurrentText(path)
1498
+ if path and path not in self._local_history:
1499
+ self._local_history.append(path)
1500
+ self.local_address.addItem(path)
1501
+
1502
+ def _set_remote_address(self, path: str) -> None:
1503
+ self.remote_address.setCurrentText(path)
1504
+ if path and path not in self._remote_history:
1505
+ self._remote_history.append(path)
1506
+ self.remote_address.addItem(path)
1507
+
1508
+ def _notify_path_result(self, title: str, message: str, target_path: str) -> None:
1509
+ p = Path(target_path)
1510
+ file_url = QUrl.fromLocalFile(str(p.resolve()))
1511
+ box = QMessageBox(self)
1512
+ box.setWindowTitle(title)
1513
+ box.setIcon(QMessageBox.Information)
1514
+ box.setText(message)
1515
+ box.setInformativeText(str(p.resolve()))
1516
+ open_btn = box.addButton("Open", QMessageBox.ActionRole)
1517
+ box.addButton(QMessageBox.Ok)
1518
+ box.exec_()
1519
+ if box.clickedButton() == open_btn:
1520
+ QDesktopServices.openUrl(file_url)
1521
+
1522
+ def find_local_matches(
1523
+ self, folder: str, needle: str, interrupt_check: Optional[Callable[[], bool]] = None
1524
+ ) -> List[Path]:
1525
+ root = Path(folder.strip() or self.local_path)
1526
+ if not root.exists() or not root.is_dir():
1527
+ return []
1528
+ out: List[Path] = []
1529
+ try:
1530
+ for p in root.rglob("*"):
1531
+ if interrupt_check and interrupt_check():
1532
+ break
1533
+ if needle in p.name.lower():
1534
+ out.append(p)
1535
+ if len(out) >= 500:
1536
+ break
1537
+ except OSError:
1538
+ return []
1539
+ return out
1540
+
1541
+ def find_remote_matches(
1542
+ self, folder: str, needle: str, interrupt_check: Optional[Callable[[], bool]] = None
1543
+ ) -> List[str]:
1544
+ root = (folder.strip() or self.remote_path).replace("\\", "/")
1545
+ needle_l = needle.lower()
1546
+ out: List[str] = []
1547
+ if self.kind == "adb":
1548
+ if interrupt_check and interrupt_check():
1549
+ return []
1550
+ serial = _first_serial_token(self.get_device_serial()) or _first_serial_token(self._session_adb_serial)
1551
+ if not serial:
1552
+ return []
1553
+ safe = root.replace("\\", "\\\\").replace('"', '\\"')
1554
+ code, stdout, stderr = run_adb(
1555
+ self.get_adb_path(),
1556
+ [*self._adb_prefix(), "shell", f'find "{safe}" 2>/dev/null | head -n 4000'],
1557
+ timeout=120,
1558
+ )
1559
+ if code != 0 and not stdout.strip():
1560
+ self._log(f"Explorer find (ADB): {stderr.strip() or 'find failed'}")
1561
+ for line in stdout.splitlines():
1562
+ if interrupt_check and interrupt_check():
1563
+ break
1564
+ line = line.strip()
1565
+ if not line or needle_l not in Path(line).name.lower():
1566
+ continue
1567
+ out.append(line)
1568
+ if len(out) >= 500:
1569
+ break
1570
+ return out
1571
+ if self.kind == "sftp" and self._sftp_client:
1572
+ stack = [root.rstrip("/") or "/"]
1573
+
1574
+ def walk() -> None:
1575
+ while stack and len(out) < 500:
1576
+ if interrupt_check and interrupt_check():
1577
+ return
1578
+ d = stack.pop()
1579
+ try:
1580
+ for a in self._sftp_client.listdir_attr(d):
1581
+ if interrupt_check and interrupt_check():
1582
+ return
1583
+ name = a.filename
1584
+ if name in (".", ".."):
1585
+ continue
1586
+ full = posixpath.join(d, name).replace("\\", "/")
1587
+ if needle_l in name.lower():
1588
+ out.append(full)
1589
+ if S_ISDIR(a.st_mode):
1590
+ stack.append(full)
1591
+ except Exception:
1592
+ continue
1593
+
1594
+ walk()
1595
+ return out
1596
+ if self.kind == "ftp" and self._ftp_client:
1597
+ start_cwd = ""
1598
+ try:
1599
+ start_cwd = self._ftp_client.pwd()
1600
+ except Exception:
1601
+ pass
1602
+
1603
+ def scan(d: str, depth: int) -> None:
1604
+ if interrupt_check and interrupt_check():
1605
+ return
1606
+ if depth > 4 or len(out) >= 200:
1607
+ return
1608
+ try:
1609
+ self._ftp_client.cwd(d)
1610
+ except Exception:
1611
+ return
1612
+ try:
1613
+ for name, facts in self._ftp_client.mlsd():
1614
+ if interrupt_check and interrupt_check():
1615
+ return
1616
+ if name in (".", ".."):
1617
+ continue
1618
+ is_dir = facts.get("type") == "dir"
1619
+ full = posixpath.join(d, name).replace("\\", "/")
1620
+ if needle_l in name.lower():
1621
+ out.append(full)
1622
+ if is_dir:
1623
+ scan(full, depth + 1)
1624
+ except (error_perm, AttributeError, Exception):
1625
+ try:
1626
+ for name in self._ftp_client.nlst():
1627
+ if interrupt_check and interrupt_check():
1628
+ return
1629
+ if name in (".", ".."):
1630
+ continue
1631
+ full = posixpath.join(d, name).replace("\\", "/")
1632
+ if needle_l in name.lower():
1633
+ out.append(full)
1634
+ except Exception:
1635
+ pass
1636
+
1637
+ try:
1638
+ scan(root.rstrip("/") or "/", 0)
1639
+ except Exception:
1640
+ pass
1641
+ finally:
1642
+ if start_cwd:
1643
+ try:
1644
+ self._ftp_client.cwd(start_cwd)
1645
+ except Exception:
1646
+ pass
1647
+ return out
1648
+ return []
1649
+
1650
+ def apply_find_local_result(self, path_str: str) -> None:
1651
+ target = Path(path_str)
1652
+ if not target.exists():
1653
+ return
1654
+ target_dir = target.parent if target.is_file() else target
1655
+ self.local_path = str(target_dir.resolve())
1656
+ self._set_local_address(self.local_path)
1657
+ self.refresh_local()
1658
+ self._select_local_basename(target.name)
1659
+
1660
+ def apply_find_remote_result(self, path_str: str) -> None:
1661
+ rp = path_str.replace("\\", "/").strip()
1662
+ if not rp:
1663
+ return
1664
+ parent = posixpath.dirname(rp) or "/"
1665
+ self.remote_path = parent
1666
+ self._set_remote_address(self.remote_path)
1667
+ self.refresh_remote()
1668
+ self._select_remote_basename(posixpath.basename(rp))
1669
+
1670
+ def _sftp_get_with_progress(self, remote_path: str, local_dest: str) -> None:
1671
+ assert self._sftp_client is not None
1672
+ try:
1673
+ st = self._sftp_client.stat(remote_path)
1674
+ total = max(st.st_size, 1)
1675
+ except Exception:
1676
+ total = 1
1677
+ dlg = QProgressDialog("Downloading from server…", None, 0, 100, self)
1678
+ dlg.setCancelButton(None)
1679
+ dlg.setMinimumDuration(0)
1680
+ dlg.setWindowModality(Qt.ApplicationModal)
1681
+
1682
+ def _cb(sent: int, size: int) -> None:
1683
+ if size:
1684
+ dlg.setValue(min(99, int(100 * sent / max(size, 1))))
1685
+ QApplication.processEvents()
1686
+
1687
+ self._sftp_client.get(remote_path, local_dest, callback=_cb)
1688
+ dlg.setValue(100)
1689
+ dlg.close()
1690
+
1691
+ def _sftp_mkdir_p(self, remote_dir: str) -> None:
1692
+ assert self._sftp_client is not None
1693
+ r = remote_dir.replace("\\", "/").rstrip("/")
1694
+ if not r or r == "/":
1695
+ return
1696
+ parts = [p for p in r.split("/") if p]
1697
+ acc = ""
1698
+ for p in parts:
1699
+ acc = f"{acc}/{p}" if acc else f"/{p}"
1700
+ try:
1701
+ self._sftp_client.stat(acc)
1702
+ except (OSError, IOError):
1703
+ try:
1704
+ self._sftp_client.mkdir(acc)
1705
+ except (OSError, IOError):
1706
+ pass
1707
+
1708
+ def _sftp_put_tree(self, local_root: Path, remote_parent: str) -> bool:
1709
+ assert self._sftp_client is not None
1710
+ remote_root = posixpath.join(remote_parent.rstrip("/"), local_root.name).replace("\\", "/")
1711
+ self._sftp_mkdir_p(remote_root)
1712
+ n = 0
1713
+ for root, _dirs, files in os.walk(local_root):
1714
+ rel = Path(root).relative_to(local_root)
1715
+ rel_s = "" if str(rel) == "." else str(rel).replace("\\", "/")
1716
+ sub_remote = posixpath.join(remote_root, rel_s).replace("//", "/") if rel_s else remote_root
1717
+ if rel_s:
1718
+ self._sftp_mkdir_p(sub_remote)
1719
+ for fn in files:
1720
+ lp = Path(root) / fn
1721
+ rp = posixpath.join(sub_remote, fn).replace("\\", "/")
1722
+ try:
1723
+ total = max(lp.stat().st_size, 1)
1724
+ except OSError:
1725
+ total = 1
1726
+ dlg = QProgressDialog(f"SFTP upload {lp.name}…", None, 0, 100, self)
1727
+ dlg.setCancelButton(None)
1728
+ dlg.setMinimumDuration(0)
1729
+ dlg.setWindowModality(Qt.ApplicationModal)
1730
+
1731
+ def _cb(sent: int, size: int) -> None:
1732
+ if size:
1733
+ dlg.setValue(min(99, int(100 * sent / max(size, 1))))
1734
+ QApplication.processEvents()
1735
+
1736
+ self._sftp_client.put(str(lp), rp, callback=_cb)
1737
+ dlg.setValue(100)
1738
+ dlg.close()
1739
+ n += 1
1740
+ self._log(f"Explorer: SFTP uploaded folder {local_root.name} ({n} file(s)).")
1741
+ return True
1742
+
1743
+ def _sftp_pull_tree(self, remote_path: str, local_parent: str) -> None:
1744
+ assert self._sftp_client is not None
1745
+ name = posixpath.basename(remote_path.rstrip("/"))
1746
+ local_root = os.path.join(local_parent, name)
1747
+ os.makedirs(local_root, exist_ok=True)
1748
+ self._sftp_pull_recursive(remote_path.rstrip("/"), local_root)
1749
+
1750
+ def _sftp_pull_recursive(self, remote_path: str, local_path: str) -> None:
1751
+ assert self._sftp_client is not None
1752
+ for a in self._sftp_client.listdir_attr(remote_path):
1753
+ if a.filename in (".", ".."):
1754
+ continue
1755
+ r = posixpath.join(remote_path, a.filename).replace("\\", "/")
1756
+ l = os.path.join(local_path, a.filename)
1757
+ if S_ISDIR(a.st_mode):
1758
+ os.makedirs(l, exist_ok=True)
1759
+ self._sftp_pull_recursive(r, l)
1760
+ else:
1761
+ self._sftp_get_with_progress(r, l)
1762
+
1763
+ def _ftp_ensure_remote_dir(self, remote_abs: str) -> None:
1764
+ """Create remote path if needed; leave FTP cwd at remote_abs."""
1765
+ assert self._ftp_client is not None
1766
+ r = remote_abs.replace("\\", "/").rstrip("/")
1767
+ if r == "" or r == "/":
1768
+ try:
1769
+ self._ftp_client.cwd("/")
1770
+ except error_perm:
1771
+ pass
1772
+ return
1773
+ try:
1774
+ self._ftp_client.cwd(r)
1775
+ return
1776
+ except error_perm:
1777
+ pass
1778
+ parent = posixpath.dirname(r)
1779
+ if not parent or parent == ".":
1780
+ parent = "/"
1781
+ base = posixpath.basename(r)
1782
+ self._ftp_ensure_remote_dir(parent)
1783
+ try:
1784
+ self._ftp_client.mkd(base)
1785
+ except error_perm:
1786
+ pass
1787
+ try:
1788
+ self._ftp_client.cwd(base)
1789
+ except error_perm:
1790
+ pass
1791
+
1792
+ def _ftp_put_tree(self, local_root: Path, remote_parent: str) -> bool:
1793
+ assert self._ftp_client is not None
1794
+ remote_root = posixpath.join(remote_parent.rstrip("/"), local_root.name).replace("\\", "/")
1795
+ self._ftp_ensure_remote_dir(remote_root)
1796
+ n = 0
1797
+ for walk_root, _dirs, files in os.walk(local_root):
1798
+ rel = Path(walk_root).relative_to(local_root)
1799
+ rel_s = "" if str(rel) == "." else str(rel).replace("\\", "/")
1800
+ sub_remote = posixpath.join(remote_root, rel_s).replace("//", "/") if rel_s else remote_root
1801
+ if rel_s:
1802
+ self._ftp_ensure_remote_dir(sub_remote)
1803
+ for fn in files:
1804
+ lp = Path(walk_root) / fn
1805
+ rp_file = posixpath.join(sub_remote, fn).replace("\\", "/")
1806
+ parent = posixpath.dirname(rp_file) or "/"
1807
+ base = posixpath.basename(rp_file)
1808
+ self._ftp_ensure_remote_dir(parent)
1809
+ try:
1810
+ total = max(lp.stat().st_size, 1)
1811
+ except OSError:
1812
+ total = 1
1813
+ dlg = QProgressDialog(f"FTP upload {lp.name}…", None, 0, 100, self)
1814
+ dlg.setCancelButton(None)
1815
+ dlg.setMinimumDuration(0)
1816
+ dlg.setWindowModality(Qt.ApplicationModal)
1817
+ sent = [0]
1818
+
1819
+ def _dcb(block: bytes) -> None:
1820
+ sent[0] += len(block)
1821
+ dlg.setValue(min(99, int(100 * sent[0] / max(total, 1))))
1822
+ QApplication.processEvents()
1823
+
1824
+ with open(lp, "rb") as f:
1825
+ self._ftp_client.storbinary(f"STOR {base}", f, blocksize=65536, callback=_dcb)
1826
+ dlg.setValue(100)
1827
+ dlg.close()
1828
+ n += 1
1829
+ self._log(f"Explorer: FTP uploaded folder {local_root.name} ({n} file(s)).")
1830
+ return True
1831
+
1832
+ def _ftp_pull_tree(self, remote_path: str, local_parent: str) -> None:
1833
+ assert self._ftp_client is not None
1834
+ name = posixpath.basename(remote_path.rstrip("/"))
1835
+ local_root = os.path.join(local_parent, name)
1836
+ os.makedirs(local_root, exist_ok=True)
1837
+ self._ftp_pull_recursive(remote_path.rstrip("/"), local_root)
1838
+
1839
+ def _ftp_pull_recursive(self, remote_path: str, local_path: str) -> None:
1840
+ assert self._ftp_client is not None
1841
+ try:
1842
+ self._ftp_client.cwd(remote_path)
1843
+ except error_perm as exc:
1844
+ self._log(f"Explorer: FTP cwd {remote_path}: {exc}")
1845
+ raise
1846
+ try:
1847
+ entries = list(self._ftp_client.mlsd())
1848
+ except (error_perm, AttributeError):
1849
+ entries = []
1850
+ for name in self._ftp_client.nlst():
1851
+ if name not in (".", ".."):
1852
+ entries.append((name, {"type": "file"}))
1853
+ for name, facts in entries:
1854
+ if name in (".", ".."):
1855
+ continue
1856
+ r = posixpath.join(remote_path, name).replace("\\", "/")
1857
+ l = os.path.join(local_path, name)
1858
+ is_dir = bool(facts) and facts.get("type") == "dir"
1859
+ if is_dir:
1860
+ os.makedirs(l, exist_ok=True)
1861
+ self._ftp_pull_recursive(r, l)
1862
+ else:
1863
+ parent = posixpath.dirname(r) or "/"
1864
+ fn = posixpath.basename(r)
1865
+ dlg = QProgressDialog(f"FTP download {fn}…", None, 0, 0, self)
1866
+ dlg.setCancelButton(None)
1867
+ dlg.setMinimumDuration(0)
1868
+ dlg.setWindowModality(Qt.ApplicationModal)
1869
+ QApplication.processEvents()
1870
+ self._ftp_ensure_remote_dir(parent)
1871
+ with open(l, "wb") as out:
1872
+ self._ftp_client.retrbinary(f"RETR {fn}", out.write)
1873
+ dlg.close()
1874
+
1875
+ def _adb_progress_exec(
1876
+ self, label: str, adb_args: List[str], timeout: int = 600, *, quiet: bool = False
1877
+ ) -> Tuple[int, str]:
1878
+ if quiet:
1879
+ code, out, err = run_adb(self.get_adb_path(), adb_args, timeout=timeout)
1880
+ return code, (err or out or "").strip()
1881
+
1882
+ dlg = QProgressDialog(label, "Cancel", 0, 100, self)
1883
+ dlg.setWindowModality(Qt.ApplicationModal)
1884
+ dlg.setMinimumDuration(0)
1885
+ dlg.setValue(0)
1886
+ dlg.setAutoClose(True)
1887
+ dlg.setAutoReset(True)
1888
+ dlg.setCancelButton(None)
1889
+ last_pct = [0]
1890
+
1891
+ def on_pct(p: int) -> None:
1892
+ if 0 <= p <= 100 and p >= last_pct[0]:
1893
+ last_pct[0] = p
1894
+ dlg.setValue(p)
1895
+ dlg.setLabelText(f"{label}\n{p}%")
1896
+ QApplication.processEvents()
1897
+
1898
+ code, out, err = run_adb_with_line_callback(
1899
+ self.get_adb_path(),
1900
+ adb_args,
1901
+ timeout=timeout,
1902
+ on_percent=on_pct,
1903
+ )
1904
+ dlg.setValue(100)
1905
+ dlg.close()
1906
+ msg = (err or out or "").strip()
1907
+ return code, msg
1908
+
1909
+ def local_go_home(self) -> None:
1910
+ self.local_path = str(Path.home())
1911
+ self.local_address.setText(self.local_path)
1912
+ self.refresh_local()
1913
+
1914
+ def remote_go_home(self) -> None:
1915
+ if self.kind == "adb":
1916
+ self.remote_path = "/sdcard"
1917
+ elif self.kind == "sftp":
1918
+ u = (self._ssh_user or "").strip()
1919
+ self.remote_path = f"/home/{u}" if u else "/"
1920
+ else:
1921
+ self.remote_path = "/"
1922
+ self.remote_address.setText(self.remote_path)
1923
+ self.refresh_remote()
1924
+
1925
+ def _update_nav_buttons(self) -> None:
1926
+ if hasattr(self, "_local_up_btn"):
1927
+ try:
1928
+ lp = Path(self.local_path)
1929
+ at_root = not lp.exists() or lp.resolve() == lp.parent.resolve()
1930
+ except OSError:
1931
+ at_root = True
1932
+ self._local_up_btn.setMinimumSize(30, 28)
1933
+ self._local_up_btn.setVisible(True)
1934
+ self._local_up_btn.setEnabled(True)
1935
+ self._local_up_btn.setToolTip(
1936
+ "Up one folder" if not at_root else "Already at the root of this path (button stays visible)"
1937
+ )
1938
+ if hasattr(self, "_remote_up_btn"):
1939
+ rp = (self.remote_path or "").strip().rstrip("/") or "/"
1940
+ at_rr = rp in ("/", "")
1941
+ self._remote_up_btn.setMinimumSize(30, 28)
1942
+ self._remote_up_btn.setVisible(True)
1943
+ self._remote_up_btn.setEnabled(True)
1944
+ self._remote_up_btn.setToolTip(
1945
+ "Up one folder" if not at_rr else "Already at root (/) — button stays visible"
1946
+ )
1947
+
1948
+ def go_local(self) -> None:
1949
+ p = Path(self._addr_local_text() or self.local_path)
1950
+ if p.exists() and p.is_dir():
1951
+ self.local_path = str(p.resolve())
1952
+ self._set_local_address(self.local_path)
1953
+ self.refresh_local()
1954
+
1955
+ def local_up(self) -> None:
1956
+ p = Path(self.local_path).parent
1957
+ if p != Path(self.local_path):
1958
+ self.local_path = str(p)
1959
+ self._set_local_address(self.local_path)
1960
+ self.refresh_local()
1961
+
1962
+ def refresh_local(self) -> None:
1963
+ self.local_table.setSortingEnabled(False)
1964
+ self.local_table.setRowCount(0)
1965
+ current = Path(self._addr_local_text() or self.local_path)
1966
+ if not current.exists() or not current.is_dir():
1967
+ self.local_table.setSortingEnabled(True)
1968
+ return
1969
+ self.local_path = str(current.resolve())
1970
+ self._set_local_address(self.local_path)
1971
+ rows = []
1972
+ parent = current.parent if current.parent != current else current
1973
+ rows.append((Path(parent), "Parent"))
1974
+ try:
1975
+ scan_rows: List[Path] = []
1976
+ with os.scandir(str(current)) as it:
1977
+ for ent in it:
1978
+ try:
1979
+ scan_rows.append(Path(ent.path))
1980
+ except OSError:
1981
+ continue
1982
+ for child in sorted(scan_rows, key=lambda p: (not p.is_dir(), p.name.lower())):
1983
+ rows.append((child, _local_type_label(child)))
1984
+ except OSError:
1985
+ pass
1986
+ self.local_table.setRowCount(len(rows))
1987
+ for i, (child, typ) in enumerate(rows):
1988
+ if typ == "Parent":
1989
+ icon = self.style().standardIcon(QStyle.SP_FileDialogToParent)
1990
+ name_item = _SortTableItem("..", icon, sort_key=(-2,))
1991
+ size_item = _SortTableItem("—", sort_key=(-1, 0))
1992
+ mtime_item = _SortTableItem("", sort_key=-1.0)
1993
+ type_item = _SortTableItem("Folder", sort_key=(-1, "folder"))
1994
+ self.local_table.setItem(i, 0, name_item)
1995
+ self.local_table.setItem(i, 1, size_item)
1996
+ self.local_table.setItem(i, 2, mtime_item)
1997
+ self.local_table.setItem(i, 3, type_item)
1998
+ self.local_table.item(i, 0).setData(Qt.UserRole, str(child))
1999
+ continue
2000
+ icon = _icon_for_local_path(child, self.icon_provider, self.style())
2001
+ name_item = _SortTableItem(child.name, icon, sort_key=_local_name_sort_key(child))
2002
+ name_item.setTextAlignment(Qt.AlignVCenter | Qt.AlignLeft)
2003
+ self.local_table.setItem(i, 0, name_item)
2004
+ self.local_table.setItem(
2005
+ i, 1, _SortTableItem(_fmt_local_listing_size(child), sort_key=_local_size_sort_key(child))
2006
+ )
2007
+ self.local_table.setItem(
2008
+ i,
2009
+ 2,
2010
+ _SortTableItem(
2011
+ _fmt_mtime(child),
2012
+ sort_key=_local_mtime_sort_key(child),
2013
+ ),
2014
+ )
2015
+ self.local_table.setItem(i, 3, _SortTableItem(typ, sort_key=_local_type_sort_key(child)))
2016
+ self.local_table.item(i, 0).setData(Qt.UserRole, str(child))
2017
+ self.local_table.setSortingEnabled(True)
2018
+ self._last_refresh_note = datetime.now().strftime("%H:%M:%S")
2019
+ self._update_nav_buttons()
2020
+ self._notify_parent_status()
2021
+ self._notify_refresh_flash("Local")
2022
+
2023
+ def _notify_parent_status(self) -> None:
2024
+ w = self.parent()
2025
+ while w:
2026
+ if hasattr(w, "_update_status_bar"):
2027
+ w._update_status_bar()
2028
+ break
2029
+ w = w.parent()
2030
+
2031
+ def _notify_refresh_flash(self, side: str) -> None:
2032
+ ts = datetime.now().strftime("%H:%M:%S")
2033
+ w = self.parent()
2034
+ while w:
2035
+ if hasattr(w, "_flash_refresh_banner"):
2036
+ w._flash_refresh_banner(side, ts)
2037
+ break
2038
+ w = w.parent()
2039
+
2040
+ def _report_remote_error(self, msg: str) -> None:
2041
+ txt = (msg or "").strip()
2042
+ if not txt:
2043
+ return
2044
+ self._log(f"Explorer remote error: {txt}")
2045
+ if "permission denied" in txt.lower():
2046
+ key = f"{self.kind}:{self.remote_path}:{txt}"
2047
+ if key != self._last_error_popup_key:
2048
+ self._last_error_popup_key = key
2049
+ QMessageBox.warning(self, "Remote permission denied", txt)
2050
+
2051
+ def on_local_activated(self, item) -> None:
2052
+ """Double-click or Enter: enter folders; open files with the default app (single click only selects)."""
2053
+ it = self.local_table.item(item.row(), 0)
2054
+ if not it:
2055
+ return
2056
+ path = it.data(Qt.UserRole)
2057
+ if not path:
2058
+ return
2059
+ p = Path(path)
2060
+ if p.is_dir():
2061
+ self.local_path = str(p)
2062
+ self._set_local_address(str(p))
2063
+ self.refresh_local()
2064
+ elif p.is_file():
2065
+ self.local_table.selectRow(item.row())
2066
+ self._launch_local_file(p)
2067
+
2068
+ def go_remote(self) -> None:
2069
+ self.remote_path = self._addr_remote_text() or self.remote_path
2070
+ self._set_remote_address(self.remote_path)
2071
+ self.refresh_remote()
2072
+
2073
+ def remote_up(self) -> None:
2074
+ rp = (self.remote_path or "").strip().rstrip("/") or "/"
2075
+ if rp in ("", "/"):
2076
+ return
2077
+ self.remote_path = posixpath.dirname(rp) or "/"
2078
+ self._set_remote_address(self.remote_path)
2079
+ self.refresh_remote()
2080
+
2081
+ def remote_root(self) -> None:
2082
+ self.remote_path = "/"
2083
+ self._set_remote_address(self.remote_path)
2084
+ self.refresh_remote()
2085
+
2086
+ def refresh_remote(self) -> None:
2087
+ self.remote_path = self._addr_remote_text() or self.remote_path
2088
+ self._set_remote_address(self.remote_path)
2089
+ if self.kind == "adb":
2090
+ self._refresh_adb()
2091
+ elif self.kind == "sftp":
2092
+ self._refresh_sftp()
2093
+ else:
2094
+ self._refresh_ftp()
2095
+ self._last_refresh_note = datetime.now().strftime("%H:%M:%S")
2096
+ self._update_nav_buttons()
2097
+ self._update_explorer_header()
2098
+ self._notify_parent_status()
2099
+ self._notify_refresh_flash("Remote")
2100
+
2101
+ def _refresh_adb(self) -> None:
2102
+ serial = _first_serial_token(self.get_device_serial()) or _first_serial_token(self._session_adb_serial)
2103
+ if not serial:
2104
+ self.remote_table.setRowCount(0)
2105
+ self._adb_last_error = ""
2106
+ return
2107
+ rp = (self.remote_path or "").strip() or "/"
2108
+ parent = posixpath.dirname(rp.rstrip("/")) or "/"
2109
+ rows: List[tuple] = []
2110
+ rows.append((RemoteItem("..", True, "", "", "", "", ""), parent))
2111
+ # Android shells vary; running a single shell command string is more compatible for paths with spaces.
2112
+ safe_rp = rp.replace("\\", "\\\\").replace('"', '\\"')
2113
+ code, stdout, stderr = run_adb(
2114
+ self.get_adb_path(),
2115
+ [*self._adb_prefix(), "shell", f'ls -la "{safe_rp}"'],
2116
+ )
2117
+ if code != 0:
2118
+ _fill_remote_table(self.remote_table, rows, self.style(), self.icon_provider)
2119
+ self._adb_last_error = stderr.strip() or "ADB list failed."
2120
+ self._report_remote_error(self._adb_last_error)
2121
+ return
2122
+ self._adb_last_error = ""
2123
+ self._last_error_popup_key = ""
2124
+ for line in stdout.splitlines():
2125
+ parsed = _parse_ls_line(line)
2126
+ if not parsed:
2127
+ continue
2128
+ rows.append((parsed, f"{rp.rstrip('/')}/{parsed.name}".replace("//", "/")))
2129
+ _fill_remote_table(self.remote_table, rows, self.style(), self.icon_provider)
2130
+
2131
+ def _refresh_sftp(self) -> None:
2132
+ if self._sftp_client is None:
2133
+ self.remote_table.setRowCount(0)
2134
+ self._sftp_last_error = ""
2135
+ return
2136
+ path = self.remote_path.rstrip("/") or "/"
2137
+ try:
2138
+ attrs = self._sftp_client.listdir_attr(path)
2139
+ except Exception as exc:
2140
+ self.remote_table.setRowCount(0)
2141
+ self._sftp_last_error = str(exc)
2142
+ self._report_remote_error(self._sftp_last_error)
2143
+ return
2144
+ self._sftp_last_error = ""
2145
+ self._last_error_popup_key = ""
2146
+ rows: List[tuple] = []
2147
+ parent = posixpath.dirname(path) or "/"
2148
+ rows.append((RemoteItem("..", True, "", "", "", "", ""), parent))
2149
+ from datetime import datetime
2150
+
2151
+ for a in sorted(attrs, key=lambda x: (not S_ISDIR(x.st_mode), x.filename.lower())):
2152
+ name = a.filename
2153
+ if name in (".", ".."):
2154
+ continue
2155
+ is_dir = S_ISDIR(a.st_mode)
2156
+ full = posixpath.join(path, name).replace("\\", "/")
2157
+ perm = oct(a.st_mode)[-4:] if a.st_mode else ""
2158
+ sz = str(a.st_size)
2159
+ mt = ""
2160
+ if a.st_mtime:
2161
+ mt = datetime.fromtimestamp(a.st_mtime).strftime("%Y-%m-%d %H:%M")
2162
+ rows.append((RemoteItem(name, is_dir, perm, "", "", sz, mt), full))
2163
+ _fill_remote_table(self.remote_table, rows, self.style(), self.icon_provider)
2164
+
2165
+ def _refresh_ftp(self) -> None:
2166
+ if self._ftp_client is None:
2167
+ self.remote_table.setRowCount(0)
2168
+ self._ftp_last_error = ""
2169
+ return
2170
+ rp = self.remote_path.rstrip("/") or "/"
2171
+ try:
2172
+ self._ftp_client.cwd(rp)
2173
+ except error_perm as exc:
2174
+ self.remote_table.setRowCount(0)
2175
+ self._ftp_last_error = str(exc)
2176
+ self._report_remote_error(self._ftp_last_error)
2177
+ return
2178
+ self._ftp_last_error = ""
2179
+ self._last_error_popup_key = ""
2180
+ rows: List[tuple] = []
2181
+ parent = posixpath.dirname(rp) or "/"
2182
+ rows.append((RemoteItem("..", True, "", "", "", "", ""), parent))
2183
+ try:
2184
+ for name, facts in self._ftp_client.mlsd():
2185
+ if name in (".", ".."):
2186
+ continue
2187
+ is_dir = facts.get("type") == "dir"
2188
+ full = posixpath.join(rp, name).replace("\\", "/")
2189
+ rows.append((RemoteItem(name, is_dir, "", "", "", "<DIR>" if is_dir else "?", ""), full))
2190
+ except (error_perm, AttributeError):
2191
+ for name in self._ftp_client.nlst():
2192
+ if name in (".", ".."):
2193
+ continue
2194
+ full = posixpath.join(rp, name).replace("\\", "/")
2195
+ rows.append((RemoteItem(name, False, "", "", "", "?", ""), full))
2196
+ _fill_remote_table(self.remote_table, rows, self.style(), self.icon_provider)
2197
+
2198
+ def on_remote_activated(self, item) -> None:
2199
+ """Double-click or Enter: enter directories; open files with default app (single click only selects)."""
2200
+ it = self.remote_table.item(item.row(), 0)
2201
+ if not it:
2202
+ return
2203
+ info = it.data(Qt.UserRole) or {}
2204
+ if info.get("is_dir"):
2205
+ self.remote_path = info["path"]
2206
+ self._set_remote_address(self.remote_path)
2207
+ self.refresh_remote()
2208
+ else:
2209
+ name_it = self.remote_table.item(item.row(), 0)
2210
+ if name_it and name_it.text() == "..":
2211
+ return
2212
+ self.remote_table.selectRow(item.row())
2213
+ self._open_remote_default_app(info["path"])
2214
+
2215
+ def status_line(self) -> str:
2216
+ ln = self.local_table.rowCount()
2217
+ rn = self.remote_table.rowCount()
2218
+ err = self._adb_last_error or self._sftp_last_error or self._ftp_last_error
2219
+ base = f"{self.kind.upper()} · Local: {ln} · Remote: {rn} @ {self.remote_path}"
2220
+ if self._last_refresh_note:
2221
+ base += f" · Refreshed {self._last_refresh_note}"
2222
+ return f"{base} · {err}" if err else base
2223
+
2224
+ def _drop_local_paths_push(self, paths: List[str]) -> None:
2225
+ items: List[str] = []
2226
+ for p in paths:
2227
+ if not p:
2228
+ continue
2229
+ lp = Path(p)
2230
+ if lp.is_file() or lp.is_dir():
2231
+ items.append(p)
2232
+ if not items:
2233
+ QMessageBox.information(self, "Push", "Drop one or more files or folders.")
2234
+ return
2235
+ self.push_paths(items)
2236
+
2237
+ def find_in_local(self) -> None:
2238
+ dlg = FindFilesDialog(self, "local", self)
2239
+ dlg.exec_()
2240
+
2241
+ def find_in_remote(self) -> None:
2242
+ dlg = FindFilesDialog(self, "remote", self)
2243
+ dlg.exec_()
2244
+
2245
+ def new_local_file(self) -> None:
2246
+ name, ok = QInputDialog.getText(self, "New file", "File name:")
2247
+ if not ok or not name.strip():
2248
+ return
2249
+ p = Path(self.local_path) / name.strip()
2250
+ if p.exists():
2251
+ QMessageBox.warning(self, "New file", "A file or folder with that name already exists.")
2252
+ return
2253
+ try:
2254
+ p.touch()
2255
+ self.refresh_local()
2256
+ except OSError as exc:
2257
+ QMessageBox.warning(self, "New file", str(exc))
2258
+
2259
+ def new_local_folder(self) -> None:
2260
+ name, ok = QInputDialog.getText(self, "New folder", "Folder name:")
2261
+ if not ok or not name.strip():
2262
+ return
2263
+ p = Path(self.local_path) / name.strip()
2264
+ if p.exists():
2265
+ QMessageBox.warning(self, "New folder", "A file or folder with that name already exists.")
2266
+ return
2267
+ try:
2268
+ p.mkdir(parents=True)
2269
+ self.refresh_local()
2270
+ except OSError as exc:
2271
+ QMessageBox.warning(self, "New folder", str(exc))
2272
+
2273
+ def edit_local(self) -> None:
2274
+ path = self._selected_local()
2275
+ if not path:
2276
+ QMessageBox.information(self, "Edit", "Select a local file first.")
2277
+ return
2278
+ p = Path(path)
2279
+ if p.is_dir():
2280
+ QMessageBox.information(self, "Edit", "Select a file, not a folder.")
2281
+ return
2282
+ if not p.is_file():
2283
+ return
2284
+ try:
2285
+ sz = p.stat().st_size
2286
+ except OSError:
2287
+ sz = 0
2288
+ if sz > _MAX_INLINE_EDITOR_BYTES:
2289
+ QMessageBox.information(
2290
+ self,
2291
+ "Edit",
2292
+ f"This file is large ({_human_bytes(sz)}). Opening in external app for responsiveness.",
2293
+ )
2294
+ self._launch_local_file(p)
2295
+ return
2296
+ PlainTextEditorDialog(self, p).exec_()
2297
+ self.refresh_local()
2298
+
2299
+ def open_local(self) -> None:
2300
+ path = self._selected_local()
2301
+ if not path:
2302
+ QMessageBox.information(self, "Open", "Select a local file first.")
2303
+ return
2304
+ p = Path(path)
2305
+ if p.is_dir():
2306
+ QMessageBox.information(self, "Open", "Select a file, not a folder.")
2307
+ return
2308
+ if not p.is_file():
2309
+ return
2310
+ self._launch_local_file(p)
2311
+
2312
+ def _launch_local_file(self, p: Path) -> None:
2313
+ if not p.is_file():
2314
+ return
2315
+ url = QUrl.fromLocalFile(str(p.resolve()))
2316
+ if not QDesktopServices.openUrl(url):
2317
+ QMessageBox.warning(
2318
+ self,
2319
+ "Open",
2320
+ "No default application is associated with this file.\n"
2321
+ "Use Edit for the built-in text editor.",
2322
+ )
2323
+
2324
+ def open_remote(self) -> None:
2325
+ info = self._selected_remote()
2326
+ if not info:
2327
+ QMessageBox.information(self, "Open", "Select a remote file first.")
2328
+ return
2329
+ if info.get("is_dir"):
2330
+ QMessageBox.information(self, "Open", "Select a file, not a folder.")
2331
+ return
2332
+ name_it = self.remote_table.item(self.remote_table.currentRow(), 0)
2333
+ if name_it and name_it.text() == "..":
2334
+ return
2335
+ self._open_remote_default_app(info["path"])
2336
+
2337
+ def delete_local(self) -> None:
2338
+ path = self._selected_local()
2339
+ if not path:
2340
+ QMessageBox.information(self, "Delete", "Select a local file or folder first.")
2341
+ return
2342
+ p = Path(path)
2343
+ if not p.exists():
2344
+ return
2345
+ if QMessageBox.question(self, "Delete", f"Delete?\n{p}") != QMessageBox.Yes:
2346
+ return
2347
+ self._log(f"Explorer: delete local {p}")
2348
+ dlg = QProgressDialog("Deleting…", None, 0, 0, self)
2349
+ dlg.setCancelButton(None)
2350
+ dlg.setMinimumDuration(0)
2351
+ dlg.setWindowModality(Qt.ApplicationModal)
2352
+ QApplication.processEvents()
2353
+ try:
2354
+ if p.is_dir():
2355
+ shutil.rmtree(p)
2356
+ else:
2357
+ p.unlink()
2358
+ dlg.close()
2359
+ self._log("Explorer: local delete completed.")
2360
+ self.refresh_local()
2361
+ except OSError as exc:
2362
+ dlg.close()
2363
+ QMessageBox.warning(self, "Delete", str(exc))
2364
+
2365
+ def local_properties(self) -> None:
2366
+ path = self._selected_local()
2367
+ if not path:
2368
+ QMessageBox.information(self, "Properties", "Select a local item first.")
2369
+ return
2370
+ p = Path(path)
2371
+ try:
2372
+ st = p.stat()
2373
+ rows: List[Tuple[str, str]] = [
2374
+ ("Path", str(p.resolve())),
2375
+ ("Name", p.name),
2376
+ (
2377
+ "Type",
2378
+ "Directory"
2379
+ if p.is_dir()
2380
+ else ("Symbolic link" if p.is_symlink() else "File"),
2381
+ ),
2382
+ ]
2383
+ if p.is_file():
2384
+ rows.append(("Size", _human_bytes(st.st_size)))
2385
+ rows.append(("Size (bytes)", str(st.st_size)))
2386
+ elif p.is_dir():
2387
+ try:
2388
+ n = sum(1 for _ in p.iterdir())
2389
+ rows.append(("Immediate children", str(n)))
2390
+ except OSError:
2391
+ pass
2392
+ ds = _dir_size_walk(p)
2393
+ if ds is None:
2394
+ rows.append(("Total size (folder tree)", "Not computed (too many entries)"))
2395
+ else:
2396
+ rows.append(("Total size (folder tree)", _human_bytes(ds)))
2397
+ rows.append(("Modified", datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S")))
2398
+ rows.append(
2399
+ (
2400
+ "Metadata changed",
2401
+ datetime.fromtimestamp(getattr(st, "st_ctime", st.st_mtime)).strftime("%Y-%m-%d %H:%M:%S"),
2402
+ )
2403
+ )
2404
+ rows.append(("Mode", oct(st.st_mode)))
2405
+ _show_properties_dialog(self, "Properties — local", rows)
2406
+ except OSError as exc:
2407
+ QMessageBox.warning(self, "Properties", str(exc))
2408
+
2409
+ def remote_properties(self) -> None:
2410
+ info = self._selected_remote()
2411
+ if not info:
2412
+ QMessageBox.information(self, "Properties", "Select a remote item first.")
2413
+ return
2414
+ name_it = self.remote_table.item(self.remote_table.currentRow(), 0)
2415
+ if name_it and name_it.text() == "..":
2416
+ return
2417
+ rp = info.get("path", "")
2418
+ rows: List[Tuple[str, str]] = [
2419
+ ("Path", rp),
2420
+ ("Name", Path(rp).name if rp else ""),
2421
+ ("Type", "Directory" if info.get("is_dir") else "File"),
2422
+ ]
2423
+ if self.kind == "adb":
2424
+ code, out, err = run_adb(
2425
+ self.get_adb_path(), [*self._adb_prefix(), "shell", "ls", "-ld", rp], timeout=25
2426
+ )
2427
+ rows.append(("ls -ld", (out or err or "").strip() or "(no output)"))
2428
+ _, out2, err2 = run_adb(
2429
+ self.get_adb_path(), [*self._adb_prefix(), "shell", "stat", rp], timeout=25
2430
+ )
2431
+ stat_txt = (out2 or err2 or "").strip()
2432
+ rows.append(("stat", stat_txt if stat_txt else "(no output)"))
2433
+ elif self.kind == "sftp" and self._sftp_client:
2434
+ try:
2435
+ st = self._sftp_client.stat(rp)
2436
+ rows.append(("Size", _human_bytes(st.st_size)))
2437
+ rows.append(("Size (bytes)", str(st.st_size)))
2438
+ rows.append(("Modified", datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S")))
2439
+ rows.append(("Mode", oct(st.st_mode)))
2440
+ rows.append(("UID", str(getattr(st, "st_uid", "?"))))
2441
+ rows.append(("GID", str(getattr(st, "st_gid", "?"))))
2442
+ except Exception as exc:
2443
+ rows.append(("Error", str(exc)))
2444
+ elif self.kind == "ftp" and self._ftp_client:
2445
+ rows.append(("Note", "Detailed attributes depend on server LIST/MLSD support."))
2446
+ it = self.remote_table.item(self.remote_table.currentRow(), 1)
2447
+ itp = self.remote_table.item(self.remote_table.currentRow(), 2)
2448
+ itd = self.remote_table.item(self.remote_table.currentRow(), 3)
2449
+ if it:
2450
+ rows.append(("Size (listed)", it.text()))
2451
+ if itp:
2452
+ rows.append(("Permissions (listed)", itp.text()))
2453
+ if itd:
2454
+ rows.append(("Date modified (listed)", itd.text()))
2455
+ _show_properties_dialog(self, "Properties — remote", rows)
2456
+
2457
+ def new_remote_file(self) -> None:
2458
+ name, ok = QInputDialog.getText(self, "New file", "File name:")
2459
+ if not ok or not name.strip():
2460
+ return
2461
+ base = self.remote_path.rstrip("/") or "/"
2462
+ path = posixpath.join(base, name.strip()).replace("\\", "/")
2463
+
2464
+ if self.kind == "adb":
2465
+ code, _, stderr = run_adb(self.get_adb_path(), [*self._adb_prefix(), "shell", "touch", path])
2466
+ if code == 0:
2467
+ self.refresh_remote()
2468
+ else:
2469
+ QMessageBox.warning(self, "New file", stderr or "Failed.")
2470
+ return
2471
+ if self.kind == "sftp" and self._sftp_client:
2472
+ try:
2473
+ self._sftp_client.putfo(io.BytesIO(b""), path)
2474
+ self.refresh_remote()
2475
+ except Exception as exc:
2476
+ QMessageBox.warning(self, "New file", str(exc))
2477
+ return
2478
+ if self.kind == "ftp" and self._ftp_client:
2479
+ try:
2480
+ self._ftp_client.cwd(self.remote_path)
2481
+ self._ftp_client.storbinary(f"STOR {name.strip()}", io.BytesIO(b""))
2482
+ self.refresh_remote()
2483
+ except Exception as exc:
2484
+ QMessageBox.warning(self, "New file", str(exc))
2485
+
2486
+ def edit_remote(self) -> None:
2487
+ info = self._selected_remote()
2488
+ if not info:
2489
+ QMessageBox.information(self, "Edit", "Select a remote file first.")
2490
+ return
2491
+ if info.get("is_dir"):
2492
+ QMessageBox.information(self, "Edit", "Select a file, not a folder.")
2493
+ return
2494
+ name_it = self.remote_table.item(self.remote_table.currentRow(), 0)
2495
+ if name_it and name_it.text() == "..":
2496
+ return
2497
+ self._open_remote_editor(info["path"])
2498
+
2499
+ def _upload_local_to_remote(self, local_fs_path: str, remote_file_path: str, *, quiet: bool = False) -> bool:
2500
+ if self.kind == "adb":
2501
+ self._log(f"Explorer: push (save) → {remote_file_path}")
2502
+ _ADB_STAGE_MAX = 128 * 1024 * 1024
2503
+ try:
2504
+ sz = os.path.getsize(local_fs_path)
2505
+ except OSError as exc:
2506
+ if not quiet:
2507
+ QMessageBox.warning(self, "Save to device", str(exc))
2508
+ self._log(f"Explorer: push stat failed: {exc}")
2509
+ return False
2510
+ if sz > _ADB_STAGE_MAX:
2511
+ code, msg = self._adb_progress_exec(
2512
+ "Push to device",
2513
+ [*self._adb_prefix(), "push", local_fs_path, remote_file_path],
2514
+ timeout=600,
2515
+ quiet=quiet,
2516
+ )
2517
+ else:
2518
+ attempts = 28 if quiet else 14
2519
+ data = _read_file_bytes_with_retry(local_fs_path, attempts=attempts, delay=0.3)
2520
+ if data is None:
2521
+ if not quiet:
2522
+ QMessageBox.warning(
2523
+ self,
2524
+ "Save to device",
2525
+ "Could not read the file (it may be locked by another program). "
2526
+ "Close the other app or save, then retry.",
2527
+ )
2528
+ self._log("Explorer: push aborted — could not read local file after retries.")
2529
+ return False
2530
+ fd, tmp = tempfile.mkstemp(prefix="dd_adbpush_", dir=tempfile.gettempdir())
2531
+ try:
2532
+ with os.fdopen(fd, "wb") as f:
2533
+ f.write(data)
2534
+ try:
2535
+ os.chmod(tmp, 0o600)
2536
+ except OSError:
2537
+ pass
2538
+ code, msg = self._adb_progress_exec(
2539
+ "Push to device",
2540
+ [*self._adb_prefix(), "push", tmp, remote_file_path],
2541
+ timeout=600,
2542
+ quiet=quiet,
2543
+ )
2544
+ finally:
2545
+ try:
2546
+ os.unlink(tmp)
2547
+ except OSError:
2548
+ pass
2549
+ if code != 0:
2550
+ if not quiet:
2551
+ QMessageBox.warning(self, "Save to device", msg or "Push failed.")
2552
+ self._log(f"Explorer: push failed ({code}) {msg[:300]}")
2553
+ return False
2554
+ self._log("Explorer: push (save) completed.")
2555
+ return True
2556
+ if self.kind == "sftp" and self._sftp_client:
2557
+ try:
2558
+ if quiet:
2559
+ self._sftp_client.put(local_fs_path, remote_file_path)
2560
+ self._log(f"Explorer: SFTP uploaded {remote_file_path}")
2561
+ return True
2562
+ try:
2563
+ total = max(os.path.getsize(local_fs_path), 1)
2564
+ except OSError:
2565
+ total = 1
2566
+ dlg = QProgressDialog("Uploading to server…", None, 0, 100, self)
2567
+ dlg.setCancelButton(None)
2568
+ dlg.setMinimumDuration(0)
2569
+ dlg.setWindowModality(Qt.ApplicationModal)
2570
+
2571
+ def _cb(sent: int, size: int) -> None:
2572
+ if size:
2573
+ dlg.setValue(min(99, int(100 * sent / max(size, 1))))
2574
+ QApplication.processEvents()
2575
+
2576
+ self._sftp_client.put(local_fs_path, remote_file_path, callback=_cb)
2577
+ dlg.setValue(100)
2578
+ dlg.close()
2579
+ self._log(f"Explorer: SFTP uploaded {remote_file_path}")
2580
+ return True
2581
+ except Exception as exc:
2582
+ if not quiet:
2583
+ QMessageBox.warning(self, "Save to remote", str(exc))
2584
+ self._log(f"Explorer: SFTP upload error: {exc}")
2585
+ return False
2586
+ if self.kind == "ftp" and self._ftp_client:
2587
+ try:
2588
+ if quiet:
2589
+ parent = posixpath.dirname(remote_file_path) or "/"
2590
+ fn = posixpath.basename(remote_file_path)
2591
+ self._ftp_client.cwd(parent)
2592
+ with open(local_fs_path, "rb") as f:
2593
+ self._ftp_client.storbinary(f"STOR {fn}", f, blocksize=65536)
2594
+ self._log(f"Explorer: FTP uploaded {remote_file_path}")
2595
+ return True
2596
+ total = max(os.path.getsize(local_fs_path), 1)
2597
+ dlg = QProgressDialog("Uploading (FTP)…", None, 0, 100, self)
2598
+ dlg.setCancelButton(None)
2599
+ dlg.setMinimumDuration(0)
2600
+ dlg.setWindowModality(Qt.ApplicationModal)
2601
+ sent = [0]
2602
+
2603
+ def _dcb(block: bytes) -> None:
2604
+ sent[0] += len(block)
2605
+ dlg.setValue(min(99, int(100 * sent[0] / total)))
2606
+ QApplication.processEvents()
2607
+
2608
+ parent = posixpath.dirname(remote_file_path) or "/"
2609
+ fn = posixpath.basename(remote_file_path)
2610
+ self._ftp_client.cwd(parent)
2611
+ with open(local_fs_path, "rb") as f:
2612
+ self._ftp_client.storbinary(f"STOR {fn}", f, blocksize=65536, callback=_dcb)
2613
+ dlg.setValue(100)
2614
+ dlg.close()
2615
+ self._log(f"Explorer: FTP uploaded {remote_file_path}")
2616
+ return True
2617
+ except Exception as exc:
2618
+ if not quiet:
2619
+ QMessageBox.warning(self, "Save to remote", str(exc))
2620
+ return False
2621
+ return False
2622
+
2623
+ def _norm_local_path(self, path: str) -> str:
2624
+ return os.path.normcase(os.path.normpath(path))
2625
+
2626
+ def _stable_open_cache_path(self, remote_path: str) -> str:
2627
+ """Same local path for each remote file so re-open always pulls/overwrites one cache (device stays in sync after upload)."""
2628
+ serial = _first_serial_token(self.get_device_serial()) or _first_serial_token(self._session_adb_serial) or "default"
2629
+ key = f"{self.kind}|{serial}|{remote_path}"
2630
+ h = hashlib.sha256(key.encode("utf-8")).hexdigest()[:24]
2631
+ root = Path(tempfile.gettempdir()) / "device_deck_open_cache" / h
2632
+ root.mkdir(parents=True, exist_ok=True)
2633
+ return str(root / posixpath.basename(remote_path))
2634
+
2635
+ def _register_external_open_sync(self, local_path: str, remote_path: str) -> None:
2636
+ """Watch file and its folder (atomic saves); push back to `remote_path` on change."""
2637
+ key = self._norm_local_path(local_path)
2638
+ self._ext_sync_remote[key] = remote_path
2639
+ if self._ext_fs_watcher is None:
2640
+ self._ext_fs_watcher = QFileSystemWatcher(self)
2641
+ self._ext_fs_watcher.fileChanged.connect(self._on_external_open_file_changed)
2642
+ self._ext_fs_watcher.directoryChanged.connect(self._on_external_dir_changed)
2643
+ for p in (local_path, str(Path(local_path).parent)):
2644
+ if p not in self._ext_fs_watcher.files():
2645
+ self._ext_fs_watcher.addPath(p)
2646
+
2647
+ def _readd_external_watch_paths(self, local_path: str) -> None:
2648
+ if self._ext_fs_watcher is None:
2649
+ return
2650
+ for p in (local_path, str(Path(local_path).parent)):
2651
+ if os.path.exists(p) and p not in self._ext_fs_watcher.files():
2652
+ self._ext_fs_watcher.addPath(p)
2653
+
2654
+ def _on_external_dir_changed(self, dir_path: str) -> None:
2655
+ """Some apps save via temp file then rename in the same folder."""
2656
+ nd = self._norm_local_path(dir_path)
2657
+ for key, remote in list(self._ext_sync_remote.items()):
2658
+ if self._norm_local_path(str(Path(key).parent)) != nd:
2659
+ continue
2660
+ QTimer.singleShot(500, lambda k=key, r=remote: self._schedule_external_sync(k, r))
2661
+
2662
+ def _schedule_external_sync(self, path: str, remote: str) -> None:
2663
+ key = self._norm_local_path(path)
2664
+ old_t = self._ext_sync_timers.pop(key, None)
2665
+ if old_t is not None:
2666
+ old_t.stop()
2667
+ old_t.deleteLater()
2668
+ t = QTimer(self)
2669
+ t.setSingleShot(True)
2670
+ t.setInterval(2000)
2671
+ t.timeout.connect(lambda: self._sync_external_file_if_ready(path, remote, 0))
2672
+ self._ext_sync_timers[key] = t
2673
+ t.start()
2674
+
2675
+ def _poll_external_mtime(self) -> None:
2676
+ """Fallback when editors do not trigger QFileSystemWatcher reliably."""
2677
+ if not self._ext_sync_remote:
2678
+ return
2679
+ for key, remote in list(self._ext_sync_remote.items()):
2680
+ if not os.path.isfile(key):
2681
+ continue
2682
+ try:
2683
+ m = os.path.getmtime(key)
2684
+ except OSError:
2685
+ continue
2686
+ nk = self._norm_local_path(key)
2687
+ prev = self._ext_last_mtime.get(nk)
2688
+ if prev is None:
2689
+ self._ext_last_mtime[nk] = m
2690
+ continue
2691
+ if m > prev + 0.5:
2692
+ self._ext_last_mtime[nk] = m
2693
+ self._schedule_external_sync(key, remote)
2694
+
2695
+ def _sync_external_file_if_ready(self, path: str, remote: str, attempt: int = 0) -> None:
2696
+ if not os.path.isfile(path):
2697
+ return
2698
+ if attempt == 0:
2699
+ self._log(f"Explorer: external app saved file → syncing to remote {remote}")
2700
+ ok = self._upload_local_to_remote(path, remote, quiet=True)
2701
+ if ok:
2702
+ self.refresh_remote()
2703
+ self._select_remote_basename(posixpath.basename(remote))
2704
+ self._readd_external_watch_paths(path)
2705
+ nk = self._norm_local_path(path)
2706
+ try:
2707
+ self._ext_last_mtime[nk] = os.path.getmtime(path)
2708
+ except OSError:
2709
+ pass
2710
+ return
2711
+ if attempt < 5:
2712
+ self._log(f"Explorer: auto-sync retry {attempt + 1}/5 (editor may still have the file locked)…")
2713
+ QTimer.singleShot(1800, lambda: self._sync_external_file_if_ready(path, remote, attempt + 1))
2714
+
2715
+ def _on_external_open_file_changed(self, path: str) -> None:
2716
+ key = self._norm_local_path(path)
2717
+ remote = self._ext_sync_remote.get(key)
2718
+ if not remote:
2719
+ for k, r in self._ext_sync_remote.items():
2720
+ if self._norm_local_path(k) == key:
2721
+ key = k
2722
+ remote = r
2723
+ break
2724
+ if not remote:
2725
+ return
2726
+ old_t = self._ext_sync_timers.pop(key, None)
2727
+ if old_t is not None:
2728
+ old_t.stop()
2729
+ old_t.deleteLater()
2730
+
2731
+ def _fire():
2732
+ self._ext_sync_timers.pop(key, None)
2733
+ if not os.path.isfile(path):
2734
+ QTimer.singleShot(900, lambda: self._sync_external_file_if_ready(path, remote, 0))
2735
+ return
2736
+ self._sync_external_file_if_ready(path, remote, 0)
2737
+
2738
+ t = QTimer(self)
2739
+ t.setSingleShot(True)
2740
+ t.setInterval(2000)
2741
+ t.timeout.connect(_fire)
2742
+ self._ext_sync_timers[key] = t
2743
+ t.start()
2744
+
2745
+ def _open_remote_default_app(self, remote_path: str) -> None:
2746
+ """Download to a stable cache path and open with the default app; saves sync back automatically."""
2747
+ dest = self._stable_open_cache_path(remote_path)
2748
+ try:
2749
+ if self.kind == "adb":
2750
+ code, msg = self._adb_progress_exec(
2751
+ "Download to open",
2752
+ [*self._adb_prefix(), "pull", remote_path, dest],
2753
+ timeout=600,
2754
+ )
2755
+ if code != 0:
2756
+ QMessageBox.warning(self, "Open", msg or "Download failed.")
2757
+ return
2758
+ elif self.kind == "sftp" and self._sftp_client:
2759
+ self._sftp_get_with_progress(remote_path, dest)
2760
+ elif self.kind == "ftp" and self._ftp_client:
2761
+ parent = posixpath.dirname(remote_path) or "/"
2762
+ fn = posixpath.basename(remote_path)
2763
+ self._ftp_client.cwd(parent)
2764
+ with open(dest, "wb") as out:
2765
+ self._ftp_client.retrbinary(f"RETR {fn}", out.write)
2766
+ else:
2767
+ return
2768
+ if not os.path.isfile(dest):
2769
+ QMessageBox.warning(self, "Open", "Could not download file.")
2770
+ return
2771
+ self._register_external_open_sync(dest, remote_path)
2772
+ nk = self._norm_local_path(dest)
2773
+ try:
2774
+ self._ext_last_mtime[nk] = os.path.getmtime(dest)
2775
+ except OSError:
2776
+ pass
2777
+ url = QUrl.fromLocalFile(os.path.normpath(dest))
2778
+ if not QDesktopServices.openUrl(url):
2779
+ QMessageBox.warning(self, "Open", "No default application for this file type.")
2780
+ except Exception as exc:
2781
+ QMessageBox.warning(self, "Open", str(exc))
2782
+
2783
+ def _open_remote_editor(self, remote_path: str) -> None:
2784
+ tmpdir = tempfile.mkdtemp(prefix="rw_edit_")
2785
+ dest = os.path.join(tmpdir, posixpath.basename(remote_path))
2786
+ try:
2787
+ if self.kind == "adb":
2788
+ self._log(f"Explorer: pull for edit ← {remote_path}")
2789
+ code, msg = self._adb_progress_exec(
2790
+ "Pull for edit",
2791
+ [*self._adb_prefix(), "pull", remote_path, dest],
2792
+ timeout=600,
2793
+ )
2794
+ if code != 0:
2795
+ QMessageBox.warning(self, "Edit", msg or "Pull failed.")
2796
+ self._log(f"Explorer: pull for edit failed ({code})")
2797
+ return
2798
+ elif self.kind == "sftp" and self._sftp_client:
2799
+ self._sftp_get_with_progress(remote_path, dest)
2800
+ elif self.kind == "ftp" and self._ftp_client:
2801
+ parent = posixpath.dirname(remote_path) or "/"
2802
+ fn = posixpath.basename(remote_path)
2803
+ self._ftp_client.cwd(parent)
2804
+ with open(dest, "wb") as out:
2805
+ self._ftp_client.retrbinary(f"RETR {fn}", out.write)
2806
+ else:
2807
+ return
2808
+ if not os.path.isfile(dest):
2809
+ QMessageBox.warning(self, "Edit", "Could not download file for editing.")
2810
+ return
2811
+ try:
2812
+ rsz = os.path.getsize(dest)
2813
+ except OSError:
2814
+ rsz = 0
2815
+ if rsz > _MAX_INLINE_EDITOR_BYTES:
2816
+ self._log(
2817
+ f"Explorer: remote file is large ({_human_bytes(rsz)}), opening externally for responsiveness."
2818
+ )
2819
+ self._register_external_open_sync(dest, remote_path)
2820
+ if not QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.normpath(dest))):
2821
+ QMessageBox.warning(self, "Edit", "No default application for this file type.")
2822
+ return
2823
+
2824
+ def _sync_to_remote(tmp: Path) -> bool:
2825
+ ok = self._upload_local_to_remote(str(tmp), remote_path)
2826
+ if ok:
2827
+ self.refresh_remote()
2828
+ self._select_remote_basename(posixpath.basename(remote_path))
2829
+ return ok
2830
+
2831
+ if PlainTextEditorDialog(self, Path(dest), after_save=_sync_to_remote).exec_() != QDialog.Accepted:
2832
+ return
2833
+ self.refresh_remote()
2834
+ except Exception as exc:
2835
+ QMessageBox.warning(self, "Edit", str(exc))
2836
+ finally:
2837
+ try:
2838
+ shutil.rmtree(tmpdir, ignore_errors=True)
2839
+ except OSError:
2840
+ pass
2841
+
2842
+ # --- toolbar actions (this session only) ---
2843
+ def _selected_local(self) -> Optional[str]:
2844
+ row = self.local_table.currentRow()
2845
+ if row < 0:
2846
+ return None
2847
+ it = self.local_table.item(row, 0)
2848
+ return it.data(Qt.UserRole) if it else None
2849
+
2850
+ def _selected_remote(self) -> Optional[dict]:
2851
+ row = self.remote_table.currentRow()
2852
+ if row < 0:
2853
+ return None
2854
+ it = self.remote_table.item(row, 0)
2855
+ return it.data(Qt.UserRole) if it else None
2856
+
2857
+ def _ensure_local_pull_target_or_warn(self) -> bool:
2858
+ target_dir = self.local_path
2859
+ if Path(target_dir).is_dir():
2860
+ return True
2861
+ QMessageBox.warning(
2862
+ self,
2863
+ "Pull",
2864
+ "Current local folder is not valid. Fix the path in the local address bar and refresh, then try again.",
2865
+ )
2866
+ return False
2867
+
2868
+ def _drop_remote_infos_pull(self, infos: List[dict]) -> None:
2869
+ good = [i for i in infos if isinstance(i, dict) and i.get("path")]
2870
+ if not good:
2871
+ return
2872
+ if not self._ensure_local_pull_target_or_warn():
2873
+ return
2874
+ n = len(good)
2875
+ for idx, info in enumerate(good):
2876
+ path = str(info["path"])
2877
+ pulled_name = posixpath.basename(path.rstrip("/")) or path
2878
+ last = idx == n - 1
2879
+ self._pull_single_remote_item(
2880
+ info,
2881
+ pulled_name,
2882
+ notify=last,
2883
+ select_basename=last,
2884
+ )
2885
+
2886
+ def _pull_single_remote_item(
2887
+ self,
2888
+ info: dict,
2889
+ pulled_name: str,
2890
+ *,
2891
+ notify: bool = True,
2892
+ select_basename: bool = True,
2893
+ ) -> None:
2894
+ target_dir = self.local_path
2895
+
2896
+ if info.get("is_dir"):
2897
+ if self.kind == "adb":
2898
+ self._log(f"Explorer: pull {info['path']} → {target_dir}")
2899
+ code, msg = self._adb_progress_exec(
2900
+ "Pull from device",
2901
+ [*self._adb_prefix(), "pull", info["path"], target_dir],
2902
+ timeout=600,
2903
+ )
2904
+ if code == 0:
2905
+ self._log(f"Explorer: pull finished — {msg[:400] if msg else 'ok'}")
2906
+ self.refresh_local()
2907
+ if notify:
2908
+ self._notify_path_result(
2909
+ "Pull complete",
2910
+ f"Pulled: {info['path']}\nTo local folder:",
2911
+ target_dir,
2912
+ )
2913
+ if select_basename:
2914
+ self._select_local_basename(pulled_name)
2915
+ else:
2916
+ self._log(f"Explorer: pull failed ({code}) {msg[:400]}")
2917
+ QMessageBox.warning(self, "Pull", msg or "Failed.")
2918
+ return
2919
+ if self.kind == "sftp" and self._sftp_client:
2920
+ try:
2921
+ dlg = QProgressDialog("Downloading folder (SFTP)…", None, 0, 0, self)
2922
+ dlg.setCancelButton(None)
2923
+ dlg.setMinimumDuration(0)
2924
+ dlg.setWindowModality(Qt.ApplicationModal)
2925
+ QApplication.processEvents()
2926
+ self._sftp_pull_tree(info["path"], target_dir)
2927
+ dlg.close()
2928
+ self._log(f"Explorer: SFTP folder pull → {target_dir}")
2929
+ self.refresh_local()
2930
+ if notify:
2931
+ self._notify_path_result(
2932
+ "Pull complete",
2933
+ f"Pulled folder: {info['path']}\nTo local folder:",
2934
+ target_dir,
2935
+ )
2936
+ if select_basename:
2937
+ self._select_local_basename(pulled_name)
2938
+ except Exception as exc:
2939
+ dlg.close()
2940
+ self._log(f"Explorer: SFTP pull error: {exc}")
2941
+ QMessageBox.warning(self, "Pull", str(exc))
2942
+ return
2943
+ if self.kind == "ftp" and self._ftp_client:
2944
+ try:
2945
+ dlg = QProgressDialog("Downloading folder (FTP)…", None, 0, 0, self)
2946
+ dlg.setCancelButton(None)
2947
+ dlg.setMinimumDuration(0)
2948
+ dlg.setWindowModality(Qt.ApplicationModal)
2949
+ QApplication.processEvents()
2950
+ self._ftp_pull_tree(info["path"], target_dir)
2951
+ dlg.close()
2952
+ self._log(f"Explorer: FTP folder pull → {target_dir}")
2953
+ self.refresh_local()
2954
+ if notify:
2955
+ self._notify_path_result(
2956
+ "Pull complete",
2957
+ f"Pulled folder: {info['path']}\nTo local folder:",
2958
+ target_dir,
2959
+ )
2960
+ if select_basename:
2961
+ self._select_local_basename(pulled_name)
2962
+ except Exception as exc:
2963
+ dlg.close()
2964
+ QMessageBox.warning(self, "Pull", str(exc))
2965
+ return
2966
+ QMessageBox.information(self, "Pull", "Folder download is not available for this session type.")
2967
+ return
2968
+
2969
+ dest = str(Path(target_dir) / Path(info["path"]).name)
2970
+
2971
+ if self.kind == "adb":
2972
+ self._log(f"Explorer: pull {info['path']} → {target_dir}")
2973
+ code, msg = self._adb_progress_exec(
2974
+ "Pull from device",
2975
+ [*self._adb_prefix(), "pull", info["path"], target_dir],
2976
+ timeout=600,
2977
+ )
2978
+ if code == 0:
2979
+ self._log(f"Explorer: pull finished — {msg[:400] if msg else 'ok'}")
2980
+ self.refresh_local()
2981
+ if notify:
2982
+ self._notify_path_result(
2983
+ "Pull complete",
2984
+ f"Pulled: {info['path']}\nSaved to:",
2985
+ dest,
2986
+ )
2987
+ if select_basename:
2988
+ self._select_local_basename(pulled_name)
2989
+ else:
2990
+ self._log(f"Explorer: pull failed ({code}) {msg[:400]}")
2991
+ QMessageBox.warning(self, "Pull", msg or "Failed.")
2992
+ return
2993
+ if self.kind == "sftp" and self._sftp_client:
2994
+ try:
2995
+ self._sftp_get_with_progress(info["path"], dest)
2996
+ self._log(f"Explorer: SFTP pull saved → {dest}")
2997
+ self.refresh_local()
2998
+ if notify:
2999
+ self._notify_path_result(
3000
+ "Pull complete",
3001
+ f"Pulled: {info['path']}\nSaved to:",
3002
+ dest,
3003
+ )
3004
+ if select_basename:
3005
+ self._select_local_basename(pulled_name)
3006
+ except Exception as exc:
3007
+ self._log(f"Explorer: SFTP pull error: {exc}")
3008
+ QMessageBox.warning(self, "Pull", str(exc))
3009
+ return
3010
+ if self.kind == "ftp" and self._ftp_client:
3011
+ try:
3012
+ dlg = QProgressDialog("Downloading (FTP)…", None, 0, 0, self)
3013
+ dlg.setCancelButton(None)
3014
+ dlg.setMinimumDuration(0)
3015
+ dlg.setWindowModality(Qt.ApplicationModal)
3016
+ QApplication.processEvents()
3017
+ parent = posixpath.dirname(info["path"]) or "/"
3018
+ fn = posixpath.basename(info["path"])
3019
+ self._ftp_client.cwd(parent)
3020
+ with open(dest, "wb") as out:
3021
+ self._ftp_client.retrbinary(f"RETR {fn}", out.write)
3022
+ dlg.close()
3023
+ self._log(f"Explorer: FTP pull saved → {dest}")
3024
+ self.refresh_local()
3025
+ if notify:
3026
+ self._notify_path_result(
3027
+ "Pull complete",
3028
+ f"Pulled: {info['path']}\nSaved to:",
3029
+ dest,
3030
+ )
3031
+ if select_basename:
3032
+ self._select_local_basename(pulled_name)
3033
+ except Exception as exc:
3034
+ QMessageBox.warning(self, "Pull", str(exc))
3035
+
3036
+ def pull_selected(self) -> None:
3037
+ info = self._selected_remote()
3038
+ if not info:
3039
+ QMessageBox.information(self, "Pull", "Select a remote item first.")
3040
+ return
3041
+ name_it = self.remote_table.item(self.remote_table.currentRow(), 0)
3042
+ if name_it and name_it.text() == "..":
3043
+ return
3044
+ pulled_name = (name_it.text() or "").strip()
3045
+ if not self._ensure_local_pull_target_or_warn():
3046
+ return
3047
+ self._pull_single_remote_item(info, pulled_name)
3048
+
3049
+ def push_paths(self, paths: List[str]) -> None:
3050
+ n_ok = 0
3051
+ items: List[str] = []
3052
+ for p in paths:
3053
+ if not p:
3054
+ continue
3055
+ lp = Path(p)
3056
+ if not lp.exists():
3057
+ continue
3058
+ if lp.is_file():
3059
+ items.append(p)
3060
+ elif lp.is_dir() and self.kind in ("adb", "sftp", "ftp"):
3061
+ items.append(p)
3062
+ if not items:
3063
+ QMessageBox.information(
3064
+ self,
3065
+ "Push",
3066
+ "Nothing to push. Select file(s) or folder(s) (ADB / SFTP / FTP).",
3067
+ )
3068
+ return
3069
+ self._log(f"Explorer: push {len(items)} item(s) → {self.remote_path}")
3070
+ last_pushed = ""
3071
+ for idx, local in enumerate(items):
3072
+ lp = Path(local)
3073
+ name = lp.name
3074
+ remote_file = posixpath.join(self.remote_path.rstrip("/"), name).replace("\\", "/")
3075
+ if lp.is_dir():
3076
+ if self.kind == "adb":
3077
+ label = f"Push ({idx + 1}/{len(items)})"
3078
+ code, msg = self._adb_progress_exec(
3079
+ label,
3080
+ [*self._adb_prefix(), "push", local, self.remote_path],
3081
+ timeout=600,
3082
+ )
3083
+ if code != 0:
3084
+ self._log(f"Explorer: push failed ({code}) {msg[:300]}")
3085
+ QMessageBox.warning(self, "Push", msg or "Failed.")
3086
+ return
3087
+ n_ok += 1
3088
+ last_pushed = name
3089
+ continue
3090
+ if self.kind == "sftp" and self._sftp_client:
3091
+ try:
3092
+ dlg = QProgressDialog(f"SFTP folder upload ({idx + 1}/{len(items)})…", None, 0, 0, self)
3093
+ dlg.setCancelButton(None)
3094
+ dlg.setMinimumDuration(0)
3095
+ dlg.setWindowModality(Qt.ApplicationModal)
3096
+ QApplication.processEvents()
3097
+ if self._sftp_put_tree(lp, self.remote_path.rstrip("/")):
3098
+ n_ok += 1
3099
+ last_pushed = name
3100
+ dlg.close()
3101
+ except Exception as exc:
3102
+ dlg.close()
3103
+ self._log(f"Explorer: SFTP push error: {exc}")
3104
+ QMessageBox.warning(self, "Push", str(exc))
3105
+ return
3106
+ continue
3107
+ if self.kind == "ftp" and self._ftp_client:
3108
+ try:
3109
+ dlg = QProgressDialog(f"FTP folder upload ({idx + 1}/{len(items)})…", None, 0, 0, self)
3110
+ dlg.setCancelButton(None)
3111
+ dlg.setMinimumDuration(0)
3112
+ dlg.setWindowModality(Qt.ApplicationModal)
3113
+ QApplication.processEvents()
3114
+ if self._ftp_put_tree(lp, self.remote_path.rstrip("/")):
3115
+ n_ok += 1
3116
+ last_pushed = name
3117
+ dlg.close()
3118
+ except Exception as exc:
3119
+ dlg.close()
3120
+ QMessageBox.warning(self, "Push", str(exc))
3121
+ return
3122
+ continue
3123
+ continue
3124
+ if self.kind == "adb":
3125
+ label = f"Push ({idx + 1}/{len(items)})"
3126
+ code, msg = self._adb_progress_exec(
3127
+ label,
3128
+ [*self._adb_prefix(), "push", local, self.remote_path],
3129
+ timeout=600,
3130
+ )
3131
+ if code != 0:
3132
+ self._log(f"Explorer: push failed ({code}) {msg[:300]}")
3133
+ QMessageBox.warning(self, "Push", msg or "Failed.")
3134
+ return
3135
+ n_ok += 1
3136
+ last_pushed = name
3137
+ continue
3138
+ if self.kind == "sftp" and self._sftp_client:
3139
+ try:
3140
+ try:
3141
+ total = max(os.path.getsize(local), 1)
3142
+ except OSError:
3143
+ total = 1
3144
+ dlg = QProgressDialog(f"Upload ({idx + 1}/{len(items)})…", None, 0, 100, self)
3145
+ dlg.setCancelButton(None)
3146
+ dlg.setMinimumDuration(0)
3147
+ dlg.setWindowModality(Qt.ApplicationModal)
3148
+
3149
+ def _cb(sent: int, size: int) -> None:
3150
+ if size:
3151
+ dlg.setValue(min(99, int(100 * sent / max(size, 1))))
3152
+ QApplication.processEvents()
3153
+
3154
+ self._sftp_client.put(local, remote_file, callback=_cb)
3155
+ dlg.setValue(100)
3156
+ dlg.close()
3157
+ n_ok += 1
3158
+ last_pushed = name
3159
+ except Exception as exc:
3160
+ self._log(f"Explorer: SFTP push error: {exc}")
3161
+ QMessageBox.warning(self, "Push", str(exc))
3162
+ return
3163
+ continue
3164
+ if self.kind == "ftp" and self._ftp_client:
3165
+ try:
3166
+ total = max(os.path.getsize(local), 1)
3167
+ dlg = QProgressDialog(f"Upload FTP ({idx + 1}/{len(items)})…", None, 0, 100, self)
3168
+ dlg.setCancelButton(None)
3169
+ dlg.setMinimumDuration(0)
3170
+ dlg.setWindowModality(Qt.ApplicationModal)
3171
+ sent = [0]
3172
+
3173
+ def _dcb(block: bytes) -> None:
3174
+ sent[0] += len(block)
3175
+ dlg.setValue(min(99, int(100 * sent[0] / total)))
3176
+ QApplication.processEvents()
3177
+
3178
+ self._ftp_client.cwd(self.remote_path)
3179
+ with open(local, "rb") as f:
3180
+ self._ftp_client.storbinary(f"STOR {name}", f, blocksize=65536, callback=_dcb)
3181
+ dlg.setValue(100)
3182
+ dlg.close()
3183
+ n_ok += 1
3184
+ last_pushed = name
3185
+ except Exception as exc:
3186
+ QMessageBox.warning(self, "Push", str(exc))
3187
+ return
3188
+ if n_ok:
3189
+ self._log(
3190
+ f"Explorer: push completed ({n_ok} item(s)) from local folder '{self.local_path}' "
3191
+ f"to remote folder '{self.remote_path}'."
3192
+ )
3193
+ self.refresh_remote()
3194
+ QMessageBox.information(
3195
+ self,
3196
+ "Push",
3197
+ f"Transferred {n_ok} item(s)\nFrom: {self.local_path}\nTo: {self.remote_path}",
3198
+ )
3199
+ if last_pushed:
3200
+ self._select_remote_basename(last_pushed)
3201
+
3202
+ def push_selected(self) -> None:
3203
+ rows = sorted({i.row() for i in self.local_table.selectedItems()})
3204
+ paths = []
3205
+ for r in rows:
3206
+ it = self.local_table.item(r, 0)
3207
+ if not it:
3208
+ continue
3209
+ p = it.data(Qt.UserRole)
3210
+ if not p:
3211
+ continue
3212
+ pp = Path(p)
3213
+ if pp.is_file() or pp.is_dir():
3214
+ paths.append(p)
3215
+ if len(paths) > 1:
3216
+ self.push_paths(paths)
3217
+ return
3218
+ local = paths[0] if paths else None
3219
+ if not local:
3220
+ local, _ = QFileDialog.getOpenFileName(self, "Select file", self.local_path)
3221
+ if not local:
3222
+ return
3223
+ self.push_paths([local])
3224
+
3225
+ def delete_selected_remote(self) -> None:
3226
+ info = self._selected_remote()
3227
+ if not info:
3228
+ return
3229
+ row = self.remote_table.currentRow()
3230
+ name_it = self.remote_table.item(row, 0)
3231
+ if name_it and name_it.text() == "..":
3232
+ return
3233
+ if QMessageBox.question(self, "Delete", f"Delete?\n{info['path']}") != QMessageBox.Yes:
3234
+ return
3235
+
3236
+ dlg = QProgressDialog("Deleting…", None, 0, 0, self)
3237
+ dlg.setCancelButton(None)
3238
+ dlg.setMinimumDuration(0)
3239
+ dlg.setWindowModality(Qt.ApplicationModal)
3240
+ QApplication.processEvents()
3241
+
3242
+ if self.kind == "adb":
3243
+ self._log(f"Explorer: delete remote {info['path']}")
3244
+ flag = "-rf" if info.get("is_dir") else "-f"
3245
+ code, _, stderr = run_adb(self.get_adb_path(), [*self._adb_prefix(), "shell", "rm", flag, info["path"]])
3246
+ dlg.close()
3247
+ if code == 0:
3248
+ self._log("Explorer: delete completed.")
3249
+ self.refresh_remote()
3250
+ else:
3251
+ self._log(f"Explorer: delete failed: {stderr or code}")
3252
+ QMessageBox.warning(self, "Delete", stderr or "Failed.")
3253
+ return
3254
+ if self.kind == "sftp" and self._sftp_client:
3255
+ try:
3256
+ self._log(f"Explorer: SFTP delete {info['path']}")
3257
+ if info.get("is_dir"):
3258
+ self._sftp_client.rmdir(info["path"])
3259
+ else:
3260
+ self._sftp_client.remove(info["path"])
3261
+ dlg.close()
3262
+ self._log("Explorer: SFTP delete completed.")
3263
+ self.refresh_remote()
3264
+ except Exception as exc:
3265
+ dlg.close()
3266
+ self._log(f"Explorer: SFTP delete error: {exc}")
3267
+ QMessageBox.warning(self, "Delete", str(exc))
3268
+ return
3269
+ if self.kind == "ftp" and self._ftp_client:
3270
+ try:
3271
+ self._log(f"Explorer: FTP delete {info['path']}")
3272
+ parent = posixpath.dirname(info["path"]) or "/"
3273
+ fn = posixpath.basename(info["path"])
3274
+ self._ftp_client.cwd(parent)
3275
+ if info.get("is_dir"):
3276
+ self._ftp_client.rmd(fn)
3277
+ else:
3278
+ self._ftp_client.delete(fn)
3279
+ dlg.close()
3280
+ self.refresh_remote()
3281
+ except Exception as exc:
3282
+ dlg.close()
3283
+ QMessageBox.warning(self, "Delete", str(exc))
3284
+
3285
+ def make_remote_folder(self) -> None:
3286
+ name, ok = QInputDialog.getText(self, "New folder", "Folder name:")
3287
+ if not ok or not name.strip():
3288
+ return
3289
+ base = self.remote_path.rstrip("/") or "/"
3290
+ path = posixpath.join(base, name.strip()).replace("\\", "/")
3291
+
3292
+ if self.kind == "adb":
3293
+ adb_path = f"{self.remote_path.rstrip('/')}/{name.strip()}".replace("//", "/")
3294
+ code, _, stderr = run_adb(self.get_adb_path(), [*self._adb_prefix(), "shell", "mkdir", "-p", adb_path])
3295
+ if code == 0:
3296
+ self.refresh_remote()
3297
+ else:
3298
+ QMessageBox.warning(self, "New folder", stderr or "Failed.")
3299
+ return
3300
+ if self.kind == "sftp" and self._sftp_client:
3301
+ try:
3302
+ self._sftp_client.mkdir(path)
3303
+ self.refresh_remote()
3304
+ except Exception as exc:
3305
+ QMessageBox.warning(self, "New folder", str(exc))
3306
+ return
3307
+ if self.kind == "ftp" and self._ftp_client:
3308
+ try:
3309
+ self._ftp_client.mkd(path)
3310
+ self.refresh_remote()
3311
+ except Exception as exc:
3312
+ QMessageBox.warning(self, "New folder", str(exc))
3313
+
3314
+
3315
+ class FileExplorerTab(QWidget):
3316
+ """WinSCP-style explorer: tabbed sessions — each tab is Local | Remote (ADB or SFTP/FTP after Login)."""
3317
+
3318
+ def __init__(
3319
+ self,
3320
+ get_adb_path: Callable[[], str],
3321
+ log: Optional[Callable[[str], None]] = None,
3322
+ config: Optional[AppConfig] = None,
3323
+ on_refresh_devices: Optional[Callable[[], None]] = None,
3324
+ get_default_ssh_host: Optional[Callable[[], str]] = None,
3325
+ on_remote_session_changed: Optional[Callable[[], None]] = None,
3326
+ ):
3327
+ super().__init__()
3328
+ self.get_adb_path = get_adb_path
3329
+ self._config = config
3330
+ self._log = log or (lambda _m: None)
3331
+ self._on_refresh_devices = on_refresh_devices
3332
+ self._get_default_ssh_host = get_default_ssh_host or (lambda: "")
3333
+ self._on_remote_session_changed = on_remote_session_changed
3334
+ self._adb_serial = ""
3335
+ self._build_ui()
3336
+ self._update_status_bar()
3337
+
3338
+ def apply_session_kind(self, _kind: ConnectionKind) -> None:
3339
+ pass
3340
+
3341
+ def _current_page(self) -> Optional[ExplorerSessionPage]:
3342
+ w = self.session_tabs.currentWidget()
3343
+ return w if isinstance(w, ExplorerSessionPage) else None
3344
+
3345
+ def get_sftp_session_profile(self) -> SessionProfile:
3346
+ page = self._current_page()
3347
+ if page and page.kind == "sftp":
3348
+ return page.get_sftp_profile()
3349
+ for i in range(self.session_tabs.count()):
3350
+ w = self.session_tabs.widget(i)
3351
+ if isinstance(w, ExplorerSessionPage) and w.kind == "sftp":
3352
+ return w.get_sftp_profile()
3353
+ return SessionProfile(ConnectionKind.SSH_SFTP)
3354
+
3355
+ def disconnect_remote_services(self) -> None:
3356
+ for i in range(self.session_tabs.count()):
3357
+ w = self.session_tabs.widget(i)
3358
+ if isinstance(w, ExplorerSessionPage):
3359
+ w.disconnect_session()
3360
+
3361
+ def set_remote_device(self, serial: str) -> None:
3362
+ """Main bar device — used to pre-select the device in the Login dialog for new ADB tabs."""
3363
+ self._adb_serial = serial if serial and not serial.startswith("No ") else ""
3364
+ self._update_status_bar()
3365
+
3366
+ def get_adb_serial(self) -> str:
3367
+ return self._adb_serial
3368
+
3369
+ def _tool_btn(self, text: str, icon, slot=None, tip: str = "") -> QToolButton:
3370
+ b = QToolButton()
3371
+ b.setObjectName("WinScpMainToolBtn")
3372
+ b.setIcon(icon)
3373
+ b.setText(text)
3374
+ b.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
3375
+ b.setAutoRaise(True)
3376
+ if tip:
3377
+ b.setToolTip(tip)
3378
+ if slot:
3379
+ b.clicked.connect(slot)
3380
+ return b
3381
+
3382
+ def _vsep(self) -> QFrame:
3383
+ f = QFrame()
3384
+ f.setFrameShape(QFrame.VLine)
3385
+ f.setFrameShadow(QFrame.Sunken)
3386
+ f.setObjectName("WinScpVSep")
3387
+ return f
3388
+
3389
+ def _build_ui(self) -> None:
3390
+ root = QVBoxLayout(self)
3391
+ root.setContentsMargins(0, 0, 0, 0)
3392
+ root.setSpacing(0)
3393
+
3394
+ chrome = QWidget()
3395
+ chrome.setObjectName("ExplorerChrome")
3396
+ chrome_col = QVBoxLayout(chrome)
3397
+ chrome_col.setContentsMargins(2, 2, 2, 0)
3398
+ chrome_col.setSpacing(0)
3399
+
3400
+ main_tb = QHBoxLayout()
3401
+ main_tb.setContentsMargins(4, 0, 4, 0)
3402
+ main_tb.setSpacing(6)
3403
+ btn_new = QPushButton("New session…")
3404
+ btn_new.setObjectName("SessionStripBtn")
3405
+ btn_new.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
3406
+ btn_new.setToolTip("Add a session: SFTP, FTP, or Android (ADB)")
3407
+ btn_new.clicked.connect(self._open_login_dialog)
3408
+ main_tb.addWidget(btn_new)
3409
+ main_tb.addStretch(1)
3410
+ chrome_col.addLayout(main_tb)
3411
+
3412
+ root.addWidget(chrome)
3413
+
3414
+ self.session_tabs = QTabWidget()
3415
+ self.session_tabs.setObjectName("ExplorerSessionTabs")
3416
+ self.session_tabs.setTabsClosable(True)
3417
+ self.session_tabs.setMovable(True)
3418
+ etb = self.session_tabs.tabBar()
3419
+ etb.setElideMode(Qt.ElideNone)
3420
+ etb.setUsesScrollButtons(True)
3421
+ self.session_tabs.currentChanged.connect(lambda _i: self._update_status_bar())
3422
+ self.session_tabs.tabCloseRequested.connect(self._on_tab_close_requested)
3423
+ self._empty_state = QLabel(
3424
+ "No explorer session is open.\n\n"
3425
+ "How to start:\n"
3426
+ "1) Click New session...\n"
3427
+ "2) Select ADB / SFTP / FTP and login\n"
3428
+ "3) Local (left) and remote (right) panes will open in the session tab"
3429
+ )
3430
+ self._empty_state.setAlignment(Qt.AlignCenter)
3431
+ self._empty_state.setObjectName("ExplorerSessionHint")
3432
+ self._empty_state.setWordWrap(True)
3433
+ self._session_stack = QStackedWidget()
3434
+ self._session_stack.addWidget(self._empty_state)
3435
+ self._session_stack.addWidget(self.session_tabs)
3436
+ root.addWidget(self._session_stack, 1)
3437
+
3438
+ self.status_bar = QFrame()
3439
+ self.status_bar.setObjectName("WinScpStatusBar")
3440
+ sl = QHBoxLayout(self.status_bar)
3441
+ sl.setContentsMargins(6, 3, 6, 3)
3442
+ self.status_label = QLabel(f"{APP_TITLE} — add a session or pick an open tab.")
3443
+ self.status_label.setObjectName("WinScpStatusText")
3444
+ sl.addWidget(self.status_label, 1)
3445
+ self.status_conn_label = QLabel("")
3446
+ self.status_conn_label.setObjectName("WinScpStatusConn")
3447
+ sl.addWidget(self.status_conn_label)
3448
+ root.addWidget(self.status_bar)
3449
+
3450
+ def _open_login_dialog(self) -> None:
3451
+ dlg = SessionLoginDialog(
3452
+ self.get_adb_path,
3453
+ self._get_default_ssh_host(),
3454
+ self._adb_serial or "",
3455
+ self,
3456
+ config=self._config,
3457
+ on_bookmarks_changed=None,
3458
+ )
3459
+ if dlg.exec_() != QDialog.Accepted:
3460
+ return
3461
+ o = dlg.outcome()
3462
+ if o:
3463
+ self._append_session_from_outcome(o)
3464
+
3465
+ def _append_session_from_outcome(self, o: SessionLoginOutcome) -> None:
3466
+ if o.kind == "adb":
3467
+ page = ExplorerSessionPage(
3468
+ "adb",
3469
+ self.get_adb_path,
3470
+ self.get_adb_serial,
3471
+ self._log,
3472
+ session_adb_serial=o.adb_serial,
3473
+ app_config=self._config,
3474
+ )
3475
+ title = f"ADB {o.adb_serial}"
3476
+ elif o.kind == "sftp":
3477
+ page = ExplorerSessionPage(
3478
+ "sftp",
3479
+ self.get_adb_path,
3480
+ self.get_adb_serial,
3481
+ self._log,
3482
+ sftp_transport=o.sftp_transport,
3483
+ sftp_client=o.sftp_client,
3484
+ sftp_host=o.sftp_host,
3485
+ sftp_user=o.sftp_user,
3486
+ sftp_port=o.sftp_port,
3487
+ sftp_password=o.sftp_password,
3488
+ app_config=self._config,
3489
+ )
3490
+ title = f"SFTP {o.sftp_user + '@' if o.sftp_user else ''}{o.sftp_host}"
3491
+ self._log(f"SFTP tab: {title}")
3492
+ if self._on_remote_session_changed:
3493
+ self._on_remote_session_changed()
3494
+ else:
3495
+ page = ExplorerSessionPage(
3496
+ "ftp",
3497
+ self.get_adb_path,
3498
+ self.get_adb_serial,
3499
+ self._log,
3500
+ ftp_client=o.ftp_client,
3501
+ ftp_host=o.ftp_host,
3502
+ ftp_port=o.ftp_port,
3503
+ ftp_user=o.ftp_user,
3504
+ ftp_password=o.ftp_password,
3505
+ app_config=self._config,
3506
+ )
3507
+ title = f"FTP {o.ftp_host}"
3508
+ self._log(f"FTP tab: {title}")
3509
+ if self._on_remote_session_changed:
3510
+ self._on_remote_session_changed()
3511
+ idx = self.session_tabs.addTab(page, title)
3512
+ self.session_tabs.setCurrentIndex(idx)
3513
+ self._update_status_bar()
3514
+
3515
+ def _on_tab_close_requested(self, index: int) -> None:
3516
+ w = self.session_tabs.widget(index)
3517
+ if isinstance(w, ExplorerSessionPage):
3518
+ w.disconnect_session()
3519
+ self.session_tabs.removeTab(index)
3520
+ self._update_status_bar()
3521
+
3522
+ def _update_status_bar(self) -> None:
3523
+ page = self._current_page()
3524
+ self.status_conn_label.setText("Sessions")
3525
+ self._session_stack.setCurrentIndex(1 if self.session_tabs.count() > 0 else 0)
3526
+ if page:
3527
+ self.status_label.setText(page.status_line())
3528
+ elif self.session_tabs.count() == 0:
3529
+ self.status_label.setText(
3530
+ f'{APP_TITLE} — use "New session…" to connect (SFTP, FTP, or Android).'
3531
+ )
3532
+ else:
3533
+ self.status_label.setText(f"{APP_TITLE} — select a session tab.")
3534
+
3535
+ def _flash_refresh_banner(self, side: str, ts: str) -> None:
3536
+ self.status_label.setStyleSheet("color: #2563eb; font-weight: 700;")
3537
+ self.status_label.setText(f"Listing updated · {side} · {ts}")
3538
+ page = self._current_page()
3539
+ if page:
3540
+ self._log(page.status_line())
3541
+ else:
3542
+ self._log(f"Explorer: {side.lower()} listing updated at {ts}.")
3543
+ QTimer.singleShot(850, self._clear_refresh_flash)
3544
+
3545
+ def _clear_refresh_flash(self) -> None:
3546
+ self.status_label.setStyleSheet("")
3547
+ self._update_status_bar()
3548
+
3549
+ def refresh_local(self) -> None:
3550
+ page = self._current_page()
3551
+ if page:
3552
+ page.refresh_local()
3553
+ self._update_status_bar()
3554
+
3555
+ def refresh_remote(self) -> None:
3556
+ page = self._current_page()
3557
+ if page:
3558
+ page.refresh_remote()
3559
+ self._update_status_bar()
3560
+
3561
+ def refresh_all_remotes(self) -> None:
3562
+ for i in range(self.session_tabs.count()):
3563
+ w = self.session_tabs.widget(i)
3564
+ if isinstance(w, ExplorerSessionPage):
3565
+ w.refresh_remote()
3566
+ self._update_status_bar()
3567
+
3568
+ def _need_session(self) -> bool:
3569
+ if self._current_page() is not None:
3570
+ return True
3571
+ QMessageBox.information(
3572
+ self,
3573
+ APP_TITLE,
3574
+ 'No file session is open. Click "New session…" above to connect (SFTP, FTP, or Android).',
3575
+ )
3576
+ return False
3577
+
3578
+ def pull_selected(self) -> None:
3579
+ if not self._need_session():
3580
+ return
3581
+ self._current_page().pull_selected()
3582
+
3583
+ def push_selected(self) -> None:
3584
+ if not self._need_session():
3585
+ return
3586
+ self._current_page().push_selected()
3587
+
3588
+ def delete_selected_remote(self) -> None:
3589
+ if not self._need_session():
3590
+ return
3591
+ self._current_page().delete_selected_remote()
3592
+
3593
+ def make_remote_folder(self) -> None:
3594
+ if not self._need_session():
3595
+ return
3596
+ self._current_page().make_remote_folder()
3597
+
3598
+ def run_adb_global(self, args: List[str], timeout: int = 60) -> Tuple[int, str, str]:
3599
+ return run_adb(self.get_adb_path(), args, timeout=timeout)
3600
+
3601
+ def run_adb_device(self, args: List[str], timeout: int = 120) -> Tuple[int, str, str]:
3602
+ serial = self._adb_serial.split(" ")[0] if self._adb_serial else ""
3603
+ prefix = ["-s", serial] if serial else []
3604
+ return run_adb(self.get_adb_path(), [*prefix, *args], timeout=timeout)
3605
+
3606
+ def action_adb_reconnect(self) -> None:
3607
+ code, out, err = self.run_adb_global(["reconnect"], timeout=30)
3608
+ msg = (out or err or "").strip() or ("OK" if code == 0 else f"exit {code}")
3609
+ self._log(f"ADB reconnect: {msg}")
3610
+ if code != 0:
3611
+ QMessageBox.warning(self, "ADB reconnect", err or "Command failed.")
3612
+ if self._on_refresh_devices:
3613
+ self._on_refresh_devices()
3614
+
3615
+ def action_restart_adb_server(self) -> None:
3616
+ c1, _, e1 = self.run_adb_global(["kill-server"], timeout=15)
3617
+ self._log(f"ADB kill-server: exit {c1}" + (f" — {e1.strip()}" if e1 and e1.strip() else ""))
3618
+ c2, out, e2 = self.run_adb_global(["start-server"], timeout=30)
3619
+ msg = (out or e2 or "").strip() or f"exit {c2}"
3620
+ self._log(f"ADB start-server: {msg}")
3621
+ if self._on_refresh_devices:
3622
+ self._on_refresh_devices()
3623
+ if c2 != 0:
3624
+ QMessageBox.warning(self, "ADB server", e2 or "start-server failed.")
3625
+
3626
+ def _ensure_device_for_adb_device_commands(self) -> bool:
3627
+ if not self._adb_serial:
3628
+ QMessageBox.information(self, "ADB", "Select a device in the bar at the top of the window first.")
3629
+ return False
3630
+ return True
3631
+
3632
+ def action_adb_root(self) -> None:
3633
+ if not self._ensure_device_for_adb_device_commands():
3634
+ return
3635
+ code, out, err = self.run_adb_device(["root"], timeout=90)
3636
+ text = (out or err or "").strip() or f"exit {code}"
3637
+ self._log(f"ADB root: {text}")
3638
+ QMessageBox.information(self, "ADB root", text)
3639
+ if self._on_refresh_devices:
3640
+ self._on_refresh_devices()
3641
+
3642
+ def action_adb_unroot(self) -> None:
3643
+ if not self._ensure_device_for_adb_device_commands():
3644
+ return
3645
+ code, out, err = self.run_adb_device(["unroot"], timeout=90)
3646
+ text = (out or err or "").strip() or f"exit {code}"
3647
+ self._log(f"ADB unroot: {text}")
3648
+ QMessageBox.information(self, "ADB unroot", text)
3649
+ if self._on_refresh_devices:
3650
+ self._on_refresh_devices()
3651
+
3652
+ def action_adb_remount(self) -> None:
3653
+ if not self._ensure_device_for_adb_device_commands():
3654
+ return
3655
+ code, out, err = self.run_adb_device(["remount"], timeout=120)
3656
+ text = (out or err or "").strip() or f"exit {code}"
3657
+ self._log(f"ADB remount: {text}")
3658
+ if code != 0:
3659
+ QMessageBox.warning(self, "ADB remount", text or err or "Failed (often needs adb root first).")
3660
+ else:
3661
+ QMessageBox.information(self, "ADB remount", text or "OK.")
3662
+
3663
+ def action_adb_reboot(self) -> None:
3664
+ if not self._ensure_device_for_adb_device_commands():
3665
+ return
3666
+ if (
3667
+ QMessageBox.question(
3668
+ self,
3669
+ "ADB reboot",
3670
+ "Send adb reboot to the selected device?",
3671
+ QMessageBox.Yes | QMessageBox.No,
3672
+ QMessageBox.No,
3673
+ )
3674
+ != QMessageBox.Yes
3675
+ ):
3676
+ return
3677
+ code, out, err = self.run_adb_device(["reboot"], timeout=30)
3678
+ msg = (out or err or "").strip() or f"exit {code}"
3679
+ self._log(f"ADB reboot: {msg}")
3680
+ QMessageBox.information(self, "ADB reboot", msg if code == 0 else (err or msg or "Failed."))