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:
oliver 2026-08-02 00:27:04 +08:00
parent 499b4cc2a1
commit 12153b89fb
7 changed files with 397 additions and 138 deletions

View file

@ -184,6 +184,23 @@ def _sftp_client(
raise 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( def sftp_list(
db: Session, db: Session,
*, *,
@ -200,12 +217,18 @@ def sftp_list(
name = str(attr.filename or "") name = str(attr.filename or "")
if not name or name in (".", ".."): if not name or name in (".", ".."):
continue continue
owner, group = _owner_group(attr)
entries.append( entries.append(
{ {
"name": name, "name": name,
"size": int(getattr(attr, "st_size", 0) or 0), "size": int(getattr(attr, "st_size", 0) or 0),
"mtime": int(getattr(attr, "st_mtime", 0) or 0), "mtime": int(getattr(attr, "st_mtime", 0) or 0),
"is_dir": bool(statmod.S_ISDIR(mode)), "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())) entries.sort(key=lambda x: (not x["is_dir"], str(x["name"]).lower()))

View file

@ -734,6 +734,20 @@ class WebcrtServiceTests(unittest.TestCase):
# Direct SSH is allowed (no raise). # Direct SSH is allowed (no raise).
_require_ssh_direct({"protocol": "ssh", "hop_enabled": False}, {"protocol": "ssh"}) _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") @patch.object(svc, "_audit")
def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None: def test_find_ssh_session_for_ne_prefers_attached(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn() conn = _FakeConn()

View file

@ -1049,6 +1049,13 @@ const en = {
loading: "Loading…", loading: "Loading…",
enterHint: "Double-click to open folder", enterHint: "Double-click to open folder",
downloadHint: "Click to download", 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: { term: {
findPh: "Find…", findPh: "Find…",

View file

@ -1046,6 +1046,13 @@ const zh = {
loading: "正在加载…", loading: "正在加载…",
enterHint: "双击进入目录", enterHint: "双击进入目录",
downloadHint: "单击下载文件", downloadHint: "单击下载文件",
colName: "名称",
colSize: "大小 (KB)",
colMtime: "修改时间",
colOwner: "所有者",
colGroup: "组",
colMode: "权限",
resizeHint: "拖动调整 SFTP / 终端宽度",
}, },
term: { term: {
findPh: "查找…", findPh: "查找…",

View file

@ -2728,6 +2728,16 @@ pre {
min-height: 0; min-height: 0;
position: relative; position: relative;
background: #0b1220; 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 { .webcrt-main__pane {
@ -3033,17 +3043,40 @@ pre {
padding: 6px; padding: 6px;
} }
.webcrt-sftp { .webcrt-sftp-split {
position: absolute; flex: 0 0 5px;
right: 0; width: 5px;
top: 0; cursor: col-resize;
bottom: 0; background: #1e293b;
width: min(320px, 40%);
background: #0f172a;
border-left: 1px solid #334155; 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; z-index: 3;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.webcrt-sftp.is-busy { .webcrt-sftp.is-busy {
@ -3055,6 +3088,7 @@ pre {
gap: 4px; gap: 4px;
padding: 6px; padding: 6px;
border-bottom: 1px solid #334155; border-bottom: 1px solid #334155;
flex-shrink: 0;
} }
.webcrt-sftp__bar input { .webcrt-sftp__bar input {
@ -3088,30 +3122,95 @@ pre {
font-size: 11px; font-size: 11px;
color: #94a3b8; color: #94a3b8;
border-bottom: 1px solid #1e293b; border-bottom: 1px solid #1e293b;
flex-shrink: 0;
} }
.webcrt-sftp__list { .webcrt-sftp__table-wrap {
list-style: none;
margin: 0;
padding: 0;
overflow: auto;
flex: 1; flex: 1;
min-height: 0;
overflow: auto;
} }
.webcrt-sftp__list button { .webcrt-sftp__table {
width: 100%; 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; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
text-align: left; min-width: 0;
background: transparent;
border: 0;
border-bottom: 1px solid #1e293b;
color: #e2e8f0;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
user-select: none;
} }
.webcrt-sftp__icon { .webcrt-sftp__icon {
@ -3127,27 +3226,11 @@ pre {
.webcrt-sftp__name { .webcrt-sftp__name {
min-width: 0; min-width: 0;
flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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) { @media (max-width: 900px) {
.webcrt-shell { .webcrt-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

@ -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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal"; import { WebTerminal, type WebTerminalHandle } from "../components/WebTerminal";
@ -17,6 +17,7 @@ import {
webcrtSftpList, webcrtSftpList,
webcrtSftpUpload, webcrtSftpUpload,
webcrtWsUrl, webcrtWsUrl,
type WebcrtSftpItem,
} from "../services/api"; } from "../services/api";
import { pageCount } from "../utils/display"; import { pageCount } from "../utils/display";
import type { CliTargetItem, ManagedNeItem } from "../types"; import type { CliTargetItem, ManagedNeItem } from "../types";
@ -32,6 +33,9 @@ import {
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions"; const SESSION_OPTS_KEY = "netx.webcrt.sessionOptions";
const LEGACY_TERM_PREFS_KEY = "netx.webcrt.termPrefs"; 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 ENCODING_OPTIONS = ["utf-8", "gbk", "gb2312", "gb18030"] as const;
const FONT_SIZE_OPTIONS = [12, 13, 14, 16, 18, 20] 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; 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}`; 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 { function connectFailedNe(err: unknown): ManagedNeItem | null {
if (!(err instanceof ApiRequestError)) return null; if (!(err instanceof ApiRequestError)) return null;
const detail = err.detail; const detail = err.detail;
@ -453,8 +492,12 @@ export function WebcrtPage() {
const [sftpOpen, setSftpOpen] = useState(false); const [sftpOpen, setSftpOpen] = useState(false);
const [sftpPath, setSftpPath] = useState("."); const [sftpPath, setSftpPath] = useState(".");
const [sftpBusy, setSftpBusy] = useState(false); 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 sftpPathRef = useRef(".");
const sftpBodyRef = useRef<HTMLDivElement | null>(null);
const sftpDragRef = useRef<{ startX: number; startW: number } | null>(null);
sftpPathRef.current = sftpPath; sftpPathRef.current = sftpPath;
const [sessionOpts, setSessionOpts] = useState<SessionOptions>(() => loadSessionOptions()); const [sessionOpts, setSessionOpts] = useState<SessionOptions>(() => loadSessionOptions());
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false); const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
@ -1120,6 +1163,36 @@ export function WebcrtPage() {
[refreshSftp], [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=... // Auto-connect from /webcrt?ne_id=...
useEffect(() => { useEffect(() => {
if (!presetNeId) return; if (!presetNeId) return;
@ -1470,7 +1543,11 @@ export function WebcrtPage() {
</button> </button>
</div> </div>
) : null} ) : 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) => { {tabs.map((tab) => {
const isActive = activeTabKey === tab.key; const isActive = activeTabKey === tab.key;
// Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus. // Only the active tab mounts xterm + WS; background tabs detach and re-attach on focus.
@ -1655,85 +1732,58 @@ export function WebcrtPage() {
</div> </div>
); );
})} })}
</div>
{sftpOpen && activeTab && sftpAllowed ? ( {sftpOpen && activeTab && sftpAllowed ? (
<div className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}> <>
<div className="webcrt-sftp__bar"> <div
<button className={`webcrt-sftp-split${sftpResizing ? " is-dragging" : ""}`}
type="button" role="separator"
title={t("webcrt.sftp.up")} aria-orientation="vertical"
disabled={sftpBusy} aria-label={t("webcrt.sftp.resizeHint")}
onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))} title={t("webcrt.sftp.resizeHint")}
> onMouseDown={onSftpSplitDown}
{t("webcrt.sftp.up")} />
</button> <aside
<input className={`webcrt-sftp${sftpBusy ? " is-busy" : ""}`}
value={sftpPath} style={{ width: sftpWidth }}
disabled={sftpBusy} >
onChange={(e) => { <div className="webcrt-sftp__bar">
const v = e.target.value; <button
sftpPathRef.current = v; type="button"
setSftpPath(v); title={t("webcrt.sftp.up")}
}} disabled={sftpBusy}
onKeyDown={(e) => { onClick={() => navigateSftp(sftpParentPath(sftpPathRef.current))}
if (e.key === "Enter") { >
e.preventDefault(); {t("webcrt.sftp.up")}
void refreshSftp(sftpPathRef.current); </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 <input
type="file" value={sftpPath}
hidden
disabled={sftpBusy} disabled={sftpBusy}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const v = e.target.value;
if (!file || !activeTab || sftpBusy) return; sftpPathRef.current = v;
const remote = joinSftpPath(sftpPathRef.current, file.name); setSftpPath(v);
void (async () => { }}
setSftpBusy(true); onKeyDown={(e) => {
try { if (e.key === "Enter") {
const body = e.preventDefault();
activeTab.target.source === "ume" void refreshSftp(sftpPathRef.current);
? { }
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 = "";
}} }}
/> />
</label> <button type="button" disabled={sftpBusy} onClick={() => void refreshSftp()}>
</div> {t("webcrt.sftp.refresh")}
{sftpBusy ? ( </button>
<div className="webcrt-sftp__status" role="status"> <label className={`webcrt-sftp__upload${sftpBusy ? " is-disabled" : ""}`}>
{t("webcrt.sftp.loading")} {t("webcrt.sftp.upload")}
</div> <input
) : null} type="file"
<ul className="webcrt-sftp__list"> hidden
{sftpItems.map((it) => (
<li key={`${sftpPath}:${it.is_dir ? "d" : "f"}:${it.name}`}>
<button
type="button"
disabled={sftpBusy} disabled={sftpBusy}
title={it.is_dir ? t("webcrt.sftp.enterHint") : t("webcrt.sftp.downloadHint")} onChange={(e) => {
onClick={() => { const file = e.target.files?.[0];
if (it.is_dir || sftpBusy) return; if (!file || !activeTab || sftpBusy) return;
const remote = joinSftpPath(sftpPathRef.current, it.name); const remote = joinSftpPath(sftpPathRef.current, file.name);
void (async () => { void (async () => {
setSftpBusy(true); setSftpBusy(true);
try { try {
@ -1741,38 +1791,101 @@ export function WebcrtPage() {
activeTab.target.source === "ume" activeTab.target.source === "ume"
? { ? {
ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id, ume_ne_id: activeTab.target.ume_ne_id || activeTab.target.id,
path: remote, remote_path: remote,
file,
} }
: { ne_id: activeTab.target.id, path: remote }; : { ne_id: activeTab.target.id, remote_path: remote, file };
const blob = await webcrtSftpDownload(body); await webcrtSftpUpload(body);
const url = URL.createObjectURL(blob); showOk(t("webcrt.sftp.uploaded"));
const a = document.createElement("a"); await refreshSftp(sftpPathRef.current);
a.href = url;
a.download = it.name;
a.click();
URL.revokeObjectURL(url);
} catch (err) { } catch (err) {
showError(webcrtErrorMessage(err, t)); showError(webcrtErrorMessage(err, t));
} finally { } finally {
setSftpBusy(false); setSftpBusy(false);
} }
})(); })();
e.target.value = "";
}} }}
onDoubleClick={() => { />
if (!it.is_dir || sftpBusy) return; </label>
navigateSftp(joinSftpPath(sftpPathRef.current, it.name)); </div>
}} {sftpBusy ? (
> <div className="webcrt-sftp__status" role="status">
<span className={`webcrt-sftp__icon${it.is_dir ? " is-dir" : ""}`}> {t("webcrt.sftp.loading")}
{it.is_dir ? <FolderIcon /> : <FileIcon />} </div>
</span> ) : null}
<span className="webcrt-sftp__name">{it.name}</span> <div className="webcrt-sftp__table-wrap">
{!it.is_dir ? <span className="webcrt-sftp__size">({it.size})</span> : null} <table className="webcrt-sftp__table">
</button> <thead>
</li> <tr>
))} <th className="webcrt-sftp__col-name">{t("webcrt.sftp.colName")}</th>
</ul> <th className="webcrt-sftp__col-size">{t("webcrt.sftp.colSize")}</th>
</div> <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} ) : null}
</div> </div>
</> </>

View file

@ -546,11 +546,23 @@ export const closeWebcrtSession = (sessionId: string) =>
`/v1/webcrt/sessions/${encodeURIComponent(sessionId)}`, `/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 = { export type WebcrtSftpListResult = {
ne_id: string; ne_id: string;
ne_name: string; ne_name: string;
path: 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 }) => export const webcrtSftpList = (body: { ne_id?: string; ume_ne_id?: string; path?: string }) =>