mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Expand WebCRT SFTP into a fuller file manager.
Add mkdir/rename/delete/chmod, multi-select batch ops, cancelable transfers with retries, per-tab paths, sorting, and safer listing limits. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d10ffed277
commit
9674de221d
10 changed files with 1146 additions and 153 deletions
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 连接失败",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TermTab, "target">): { 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<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("sftp_chmod_invalid_mode")) return t("webcrt.err.sftpChmodInvalid");
|
||||
if (raw.includes("aborted")) return t("webcrt.err.sftpAborted");
|
||||
if (raw.includes("websocket_error")) return t("webcrt.err.websocket");
|
||||
return raw;
|
||||
}
|
||||
|
|
@ -640,12 +671,22 @@ export function WebcrtPage() {
|
|||
const [sftpPath, setSftpPath] = useState(".");
|
||||
const [sftpBusy, setSftpBusy] = useState(false);
|
||||
const [sftpItems, setSftpItems] = useState<WebcrtSftpItem[]>([]);
|
||||
const [sftpSelected, setSftpSelected] = useState<string[]>([]);
|
||||
const [sftpSortKey, setSftpSortKey] = useState<SftpSortKey>("name");
|
||||
const [sftpSortDir, setSftpSortDir] = useState<"asc" | "desc">("asc");
|
||||
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 [sftpTransferring, setSftpTransferring] = useState(false);
|
||||
const [sftpListTruncated, setSftpListTruncated] = useState(false);
|
||||
const [sftpListMaxEntries, setSftpListMaxEntries] = useState(0);
|
||||
const sftpPathRef = useRef(".");
|
||||
const sftpPathByTabRef = useRef<Record<string, string>>({});
|
||||
const sftpActiveTabRef = useRef("");
|
||||
const sftpSelectAnchorRef = useRef("");
|
||||
const sftpAbortRef = useRef<AbortController | null>(null);
|
||||
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);
|
||||
|
|
@ -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<HTMLElement>) => {
|
||||
|
|
@ -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() {
|
|||
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
|
||||
{t("webcrt.sftp.refresh")}
|
||||
</button>
|
||||
<button type="button" disabled={sftpBusy} onClick={() => void mkdirSftp()}>
|
||||
{t("webcrt.sftp.mkdir")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy || sftpSelected.length !== 1}
|
||||
onClick={() => void renameSftpSelected()}
|
||||
>
|
||||
{t("webcrt.sftp.rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy || sftpSelectedFiles.length === 0}
|
||||
onClick={() => void downloadSftpItems(sftpSelectedFiles)}
|
||||
>
|
||||
{t("webcrt.sftp.download")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy || sftpSelected.length === 0}
|
||||
onClick={() => void chmodSftpSelected()}
|
||||
>
|
||||
{t("webcrt.sftp.chmod")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy || sftpSelected.length === 0}
|
||||
onClick={() => void removeSftpSelected()}
|
||||
>
|
||||
{t("webcrt.sftp.delete")}
|
||||
</button>
|
||||
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
|
||||
{t("webcrt.sftp.upload")}
|
||||
<input
|
||||
|
|
@ -2087,7 +2459,24 @@ export function WebcrtPage() {
|
|||
</div>
|
||||
{sftpBusy || sftpStatus ? (
|
||||
<div className="webcrt-sftp__status" role="status">
|
||||
{sftpStatus || t("webcrt.sftp.loading")}
|
||||
<span>{sftpStatus || t("webcrt.sftp.loading")}</span>
|
||||
{sftpTransferring ? (
|
||||
<button
|
||||
type="button"
|
||||
className="webcrt-sftp__cancel"
|
||||
onClick={cancelSftpTransfer}
|
||||
>
|
||||
{t("webcrt.sftp.cancel")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!sftpBusy && sftpListTruncated ? (
|
||||
<div className="webcrt-sftp__status webcrt-sftp__status--warn" role="status">
|
||||
{t("webcrt.sftp.listTruncated", {
|
||||
count: sftpItems.length,
|
||||
max: sftpListMaxEntries || sftpItems.length,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{sftpDragOver ? (
|
||||
|
|
@ -2122,10 +2511,17 @@ export function WebcrtPage() {
|
|||
).map(([key, labelKey]) => (
|
||||
<th
|
||||
key={key}
|
||||
className={`webcrt-sftp__col-${key}`}
|
||||
className={`webcrt-sftp__col-${key}${sftpSortKey === key ? " is-sorted" : ""}`}
|
||||
style={{ width: sftpColWidths[key] }}
|
||||
>
|
||||
<span className="webcrt-sftp__th-label">{t(labelKey)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="webcrt-sftp__th-label"
|
||||
onClick={() => toggleSftpSort(key)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
{sftpSortKey === key ? (sftpSortDir === "asc" ? " ▲" : " ▼") : ""}
|
||||
</button>
|
||||
<span
|
||||
className="webcrt-sftp__col-resizer"
|
||||
title={t("webcrt.sftp.colResizeHint")}
|
||||
|
|
@ -2136,53 +2532,29 @@ export function WebcrtPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sftpItems.map((it) => (
|
||||
{!sftpBusy && sftpSortedItems.length === 0 ? (
|
||||
<tr className="webcrt-sftp__empty-row">
|
||||
<td colSpan={6}>{t("webcrt.sftp.empty")}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{sftpSortedItems.map((it) => (
|
||||
<tr
|
||||
key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}
|
||||
className={sftpBusy ? "is-busy" : undefined}
|
||||
className={`${sftpBusy ? "is-busy" : ""}${
|
||||
sftpSelected.includes(it.name) ? " is-selected" : ""
|
||||
}`}
|
||||
title={it.is_dir ? t("webcrt.sftp.enterHint") : t("webcrt.sftp.downloadHint")}
|
||||
onClick={() => {
|
||||
if (it.is_dir || sftpBusy) return;
|
||||
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"
|
||||
? {
|
||||
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||
path: remote,
|
||||
}
|
||||
: { ne_id: activeTab.target.id, path: remote };
|
||||
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;
|
||||
a.download = it.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
setSftpStatus("");
|
||||
}
|
||||
})();
|
||||
onClick={(e) => {
|
||||
if (sftpBusy) return;
|
||||
selectSftpRow(it.name, e, sftpOrderedNames);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!it.is_dir || sftpBusy) return;
|
||||
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
||||
if (sftpBusy) return;
|
||||
if (it.is_dir) {
|
||||
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
||||
return;
|
||||
}
|
||||
void downloadSftpItem(it);
|
||||
}}
|
||||
>
|
||||
<td className="webcrt-sftp__col-name" style={{ width: sftpColWidths.name }}>
|
||||
|
|
|
|||
|
|
@ -563,29 +563,84 @@ export type WebcrtSftpListResult = {
|
|||
ne_name: string;
|
||||
path: string;
|
||||
items: WebcrtSftpItem[];
|
||||
truncated?: boolean;
|
||||
max_entries?: number;
|
||||
};
|
||||
|
||||
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
|
||||
apiPost<WebcrtSftpListResult>("/v1/webcrt/sftp/list", body);
|
||||
|
||||
export const webcrtSftpMkdir = (body: { ne_id?: string; ume_ne_id?: string; path: string }) =>
|
||||
apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/mkdir", body);
|
||||
|
||||
export const webcrtSftpRemove = (body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
}) => apiPost<{ ok: boolean; path: string; ne_id: string }>("/v1/webcrt/sftp/remove", body);
|
||||
|
||||
export const webcrtSftpRename = (body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
old_path: string;
|
||||
new_path: string;
|
||||
}) => apiPost<{ ok: boolean; old_path: string; new_path: string; ne_id: string }>("/v1/webcrt/sftp/rename", body);
|
||||
|
||||
export const webcrtSftpChmod = (body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
path: string;
|
||||
mode: string;
|
||||
}) => apiPost<{ ok: boolean; path: string; mode: string; ne_id: string }>("/v1/webcrt/sftp/chmod", body);
|
||||
|
||||
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,
|
||||
export type WebcrtSftpTransferOpts = {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (p: WebcrtSftpTransferProgress) => void;
|
||||
/** Extra attempts after the first failure. Default 2 (3 tries total). */
|
||||
retries?: number;
|
||||
onRetry?: (attempt: number, err: unknown) => void;
|
||||
};
|
||||
|
||||
const isAbortError = (err: unknown): boolean => {
|
||||
const raw = String(err || "").toLowerCase();
|
||||
return raw.includes("aborted") || raw.includes("abort");
|
||||
};
|
||||
|
||||
async function withSftpRetries<T>(
|
||||
run: () => Promise<T>,
|
||||
opts?: Pick<WebcrtSftpTransferOpts, "retries" | "signal" | "onRetry">,
|
||||
): Promise<T> {
|
||||
const retries = Math.max(0, Number(opts?.retries ?? 2));
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
if (opts?.signal?.aborted) throw new Error("aborted");
|
||||
try {
|
||||
return await run();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (isAbortError(err) || attempt >= retries) break;
|
||||
opts?.onRetry?.(attempt + 1, err);
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr || "transfer_failed"));
|
||||
}
|
||||
|
||||
async function webcrtSftpDownloadOnce(
|
||||
body: { ne_id?: string; ume_ne_id?: string; path: string },
|
||||
opts?: Pick<WebcrtSftpTransferOpts, "signal" | "onProgress">,
|
||||
): Promise<Blob> {
|
||||
const path = "/v1/webcrt/sftp/download";
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
signal: opts?.signal,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
|
|
@ -598,32 +653,57 @@ export async function webcrtSftpDownload(
|
|||
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 });
|
||||
opts?.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 });
|
||||
try {
|
||||
for (;;) {
|
||||
if (opts?.signal?.aborted) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error("aborted");
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
loaded += value.byteLength;
|
||||
opts?.onProgress?.({ loaded, total });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (opts?.signal?.aborted || isAbortError(err)) throw new Error("aborted");
|
||||
throw err;
|
||||
}
|
||||
return new Blob(chunks as BlobPart[], { type: "application/octet-stream" });
|
||||
}
|
||||
|
||||
export function webcrtSftpUpload(
|
||||
export async function webcrtSftpDownload(
|
||||
body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
path: string;
|
||||
},
|
||||
optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void),
|
||||
): Promise<Blob> {
|
||||
const opts: WebcrtSftpTransferOpts =
|
||||
typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {};
|
||||
return withSftpRetries(
|
||||
() => webcrtSftpDownloadOnce(body, opts),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
function webcrtSftpUploadOnce(
|
||||
body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
remote_path: string;
|
||||
file: File;
|
||||
},
|
||||
onProgress?: (p: WebcrtSftpTransferProgress) => void,
|
||||
opts?: Pick<WebcrtSftpTransferOpts, "signal" | "onProgress">,
|
||||
): Promise<{ ok: boolean; path: string; size: number }> {
|
||||
const path = "/v1/webcrt/sftp/upload";
|
||||
const fd = new FormData();
|
||||
|
|
@ -634,18 +714,33 @@ export function webcrtSftpUpload(
|
|||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const onAbort = () => {
|
||||
try {
|
||||
xhr.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
return;
|
||||
}
|
||||
opts.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
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({
|
||||
if (!opts?.onProgress) return;
|
||||
opts.onProgress({
|
||||
loaded: Number(ev.loaded || 0),
|
||||
total: ev.lengthComputable ? Number(ev.total || 0) : body.file.size || 0,
|
||||
});
|
||||
};
|
||||
xhr.onload = () => {
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
if (xhr.status === 401) {
|
||||
handleUnauthorized(path);
|
||||
reject(new Error("401 unauthorized"));
|
||||
|
|
@ -661,12 +756,32 @@ export function webcrtSftpUpload(
|
|||
reject(new Error("invalid_upload_response"));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("network_error"));
|
||||
xhr.onabort = () => reject(new Error("aborted"));
|
||||
xhr.onerror = () => {
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
reject(new Error("network_error"));
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
reject(new Error("aborted"));
|
||||
};
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
export function webcrtSftpUpload(
|
||||
body: {
|
||||
ne_id?: string;
|
||||
ume_ne_id?: string;
|
||||
remote_path: string;
|
||||
file: File;
|
||||
},
|
||||
optsOrProgress?: WebcrtSftpTransferOpts | ((p: WebcrtSftpTransferProgress) => void),
|
||||
): Promise<{ ok: boolean; path: string; size: number }> {
|
||||
const opts: WebcrtSftpTransferOpts =
|
||||
typeof optsOrProgress === "function" ? { onProgress: optsOrProgress } : optsOrProgress || {};
|
||||
return withSftpRetries(() => webcrtSftpUploadOnce(body, opts), opts);
|
||||
}
|
||||
|
||||
export const webcrtWsUrl = (sessionId: string): string => {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue