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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 01:10:30 +08:00
parent 7d8b698b91
commit d10ffed277
9 changed files with 769 additions and 120 deletions

View file

@ -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",
},

View file

@ -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 连接失败",
},

View file

@ -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;

View file

@ -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<SftpColKey, number>;
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<string, unknown>;
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<FsEntryLike["createReader"]>,
): Promise<FsEntryLike[]> {
const all: FsEntryLike[] = [];
for (;;) {
const batch = await new Promise<FsEntryLike[]>((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<void> {
if (entry.isFile) {
const file = await new Promise<File>((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<SftpUploadItem[]> {
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<string,
}
if (raw.includes("sftp_hop_not_supported")) return t("webcrt.err.sftpHop");
if (raw.includes("sftp_requires_ssh")) return t("webcrt.err.sftpSsh");
if (raw.includes("sftp_file_too_large")) return t("webcrt.err.sftpTooLarge");
if (raw.includes("websocket_error")) return t("webcrt.err.websocket");
return raw;
}
@ -495,9 +642,14 @@ export function WebcrtPage() {
const [sftpItems, setSftpItems] = useState<WebcrtSftpItem[]>([]);
const [sftpWidth, setSftpWidth] = useState(() => loadSftpWidth());
const [sftpResizing, setSftpResizing] = useState(false);
const [sftpColWidths, setSftpColWidths] = useState<SftpColWidths>(() => loadSftpColWidths());
const [sftpDragOver, setSftpDragOver] = useState(false);
const [sftpStatus, setSftpStatus] = useState("");
const sftpPathRef = useRef(".");
const sftpBodyRef = useRef<HTMLDivElement | null>(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<SessionOptions>(() => 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<HTMLElement>) => {
if (![...e.dataTransfer.types].includes("Files")) return;
e.preventDefault();
e.stopPropagation();
sftpDragDepthRef.current += 1;
setSftpDragOver(true);
}, []);
const onSftpDragOver = useCallback((e: ReactDragEvent<HTMLElement>) => {
if (![...e.dataTransfer.types].includes("Files")) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}, []);
const onSftpDragLeave = useCallback((e: ReactDragEvent<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLDivElement>) => {
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<HTMLSpanElement>) => {
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}
/>
<aside
className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}
className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}${sftpDragOver ? " is-drop-target" : ""}`}
style={{ width: sftpWidth }}
onDragEnter={onSftpDragEnter}
onDragOver={onSftpDragOver}
onDragLeave={onSftpDragLeave}
onDrop={onSftpDrop}
>
<div className="webcrt-sftp__bar">
<button
@ -1779,51 +2074,65 @@ export function WebcrtPage() {
<input
type="file"
hidden
multiple
disabled={sftpBusy}
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !activeTab || sftpBusy) return;
const remote = joinSftpPath(sftpPathRef.current, file.name);
void (async () => {
setSftpBusy(true);
try {
const body =
activeTab.target.source === "ume"
? {
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
remote_path: remote,
file,
}
: { ne_id: activeTab.target.id, remote_path: remote, file };
await webcrtSftpUpload(body);
showOk(t("webcrt.sftp.uploaded"));
await refreshSftp(sftpPathRef.current);
} catch (err) {
showError(webcrtErrorMessage(err, t));
} finally {
setSftpBusy(false);
}
})();
const files = Array.from(e.target.files || []);
e.target.value = "";
if (!files.length || sftpBusy) return;
void uploadSftpItems(files.map((file) => ({ file, relativePath: file.name })));
}}
/>
</label>
</div>
{sftpBusy ? (
{sftpBusy || sftpStatus ? (
<div className="webcrt-sftp__status" role="status">
{t("webcrt.sftp.loading")}
{sftpStatus || t("webcrt.sftp.loading")}
</div>
) : null}
{sftpDragOver ? (
<div className="webcrt-sftp__drop-overlay" aria-hidden>
{t("webcrt.sftp.dropHint")}
</div>
) : null}
<div className="webcrt-sftp__table-wrap">
<table className="webcrt-sftp__table">
<table
className="webcrt-sftp__table"
style={{
width: sumSftpColWidths(sftpColWidths),
minWidth: sumSftpColWidths(sftpColWidths),
}}
>
<colgroup>
{SFTP_COL_KEYS.map((key) => (
<col key={key} style={{ width: sftpColWidths[key] }} />
))}
</colgroup>
<thead>
<tr>
<th className="webcrt-sftp__col-name">{t("webcrt.sftp.colName")}</th>
<th className="webcrt-sftp__col-size">{t("webcrt.sftp.colSize")}</th>
<th className="webcrt-sftp__col-mtime">{t("webcrt.sftp.colMtime")}</th>
<th className="webcrt-sftp__col-owner">{t("webcrt.sftp.colOwner")}</th>
<th className="webcrt-sftp__col-group">{t("webcrt.sftp.colGroup")}</th>
<th className="webcrt-sftp__col-mode">{t("webcrt.sftp.colMode")}</th>
{(
[
["name", "webcrt.sftp.colName"],
["size", "webcrt.sftp.colSize"],
["mtime", "webcrt.sftp.colMtime"],
["owner", "webcrt.sftp.colOwner"],
["group", "webcrt.sftp.colGroup"],
["mode", "webcrt.sftp.colMode"],
] as const
).map(([key, labelKey]) => (
<th
key={key}
className={`webcrt-sftp__col-${key}`}
style={{ width: sftpColWidths[key] }}
>
<span className="webcrt-sftp__th-label">{t(labelKey)}</span>
<span
className="webcrt-sftp__col-resizer"
title={t("webcrt.sftp.colResizeHint")}
onMouseDown={(e) => onSftpColResizeDown(key, e)}
/>
</th>
))}
</tr>
</thead>
<tbody>
@ -1837,6 +2146,9 @@ export function WebcrtPage() {
const remote = joinSftpPath(sftpPathRef.current, it.name);
void (async () => {
setSftpBusy(true);
setSftpStatus(
t("webcrt.sftp.downloading", { name: it.name, pct: "0%" }),
);
try {
const body =
activeTab.target.source === "ume"
@ -1845,7 +2157,15 @@ export function WebcrtPage() {
path: remote,
}
: { ne_id: activeTab.target.id, path: remote };
const blob = await webcrtSftpDownload(body);
const blob = await webcrtSftpDownload(body, (p) => {
const pct = formatSftpTransferPct(p.loaded, p.total || it.size);
setSftpStatus(
t("webcrt.sftp.downloading", {
name: it.name,
pct: pct || "…",
}),
);
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@ -1856,6 +2176,7 @@ export function WebcrtPage() {
showError(webcrtErrorMessage(err, t));
} finally {
setSftpBusy(false);
setSftpStatus("");
}
})();
}}
@ -1864,7 +2185,7 @@ export function WebcrtPage() {
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
}}
>
<td className="webcrt-sftp__col-name">
<td className="webcrt-sftp__col-name" style={{ width: sftpColWidths.name }}>
<span className="webcrt-sftp__name-cell">
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}>
{it.is_dir ? <FolderIcon /> : <FileIcon />}
@ -1872,13 +2193,21 @@ export function WebcrtPage() {
<span className="webcrt-sftp__name">{it.name}</span>
</span>
</td>
<td className="webcrt-sftp__col-size">
<td className="webcrt-sftp__col-size" style={{ width: sftpColWidths.size }}>
{formatSftpSizeKb(it.size, it.is_dir)}
</td>
<td className="webcrt-sftp__col-mtime">{formatSftpMtime(it.mtime)}</td>
<td className="webcrt-sftp__col-owner">{it.owner || ""}</td>
<td className="webcrt-sftp__col-group">{it.group || ""}</td>
<td className="webcrt-sftp__col-mode">{it.mode || ""}</td>
<td className="webcrt-sftp__col-mtime" style={{ width: sftpColWidths.mtime }}>
{formatSftpMtime(it.mtime)}
</td>
<td className="webcrt-sftp__col-owner" style={{ width: sftpColWidths.owner }}>
{it.owner || ""}
</td>
<td className="webcrt-sftp__col-group" style={{ width: sftpColWidths.group }}>
{it.group || ""}
</td>
<td className="webcrt-sftp__col-mode" style={{ width: sftpColWidths.mode }}>
{it.mode || ""}
</td>
</tr>
))}
</tbody>

View file

@ -568,11 +568,19 @@ export type WebcrtSftpListResult = {
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
apiPost<WebcrtSftpListResult>("/v1/webcrt/sftp/list", body);
export async function webcrtSftpDownload(body: {
ne_id?: string;
ume_ne_id?: string;
path: string;
}): Promise<Blob> {
export type WebcrtSftpTransferProgress = {
loaded: number;
total: number;
};
export async function webcrtSftpDownload(
body: {
ne_id?: string;
ume_ne_id?: string;
path: string;
},
onProgress?: (p: WebcrtSftpTransferProgress) => void,
): Promise<Blob> {
const path = "/v1/webcrt/sftp/download";
const res = await fetch(path, {
method: "POST",
@ -587,35 +595,76 @@ export async function webcrtSftpDownload(body: {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.blob();
const total = Number(res.headers.get("content-length") || 0);
if (!res.body) {
const blob = await res.blob();
onProgress?.({ loaded: blob.size, total: total || blob.size });
return blob;
}
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let loaded = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
loaded += value.byteLength;
onProgress?.({ loaded, total });
}
}
return new Blob(chunks as BlobPart[], { type: "application/octet-stream" });
}
export async function webcrtSftpUpload(body: {
ne_id?: string;
ume_ne_id?: string;
remote_path: string;
file: File;
}): Promise<{ ok: boolean; path: string; size: number }> {
export function webcrtSftpUpload(
body: {
ne_id?: string;
ume_ne_id?: string;
remote_path: string;
file: File;
},
onProgress?: (p: WebcrtSftpTransferProgress) => void,
): Promise<{ ok: boolean; path: string; size: number }> {
const path = "/v1/webcrt/sftp/upload";
const fd = new FormData();
if (body.ne_id) fd.append("ne_id", body.ne_id);
if (body.ume_ne_id) fd.append("ume_ne_id", body.ume_ne_id);
fd.append("remote_path", body.remote_path);
fd.append("file", body.file);
const res = await fetch(path, {
method: "POST",
headers: authHeaders(),
body: fd,
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", path);
const tok = getAuthToken();
if (tok) xhr.setRequestHeader("Authorization", `Bearer ${tok}`);
xhr.responseType = "text";
xhr.upload.onprogress = (ev) => {
if (!onProgress) return;
onProgress({
loaded: Number(ev.loaded || 0),
total: ev.lengthComputable ? Number(ev.total || 0) : body.file.size || 0,
});
};
xhr.onload = () => {
if (xhr.status === 401) {
handleUnauthorized(path);
reject(new Error("401 unauthorized"));
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(xhr.responseText || `HTTP ${xhr.status}`));
return;
}
try {
resolve(JSON.parse(xhr.responseText || "{}") as { ok: boolean; path: string; size: number });
} catch {
reject(new Error("invalid_upload_response"));
}
};
xhr.onerror = () => reject(new Error("network_error"));
xhr.onabort = () => reject(new Error("aborted"));
xhr.send(fd);
});
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("401 unauthorized");
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return (await res.json()) as { ok: boolean; path: string; size: number };
}
export const webcrtWsUrl = (sessionId: string): string => {