mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Reuse SSH for WebCRT SFTP and fix folder navigation UX.
Open SFTP on the live session transport after SSH connect, pass the target path on every list refresh, and use folder/file icons in the browser panel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d87bdf4be1
commit
499b4cc2a1
10 changed files with 525 additions and 131 deletions
|
|
@ -69,7 +69,12 @@ type Props = {
|
|||
pasteDelayMs?: number;
|
||||
copyOnSelect?: boolean;
|
||||
keywordHighlight?: KeywordHighlightConfig;
|
||||
onStatus?: (state: string, message?: string, phase?: string) => void;
|
||||
onStatus?: (
|
||||
state: string,
|
||||
message?: string,
|
||||
phase?: string,
|
||||
meta?: { sftpReady?: boolean; cliHop?: boolean },
|
||||
) => void;
|
||||
onReady?: () => void;
|
||||
onStdout?: (data: string) => void;
|
||||
};
|
||||
|
|
@ -481,6 +486,8 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
state?: string;
|
||||
message?: string;
|
||||
phase?: string;
|
||||
sftp_ready?: boolean;
|
||||
cli_hop?: boolean;
|
||||
};
|
||||
if (msg.type === "stdout" && typeof msg.data === "string") {
|
||||
writeStdout(msg.data);
|
||||
|
|
@ -490,7 +497,14 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
|
|||
if (msg.type === "status") {
|
||||
if (!isActiveSocket()) return;
|
||||
const phase = typeof msg.phase === "string" ? msg.phase : undefined;
|
||||
onStatusRef.current?.(String(msg.state || ""), msg.message, phase);
|
||||
const meta =
|
||||
typeof msg.sftp_ready === "boolean" || typeof msg.cli_hop === "boolean"
|
||||
? {
|
||||
sftpReady: typeof msg.sftp_ready === "boolean" ? msg.sftp_ready : undefined,
|
||||
cliHop: typeof msg.cli_hop === "boolean" ? msg.cli_hop : undefined,
|
||||
}
|
||||
: undefined;
|
||||
onStatusRef.current?.(String(msg.state || ""), msg.message, phase, meta);
|
||||
if (msg.state === "connected" || msg.state === "connecting") {
|
||||
maybeFocus();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,9 @@ const en = {
|
|||
upload: "Upload",
|
||||
uploaded: "Upload complete",
|
||||
dir: "dir",
|
||||
loading: "Loading…",
|
||||
enterHint: "Double-click to open folder",
|
||||
downloadHint: "Click to download",
|
||||
},
|
||||
term: {
|
||||
findPh: "Find…",
|
||||
|
|
@ -1073,6 +1076,7 @@ const en = {
|
|||
sftpHop: "SFTP does not support hop devices yet (direct SSH only)",
|
||||
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)",
|
||||
queueDropped: "Terminal output too fast; about {{count}} chunk(s) dropped — transcript may be incomplete",
|
||||
websocket: "WebSocket connection failed",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1043,6 +1043,9 @@ const zh = {
|
|||
upload: "上传",
|
||||
uploaded: "上传成功",
|
||||
dir: "目录",
|
||||
loading: "正在加载…",
|
||||
enterHint: "双击进入目录",
|
||||
downloadHint: "单击下载文件",
|
||||
},
|
||||
term: {
|
||||
findPh: "查找…",
|
||||
|
|
@ -1070,6 +1073,7 @@ const zh = {
|
|||
sftpHop: "SFTP 暂不支持跳板设备,请使用直连 SSH",
|
||||
sftpSsh: "SFTP 仅支持 SSH 协议",
|
||||
sftpNeedPassword: "SFTP 需要已保存的 SSH 密码(会话弹窗密码不会用于文件传输)",
|
||||
sftpUnsupported: "当前 SSH 会话未提供 SFTP(设备可能未开启)",
|
||||
queueDropped: "终端输出过快,已丢弃约 {{count}} 段,内容可能不完整",
|
||||
websocket: "WebSocket 连接失败",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3046,6 +3046,10 @@ pre {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.webcrt-sftp.is-busy {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.webcrt-sftp__bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
|
@ -3073,6 +3077,19 @@ pre {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webcrt-sftp__bar button:disabled,
|
||||
.webcrt-sftp__upload.is-disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.webcrt-sftp__status {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
|
@ -3083,6 +3100,9 @@ pre {
|
|||
|
||||
.webcrt-sftp__list button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
|
@ -3091,12 +3111,43 @@ pre {
|
|||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list button:hover {
|
||||
.webcrt-sftp__icon {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: #94a3b8;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.webcrt-sftp__icon.is-dir {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.webcrt-sftp__name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.webcrt-sftp__size {
|
||||
flex-shrink: 0;
|
||||
color: #64748b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list button:hover:not(:disabled) {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.webcrt-shell {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ type TermTab = {
|
|||
encoding: string;
|
||||
/** From session create — nested CLI hop cannot use SFTP. */
|
||||
cliHop?: boolean;
|
||||
/** SFTP channel attached on the live SSH transport after connect. */
|
||||
sftpReady?: boolean;
|
||||
};
|
||||
|
||||
type TabMenuState = { key: string; x: number; y: number };
|
||||
|
|
@ -274,6 +276,22 @@ function ComputerIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden>
|
||||
<path d="M3 6a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden>
|
||||
<path d="M6 2h8l4 4v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm7 1.5V8h4.5L13 3.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function isSshAuthFailure(err: unknown): boolean {
|
||||
const raw = String(err).toLowerCase();
|
||||
return (
|
||||
|
|
@ -319,26 +337,47 @@ function isDeviceClosedMessage(err: unknown): boolean {
|
|||
|
||||
/** SFTP is direct SSH only; returns i18n key when unavailable. */
|
||||
function sftpUnavailableReason(
|
||||
tab: Pick<TermTab, "target" | "cliHop">,
|
||||
): "webcrt.err.sftpSsh" | "webcrt.err.sftpHop" | "webcrt.err.sftpNeedPassword" | null {
|
||||
tab: Pick<TermTab, "target" | "cliHop" | "sftpReady" | "status">,
|
||||
):
|
||||
| "webcrt.err.sftpSsh"
|
||||
| "webcrt.err.sftpHop"
|
||||
| "webcrt.err.sftpNeedPassword"
|
||||
| "webcrt.err.sftpUnsupported"
|
||||
| null {
|
||||
const proto = String(tab.target.protocol || "ssh").toLowerCase();
|
||||
if (proto === "telnet") return "webcrt.err.sftpSsh";
|
||||
if (tab.cliHop || tab.target.hop_enabled) return "webcrt.err.sftpHop";
|
||||
// Inventory/quick-connect SFTP opens a fresh Paramiko session from saved DB creds.
|
||||
if (tab.target.source !== "ume" && !tab.target.has_password) return "webcrt.err.sftpNeedPassword";
|
||||
// Prefer the live SSH session channel; inventory still needs saved password for pool fallback.
|
||||
if (tab.target.source !== "ume" && !tab.target.has_password && tab.sftpReady !== true) {
|
||||
return "webcrt.err.sftpNeedPassword";
|
||||
}
|
||||
if (tab.status === "connected" && tab.sftpReady === false) return "webcrt.err.sftpUnsupported";
|
||||
return null;
|
||||
}
|
||||
|
||||
function sftpParentPath(path: string): string {
|
||||
const cur = String(path || ".").replace(/\\/g, "/").replace(/\/+$/, "") || ".";
|
||||
if (cur === "." || cur === "/") return cur === "/" ? "/" : ".";
|
||||
const parts = cur.split("/").filter((p, i) => p || i === 0);
|
||||
const parts = cur.split("/").filter((p, i) => (i === 0 && cur.startsWith("/")) || Boolean(p));
|
||||
if (parts.length <= 1) return cur.startsWith("/") ? "/" : ".";
|
||||
parts.pop();
|
||||
const parent = parts.join("/") || (cur.startsWith("/") ? "/" : ".");
|
||||
if (cur.startsWith("/")) {
|
||||
return parts.length <= 1 ? "/" : parts.join("/");
|
||||
}
|
||||
const parent = parts.join("/");
|
||||
return parent || ".";
|
||||
}
|
||||
|
||||
function joinSftpPath(base: string, name: string): string {
|
||||
const n = String(name || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
if (!n || n === ".") return String(base || ".") || ".";
|
||||
if (n === "..") return sftpParentPath(base);
|
||||
const b = String(base || ".").replace(/\\/g, "/").replace(/\/+$/, "") || ".";
|
||||
if (b === "." || b === "") return n;
|
||||
if (b === "/") return `/${n}`;
|
||||
return `${b}/${n}`;
|
||||
}
|
||||
|
||||
function connectFailedNe(err: unknown): ManagedNeItem | null {
|
||||
if (!(err instanceof ApiRequestError)) return null;
|
||||
const detail = err.detail;
|
||||
|
|
@ -413,7 +452,10 @@ export function WebcrtPage() {
|
|||
const [activeTabKey, setActiveTabKey] = useState("");
|
||||
const [sftpOpen, setSftpOpen] = useState(false);
|
||||
const [sftpPath, setSftpPath] = useState(".");
|
||||
const [sftpBusy, setSftpBusy] = useState(false);
|
||||
const [sftpItems, setSftpItems] = useState<Array<{ name: string; size: number; mtime: number; is_dir: boolean }>>([]);
|
||||
const sftpPathRef = useRef(".");
|
||||
sftpPathRef.current = sftpPath;
|
||||
const [sessionOpts, setSessionOpts] = useState<SessionOptions>(() => loadSessionOptions());
|
||||
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
|
||||
const [sessionOptsModalOpen, setSessionOptsModalOpen] = useState(false);
|
||||
|
|
@ -527,7 +569,13 @@ export function WebcrtPage() {
|
|||
}, []);
|
||||
|
||||
const attachSessionResult = useCallback(
|
||||
(target: CliTargetItem, sessionId: string, encoding: string, cliHop?: boolean) => {
|
||||
(
|
||||
target: CliTargetItem,
|
||||
sessionId: string,
|
||||
encoding: string,
|
||||
cliHop?: boolean,
|
||||
sftpReady?: boolean,
|
||||
) => {
|
||||
const key = targetKey(target);
|
||||
const existing = tabsRef.current.find((tab) => tab.key === key);
|
||||
const pending: TermTab = {
|
||||
|
|
@ -542,6 +590,7 @@ export function WebcrtPage() {
|
|||
encoding,
|
||||
errorMessage: undefined,
|
||||
cliHop: Boolean(cliHop),
|
||||
sftpReady: sftpReady === true ? true : sftpReady === false ? false : existing?.sftpReady,
|
||||
};
|
||||
setTabs((prev) => {
|
||||
const without = prev.filter((x) => x.key !== key);
|
||||
|
|
@ -650,6 +699,7 @@ export function WebcrtPage() {
|
|||
connectPhase: "authenticating",
|
||||
termEpoch: pending.termEpoch + 1,
|
||||
cliHop: Boolean(sess.cli_hop),
|
||||
sftpReady: typeof sess.sftp_ready === "boolean" ? sess.sftp_ready : undefined,
|
||||
});
|
||||
showOk(t("webcrt.opened", { name: deviceLabel(target) }));
|
||||
} catch (err) {
|
||||
|
|
@ -745,7 +795,13 @@ export function WebcrtPage() {
|
|||
connect_status: result.ne.connect_status || "unknown",
|
||||
cli_profile_ready: true,
|
||||
};
|
||||
attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop));
|
||||
attachSessionResult(
|
||||
target,
|
||||
result.session_id,
|
||||
dims.encoding,
|
||||
Boolean(result.cli_hop),
|
||||
result.sftp_ready,
|
||||
);
|
||||
setHostDialogOpen(false);
|
||||
setHostForm(emptyHostForm());
|
||||
setSource(listSource === "managed" ? "managed" : "webcrt");
|
||||
|
|
@ -808,6 +864,7 @@ export function WebcrtPage() {
|
|||
sess.session_id,
|
||||
dims.encoding,
|
||||
Boolean(sess.cli_hop),
|
||||
sess.sftp_ready,
|
||||
);
|
||||
setAuthDialog(null);
|
||||
setAuthForm(emptyAuthForm());
|
||||
|
|
@ -842,7 +899,13 @@ export function WebcrtPage() {
|
|||
connect_status: result.ne.connect_status || "unknown",
|
||||
cli_profile_ready: true,
|
||||
};
|
||||
attachSessionResult(target, result.session_id, dims.encoding, Boolean(result.cli_hop));
|
||||
attachSessionResult(
|
||||
target,
|
||||
result.session_id,
|
||||
dims.encoding,
|
||||
Boolean(result.cli_hop),
|
||||
result.sftp_ready,
|
||||
);
|
||||
setAuthDialog(null);
|
||||
setAuthForm(emptyAuthForm());
|
||||
setSource("webcrt");
|
||||
|
|
@ -1022,21 +1085,40 @@ export function WebcrtPage() {
|
|||
}
|
||||
}, [activeTabKey, showOk, showError, t]);
|
||||
|
||||
const refreshSftp = useCallback(async () => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
try {
|
||||
const body =
|
||||
tab.target.source === "ume"
|
||||
? { ume_ne_id: tab.target.ume_ne_id || tab.target.id, path: sftpPath }
|
||||
: { ne_id: tab.target.id, path: sftpPath };
|
||||
const res = await webcrtSftpList(body);
|
||||
setSftpItems(res.items || []);
|
||||
setSftpPath(res.path || sftpPath);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
}
|
||||
}, [activeTabKey, showError, sftpPath, t]);
|
||||
const refreshSftp = useCallback(
|
||||
async (pathOverride?: string) => {
|
||||
const tab = tabsRef.current.find((x) => x.key === activeTabKey);
|
||||
if (!tab) return;
|
||||
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);
|
||||
setSftpItems(res.items || []);
|
||||
const nextPath = String(res.path || path).trim() || path;
|
||||
sftpPathRef.current = nextPath;
|
||||
setSftpPath(nextPath);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
},
|
||||
[activeTabKey, showError, t],
|
||||
);
|
||||
|
||||
const navigateSftp = useCallback(
|
||||
(nextPath: string) => {
|
||||
const path = String(nextPath || ".").trim() || ".";
|
||||
sftpPathRef.current = path;
|
||||
setSftpPath(path);
|
||||
void refreshSftp(path);
|
||||
},
|
||||
[refreshSftp],
|
||||
);
|
||||
|
||||
// Auto-connect from /webcrt?ne_id=...
|
||||
useEffect(() => {
|
||||
|
|
@ -1482,12 +1564,14 @@ export function WebcrtPage() {
|
|||
onReady={() => {
|
||||
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
||||
}}
|
||||
onStatus={(state, message, phase) => {
|
||||
onStatus={(state, message, phase, meta) => {
|
||||
if (state === "connected") {
|
||||
updateTab(tab.key, {
|
||||
status: "connected",
|
||||
connectPhase: undefined,
|
||||
errorMessage: undefined,
|
||||
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
|
||||
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
|
||||
});
|
||||
window.setTimeout(() => termRefs.current.get(tab.key)?.focus(), 40);
|
||||
} else if (state === "open" || state === "connecting") {
|
||||
|
|
@ -1498,6 +1582,8 @@ export function WebcrtPage() {
|
|||
updateTab(tab.key, {
|
||||
status: "connecting",
|
||||
connectPhase: nextPhase,
|
||||
...(typeof meta?.sftpReady === "boolean" ? { sftpReady: meta.sftpReady } : {}),
|
||||
...(typeof meta?.cliHop === "boolean" ? { cliHop: meta.cliHop } : {}),
|
||||
});
|
||||
} else if (state === "warning") {
|
||||
const m = String(message || "");
|
||||
|
|
@ -1570,33 +1656,46 @@ export function WebcrtPage() {
|
|||
);
|
||||
})}
|
||||
{sftpOpen && activeTab && sftpAllowed ? (
|
||||
<div className="webcrt-sftp">
|
||||
<div className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}>
|
||||
<div className="webcrt-sftp__bar">
|
||||
<button
|
||||
type="button"
|
||||
title={t("webcrt.sftp.up")}
|
||||
onClick={() => {
|
||||
const parent = sftpParentPath(sftpPath);
|
||||
setSftpPath(parent);
|
||||
window.setTimeout(() => void refreshSftp(), 0);
|
||||
}}
|
||||
disabled={sftpBusy}
|
||||
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
|
||||
>
|
||||
{t("webcrt.sftp.up")}
|
||||
</button>
|
||||
<input value={sftpPath} onChange={(e) => setSftpPath(e.target.value)} />
|
||||
<button type="button" onClick={() => void refreshSftp()}>
|
||||
<input
|
||||
value={sftpPath}
|
||||
disabled={sftpBusy}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
sftpPathRef.current = v;
|
||||
setSftpPath(v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void refreshSftp(sftpPathRef.current);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
|
||||
{t("webcrt.sftp.refresh")}
|
||||
</button>
|
||||
<label className="webcrt-sftp__upload">
|
||||
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
|
||||
{t("webcrt.sftp.upload")}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
disabled={sftpBusy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !activeTab) return;
|
||||
const remote = `${sftpPath.replace(/\/$/, "")}/${file.name}`.replace(/^\.\//, "");
|
||||
if (!file || !activeTab || sftpBusy) return;
|
||||
const remote = joinSftpPath(sftpPathRef.current, file.name);
|
||||
void (async () => {
|
||||
setSftpBusy(true);
|
||||
try {
|
||||
const body =
|
||||
activeTab.target.source === "ume"
|
||||
|
|
@ -1608,9 +1707,11 @@ export function WebcrtPage() {
|
|||
: { ne_id: activeTab.target.id, remote_path: remote, file };
|
||||
await webcrtSftpUpload(body);
|
||||
showOk(t("webcrt.sftp.uploaded"));
|
||||
await refreshSftp();
|
||||
await refreshSftp(sftpPathRef.current);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
})();
|
||||
e.target.value = "";
|
||||
|
|
@ -1618,30 +1719,31 @@ export function WebcrtPage() {
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
{sftpBusy ? (
|
||||
<div className="webcrt-sftp__status" role="status">
|
||||
{t("webcrt.sftp.loading")}
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="webcrt-sftp__list">
|
||||
{sftpItems.map((it) => (
|
||||
<li key={it.name}>
|
||||
<li key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sftpBusy}
|
||||
title={it.is_dir ? t("webcrt.sftp.enterHint") : t("webcrt.sftp.downloadHint")}
|
||||
onClick={() => {
|
||||
if (it.is_dir) {
|
||||
const next = `${sftpPath.replace(/\/$/, "")}/${it.name}`.replace(/^\.\//, "");
|
||||
setSftpPath(next);
|
||||
window.setTimeout(() => void refreshSftp(), 0);
|
||||
return;
|
||||
}
|
||||
if (it.is_dir || sftpBusy) return;
|
||||
const remote = joinSftpPath(sftpPathRef.current, it.name);
|
||||
void (async () => {
|
||||
setSftpBusy(true);
|
||||
try {
|
||||
const body =
|
||||
activeTab.target.source === "ume"
|
||||
? {
|
||||
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
|
||||
path: remote,
|
||||
}
|
||||
: {
|
||||
ne_id: activeTab.target.id,
|
||||
path: `${sftpPath.replace(/\/$/, "")}/${it.name}`,
|
||||
};
|
||||
: { ne_id: activeTab.target.id, path: remote };
|
||||
const blob = await webcrtSftpDownload(body);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
|
|
@ -1651,13 +1753,21 @@ export function WebcrtPage() {
|
|||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!it.is_dir || sftpBusy) return;
|
||||
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
||||
}}
|
||||
>
|
||||
{it.is_dir ? `[${t("webcrt.sftp.dir")}] ` : ""}
|
||||
{it.name}
|
||||
{!it.is_dir ? ` (${it.size})` : ""}
|
||||
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}>
|
||||
{it.is_dir ? <FolderIcon /> : <FileIcon />}
|
||||
</span>
|
||||
<span className="webcrt-sftp__name">{it.name}</span>
|
||||
{!it.is_dir ? <span className="webcrt-sftp__size">({it.size})</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -503,6 +503,8 @@ export type WebcrtSessionCreateResult = {
|
|||
state?: string;
|
||||
/** True when session used a vendor CLI hop (nested stelnet/telnet). */
|
||||
cli_hop?: boolean;
|
||||
/** True when SFTP channel opened on the same SSH transport. */
|
||||
sftp_ready?: boolean;
|
||||
};
|
||||
|
||||
export const createWebcrtSession = (body: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue