From d10ffed277913fd9f62e363997097e66d0992245 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 01:10:30 +0800 Subject: [PATCH] Improve WebCRT SFTP transfers and file-panel UX. Stream large uploads/downloads with progress, raise the size cap, and support drag-drop folders plus fixed/resizable columns. Co-authored-by: Cursor --- netx_api/config.py | 3 + netx_api/webcrt_router.py | 48 ++-- netx_api/webcrt_sftp.py | 215 +++++++++++++++--- tests/test_webcrt.py | 35 +++ web/src/i18n/en.ts | 7 + web/src/i18n/zh.ts | 7 + web/src/index.css | 64 +++++- web/src/pages/WebcrtPage.tsx | 411 +++++++++++++++++++++++++++++++---- web/src/services/api.ts | 99 ++++++--- 9 files changed, 769 insertions(+), 120 deletions(-) diff --git a/netx_api/config.py b/netx_api/config.py index 4aacfef..b3b5aa6 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -101,6 +101,9 @@ class Settings(BaseSettings): webcrt_session_log_enabled: bool = True # Reader: short blocking wait instead of fixed 40ms spin (seconds). webcrt_reader_poll_sec: float = 0.01 + # 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 # 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 82f6360..ba6230d 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -213,19 +213,26 @@ def api_sftp_list(body: WebcrtSftpListBody, db: Session = Depends(get_db)) -> di @router.post("/sftp/download") def api_sftp_download(body: WebcrtSftpDownloadBody, db: Session = Depends(get_db)) -> Any: - from fastapi.responses import Response + from fastapi.responses import StreamingResponse - from .webcrt_sftp import sftp_download + 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") - data, filename = sftp_download(db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path) - return Response( - content=data, + stream = SftpDownloadStream( + db, managed_ne_id=mid or None, ume_ne_id=uid or None, path=body.path + ).open() + headers = { + "Content-Disposition": stream.content_disposition(), + "Content-Length": str(int(stream.size)), + "Cache-Control": "no-store", + } + return StreamingResponse( + stream, media_type="application/octet-stream", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + headers=headers, ) @@ -237,22 +244,31 @@ async def api_sftp_upload( remote_path: str = Form(...), file: UploadFile = File(...), ) -> dict[str, Any]: - from .webcrt_sftp import sftp_upload + from starlette.concurrency import run_in_threadpool + + 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") - content = await file.read() - if len(content) > 8 * 1024 * 1024: + 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: raise HTTPException(status_code=413, detail="sftp_file_too_large") - return sftp_upload( - db, - managed_ne_id=mid or None, - ume_ne_id=uid or None, - remote_path=remote_path, - data=content, - ) + + def _upload() -> dict[str, Any]: + return sftp_upload_stream( + db, + managed_ne_id=mid or None, + ume_ne_id=uid or None, + remote_path=remote_path, + reader=file.file, + expected_size=int(expected) if expected is not None else None, + ) + + return await run_in_threadpool(_upload) @router.websocket("/sessions/{session_id}/ws") diff --git a/netx_api/webcrt_sftp.py b/netx_api/webcrt_sftp.py index ffb577d..558cd23 100644 --- a/netx_api/webcrt_sftp.py +++ b/netx_api/webcrt_sftp.py @@ -7,9 +7,10 @@ import posixpath import stat as statmod import threading import time -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field -from typing import Any, Iterator +from typing import Any, BinaryIO, Iterator +from urllib.parse import quote import paramiko from fastapi import HTTPException @@ -201,6 +202,43 @@ def _owner_group(attr: Any) -> tuple[str, str]: return ("" if uid is None else str(uid), "" if gid is None else str(gid)) +def _mkdir_p(sftp: Any, remote: str) -> None: + """Create remote directory and parents (like mkdir -p).""" + path = _normalize_remote(remote, allow_dot=False) + if not path or path in (".", "/"): + return + to_create: list[str] = [] + cur = path + while cur and cur not in (".", "/"): + to_create.append(cur) + parent = posixpath.dirname(cur) + if parent == cur: + break + cur = parent + for candidate in reversed(to_create): + try: + st = sftp.stat(candidate) + mode = int(getattr(st, "st_mode", 0) or 0) + if not statmod.S_ISDIR(mode): + raise HTTPException(status_code=400, detail=f"sftp_not_a_directory:{candidate}") + continue + except HTTPException: + raise + except Exception: + pass + try: + sftp.mkdir(candidate) + except Exception as exc: + # Race: another client created it; accept if it is now a directory. + try: + st = sftp.stat(candidate) + if statmod.S_ISDIR(int(getattr(st, "st_mode", 0) or 0)): + continue + except Exception: + pass + raise HTTPException(status_code=502, detail=f"sftp_mkdir_failed:{candidate}:{exc}") from exc + + def sftp_list( db: Session, *, @@ -244,27 +282,148 @@ def sftp_list( raise HTTPException(status_code=502, detail=f"sftp_list_failed:{exc}") from exc -def sftp_download( +def _sftp_max_file_bytes() -> int: + return max(1, int(settings.webcrt_sftp_max_file_bytes or (512 * 1024 * 1024))) + + +def _sftp_chunk_bytes() -> int: + return max(4 * 1024, int(settings.webcrt_sftp_chunk_bytes or (64 * 1024))) + + +def _content_disposition(filename: str) -> str: + name = str(filename or "download.bin").replace('"', "").replace("\r", "").replace("\n", "") + if not name: + name = "download.bin" + ascii_name = name.encode("ascii", "replace").decode("ascii") or "download.bin" + return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}" + + +class SftpDownloadStream: + """Hold the SFTP channel open while StreamingResponse consumes chunks.""" + + def __init__( + self, + db: Session, + *, + managed_ne_id: str | None, + ume_ne_id: str | None, + path: str, + ) -> None: + self._db = db + self._managed_ne_id = managed_ne_id + self._ume_ne_id = ume_ne_id + self.remote = _normalize_remote(path, allow_dot=False) + self.filename = "download.bin" + self.size = 0 + self._stack: ExitStack | None = None + self._fh: Any = None + self._chunk = _sftp_chunk_bytes() + + def open(self) -> "SftpDownloadStream": + if not self.remote or self.remote.endswith("/"): + raise HTTPException(status_code=400, detail="sftp_path_required") + stack = ExitStack() + try: + sftp, _device = stack.enter_context( + _sftp_client(self._db, managed_ne_id=self._managed_ne_id, ume_ne_id=self._ume_ne_id) + ) + try: + st = sftp.stat(self.remote) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc + mode = int(getattr(st, "st_mode", 0) or 0) + if statmod.S_ISDIR(mode): + raise HTTPException(status_code=400, detail="sftp_path_is_directory") + size = int(getattr(st, "st_size", 0) or 0) + if size > _sftp_max_file_bytes(): + raise HTTPException(status_code=413, detail="sftp_file_too_large") + self.size = max(0, size) + self.filename = posixpath.basename(self.remote) or "download.bin" + try: + self._fh = stack.enter_context(sftp.open(self.remote, "rb")) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc + except Exception: + stack.close() + raise + self._stack = stack + return self + + def __iter__(self) -> Iterator[bytes]: + fh = self._fh + if fh is None: + return + try: + while True: + chunk = fh.read(self._chunk) + if not chunk: + break + yield chunk + finally: + self.close() + + def close(self) -> None: + stack = self._stack + self._stack = None + self._fh = None + if stack is not None: + try: + stack.close() + except Exception: + pass + + def content_disposition(self) -> str: + return _content_disposition(self.filename) + + +def sftp_upload_stream( db: Session, *, managed_ne_id: str | None, ume_ne_id: str | None, - path: str, -) -> tuple[bytes, str]: - remote = _normalize_remote(path, allow_dot=False) - if not remote or remote.endswith("/"): + remote_path: str, + reader: BinaryIO, + expected_size: int | None = None, +) -> dict[str, Any]: + remote = _normalize_remote(remote_path, allow_dot=False) + if not remote: raise HTTPException(status_code=400, detail="sftp_path_required") + max_bytes = _sftp_max_file_bytes() + if expected_size is not None and int(expected_size) > max_bytes: + raise HTTPException(status_code=413, detail="sftp_file_too_large") + chunk_size = _sftp_chunk_bytes() try: - with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, _device): - with sftp.open(remote, "rb") as fh: - data = fh.read(8 * 1024 * 1024 + 1) - if len(data) > 8 * 1024 * 1024: - raise HTTPException(status_code=413, detail="sftp_file_too_large") - return data, posixpath.basename(remote) or "download.bin" + with _sftp_client(db, managed_ne_id=managed_ne_id, ume_ne_id=ume_ne_id) as (sftp, device): + parent = posixpath.dirname(remote) + if parent and parent not in (".", "/"): + _mkdir_p(sftp, parent) + written = 0 + try: + with sftp.open(remote, "wb") as fh: + while True: + buf = reader.read(chunk_size) + if not buf: + break + written += len(buf) + if written > max_bytes: + raise HTTPException(status_code=413, detail="sftp_file_too_large") + fh.write(buf) + except HTTPException: + try: + sftp.remove(remote) + except Exception: + pass + raise + return { + "ok": True, + "ne_id": str(device.get("id") or ""), + "path": remote, + "size": written, + } except HTTPException: raise except Exception as exc: - raise HTTPException(status_code=502, detail=f"sftp_download_failed:{exc}") from exc + raise HTTPException(status_code=502, detail=f"sftp_upload_failed:{exc}") from exc def sftp_upload( @@ -275,20 +434,14 @@ def sftp_upload( remote_path: str, data: bytes, ) -> dict[str, Any]: - remote = _normalize_remote(remote_path, allow_dot=False) - if not remote: - 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): - with sftp.open(remote, "wb") as fh: - fh.write(data) - return { - "ok": True, - "ne_id": str(device.get("id") or ""), - "path": remote, - "size": len(data), - } - except HTTPException: - raise - except Exception as exc: - raise HTTPException(status_code=502, detail=f"sftp_upload_failed:{exc}") from exc + """Compatibility helper for tests — wraps streamed upload over an in-memory buffer.""" + import io + + return sftp_upload_stream( + db, + managed_ne_id=managed_ne_id, + ume_ne_id=ume_ne_id, + remote_path=remote_path, + reader=io.BytesIO(data or b""), + expected_size=len(data or b""), + ) diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index e123c02..10ce206 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -748,6 +748,41 @@ class WebcrtServiceTests(unittest.TestCase): owner2, group2 = _owner_group(SimpleNamespace(longname="", st_uid=0, st_gid=1)) self.assertEqual((owner2, group2), ("0", "1")) + def test_sftp_mkdir_p(self) -> None: + from netx_api.webcrt_sftp import _mkdir_p + + class _FakeSftp: + def __init__(self) -> None: + self.dirs: set[str] = {"/"} + self.mkdir_calls: list[str] = [] + + def stat(self, path: str) -> object: + if path in self.dirs or path == "/": + return type("St", (), {"st_mode": 0o040755})() + raise FileNotFoundError(path) + + def mkdir(self, path: str) -> None: + self.mkdir_calls.append(path) + self.dirs.add(path) + + sftp = _FakeSftp() + _mkdir_p(sftp, "/a/b/c") + self.assertEqual(sftp.mkdir_calls, ["/a", "/a/b", "/a/b/c"]) + _mkdir_p(sftp, "/a/b/c") # idempotent + self.assertEqual(sftp.mkdir_calls, ["/a", "/a/b", "/a/b/c"]) + + 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 + + self.assertGreaterEqual(_sftp_max_file_bytes(), 8 * 1024 * 1024) + self.assertGreaterEqual(_sftp_chunk_bytes(), 4 * 1024) + 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) + @patch.object(svc, "_audit") def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None: conn = _FakeConn() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f6e6fb6..3ad949a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1045,6 +1045,11 @@ const en = { up: "Up", upload: "Upload", uploaded: "Upload complete", + uploading: "Upload {{done}}/{{total}} · {{name}} {{pct}}", + downloading: "Download {{name}} {{pct}}", + uploadPartial: "Uploaded {{ok}}, failed {{failed}}", + dropHint: "Drop to upload into current folder", + dropEmpty: "No uploadable files found", dir: "dir", loading: "Loading…", enterHint: "Double-click to open folder", @@ -1056,6 +1061,7 @@ const en = { colGroup: "Group", colMode: "Access", resizeHint: "Drag to resize SFTP / terminal", + colResizeHint: "Drag to resize column", }, term: { findPh: "Find…", @@ -1084,6 +1090,7 @@ const en = { sftpSsh: "SFTP requires SSH", 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)", 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 b16970a..e0686b7 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1042,6 +1042,11 @@ const zh = { up: "上级", upload: "上传", uploaded: "上传成功", + uploading: "上传 {{done}}/{{total}} · {{name}} {{pct}}", + downloading: "下载 {{name}} {{pct}}", + uploadPartial: "上传完成 {{ok}} 个,失败 {{failed}} 个", + dropHint: "松开以上传到当前目录", + dropEmpty: "未识别到可上传的文件", dir: "目录", loading: "正在加载…", enterHint: "双击进入目录", @@ -1053,6 +1058,7 @@ const zh = { colGroup: "组", colMode: "权限", resizeHint: "拖动调整 SFTP / 终端宽度", + colResizeHint: "拖动调整列宽", }, term: { findPh: "查找…", @@ -1081,6 +1087,7 @@ const zh = { sftpSsh: "SFTP 仅支持 SSH 协议", sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)", sftpUnsupported: "当前 SSH 会话未提供 SFTP(设备可能未开启)", + sftpTooLarge: "文件超过大小限制(默认 512MB)", queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整", websocket: "WebSocket 连接失败", }, diff --git a/web/src/index.css b/web/src/index.css index 7272566..41f17ba 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3083,6 +3083,28 @@ pre { cursor: progress; } +.webcrt-sftp.is-drop-target { + outline: 1px solid #0ea5e9; + outline-offset: -1px; +} + +.webcrt-sftp__drop-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: #e0f2fe; + background: rgba(14, 165, 233, 0.18); + border: 1px dashed #38bdf8; + pointer-events: none; +} + .webcrt-sftp__bar { display: flex; align-items: center; @@ -3151,7 +3173,6 @@ pre { } .webcrt-sftp__table { - width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 11px; @@ -3170,10 +3191,44 @@ pre { text-align: left; text-transform: uppercase; letter-spacing: 0.04em; - padding: 4px 6px; + padding: 4px 8px 4px 6px; border-bottom: 1px solid #334155; white-space: nowrap; user-select: none; + overflow: hidden; +} + +.webcrt-sftp__th-label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 4px; +} + +.webcrt-sftp__col-resizer { + position: absolute; + top: 0; + right: 0; + width: 5px; + height: 100%; + cursor: col-resize; + z-index: 2; +} + +.webcrt-sftp__col-resizer::after { + content: ""; + position: absolute; + top: 2px; + bottom: 2px; + right: 1px; + width: 1px; + background: transparent; +} + +.webcrt-sftp__col-resizer:hover::after, +.webcrt-sftp__col-resizer:active::after { + background: #0ea5e9; } .webcrt-sftp__table tbody tr { @@ -3200,12 +3255,10 @@ pre { } .webcrt-sftp__col-name { - width: 34%; color: #e2e8f0; } .webcrt-sftp__col-size { - width: 12%; text-align: right; font-variant-numeric: tabular-nums; color: #64748b; @@ -3216,19 +3269,16 @@ pre { } .webcrt-sftp__col-mtime { - width: 22%; font-variant-numeric: tabular-nums; color: #64748b; } .webcrt-sftp__col-owner, .webcrt-sftp__col-group { - width: 10%; color: #64748b; } .webcrt-sftp__col-mode { - width: 12%; font-family: Consolas, "Courier New", monospace; font-size: 10px; color: #475569; diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index d2b0f25..6aa1616 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type DragEvent as ReactDragEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal"; @@ -36,6 +44,28 @@ const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs"; const SFTP_WIDTH_KEY = "netx.webcrt.sftpWidth"; const SFTP_WIDTH_DEFAULT = 480; const SFTP_WIDTH_MIN = 280; +const SFTP_COL_WIDTHS_KEY = "netx.webcrt.sftpColWidths"; + +type SftpColKey = "name" | "size" | "mtime" | "owner" | "group" | "mode"; +type SftpColWidths = Record; + +const SFTP_COL_KEYS: SftpColKey[] = ["name", "size", "mtime", "owner", "group", "mode"]; +const SFTP_COL_WIDTH_DEFAULTS: SftpColWidths = { + name: 220, + size: 72, + mtime: 140, + owner: 72, + group: 72, + mode: 88, +}; +const SFTP_COL_WIDTH_MIN: SftpColWidths = { + name: 120, + size: 48, + mtime: 96, + owner: 48, + group: 48, + mode: 64, +}; const ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const; const FONT_SIZE_OPTIONS = [12, 13, 14, 16, 18, 20] as const; const PASTE_DELAY_OPTIONS = [0, 20, 40, 60, 100, 150] as const; @@ -400,6 +430,48 @@ function saveSftpWidth(width: number): void { } } +function clampSftpColWidth(key: SftpColKey, width: number): number { + const min = SFTP_COL_WIDTH_MIN[key]; + const n = Math.round(Number(width) || 0); + if (!Number.isFinite(n)) return SFTP_COL_WIDTH_DEFAULTS[key]; + return Math.max(min, Math.min(n, 640)); +} + +function normalizeSftpColWidths(raw: unknown): SftpColWidths { + const base = { ...SFTP_COL_WIDTH_DEFAULTS }; + if (!raw || typeof raw !== "object") return base; + const obj = raw as Record; + for (const key of SFTP_COL_KEYS) { + const v = obj[key]; + if (typeof v === "number" || typeof v === "string") { + base[key] = clampSftpColWidth(key, Number(v)); + } + } + return base; +} + +function loadSftpColWidths(): SftpColWidths { + try { + const raw = localStorage.getItem(SFTP_COL_WIDTHS_KEY); + if (!raw) return { ...SFTP_COL_WIDTH_DEFAULTS }; + return normalizeSftpColWidths(JSON.parse(raw)); + } catch { + return { ...SFTP_COL_WIDTH_DEFAULTS }; + } +} + +function saveSftpColWidths(widths: SftpColWidths): void { + try { + localStorage.setItem(SFTP_COL_WIDTHS_KEY, JSON.stringify(widths)); + } catch { + /* ignore */ + } +} + +function sumSftpColWidths(widths: SftpColWidths): number { + return SFTP_COL_KEYS.reduce((acc, key) => acc + widths[key], 0); +} + function formatSftpSizeKb(bytes: number, isDir: boolean): string { if (isDir) return ""; const kb = Number(bytes || 0) / 1024; @@ -417,6 +489,80 @@ function formatSftpMtime(sec: number): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } +function formatSftpTransferPct(loaded: number, total: number): string { + const t = Number(total || 0); + if (!Number.isFinite(t) || t <= 0) return ""; + const pct = Math.min(100, Math.max(0, Math.round((Number(loaded || 0) / t) * 100))); + return `${pct}%`; +} + +type SftpUploadItem = { file: File; relativePath: string }; + +type FsEntryLike = { + isFile: boolean; + isDirectory: boolean; + name: string; + file: (ok: (f: File) => void, err?: (e: DOMException) => void) => void; + createReader: () => { + readEntries: (ok: (entries: FsEntryLike[]) => void, err?: (e: DOMException) => void) => void; + }; +}; + +async function readAllDirectoryEntries( + reader: ReturnType, +): Promise { + const all: FsEntryLike[] = []; + for (;;) { + const batch = await new Promise((resolve, reject) => { + reader.readEntries(resolve, reject); + }); + if (!batch.length) break; + all.push(...batch); + } + return all; +} + +async function walkFsEntry(entry: FsEntryLike, prefix: string, out: SftpUploadItem[]): Promise { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); + const relativePath = (prefix ? `${prefix}/${entry.name}` : entry.name).replace(/\\/g, "/"); + out.push({ file, relativePath }); + return; + } + if (!entry.isDirectory) return; + const nextPrefix = prefix ? `${prefix}/${entry.name}` : entry.name; + const children = await readAllDirectoryEntries(entry.createReader()); + for (const child of children) { + await walkFsEntry(child, nextPrefix, out); + } +} + +async function collectDroppedSftpUploads(dt: DataTransfer): Promise { + const out: SftpUploadItem[] = []; + const items = Array.from(dt.items || []); + const entries = items + .map((it) => { + const getter = (it as DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry; + return typeof getter === "function" ? (getter.call(it) as FsEntryLike | null) : null; + }) + .filter((e): e is FsEntryLike => Boolean(e)); + if (entries.length) { + for (const entry of entries) { + await walkFsEntry(entry, "", out); + } + return out; + } + for (const file of Array.from(dt.files || [])) { + const rel = String((file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name) + .replace(/\\/g, "/") + .replace(/^\/+/, ""); + out.push({ file, relativePath: rel || file.name }); + } + return out; +} + function connectFailedNe(err: unknown): ManagedNeItem | null { if (!(err instanceof ApiRequestError)) return null; const detail = err.detail; @@ -445,6 +591,7 @@ function webcrtErrorMessage(err: unknown, t: (key: string, vars?: Record([]); 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 sftpPathRef = useRef("."); const sftpBodyRef = useRef(null); const sftpDragRef = useRef<{ startX: number; startW: number } | null>(null); + const sftpColDragRef = useRef<{ key: SftpColKey; startX: number; startW: number } | null>(null); + const sftpDragDepthRef = useRef(0); sftpPathRef.current = sftpPath; const [sessionOpts, setSessionOpts] = useState(() => loadSessionOptions()); const [optionsMenuOpen, setOptionsMenuOpen] = useState(false); @@ -1163,6 +1315,118 @@ export function WebcrtPage() { [refreshSftp], ); + const uploadSftpItems = useCallback( + async (items: SftpUploadItem[]) => { + const tab = tabsRef.current.find((x) => x.key === activeTabKey); + if (!tab || !items.length) return; + setSftpBusy(true); + setSftpDragOver(false); + sftpDragDepthRef.current = 0; + let done = 0; + let failed = 0; + try { + for (const item of items) { + done += 1; + const shortName = String(item.relativePath || item.file.name).split("/").pop() || item.file.name; + setSftpStatus( + t("webcrt.sftp.uploading", { done, total: items.length, name: shortName, pct: "0%" }), + ); + const rel = String(item.relativePath || item.file.name) + .replace(/\\/g, "/") + .replace(/^\/+/, "") + .replace(/\/+/g, "/"); + if (!rel || rel.includes("..")) { + failed += 1; + continue; + } + 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 || "…", + }), + ); + }); + } catch (err) { + failed += 1; + if (items.length === 1) throw err; + } + } + await refreshSftp(sftpPathRef.current); + if (failed > 0) { + showError(t("webcrt.sftp.uploadPartial", { ok: items.length - failed, failed })); + } else { + showOk(t("webcrt.sftp.uploaded")); + } + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } finally { + setSftpBusy(false); + setSftpStatus(""); + } + }, + [activeTabKey, refreshSftp, showError, showOk, t], + ); + + const onSftpDragEnter = useCallback((e: ReactDragEvent) => { + if (![...e.dataTransfer.types].includes("Files")) return; + e.preventDefault(); + e.stopPropagation(); + sftpDragDepthRef.current += 1; + setSftpDragOver(true); + }, []); + + const onSftpDragOver = useCallback((e: ReactDragEvent) => { + if (![...e.dataTransfer.types].includes("Files")) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + }, []); + + const onSftpDragLeave = useCallback((e: ReactDragEvent) => { + if (![...e.dataTransfer.types].includes("Files")) return; + e.preventDefault(); + e.stopPropagation(); + sftpDragDepthRef.current = Math.max(0, sftpDragDepthRef.current - 1); + if (sftpDragDepthRef.current === 0) setSftpDragOver(false); + }, []); + + const onSftpDrop = useCallback( + (e: ReactDragEvent) => { + e.preventDefault(); + e.stopPropagation(); + sftpDragDepthRef.current = 0; + setSftpDragOver(false); + if (sftpBusy) return; + void (async () => { + try { + const items = await collectDroppedSftpUploads(e.dataTransfer); + if (!items.length) { + showError(t("webcrt.sftp.dropEmpty")); + return; + } + await uploadSftpItems(items); + } catch (err) { + showError(webcrtErrorMessage(err, t)); + } + })(); + }, + [showError, sftpBusy, t, uploadSftpItems], + ); + const onSftpSplitDown = useCallback((e: ReactMouseEvent) => { e.preventDefault(); sftpDragRef.current = { startX: e.clientX, startW: sftpWidth }; @@ -1193,6 +1457,33 @@ export function WebcrtPage() { document.addEventListener("mouseup", onUp); }, [sftpWidth]); + const onSftpColResizeDown = useCallback((key: SftpColKey, e: ReactMouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + sftpColDragRef.current = { key, startX: e.clientX, startW: sftpColWidths[key] }; + const onMove = (ev: MouseEvent) => { + const drag = sftpColDragRef.current; + if (!drag) return; + const next = clampSftpColWidth(drag.key, drag.startW + (ev.clientX - drag.startX)); + setSftpColWidths((prev) => (prev[drag.key] === next ? prev : { ...prev, [drag.key]: next })); + }; + const onUp = () => { + sftpColDragRef.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + setSftpColWidths((prev) => { + saveSftpColWidths(prev); + return prev; + }); + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, [sftpColWidths]); + // Auto-connect from /webcrt?ne_id=... useEffect(() => { if (!presetNeId) return; @@ -1744,8 +2035,12 @@ export function WebcrtPage() { onMouseDown={onSftpSplitDown} />