diff --git a/netx_api/webcrt_sftp.py b/netx_api/webcrt_sftp.py index 544be0b..ffb577d 100644 --- a/netx_api/webcrt_sftp.py +++ b/netx_api/webcrt_sftp.py @@ -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())) diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index b61f334..e123c02 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -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() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a8f2e46..5626ac8 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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…", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1aa9dbe..e958abe 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1046,6 +1046,13 @@ const zh = { loading: "正在加载…", enterHint: "双击进入目录", downloadHint: "单击下载文件", + colName: "名称", + colSize: "大小 (KB)", + colMtime: "修改时间", + colOwner: "所有者", + colGroup: "组", + colMode: "权限", + resizeHint: "拖动调整 SFTP / 终端宽度", }, term: { findPh: "查找…", diff --git a/web/src/index.css b/web/src/index.css index 684e1fe..87fc237 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 6cee764..d2b0f25 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -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>([]); + const [sftpItems, setSftpItems] = useState([]); + const [sftpWidth, setSftpWidth] = useState(() => loadSftpWidth()); + const [sftpResizing, setSftpResizing] = useState(false); const sftpPathRef = useRef("."); + const sftpBodyRef = useRef(null); + const sftpDragRef = useRef<{ startX: number; startW: number } | null>(null); sftpPathRef.current = sftpPath; const [sessionOpts, setSessionOpts] = useState(() => loadSessionOptions()); const [optionsMenuOpen, setOptionsMenuOpen] = useState(false); @@ -1120,6 +1163,36 @@ export function WebcrtPage() { [refreshSftp], ); + const onSftpSplitDown = useCallback((e: ReactMouseEvent) => { + 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() { ) : null} -
+
+
{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() {
); })} +
{sftpOpen && activeTab && sftpAllowed ? ( -
-
- - { - const v = e.target.value; - sftpPathRef.current = v; - setSftpPath(v); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void refreshSftp(sftpPathRef.current); - } - }} - /> - - +
+ {sftpBusy ? ( +
+ {t("webcrt.sftp.loading")} +
+ ) : null} +
+ + + + + + + + + + + + + {sftpItems.map((it) => ( + { + 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)); + }} + > + + + + + + + + ))} + +
{t("webcrt.sftp.colName")}{t("webcrt.sftp.colSize")}{t("webcrt.sftp.colMtime")}{t("webcrt.sftp.colOwner")}{t("webcrt.sftp.colGroup")}{t("webcrt.sftp.colMode")}
+ + + {it.is_dir ? : } + + {it.name} + + + {formatSftpSizeKb(it.size, it.is_dir)} + {formatSftpMtime(it.mtime)}{it.owner || ""}{it.group || ""}{it.mode || ""}
+
+ + ) : null}
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 4b3d2e6..1767fb3 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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 }) =>