mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Enrich WebCRT SFTP listing and add a resizable terminal split.
Show size, mtime, owner, group, and mode, and let users drag the SFTP panel width. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
499b4cc2a1
commit
12153b89fb
7 changed files with 397 additions and 138 deletions
|
|
@ -184,6 +184,23 @@ def _sftp_client(
|
|||
raise
|
||||
|
||||
|
||||
def _filemode(mode: int) -> str:
|
||||
try:
|
||||
return str(statmod.filemode(int(mode)))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _owner_group(attr: Any) -> tuple[str, str]:
|
||||
longname = str(getattr(attr, "longname", "") or "").strip()
|
||||
parts = longname.split()
|
||||
if len(parts) >= 4 and parts[0][:1] in ("-", "d", "l", "c", "b", "p", "s"):
|
||||
return str(parts[2]), str(parts[3])
|
||||
uid = getattr(attr, "st_uid", None)
|
||||
gid = getattr(attr, "st_gid", None)
|
||||
return ("" if uid is None else str(uid), "" if gid is None else str(gid))
|
||||
|
||||
|
||||
def sftp_list(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -200,12 +217,18 @@ def sftp_list(
|
|||
name = str(attr.filename or "")
|
||||
if not name or name in (".", ".."):
|
||||
continue
|
||||
owner, group = _owner_group(attr)
|
||||
entries.append(
|
||||
{
|
||||
"name": name,
|
||||
"size": int(getattr(attr, "st_size", 0) or 0),
|
||||
"mtime": int(getattr(attr, "st_mtime", 0) or 0),
|
||||
"is_dir": bool(statmod.S_ISDIR(mode)),
|
||||
"mode": _filemode(mode),
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"uid": int(getattr(attr, "st_uid", 0) or 0),
|
||||
"gid": int(getattr(attr, "st_gid", 0) or 0),
|
||||
}
|
||||
)
|
||||
entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))
|
||||
|
|
|
|||
|
|
@ -734,6 +734,20 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
# Direct SSH is allowed (no raise).
|
||||
_require_ssh_direct({"protocol": "ssh", "hop_enabled": False}, {"protocol": "ssh"})
|
||||
|
||||
def test_sftp_list_metadata_helpers(self) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from netx_api.webcrt_sftp import _filemode, _owner_group
|
||||
|
||||
self.assertEqual(_filemode(0o100644), "-rw-r--r--")
|
||||
self.assertEqual(_filemode(0o040755), "drwxr-xr-x")
|
||||
owner, group = _owner_group(
|
||||
SimpleNamespace(longname="-rw------- 1 alice eng 12 Jan 1 00:00 a", st_uid=1000, st_gid=100)
|
||||
)
|
||||
self.assertEqual((owner, group), ("alice", "eng"))
|
||||
owner2, group2 = _owner_group(SimpleNamespace(longname="", st_uid=0, st_gid=1))
|
||||
self.assertEqual((owner2, group2), ("0", "1"))
|
||||
|
||||
@patch.object(svc, "_audit")
|
||||
def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None:
|
||||
conn = _FakeConn()
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,13 @@ const en = {
|
|||
loading: "Loading…",
|
||||
enterHint: "Double-click to open folder",
|
||||
downloadHint: "Click to download",
|
||||
colName: "Name",
|
||||
colSize: "Size (KB)",
|
||||
colMtime: "Last modified",
|
||||
colOwner: "Owner",
|
||||
colGroup: "Group",
|
||||
colMode: "Access",
|
||||
resizeHint: "Drag to resize SFTP / terminal",
|
||||
},
|
||||
term: {
|
||||
findPh: "Find…",
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,13 @@ const zh = {
|
|||
loading: "正在加载…",
|
||||
enterHint: "双击进入目录",
|
||||
downloadHint: "单击下载文件",
|
||||
colName: "名称",
|
||||
colSize: "大小 (KB)",
|
||||
colMtime: "修改时间",
|
||||
colOwner: "所有者",
|
||||
colGroup: "组",
|
||||
colMode: "权限",
|
||||
resizeHint: "拖动调整 SFTP / 终端宽度",
|
||||
},
|
||||
term: {
|
||||
findPh: "查找…",
|
||||
|
|
|
|||
|
|
@ -2728,6 +2728,16 @@ pre {
|
|||
min-height: 0;
|
||||
position: relative;
|
||||
background: #0b1220;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.webcrt-main__terms {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.webcrt-main__pane {
|
||||
|
|
@ -3033,17 +3043,40 @@ pre {
|
|||
padding: 6px;
|
||||
}
|
||||
|
||||
.webcrt-sftp {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 40%);
|
||||
background: #0f172a;
|
||||
.webcrt-sftp-split {
|
||||
flex: 0 0 5px;
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
background: #1e293b;
|
||||
border-left: 1px solid #334155;
|
||||
border-right: 1px solid #334155;
|
||||
z-index: 4;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.webcrt-sftp-split:hover,
|
||||
.webcrt-sftp-split.is-dragging {
|
||||
background: #0ea5e9;
|
||||
}
|
||||
|
||||
.webcrt-sftp-split::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 -3px;
|
||||
}
|
||||
|
||||
.webcrt-sftp {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 420px;
|
||||
max-width: 75%;
|
||||
min-width: 280px;
|
||||
background: #0f172a;
|
||||
border-left: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.webcrt-sftp.is-busy {
|
||||
|
|
@ -3055,6 +3088,7 @@ pre {
|
|||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #334155;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sftp__bar input {
|
||||
|
|
@ -3088,30 +3122,95 @@ pre {
|
|||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
.webcrt-sftp__table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.webcrt-sftp__list button {
|
||||
.webcrt-sftp__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #334155;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table tbody tr {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table tbody tr:hover {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table tbody tr.is-busy {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.webcrt-sftp__table td {
|
||||
padding: 5px 8px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.webcrt-sftp__col-name {
|
||||
width: 36%;
|
||||
}
|
||||
|
||||
.webcrt-sftp__col-size {
|
||||
width: 12%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.webcrt-sftp__col-mtime {
|
||||
width: 20%;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.webcrt-sftp__col-owner,
|
||||
.webcrt-sftp__col-group {
|
||||
width: 10%;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.webcrt-sftp__col-mode {
|
||||
width: 12%;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.webcrt-sftp__name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webcrt-sftp__icon {
|
||||
|
|
@ -3127,27 +3226,11 @@ pre {
|
|||
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal";
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
webcrtSftpList,
|
||||
webcrtSftpUpload,
|
||||
webcrtWsUrl,
|
||||
type WebcrtSftpItem,
|
||||
} from "../services/api";
|
||||
import { pageCount } from "../utils/display";
|
||||
import type { CliTargetItem, ManagedNeItem } from "../types";
|
||||
|
|
@ -32,6 +33,9 @@ import {
|
|||
const PAGE_SIZE = 50;
|
||||
const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions";
|
||||
const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs";
|
||||
const SFTP_WIDTH_KEY = "netx.webcrt.sftpWidth";
|
||||
const SFTP_WIDTH_DEFAULT = 480;
|
||||
const SFTP_WIDTH_MIN = 280;
|
||||
const ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const;
|
||||
const FONT_SIZE_OPTIONS = [12, 13, 14, 16, 18, 20] as const;
|
||||
const PASTE_DELAY_OPTIONS = [0, 20, 40, 60, 100, 150] as const;
|
||||
|
|
@ -378,6 +382,41 @@ function joinSftpPath(base: string, name: string): string {
|
|||
return `${b}/${n}`;
|
||||
}
|
||||
|
||||
function loadSftpWidth(): number {
|
||||
try {
|
||||
const n = Number(localStorage.getItem(SFTP_WIDTH_KEY));
|
||||
if (Number.isFinite(n) && n >= SFTP_WIDTH_MIN) return Math.min(Math.round(n), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return SFTP_WIDTH_DEFAULT;
|
||||
}
|
||||
|
||||
function saveSftpWidth(width: number): void {
|
||||
try {
|
||||
localStorage.setItem(SFTP_WIDTH_KEY, String(Math.round(width)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function formatSftpSizeKb(bytes: number, isDir: boolean): string {
|
||||
if (isDir) return "";
|
||||
const kb = Number(bytes || 0) / 1024;
|
||||
if (!Number.isFinite(kb) || kb <= 0) return "0";
|
||||
if (kb < 10) return kb.toFixed(1);
|
||||
return String(Math.round(kb));
|
||||
}
|
||||
|
||||
function formatSftpMtime(sec: number): string {
|
||||
const n = Number(sec || 0);
|
||||
if (!Number.isFinite(n) || n <= 0) return "";
|
||||
const d = new Date(n * 1000);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (x: number) => String(x).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function connectFailedNe(err: unknown): ManagedNeItem | null {
|
||||
if (!(err instanceof ApiRequestError)) return null;
|
||||
const detail = err.detail;
|
||||
|
|
@ -453,8 +492,12 @@ export function WebcrtPage() {
|
|||
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 [sftpItems, setSftpItems] = useState<WebcrtSftpItem[]>([]);
|
||||
const [sftpWidth, setSftpWidth] = useState(() => loadSftpWidth());
|
||||
const [sftpResizing, setSftpResizing] = useState(false);
|
||||
const sftpPathRef = useRef(".");
|
||||
const sftpBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const sftpDragRef = useRef<{ startX: number; startW: number } | null>(null);
|
||||
sftpPathRef.current = sftpPath;
|
||||
const [sessionOpts, setSessionOpts] = useState<SessionOptions>(() => loadSessionOptions());
|
||||
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
|
||||
|
|
@ -1120,6 +1163,36 @@ export function WebcrtPage() {
|
|||
[refreshSftp],
|
||||
);
|
||||
|
||||
const onSftpSplitDown = useCallback((e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
sftpDragRef.current = { startX: e.clientX, startW: sftpWidth };
|
||||
setSftpResizing(true);
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const drag = sftpDragRef.current;
|
||||
if (!drag) return;
|
||||
const bodyW = sftpBodyRef.current?.clientWidth || window.innerWidth;
|
||||
const maxW = Math.max(SFTP_WIDTH_MIN, Math.floor(bodyW * 0.75));
|
||||
const next = Math.max(SFTP_WIDTH_MIN, Math.min(maxW, drag.startW + (drag.startX - ev.clientX)));
|
||||
setSftpWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
sftpDragRef.current = null;
|
||||
setSftpResizing(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
setSftpWidth((w) => {
|
||||
saveSftpWidth(w);
|
||||
return w;
|
||||
});
|
||||
};
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}, [sftpWidth]);
|
||||
|
||||
// Auto-connect from /webcrt?ne_id=...
|
||||
useEffect(() => {
|
||||
if (!presetNeId) return;
|
||||
|
|
@ -1470,7 +1543,11 @@ export function WebcrtPage() {
|
|||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="webcrt-main__body">
|
||||
<div
|
||||
ref={sftpBodyRef}
|
||||
className={`webcrt-main__body${sftpOpen && activeTab && sftpAllowed ? " has-sftp" : ""}`}
|
||||
>
|
||||
<div className="webcrt-main__terms">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTabKey === tab.key;
|
||||
// Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus.
|
||||
|
|
@ -1655,85 +1732,58 @@ export function WebcrtPage() {
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sftpOpen && activeTab && sftpAllowed ? (
|
||||
<div className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}>
|
||||
<div className="webcrt-sftp__bar">
|
||||
<button
|
||||
type="button"
|
||||
title={t("webcrt.sftp.up")}
|
||||
disabled={sftpBusy}
|
||||
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
|
||||
>
|
||||
{t("webcrt.sftp.up")}
|
||||
</button>
|
||||
<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${sftpBusy ? " is-disabled" : ""}`}>
|
||||
{t("webcrt.sftp.upload")}
|
||||
<>
|
||||
<div
|
||||
className={`webcrt-sftp-split${sftpResizing ? " is-dragging" : ""}`}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("webcrt.sftp.resizeHint")}
|
||||
title={t("webcrt.sftp.resizeHint")}
|
||||
onMouseDown={onSftpSplitDown}
|
||||
/>
|
||||
<aside
|
||||
className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}
|
||||
style={{ width: sftpWidth }}
|
||||
>
|
||||
<div className="webcrt-sftp__bar">
|
||||
<button
|
||||
type="button"
|
||||
title={t("webcrt.sftp.up")}
|
||||
disabled={sftpBusy}
|
||||
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
|
||||
>
|
||||
{t("webcrt.sftp.up")}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
value={sftpPath}
|
||||
disabled={sftpBusy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !activeTab || sftpBusy) return;
|
||||
const remote = joinSftpPath(sftpPathRef.current, file.name);
|
||||
void (async () => {
|
||||
setSftpBusy(true);
|
||||
try {
|
||||
const body =
|
||||
activeTab.target.source === "ume"
|
||||
? {
|
||||
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||
remote_path: remote,
|
||||
file,
|
||||
}
|
||||
: { ne_id: activeTab.target.id, remote_path: remote, file };
|
||||
await webcrtSftpUpload(body);
|
||||
showOk(t("webcrt.sftp.uploaded"));
|
||||
await refreshSftp(sftpPathRef.current);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
})();
|
||||
e.target.value = "";
|
||||
const v = e.target.value;
|
||||
sftpPathRef.current = v;
|
||||
setSftpPath(v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void refreshSftp(sftpPathRef.current);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
<button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
|
||||
{t("webcrt.sftp.refresh")}
|
||||
</button>
|
||||
<label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
|
||||
{t("webcrt.sftp.upload")}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
disabled={sftpBusy}
|
||||
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);
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !activeTab || sftpBusy) return;
|
||||
const remote = joinSftpPath(sftpPathRef.current, file.name);
|
||||
void (async () => {
|
||||
setSftpBusy(true);
|
||||
try {
|
||||
|
|
@ -1741,38 +1791,101 @@ export function WebcrtPage() {
|
|||
activeTab.target.source === "ume"
|
||||
? {
|
||||
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
|
||||
path: remote,
|
||||
remote_path: remote,
|
||||
file,
|
||||
}
|
||||
: { ne_id: activeTab.target.id, path: remote };
|
||||
const blob = await webcrtSftpDownload(body);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = it.name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
: { ne_id: activeTab.target.id, remote_path: remote, file };
|
||||
await webcrtSftpUpload(body);
|
||||
showOk(t("webcrt.sftp.uploaded"));
|
||||
await refreshSftp(sftpPathRef.current);
|
||||
} catch (err) {
|
||||
showError(webcrtErrorMessage(err, t));
|
||||
} finally {
|
||||
setSftpBusy(false);
|
||||
}
|
||||
})();
|
||||
e.target.value = "";
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!it.is_dir || sftpBusy) return;
|
||||
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{sftpBusy ? (
|
||||
<div className="webcrt-sftp__status" role="status">
|
||||
{t("webcrt.sftp.loading")}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="webcrt-sftp__table-wrap">
|
||||
<table className="webcrt-sftp__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="webcrt-sftp__col-name">{t("webcrt.sftp.colName")}</th>
|
||||
<th className="webcrt-sftp__col-size">{t("webcrt.sftp.colSize")}</th>
|
||||
<th className="webcrt-sftp__col-mtime">{t("webcrt.sftp.colMtime")}</th>
|
||||
<th className="webcrt-sftp__col-owner">{t("webcrt.sftp.colOwner")}</th>
|
||||
<th className="webcrt-sftp__col-group">{t("webcrt.sftp.colGroup")}</th>
|
||||
<th className="webcrt-sftp__col-mode">{t("webcrt.sftp.colMode")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sftpItems.map((it) => (
|
||||
<tr
|
||||
key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}
|
||||
className={sftpBusy ? "is-busy" : undefined}
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (!it.is_dir || sftpBusy) return;
|
||||
navigateSftp(joinSftpPath(sftpPathRef.current, it.name));
|
||||
}}
|
||||
>
|
||||
<td className="webcrt-sftp__col-name">
|
||||
<span className="webcrt-sftp__name-cell">
|
||||
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}>
|
||||
{it.is_dir ? <FolderIcon /> : <FileIcon />}
|
||||
</span>
|
||||
<span className="webcrt-sftp__name">{it.name}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="webcrt-sftp__col-size">
|
||||
{formatSftpSizeKb(it.size, it.is_dir)}
|
||||
</td>
|
||||
<td className="webcrt-sftp__col-mtime">{formatSftpMtime(it.mtime)}</td>
|
||||
<td className="webcrt-sftp__col-owner">{it.owner || ""}</td>
|
||||
<td className="webcrt-sftp__col-group">{it.group || ""}</td>
|
||||
<td className="webcrt-sftp__col-mode">{it.mode || ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -546,11 +546,23 @@ export const closeWebcrtSession = (sessionId: string) =>
|
|||
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
|
||||
export type WebcrtSftpItem = {
|
||||
name: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
is_dir: boolean;
|
||||
mode?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
uid?: number;
|
||||
gid?: number;
|
||||
};
|
||||
|
||||
export type WebcrtSftpListResult = {
|
||||
ne_id: string;
|
||||
ne_name: string;
|
||||
path: string;
|
||||
items: Array<{ name: string; size: number; mtime: number; is_dir: boolean }>;
|
||||
items: WebcrtSftpItem[];
|
||||
};
|
||||
|
||||
export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue