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:
oliver 2026-08-02 01:33:37 +08:00
parent d10ffed277
commit 9674de221d
10 changed files with 1146 additions and 153 deletions

View file

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

View file

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

View file

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

View file

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

View file

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