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.
- adbsshdeck-0.1.1.dist-info/LICENSE +21 -0
- adbsshdeck-0.1.1.dist-info/METADATA +136 -0
- adbsshdeck-0.1.1.dist-info/RECORD +29 -0
- adbsshdeck-0.1.1.dist-info/WHEEL +5 -0
- adbsshdeck-0.1.1.dist-info/entry_points.txt +2 -0
- adbsshdeck-0.1.1.dist-info/top_level.txt +1 -0
- devicedeck/__init__.py +4 -0
- devicedeck/__main__.py +4 -0
- devicedeck/app.py +45 -0
- devicedeck/config.py +130 -0
- devicedeck/services/__init__.py +1 -0
- devicedeck/services/adb_devices.py +74 -0
- devicedeck/services/commands.py +96 -0
- devicedeck/services/remote_clients.py +84 -0
- devicedeck/session.py +52 -0
- devicedeck/ui/__init__.py +1 -0
- devicedeck/ui/app_icon.py +46 -0
- devicedeck/ui/combo_utils.py +45 -0
- devicedeck/ui/first_run_dialog.py +107 -0
- devicedeck/ui/icon_utils.py +144 -0
- devicedeck/ui/main_window.py +691 -0
- devicedeck/ui/preferences_dialog.py +122 -0
- devicedeck/ui/session_login_dialog.py +795 -0
- devicedeck/ui/styles.py +1021 -0
- devicedeck/ui/tabs/__init__.py +1 -0
- devicedeck/ui/tabs/file_explorer_tab.py +3680 -0
- devicedeck/ui/tabs/scrcpy_tab.py +770 -0
- devicedeck/ui/tabs/terminal_tab.py +1192 -0
- devicedeck/ui/win_scrcpy_hotkey.py +76 -0
|
@@ -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."))
|