diff --git a/netx_api/config.py b/netx_api/config.py index b3b5aa6..ec66b27 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -104,6 +104,9 @@ class Settings(BaseSettings): # WebCRT SFTP transfer limits (streamed; default 512 MiB per file). webcrt_sftp_max_file_bytes: int = 512 * 1024 * 1024 webcrt_sftp_chunk_bytes: int = 64 * 1024 + # Cap directory listings so huge folders cannot pin the API/UI. + webcrt_sftp_list_max_entries: int = 5000 + webcrt_sftp_list_timeout_sec: float = 30.0 # Local app login / audit (lab defaults; override in production) auth_enabled: bool = True # Stable default so JWT survives restarts without .env. Override in production. diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index ba6230d..6c56020 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -80,6 +80,33 @@ class WebcrtSftpDownloadBody(BaseModel): path: str +class WebcrtSftpMkdirBody(BaseModel): + ne_id: str | None = Field(default=None) + ume_ne_id: str | None = Field(default=None) + path: str + + +class WebcrtSftpRemoveBody(BaseModel): + ne_id: str | None = Field(default=None) + ume_ne_id: str | None = Field(default=None) + path: str + recursive: bool = False + + +class WebcrtSftpRenameBody(BaseModel): + ne_id: str | None = Field(default=None) + ume_ne_id: str | None = Field(default=None) + old_path: str + new_path: str + + +class WebcrtSftpChmodBody(BaseModel): + ne_id: str | None = Field(default=None) + ume_ne_id: str | None = Field(default=None) + path: str + mode: str + + def _client_label(request: Request | None = None, websocket: WebSocket | None = None) -> str: host = "" if request is not None: @@ -200,15 +227,64 @@ def api_close_session(session_id: str, request: Request) -> dict[str, Any]: return close_session(session_id, reason="client_delete", client=_client_label(request=request)) +def _sftp_ne_ids(ne_id: str | None, ume_ne_id: str | None) -> tuple[str | None, str | None]: + mid = str(ne_id or "").strip() + uid = str(ume_ne_id or "").strip() + if bool(mid) == bool(uid): + raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required") + return (mid or None, uid or None) + + @router.post("/sftp/list") def api_sftp_list(body: WebcrtSftpListBody, db: Session = Depends(get_db)) -> dict[str, Any]: from .webcrt_sftp import sftp_list - mid = str(body.ne_id or "").strip() - uid = str(body.ume_ne_id or "").strip() - if bool(mid) == bool(uid): - raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required") - return sftp_list(db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path) + mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id) + return sftp_list(db, managed_ne_id=mid, ume_ne_id=uid, path=body.path) + + +@router.post("/sftp/mkdir") +def api_sftp_mkdir(body: WebcrtSftpMkdirBody, db: Session = Depends(get_db)) -> dict[str, Any]: + from .webcrt_sftp import sftp_mkdir + + mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id) + return sftp_mkdir(db, managed_ne_id=mid, ume_ne_id=uid, path=body.path) + + +@router.post("/sftp/remove") +def api_sftp_remove(body: WebcrtSftpRemoveBody, db: Session = Depends(get_db)) -> dict[str, Any]: + from .webcrt_sftp import sftp_remove + + mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id) + return sftp_remove( + db, + managed_ne_id=mid, + ume_ne_id=uid, + path=body.path, + recursive=bool(body.recursive), + ) + + +@router.post("/sftp/rename") +def api_sftp_rename(body: WebcrtSftpRenameBody, db: Session = Depends(get_db)) -> dict[str, Any]: + from .webcrt_sftp import sftp_rename + + mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id) + return sftp_rename( + db, + managed_ne_id=mid, + ume_ne_id=uid, + old_path=body.old_path, + new_path=body.new_path, + ) + + +@router.post("/sftp/chmod") +def api_sftp_chmod(body: WebcrtSftpChmodBody, db: Session = Depends(get_db)) -> dict[str, Any]: + from .webcrt_sftp import sftp_chmod + + mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id) + return sftp_chmod(db, managed_ne_id=mid, ume_ne_id=uid, path=body.path, mode=body.mode) @router.post("/sftp/download") @@ -217,13 +293,8 @@ def api_sftp_download(body: WebcrtSftpDownloadBody, db: Session = Depends(get_db from .webcrt_sftp import SftpDownloadStream - mid = str(body.ne_id or "").strip() - uid = str(body.ume_ne_id or "").strip() - if bool(mid) == bool(uid): - raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required") - stream = SftpDownloadStream( - db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path - ).open() + mid, uid = _sftp_ne_ids(body.ne_id, body.ume_ne_id) + stream = SftpDownloadStream(db, managed_ne_id=mid, ume_ne_id=uid, path=body.path).open() headers = { "Content-Disposition": stream.content_disposition(), "Content-Length": str(int(stream.size)), @@ -249,10 +320,7 @@ async def api_sftp_upload( from .config import settings from .webcrt_sftp import sftp_upload_stream - mid = str(ne_id or "").strip() - uid = str(ume_ne_id or "").strip() - if bool(mid) == bool(uid): - raise HTTPException(status_code=400, detail="exactly_one_of_ne_id_or_ume_ne_id_required") + mid, uid = _sftp_ne_ids(ne_id, ume_ne_id) max_bytes = max(1, int(settings.webcrt_sftp_max_file_bytes or (512 * 1024 * 1024))) expected = getattr(file, "size", None) if expected is not None and int(expected) > max_bytes: @@ -261,8 +329,8 @@ async def api_sftp_upload( def _upload() -> dict[str, Any]: return sftp_upload_stream( db, - managed_ne_id=mid or None, - ume_ne_id=uid or None, + managed_ne_id=mid, + ume_ne_id=uid, remote_path=remote_path, reader=file.file, expected_size=int(expected) if expected is not None else None, diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index 85f4cfc..d2d89f4 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -519,25 +519,14 @@ class WebcrtSession: except Exception: pass - def _ensure_sftp_unlocked(self) -> Any: - """Caller must hold ``_sftp_lock``.""" - import paramiko - + def _ssh_transport_unlocked(self) -> Any: + """Caller must hold ``_sftp_lock``. Returns an active Paramiko Transport.""" if self.closed or self.conn is None: raise RuntimeError("session_closed") if str(self.protocol or "ssh").lower() != "ssh": raise RuntimeError("sftp_requires_ssh") if self.cli_hop_guard: raise RuntimeError("sftp_hop_not_supported") - if self._sftp is not None: - sock = getattr(self._sftp, "sock", None) - if sock is not None and not bool(getattr(sock, "closed", False)): - return self._sftp - try: - self._sftp.close() - except Exception: - pass - self._sftp = None channel = getattr(self.conn, "remote_conn", None) transport = None if channel is not None and hasattr(channel, "get_transport"): @@ -547,6 +536,22 @@ class WebcrtSession: transport = None if transport is None or not bool(getattr(transport, "is_active", lambda: False)()): raise RuntimeError("ssh_transport_unavailable") + return transport + + def _ensure_sftp_unlocked(self) -> Any: + """Caller must hold ``_sftp_lock``. Shared probe client (sftp_ready).""" + import paramiko + + if self._sftp is not None: + sock = getattr(self._sftp, "sock", None) + if sock is not None and not bool(getattr(sock, "closed", False)): + return self._sftp + try: + self._sftp.close() + except Exception: + pass + self._sftp = None + transport = self._ssh_transport_unlocked() self._sftp = paramiko.SFTPClient.from_transport(transport) if self._sftp is None: raise RuntimeError("sftp_open_failed") @@ -558,11 +563,36 @@ class WebcrtSession: with self._sftp_lock: return self._ensure_sftp_unlocked() - def run_sftp(self, fn: Any) -> Any: - """Run ``fn(sftp)`` while holding the session SFTP lock.""" + def open_ephemeral_sftp(self) -> Any: + """Open a dedicated SFTP channel for one operation; caller must ``close()`` it. + + Only holds ``_sftp_lock`` briefly while resolving the SSH transport, so long + list/upload/download work does not block other SFTP ops on the same session. + """ + import paramiko + with self._sftp_lock: - sftp = self._ensure_sftp_unlocked() + transport = self._ssh_transport_unlocked() + # Keep probe client warm for UI sftp_ready without sharing it for I/O. + try: + self._ensure_sftp_unlocked() + except Exception: + pass + sftp = paramiko.SFTPClient.from_transport(transport) + if sftp is None: + raise RuntimeError("sftp_open_failed") + return sftp + + def run_sftp(self, fn: Any) -> Any: + """Run ``fn(sftp)`` on an ephemeral channel (does not hold the lock during ``fn``).""" + sftp = self.open_ephemeral_sftp() + try: return fn(sftp) + finally: + try: + sftp.close() + except Exception: + pass def try_attach_sftp(self) -> bool: """Best-effort SFTP channel open after SSH login (does not fail the shell).""" diff --git a/netx_api/webcrt_sftp.py b/netx_api/webcrt_sftp.py index 558cd23..777873a 100644 --- a/netx_api/webcrt_sftp.py +++ b/netx_api/webcrt_sftp.py @@ -150,39 +150,65 @@ def _sftp_client( managed_ne_id: str | None, ume_ne_id: str | None, ) -> Iterator[tuple[Any, dict[str, Any]]]: - """Yield ``(sftp, device)`` — prefers live WebCRT SSH session channel.""" + """Yield ``(sftp, device)`` — prefers a short-lived channel on the live SSH session.""" creds, device = _resolve(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) ne_key = str(device.get("id") or managed_ne_id or ume_ne_id or "").strip() sess = find_ssh_session_for_ne(ne_key) if ne_key else None if sess is not None: - opened = False + sftp = None try: - with sess._sftp_lock: - sftp = sess._ensure_sftp_unlocked() - opened = True - yield sftp, device + sftp = sess.open_ephemeral_sftp() + yield sftp, device return except HTTPException: raise except Exception as exc: - if opened: - # Operation failed on an already-open session channel — don't double-yield. + if sftp is not None: raise HTTPException(status_code=502, detail=f"sftp_failed:{exc}") from exc _log.debug("session sftp open failed ne=%s: %s — pool fallback", ne_key, exc) + finally: + if sftp is not None: + try: + sftp.close() + except Exception: + pass key = _pool_key(managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) entry = _get_pooled(key, creds) + # Borrow transport under a short lock, then use a dedicated SFTP channel for I/O. with entry.lock: entry.last_used = time.time() + transport = None try: - yield entry.sftp, device + transport = entry.client.get_transport() except Exception: - # Drop broken pooled socket so the next call reconnects once. + transport = None + if transport is None or not bool(getattr(transport, "is_active", lambda: False)()): with _pool_lock: cur = _pool.pop(key, None) if cur is not None: _close_pooled(cur) - raise + raise HTTPException(status_code=502, detail="sftp_pool_transport_unavailable") + sftp = None + try: + sftp = paramiko.SFTPClient.from_transport(transport) + if sftp is None: + raise HTTPException(status_code=502, detail="sftp_open_failed") + yield sftp, device + except HTTPException: + raise + except Exception: + with _pool_lock: + cur = _pool.pop(key, None) + if cur is not None: + _close_pooled(cur) + raise + finally: + if sftp is not None: + try: + sftp.close() + except Exception: + pass def _filemode(mode: int) -> str: @@ -239,6 +265,188 @@ def _mkdir_p(sftp: Any, remote: str) -> None: raise HTTPException(status_code=502, detail=f"sftp_mkdir_failed:{candidate}:{exc}") from exc +def _rmtree(sftp: Any, remote: str) -> None: + path = _normalize_remote(remote, allow_dot=False) + if not path or path in (".", "/"): + raise HTTPException(status_code=400, detail="sftp_path_required") + try: + for attr in sftp.listdir_attr(path): + name = str(getattr(attr, "filename", "") or "") + if not name or name in (".", ".."): + continue + child = posixpath.join(path, name) + mode = int(getattr(attr, "st_mode", 0) or 0) + if statmod.S_ISDIR(mode): + _rmtree(sftp, child) + else: + sftp.remove(child) + sftp.rmdir(path) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_remove_failed:{exc}") from exc + + +def sftp_mkdir( + db: Session, + *, + managed_ne_id: str | None, + ume_ne_id: str | None, + path: str, +) -> dict[str, Any]: + remote = _normalize_remote(path, allow_dot=False) + if not remote or remote in (".", "/"): + raise HTTPException(status_code=400, detail="sftp_path_required") + try: + with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device): + _mkdir_p(sftp, remote) + return { + "ok": True, + "ne_id": str(device.get("id") or ""), + "path": remote, + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_mkdir_failed:{exc}") from exc + + +def sftp_remove( + db: Session, + *, + managed_ne_id: str | None, + ume_ne_id: str | None, + path: str, + recursive: bool = False, +) -> dict[str, Any]: + remote = _normalize_remote(path, allow_dot=False) + if not remote or remote in (".", "/"): + raise HTTPException(status_code=400, detail="sftp_path_required") + try: + with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device): + try: + st = sftp.stat(remote) + except Exception as exc: + raise HTTPException(status_code=404, detail=f"sftp_not_found:{exc}") from exc + mode = int(getattr(st, "st_mode", 0) or 0) + if statmod.S_ISDIR(mode): + if recursive: + _rmtree(sftp, remote) + else: + try: + sftp.rmdir(remote) + except Exception as exc: + raise HTTPException(status_code=409, detail="sftp_dir_not_empty") from exc + else: + sftp.remove(remote) + return { + "ok": True, + "ne_id": str(device.get("id") or ""), + "path": remote, + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_remove_failed:{exc}") from exc + + +def sftp_rename( + db: Session, + *, + managed_ne_id: str | None, + ume_ne_id: str | None, + old_path: str, + new_path: str, +) -> dict[str, Any]: + src = _normalize_remote(old_path, allow_dot=False) + dst = _normalize_remote(new_path, allow_dot=False) + if not src or not dst or src in (".", "/") or dst in (".", "/"): + raise HTTPException(status_code=400, detail="sftp_path_required") + if src == dst: + return {"ok": True, "ne_id": "", "old_path": src, "new_path": dst} + try: + with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device): + parent = posixpath.dirname(dst) + if parent and parent not in (".", "/"): + _mkdir_p(sftp, parent) + try: + sftp.rename(src, dst) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_rename_failed:{exc}") from exc + return { + "ok": True, + "ne_id": str(device.get("id") or ""), + "old_path": src, + "new_path": dst, + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_rename_failed:{exc}") from exc + + +def _parse_chmod_mode(mode: str | int) -> int: + if isinstance(mode, int): + return int(mode) & 0o7777 + raw = str(mode or "").strip().lower() + if not raw: + raise HTTPException(status_code=400, detail="sftp_chmod_invalid_mode") + if raw.startswith("0o"): + raw = raw[2:] + if raw.isdigit(): + try: + return int(raw, 8) & 0o7777 + except ValueError as exc: + raise HTTPException(status_code=400, detail="sftp_chmod_invalid_mode") from exc + sym = raw.lstrip("d").lstrip("-") + if len(sym) != 9 or any(c not in "rwx-" for c in sym): + raise HTTPException(status_code=400, detail="sftp_chmod_invalid_mode") + bits = {"r": 4, "w": 2, "x": 1, "-": 0} + value = 0 + for i in range(3): + trip = sym[i * 3 : (i + 1) * 3] + value = (value << 3) | (bits[trip[0]] + bits[trip[1]] + bits[trip[2]]) + return value & 0o7777 + + +def sftp_chmod( + db: Session, + *, + managed_ne_id: str | None, + ume_ne_id: str | None, + path: str, + mode: str | int, +) -> dict[str, Any]: + remote = _normalize_remote(path, allow_dot=False) + if not remote or remote in (".", "/"): + raise HTTPException(status_code=400, detail="sftp_path_required") + mode_int = _parse_chmod_mode(mode) + try: + with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device): + try: + sftp.chmod(remote, mode_int) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_chmod_failed:{exc}") from exc + return { + "ok": True, + "ne_id": str(device.get("id") or ""), + "path": remote, + "mode": f"{mode_int:04o}", + } + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_chmod_failed:{exc}") from exc + + +def _list_max_entries() -> int: + return max(100, int(settings.webcrt_sftp_list_max_entries or 5000)) + + +def _list_timeout_sec() -> float: + return max(1.0, float(settings.webcrt_sftp_list_timeout_sec or 30.0)) + + def sftp_list( db: Session, *, @@ -247,10 +455,17 @@ def sftp_list( path: str = ".", ) -> dict[str, Any]: remote = _normalize_remote(path, allow_dot=True) or "." + max_entries = _list_max_entries() + timeout_sec = _list_timeout_sec() try: with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device): entries = [] + truncated = False + deadline = time.monotonic() + timeout_sec for attr in sftp.listdir_attr(remote): + if time.monotonic() > deadline: + truncated = True + break mode = int(getattr(attr, "st_mode", 0) or 0) name = str(attr.filename or "") if not name or name in (".", ".."): @@ -269,12 +484,17 @@ def sftp_list( "gid": int(getattr(attr, "st_gid", 0) or 0), } ) + if len(entries) >= max_entries: + truncated = True + break entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower())) return { "ne_id": str(device.get("id") or ""), "ne_name": str(device.get("name") or ""), "path": remote, "items": entries, + "truncated": truncated, + "max_entries": max_entries, } except HTTPException: raise diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index 10ce206..3266133 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -771,17 +771,85 @@ class WebcrtServiceTests(unittest.TestCase): _mkdir_p(sftp, "/a/b/c") # idempotent self.assertEqual(sftp.mkdir_calls, ["/a", "/a/b", "/a/b/c"]) + def test_sftp_rmtree(self) -> None: + from netx_api.webcrt_sftp import _rmtree + + class _Attr: + def __init__(self, name: str, is_dir: bool = False) -> None: + self.filename = name + self.st_mode = 0o040755 if is_dir else 0o100644 + + class _FakeSftp: + def __init__(self) -> None: + self.tree = { + "/p": ["a", "d"], + "/p/d": ["f"], + } + self.removed: list[str] = [] + self.rmdirs: list[str] = [] + + def listdir_attr(self, path: str): + names = self.tree.get(path, []) + out = [] + for n in names: + child = f"{path.rstrip('/')}/{n}" + out.append(_Attr(n, is_dir=child in self.tree)) + return out + + def remove(self, path: str) -> None: + self.removed.append(path) + + def rmdir(self, path: str) -> None: + self.rmdirs.append(path) + self.tree.pop(path, None) + + sftp = _FakeSftp() + _rmtree(sftp, "/p") + self.assertEqual(sorted(sftp.removed), ["/p/a", "/p/d/f"]) + self.assertEqual(sftp.rmdirs, ["/p/d", "/p"]) + def test_sftp_transfer_helpers(self) -> None: from netx_api import config as cfg - from netx_api.webcrt_sftp import _content_disposition, _sftp_chunk_bytes, _sftp_max_file_bytes + from netx_api.webcrt_sftp import ( + _content_disposition, + _list_max_entries, + _list_timeout_sec, + _sftp_chunk_bytes, + _sftp_max_file_bytes, + ) self.assertGreaterEqual(_sftp_max_file_bytes(), 8 * 1024 * 1024) self.assertGreaterEqual(_sftp_chunk_bytes(), 4 * 1024) + self.assertGreaterEqual(_list_max_entries(), 100) + self.assertGreaterEqual(_list_timeout_sec(), 1.0) dispo = _content_disposition('报告"A".bin') self.assertIn("filename=", dispo) self.assertIn("filename*=UTF-8''", dispo) self.assertNotIn("\n", dispo) self.assertEqual(int(cfg.settings.webcrt_sftp_max_file_bytes), 512 * 1024 * 1024) + self.assertEqual(int(cfg.settings.webcrt_sftp_list_max_entries), 5000) + + def test_sftp_rename_rejects_bad_paths(self) -> None: + from unittest.mock import MagicMock + + from netx_api.webcrt_sftp import sftp_rename + + db = MagicMock() + with self.assertRaises(HTTPException) as cm: + sftp_rename(db, managed_ne_id="n1", ume_ne_id=None, old_path=".", new_path="a") + self.assertEqual(cm.exception.status_code, 400) + self.assertEqual(cm.exception.detail, "sftp_path_required") + + def test_sftp_parse_chmod_mode(self) -> None: + from netx_api.webcrt_sftp import _parse_chmod_mode + + self.assertEqual(_parse_chmod_mode("755"), 0o755) + self.assertEqual(_parse_chmod_mode("0644"), 0o644) + self.assertEqual(_parse_chmod_mode("rwxr-xr-x"), 0o755) + self.assertEqual(_parse_chmod_mode("-rw-r--r--"), 0o644) + with self.assertRaises(HTTPException) as cm: + _parse_chmod_mode("bad") + self.assertEqual(cm.exception.detail, "sftp_chmod_invalid_mode") @patch.object(svc, "_audit") def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None: diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 3ad949a..8029cda 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1046,14 +1046,39 @@ const en = { upload: "Upload", uploaded: "Upload complete", uploading: "Upload {{done}}/{{total}} · {{name}} {{pct}}", - downloading: "Download {{name}} {{pct}}", + downloading: "Download {{done}}/{{total}} · {{name}} {{pct}}", + downloaded: "Downloaded {{count}} file(s)", + downloadPartial: "Downloaded {{ok}}, failed {{failed}}", uploadPartial: "Uploaded {{ok}}, failed {{failed}}", + retrying: "Retrying {{name}} ({{attempt}}/{{max}})…", + cancel: "Cancel", dropHint: "Drop to upload into current folder", dropEmpty: "No uploadable files found", + mkdir: "New", + mkdirPrompt: "New folder name", + mkdirInvalid: "Invalid folder name (no / or ..)", + mkdirOk: "Folder created", + rename: "Rename", + renamePrompt: "New name", + renameInvalid: "Invalid name (no / or ..)", + renamed: "Renamed", + download: "Download", + chmod: "chmod", + chmodPrompt: "Mode (e.g. 755 or rwxr-xr-x)", + chmodOk: "Permissions updated", + chmodPartial: "chmod ok {{ok}}, failed {{failed}}", + delete: "Delete", + deleteConfirm: "Delete {{name}}?", + deleteRecursiveConfirm: "{{name}} includes folders; non-empty folders will be removed recursively. Continue?", + deletePartial: "Deleted {{ok}}, failed {{failed}}", + deleted: "Deleted", + selectedCount: "{{count}} selected", + empty: "This folder is empty", + listTruncated: "Folder is large; showing first {{count}} entries (limit {{max}})", dir: "dir", loading: "Loading…", - enterHint: "Double-click to open folder", - downloadHint: "Click to download", + enterHint: "Click to select; Ctrl/Shift multi-select; double-click to open", + downloadHint: "Click to select; Ctrl/Shift multi-select; double-click to download", colName: "Name", colSize: "Size", colMtime: "Modified", @@ -1091,6 +1116,8 @@ const en = { sftpNeedPassword: "SFTP needs a saved SSH password (one-shot dialog passwords are not reused for files)", sftpUnsupported: "This SSH session has no SFTP channel (device may not offer it)", sftpTooLarge: "File exceeds size limit (default 512MB)", + sftpChmodInvalid: "Invalid mode (use 755 or rwxr-xr-x)", + sftpAborted: "Transfer cancelled", queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete", websocket: "WebSocket connection failed", }, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index e0686b7..0ddd074 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1043,14 +1043,39 @@ const zh = { upload: "上传", uploaded: "上传成功", uploading: "上传 {{done}}/{{total}} · {{name}} {{pct}}", - downloading: "下载 {{name}} {{pct}}", + downloading: "下载 {{done}}/{{total}} · {{name}} {{pct}}", + downloaded: "已下载 {{count}} 个文件", + downloadPartial: "下载完成 {{ok}} 个,失败 {{failed}} 个", uploadPartial: "上传完成 {{ok}} 个,失败 {{failed}} 个", + retrying: "重试 {{name}}({{attempt}}/{{max}})…", + cancel: "取消", dropHint: "松开以上传到当前目录", dropEmpty: "未识别到可上传的文件", + mkdir: "新建", + mkdirPrompt: "新建目录名称", + mkdirInvalid: "目录名无效(不要包含 / 或 ..)", + mkdirOk: "目录已创建", + rename: "重命名", + renamePrompt: "新名称", + renameInvalid: "名称无效(不要包含 / 或 ..)", + renamed: "已重命名", + download: "下载", + chmod: "权限", + chmodPrompt: "权限模式(如 755 或 rwxr-xr-x)", + chmodOk: "权限已更新", + chmodPartial: "权限更新 {{ok}} 个,失败 {{failed}} 个", + delete: "删除", + deleteConfirm: "确定删除 {{name}}?", + deleteRecursiveConfirm: "{{name}} 含目录,非空目录将递归删除,是否继续?", + deletePartial: "删除完成 {{ok}} 个,失败 {{failed}} 个", + deleted: "已删除", + selectedCount: "已选 {{count}} 项", + empty: "当前目录为空", + listTruncated: "目录条目过多,仅显示前 {{count}} 项(上限 {{max}})", dir: "目录", loading: "正在加载…", - enterHint: "双击进入目录", - downloadHint: "单击下载文件", + enterHint: "单击选中;Ctrl/Shift 多选;双击进入", + downloadHint: "单击选中;Ctrl/Shift 多选;双击下载", colName: "名称", colSize: "大小", colMtime: "修改时间", @@ -1088,6 +1113,8 @@ const zh = { sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)", sftpUnsupported: "当前 SSH 会话未提供 SFTP(设备可能未开启)", sftpTooLarge: "文件超过大小限制(默认 512MB)", + sftpChmodInvalid: "权限模式无效(请用 755 或 rwxr-xr-x)", + sftpAborted: "传输已取消", queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整", websocket: "WebSocket 连接失败", }, diff --git a/web/src/index.css b/web/src/index.css index 41f17ba..686f9c8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3107,6 +3107,7 @@ pre { .webcrt-sftp__bar { display: flex; + flex-wrap: wrap; align-items: center; gap: 4px; padding: 5px 6px; @@ -3159,6 +3160,10 @@ pre { } .webcrt-sftp__status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; padding: 3px 8px; font-size: 10px; color: #64748b; @@ -3166,6 +3171,30 @@ pre { flex-shrink: 0; } +.webcrt-sftp__status--warn { + color: #fbbf24; + background: rgba(251, 191, 36, 0.08); +} + +.webcrt-sftp__cancel { + flex-shrink: 0; + height: 20px; + padding: 0 8px; + border: 1px solid #475569; + border-radius: 4px; + background: #1e293b; + color: #f87171; + font-size: 10px; + font-weight: 600; + cursor: pointer; +} + +.webcrt-sftp__cancel:hover { + background: #7f1d1d; + border-color: #ef4444; + color: #fecaca; +} + .webcrt-sftp__table-wrap { flex: 1; min-height: 0; @@ -3200,10 +3229,28 @@ pre { .webcrt-sftp__th-label { display: block; + width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - padding-right: 4px; + padding: 0 4px 0 0; + margin: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: inherit; + text-transform: inherit; + letter-spacing: inherit; + cursor: pointer; +} + +.webcrt-sftp__th-label:hover { + color: #94a3b8; +} + +.webcrt-sftp__table thead th.is-sorted .webcrt-sftp__th-label { + color: #94a3b8; } .webcrt-sftp__col-resizer { @@ -3240,11 +3287,26 @@ pre { background: #111827; } +.webcrt-sftp__table tbody tr.is-selected { + background: #1e3a5f; +} + +.webcrt-sftp__table tbody tr.is-selected:hover { + background: #234872; +} + .webcrt-sftp__table tbody tr.is-busy { opacity: 0.7; cursor: progress; } +.webcrt-sftp__empty-row td { + color: #64748b; + text-align: center; + padding: 18px 8px; + cursor: default; +} + .webcrt-sftp__table td { padding: 3px 6px; border-bottom: 1px solid #1e293b; @@ -3264,7 +3326,8 @@ pre { color: #64748b; } -.webcrt-sftp__table thead th.webcrt-sftp__col-size { +.webcrt-sftp__table thead th.webcrt-sftp__col-size, +.webcrt-sftp__table thead th.webcrt-sftp__col-size .webcrt-sftp__th-label { text-align: right; } diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 6aa1616..4614cd1 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -23,6 +23,10 @@ import { updateManagedNe, webcrtSftpDownload, webcrtSftpList, + webcrtSftpChmod, + webcrtSftpMkdir, + webcrtSftpRemove, + webcrtSftpRename, webcrtSftpUpload, webcrtWsUrl, type WebcrtSftpItem, @@ -496,6 +500,31 @@ function formatSftpTransferPct(loaded: number, total: number): string { return `${pct}%`; } +type SftpSortKey = SftpColKey; + +function sortSftpItems(items: WebcrtSftpItem[], key: SftpSortKey, dir: "asc" | "desc"): WebcrtSftpItem[] { + const mul = dir === "asc" ? 1 : -1; + return [...items].sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + let cmp = 0; + if (key === "size" || key === "mtime") { + cmp = Number(a[key] || 0) - Number(b[key] || 0); + } else if (key === "name") { + cmp = String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base" }); + } else { + cmp = String(a[key] || "").localeCompare(String(b[key] || ""), undefined, { sensitivity: "base" }); + } + if (cmp !== 0) return cmp * mul; + return String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base" }); + }); +} + +function sftpTargetIds(tab: Pick): { ne_id?: string; ume_ne_id?: string } { + return tab.target.source === "ume" + ? { ume_ne_id: tab.target.ume_ne_id || tab.target.id } + : { ne_id: tab.target.id }; +} + type SftpUploadItem = { file: File; relativePath: string }; type FsEntryLike = { @@ -592,6 +621,8 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record([]); + const [sftpSelected, setSftpSelected] = useState([]); + const [sftpSortKey, setSftpSortKey] = useState("name"); + const [sftpSortDir, setSftpSortDir] = useState<"asc" | "desc">("asc"); const [sftpWidth, setSftpWidth] = useState(() => loadSftpWidth()); const [sftpResizing, setSftpResizing] = useState(false); const [sftpColWidths, setSftpColWidths] = useState(() => loadSftpColWidths()); const [sftpDragOver, setSftpDragOver] = useState(false); const [sftpStatus, setSftpStatus] = useState(""); + const [sftpTransferring, setSftpTransferring] = useState(false); + const [sftpListTruncated, setSftpListTruncated] = useState(false); + const [sftpListMaxEntries, setSftpListMaxEntries] = useState(0); const sftpPathRef = useRef("."); + const sftpPathByTabRef = useRef>({}); + const sftpActiveTabRef = useRef(""); + const sftpSelectAnchorRef = useRef(""); + const sftpAbortRef = useRef(null); const sftpBodyRef = useRef(null); const sftpDragRef = useRef<{ startX: number; startW: number } | null>(null); const sftpColDragRef = useRef<{ key: SftpColKey; startX: number; startW: number } | null>(null); @@ -1287,15 +1328,16 @@ export function WebcrtPage() { const path = String(pathOverride ?? sftpPathRef.current ?? ".").trim() || "."; setSftpBusy(true); try { - const body = - tab.target.source === "ume" - ? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path } - : { ne_id: tab.target.id, path }; - const res = await webcrtSftpList(body); + const res = await webcrtSftpList({ ...sftpTargetIds(tab), path }); setSftpItems(res.items || []); + setSftpListTruncated(Boolean(res.truncated)); + setSftpListMaxEntries(Number(res.max_entries || 0)); const nextPath = String(res.path || path).trim() || path; sftpPathRef.current = nextPath; setSftpPath(nextPath); + if (activeTabKey) sftpPathByTabRef.current[activeTabKey] = nextPath; + const names = new Set((res.items || []).map((it) => it.name)); + setSftpSelected((cur) => cur.filter((n) => names.has(n))); } catch (err) { showError(webcrtErrorMessage(err, t)); } finally { @@ -1310,22 +1352,291 @@ export function WebcrtPage() { const path = String(nextPath || ".").trim() || "."; sftpPathRef.current = path; setSftpPath(path); + if (activeTabKey) sftpPathByTabRef.current[activeTabKey] = path; + setSftpSelected([]); + sftpSelectAnchorRef.current = ""; void refreshSftp(path); }, - [refreshSftp], + [activeTabKey, refreshSftp], ); + const beginSftpTransfer = useCallback(() => { + sftpAbortRef.current?.abort(); + const ac = new AbortController(); + sftpAbortRef.current = ac; + setSftpTransferring(true); + setSftpBusy(true); + return ac; + }, []); + + const endSftpTransfer = useCallback(() => { + sftpAbortRef.current = null; + setSftpTransferring(false); + setSftpBusy(false); + setSftpStatus(""); + }, []); + + const cancelSftpTransfer = useCallback(() => { + sftpAbortRef.current?.abort(); + }, []); + + const selectSftpRow = useCallback( + (name: string, e: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean }, orderedNames: string[]) => { + const multi = Boolean(e.ctrlKey || e.metaKey); + const range = Boolean(e.shiftKey); + if (range && sftpSelectAnchorRef.current) { + const a = orderedNames.indexOf(sftpSelectAnchorRef.current); + const b = orderedNames.indexOf(name); + if (a >= 0 && b >= 0) { + const [lo, hi] = a < b ? [a, b] : [b, a]; + setSftpSelected(orderedNames.slice(lo, hi + 1)); + return; + } + } + if (multi) { + setSftpSelected((prev) => + prev.includes(name) ? prev.filter((x) => x !== name) : [...prev, name], + ); + sftpSelectAnchorRef.current = name; + return; + } + setSftpSelected([name]); + sftpSelectAnchorRef.current = name; + }, + [], + ); + + const downloadSftpItems = useCallback( + async (items: WebcrtSftpItem[]) => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + const files = items.filter((it) => !it.is_dir); + if (!tab || !files.length || sftpBusy) return; + const ac = beginSftpTransfer(); + let done = 0; + let failed = 0; + try { + for (const it of files) { + if (ac.signal.aborted) throw new Error("aborted"); + done += 1; + setSftpStatus( + t("webcrt.sftp.downloading", { + name: it.name, + pct: "0%", + done, + total: files.length, + }), + ); + try { + const remote = joinSftpPath(sftpPathRef.current, it.name); + const blob = await webcrtSftpDownload( + { ...sftpTargetIds(tab), path: remote }, + { + signal: ac.signal, + retries: 2, + onRetry: (attempt) => { + setSftpStatus( + t("webcrt.sftp.retrying", { name: it.name, attempt, max: 3 }), + ); + }, + onProgress: (p) => { + const pct = formatSftpTransferPct(p.loaded, p.total || it.size); + setSftpStatus( + t("webcrt.sftp.downloading", { + name: it.name, + pct: pct || "…", + done, + total: files.length, + }), + ); + }, + }, + ); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = it.name; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + if (String(err).includes("aborted")) throw err; + failed += 1; + if (files.length === 1) throw err; + } + } + if (failed > 0) showError(t("webcrt.sftp.downloadPartial", { ok: files.length - failed, failed })); + else if (files.length > 1) showOk(t("webcrt.sftp.downloaded", { count: files.length })); + } catch (err) { + if (!String(err).includes("aborted")) showError(webcrtErrorMessage(err, t)); + else showError(t("webcrt.err.sftpAborted")); + } finally { + endSftpTransfer(); + } + }, + [activeTabKey, beginSftpTransfer, endSftpTransfer, showError, showOk, sftpBusy, t], + ); + + const downloadSftpItem = useCallback( + async (it: WebcrtSftpItem) => { + await downloadSftpItems([it]); + }, + [downloadSftpItems], + ); + + const mkdirSftp = useCallback(async () => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab || sftpBusy) return; + const name = window.prompt(t("webcrt.sftp.mkdirPrompt"), ""); + if (name == null) return; + const trimmed = String(name).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); + if (!trimmed || trimmed.includes("..") || trimmed.includes("/")) { + showError(t("webcrt.sftp.mkdirInvalid")); + return; + } + setSftpBusy(true); + try { + const remote = joinSftpPath(sftpPathRef.current, trimmed); + await webcrtSftpMkdir({ ...sftpTargetIds(tab), path: remote }); + showOk(t("webcrt.sftp.mkdirOk")); + await refreshSftp(sftpPathRef.current); + setSftpSelected([trimmed]); + sftpSelectAnchorRef.current = trimmed; + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } finally { + setSftpBusy(false); + } + }, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, t]); + + const removeSftpSelected = useCallback(async () => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab || !sftpSelected.length || sftpBusy) return; + const items = sftpItems.filter((x) => sftpSelected.includes(x.name)); + if (!items.length) return; + const label = + items.length === 1 ? items[0].name : t("webcrt.sftp.selectedCount", { count: items.length }); + if (!window.confirm(t("webcrt.sftp.deleteConfirm", { name: label }))) return; + const hasDir = items.some((x) => x.is_dir); + let recursive = false; + if (hasDir) { + recursive = window.confirm(t("webcrt.sftp.deleteRecursiveConfirm", { name: label })); + } + setSftpBusy(true); + let failed = 0; + try { + for (const item of items) { + const remote = joinSftpPath(sftpPathRef.current, item.name); + try { + try { + await webcrtSftpRemove({ + ...sftpTargetIds(tab), + path: remote, + recursive: false, + }); + } catch (err) { + const raw = String(err); + if (item.is_dir && raw.includes("sftp_dir_not_empty")) { + if (!recursive) throw err; + await webcrtSftpRemove({ ...sftpTargetIds(tab), path: remote, recursive: true }); + } else { + throw err; + } + } + } catch { + failed += 1; + } + } + setSftpSelected([]); + await refreshSftp(sftpPathRef.current); + if (failed > 0) showError(t("webcrt.sftp.deletePartial", { ok: items.length - failed, failed })); + else showOk(t("webcrt.sftp.deleted")); + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } finally { + setSftpBusy(false); + } + }, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpItems, sftpSelected, t]); + + const renameSftpSelected = useCallback(async () => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab || sftpSelected.length !== 1 || sftpBusy) return; + const name = sftpSelected[0]; + const next = window.prompt(t("webcrt.sftp.renamePrompt"), name); + if (next == null) return; + const trimmed = String(next).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); + if (!trimmed || trimmed.includes("..") || trimmed.includes("/")) { + showError(t("webcrt.sftp.renameInvalid")); + return; + } + if (trimmed === name) return; + const oldPath = joinSftpPath(sftpPathRef.current, name); + const newPath = joinSftpPath(sftpPathRef.current, trimmed); + setSftpBusy(true); + try { + await webcrtSftpRename({ ...sftpTargetIds(tab), old_path: oldPath, new_path: newPath }); + showOk(t("webcrt.sftp.renamed")); + setSftpSelected([trimmed]); + sftpSelectAnchorRef.current = trimmed; + await refreshSftp(sftpPathRef.current); + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } finally { + setSftpBusy(false); + } + }, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpSelected, t]); + + const chmodSftpSelected = useCallback(async () => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab || !sftpSelected.length || sftpBusy) return; + const sample = sftpItems.find((x) => x.name === sftpSelected[0]); + const preset = sample?.mode ? sample.mode.replace(/^./, "").replace(/[^rwx-]/g, "") : "755"; + const mode = window.prompt(t("webcrt.sftp.chmodPrompt"), preset.length === 9 ? preset : "755"); + if (mode == null) return; + const trimmed = String(mode).trim(); + if (!trimmed) return; + setSftpBusy(true); + let failed = 0; + try { + for (const name of sftpSelected) { + const remote = joinSftpPath(sftpPathRef.current, name); + try { + await webcrtSftpChmod({ ...sftpTargetIds(tab), path: remote, mode: trimmed }); + } catch { + failed += 1; + } + } + await refreshSftp(sftpPathRef.current); + if (failed > 0) showError(t("webcrt.sftp.chmodPartial", { ok: sftpSelected.length - failed, failed })); + else showOk(t("webcrt.sftp.chmodOk")); + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } finally { + setSftpBusy(false); + } + }, [activeTabKey, refreshSftp, showError, showOk, sftpBusy, sftpItems, sftpSelected, t]); + + const toggleSftpSort = useCallback((key: SftpSortKey) => { + setSftpSortKey((prev) => { + if (prev === key) { + setSftpSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + setSftpSortDir(key === "mtime" || key === "size" ? "desc" : "asc"); + return key; + }); + }, []); + const uploadSftpItems = useCallback( async (items: SftpUploadItem[]) => { const tab = tabsRef.current.find((x) => x.key === activeTabKey); - if (!tab || !items.length) return; - setSftpBusy(true); + if (!tab || !items.length || sftpBusy) return; + const ac = beginSftpTransfer(); setSftpDragOver(false); sftpDragDepthRef.current = 0; let done = 0; let failed = 0; try { for (const item of items) { + if (ac.signal.aborted) throw new Error("aborted"); done += 1; const shortName = String(item.relativePath || item.file.name).split("/").pop() || item.file.name; setSftpStatus( @@ -1341,26 +1652,29 @@ export function WebcrtPage() { } const remote = joinSftpPath(sftpPathRef.current, rel); try { - const body = - tab.target.source === "ume" - ? { - ume_ne_id: tab.target.ume_ne_id || tab.target.id, - remote_path: remote, - file: item.file, - } - : { ne_id: tab.target.id, remote_path: remote, file: item.file }; - await webcrtSftpUpload(body, (p) => { - const pct = formatSftpTransferPct(p.loaded, p.total || item.file.size); - setSftpStatus( - t("webcrt.sftp.uploading", { - done, - total: items.length, - name: shortName, - pct: pct || "…", - }), - ); - }); + await webcrtSftpUpload( + { ...sftpTargetIds(tab), remote_path: remote, file: item.file }, + { + signal: ac.signal, + retries: 2, + onRetry: (attempt) => { + setSftpStatus(t("webcrt.sftp.retrying", { name: shortName, attempt, max: 3 })); + }, + onProgress: (p) => { + const pct = formatSftpTransferPct(p.loaded, p.total || item.file.size); + setSftpStatus( + t("webcrt.sftp.uploading", { + done, + total: items.length, + name: shortName, + pct: pct || "…", + }), + ); + }, + }, + ); } catch (err) { + if (String(err).includes("aborted")) throw err; failed += 1; if (items.length === 1) throw err; } @@ -1372,13 +1686,13 @@ export function WebcrtPage() { showOk(t("webcrt.sftp.uploaded")); } } catch (err) { - showError(webcrtErrorMessage(err, t)); + if (String(err).includes("aborted")) showError(t("webcrt.err.sftpAborted")); + else showError(webcrtErrorMessage(err, t)); } finally { - setSftpBusy(false); - setSftpStatus(""); + endSftpTransfer(); } }, - [activeTabKey, refreshSftp, showError, showOk, t], + [activeTabKey, beginSftpTransfer, endSftpTransfer, refreshSftp, showError, showOk, sftpBusy, t], ); const onSftpDragEnter = useCallback((e: ReactDragEvent) => { @@ -1541,11 +1855,29 @@ export function WebcrtPage() { const activeTab = tabs.find((x) => x.key === activeTabKey) || null; const sftpBlockedKey = activeTab ? sftpUnavailableReason(activeTab) : "webcrt.err.sftpSsh"; const sftpAllowed = Boolean(activeTab && !sftpBlockedKey); + const sftpSortedItems = sortSftpItems(sftpItems, sftpSortKey, sftpSortDir); + const sftpOrderedNames = sftpSortedItems.map((it) => it.name); + const sftpSelectedItems = sftpSortedItems.filter((it) => sftpSelected.includes(it.name)); + const sftpSelectedFiles = sftpSelectedItems.filter((it) => !it.is_dir); useEffect(() => { if (sftpOpen && !sftpAllowed) setSftpOpen(false); }, [sftpOpen, sftpAllowed]); + useEffect(() => { + const prev = sftpActiveTabRef.current; + if (prev === activeTabKey) return; + if (prev) sftpPathByTabRef.current[prev] = sftpPathRef.current; + sftpActiveTabRef.current = activeTabKey; + if (!sftpOpen || !activeTabKey || !sftpAllowed) return; + const saved = sftpPathByTabRef.current[activeTabKey] || "."; + sftpPathRef.current = saved; + setSftpPath(saved); + setSftpSelected([]); + sftpSelectAnchorRef.current = ""; + void refreshSftp(saved); + }, [activeTabKey, refreshSftp, sftpAllowed, sftpOpen]); + const renameWebcrtSession = useCallback( async (target: CliTargetItem, nextName: string) => { const name = nextName.trim(); @@ -1826,8 +2158,17 @@ export function WebcrtPage() { showError(t(sftpBlockedKey || "webcrt.err.sftpSsh")); return; } - setSftpOpen((v) => !v); - if (!sftpOpen) void refreshSftp(); + setSftpOpen((v) => { + const next = !v; + if (next) { + const saved = + sftpPathByTabRef.current[activeTabKey] || sftpPathRef.current || "."; + sftpPathRef.current = saved; + setSftpPath(saved); + void refreshSftp(saved); + } + return next; + }); }} > {t("webcrt.sftp.title")} @@ -2069,6 +2410,37 @@ export function WebcrtPage() { + + + + +