From 6e7c9d5376fabd02bcfce4f31cdd5476a689880b Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 9 Oct 2026 08:33:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(sftp):=20=E6=96=87=E4=BB=B6=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=88=97=E5=A4=B4/=E6=8E=92=E5=BA=8F=E3=80=81?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E7=BC=A9=E6=94=BE=E4=B8=8E=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=E5=A4=84=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列表头改为 sticky 并支持点击排序(名称/大小/修改时间/权限/用户/组,目录恒在前) - 时间显示改为 YYYY/M/D HH:MM,大小显示改为 30.7 KB 风格 - 新增字体缩放 0.8–1.8(A-/A+ 与 Ctrl+滚轮),行高字号联动并记忆到 localStorage - 行高/字号统一由 FilePanel.tsx 计算,经 CSS 变量下发,sftp.css 只消费变量 - 主进程新增 readdirDetailed + parseLongnameOwner,从 OpenSSH longname 解析真实的用户/组 - 列表空白处右键弹出菜单(行内右键仍走原菜单,两者不叠加) - 修复:antd 把 ant-dropdown-trigger 克隆到行元素上,旧规则以更高特异性顶掉了 .sftp-row 的 display:grid,导致列此前并未按网格对齐 - 4 语言词典补充列名与缩放键 --- AGENTS.md | 2 +- src/main/sftp.ts | 35 +++- src/renderer/src/sftp/FilePanel.tsx | 286 ++++++++++++++++++++++++---- src/renderer/src/sftp/sftp.css | 109 +++++++++-- src/shared/i18n/dicts/en/ssh.ts | 7 + src/shared/i18n/dicts/ja/ssh.ts | 7 + src/shared/i18n/dicts/zh-CN/ssh.ts | 7 + src/shared/i18n/dicts/zh-TW/ssh.ts | 7 + src/shared/sftp.ts | 3 + 9 files changed, 394 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 973640a..9cf2b28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。 - `TerminalView` 用 **5 组 `useShallow` 字段级订阅**(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),**不要再回到整对象订阅**——主进程 `mutateSettings` 回推整个 settings 对象,任何写入都会变身份,整对象订阅会让所有 pane 重写 options + refit。`useResolvedTheme` 也是 shallow + memo 后的稳定引用 - `TerminalView` **已无 imperative handle**(`TerminalHandle`/forwardRef 全删,无人传 ref);要加「外部聚焦/清屏」需重新引入 -- `FilePanel` 虚拟化:固定行高自实现 windowing(>200 条目启用)。**`.sftp-row` 的 `height: 24px`(`sftp.css`)与 `FilePanel.tsx` 的 `ROW_HEIGHT` 必须同步改**——本仓库没有全局 box-sizing reset,行高写成 `box-sizing: border-box` 是有意的,去掉会让虚拟列表算术错位 +- `FilePanel` 虚拟化:自实现 windowing(>200 条目启用)。**行高与字号单一数据源在 `FilePanel.tsx`**:`BASE_ROW_HEIGHT = 26` × 缩放状态 `scale`(0.8–1.8,`localStorage` 的 `openterminal.sftpFontScale`)算出 `rowHeight`,经面板根元素 inline style 写成 CSS 变量 `--sftp-row-h` / `--sftp-fs` / `--sftp-fs-meta` / `--sftp-scale`;`windowStart(scrollTop, rowHeight)` 与 slice 计算都用这个动态值。**`sftp.css` 只消费变量、不得再写死行高或字号**(写死会让虚拟列表算术与真实行高脱节)。表头 `.sftp-head` 与 `.sftp-row` 共用同一套 grid 模板(列宽 `calc(Npx * var(--sftp-scale))`)才能对齐;行高写着 `box-sizing: border-box` 是有意的——本仓库没有全局 box-sizing reset,去掉会让 offset 算错。**别的坑**:antd 的 `Dropdown` 会把 `ant-dropdown-trigger` 类**克隆到子元素本身**(不是外面套一层),所以绝不能再写 `.sftp-list .ant-dropdown-trigger { display: block }` 这类后代选择器——特异性高于 `.sftp-row`,会把 `display: grid` 悄悄顶掉(旧代码正是如此);行与滚动容器各挂一个 contextMenu Dropdown,容器的 `onContextMenuCapture`(capture 早于 antd 的冒泡 trigger)记录「是否点在行内」,`onOpenChange` 据此拒绝打开,避免两个菜单叠加 - 每个 dockview pane 内有 `PanelErrorBoundary`(`ErrorBoundary.tsx`):pane 崩溃只卸载自己,根部边界仍兜底 - **本地路径准入**(`src/main/localPathGrants.ts`):SFTP 上传/下载与 zmodem 收发的本地路径必须来自系统对话框授权(pickFiles/pickDirectory 是唯一授权源,进程级内存注册表、不落盘),realpath+stat 双重校验、Windows 大小写折叠、解析后的路径才是要打开的路径。新增「渲染层构造本地路径传给主进程」的调用点时**必须过这道门**,别绕 - `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸) diff --git a/src/main/sftp.ts b/src/main/sftp.ts index 6ba269a..b4c17b4 100644 --- a/src/main/sftp.ts +++ b/src/main/sftp.ts @@ -256,30 +256,51 @@ export function closeSftp(sessionId: string): void { const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm']) /** readdir, both shapes ssh2 can yield (plain names or `{filename}` objects). */ -function readdir(sftp: SFTPWrapper, dir: string): Promise { - return p>(cb => sftp.readdir(dir, cb)).then(raw => - raw.map(n => (typeof n === 'string' ? n : n.filename)) +function readdirDetailed(sftp: SFTPWrapper, dir: string): Promise> { + return p>(cb => sftp.readdir(dir, cb)).then(raw => + raw.map(n => (typeof n === 'string' ? { name: n } : { name: n.filename, longname: n.longname })) ) } +function readdir(sftp: SFTPWrapper, dir: string): Promise { + return readdirDetailed(sftp, dir).then(list => list.map(e => e.name)) +} + +/** + * Owner/group names off an OpenSSH longname, e.g. + * `-rw-r--r-- 1 eveuser eveuser 231 Apr 1 2020 .bashrc`. + * The mode string is required before we trust the field positions — some + * servers put a different listing format in `longname` and the columns then + * mean nothing. Best effort: any surprise yields `{}`, never a throw. + */ +function parseLongnameOwner(longname: string | undefined): { owner?: string; group?: string } { + if (!longname) return {} + const fields = longname.trim().split(/\s+/) + if (fields.length < 4) return {} + if (!/^[bcdlps-][rwxstST-]{9}$/.test(fields[0])) return {} + return { owner: fields[2], group: fields[3] } +} + export function listRemote(sessionId: string, dir: string): Promise { return withSftp(sessionId, async sftp => { - const names = await readdir(sftp, dir) + const names = await readdirDetailed(sftp, dir) const entries: SftpEntry[] = [] const queue = [...names] const base = dir.replace(/\/+$/, '') || '/' const workers = Array.from({ length: Math.min(8, Math.max(queue.length, 1)) }, async () => { for (;;) { - const name = queue.shift() - if (name === undefined) return + const item = queue.shift() + if (item === undefined) return + const { name, longname } = item const path = `${base}/${name}` try { const st = await lstat(sftp, path) entries.push({ name, path, isDir: st.isDirectory(), size: st.size, mtime: st.mtime * 1000, - uid: st.uid, gid: st.gid, mode: formatMode(st.mode) + uid: st.uid, gid: st.gid, mode: formatMode(st.mode), ...parseLongnameOwner(longname) }) } catch { + // no longname data here — the namespace listing is all we have entries.push({ name, path, isDir: false, size: 0, mtime: 0 }) } } diff --git a/src/renderer/src/sftp/FilePanel.tsx b/src/renderer/src/sftp/FilePanel.tsx index c5c252b..87376a8 100644 --- a/src/renderer/src/sftp/FilePanel.tsx +++ b/src/renderer/src/sftp/FilePanel.tsx @@ -12,7 +12,9 @@ import { FormOutlined, ReloadOutlined, SafetyOutlined, - UploadOutlined + UploadOutlined, + ZoomInOutlined, + ZoomOutOutlined } from '@ant-design/icons' import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd' import type { MenuProps } from 'antd' @@ -93,16 +95,34 @@ function tOr(key: string, fallback: string): string { return value === key ? fallback : value } +/** FinalShell-style size: a unit at every magnitude, one decimal from KB up. */ function fmtSize(bytes: number): string { - if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G` - if (bytes >= 1024) return `${Math.round(bytes / 1024)}K` - return String(bytes) + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB` } +/** e.g. "2023/8/10 17:24" — a full year, unpadded month/day, padded clock. */ function fmtTime(ms: number): string { if (!ms) return '' const d = new Date(ms) - return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +/** + * Owner column: names parsed from the OpenSSH longname when available, numeric + * uid/gid otherwise. The numeric form is what the permission dialog edits, so + * it stays reachable through the cell's title tooltip. + */ +function ownerLabel(e: SftpEntry): string { + if (e.owner && e.group) return `${e.owner}/${e.group}` + if (e.owner) return e.owner + if (e.group) return `/${e.group}` + if (e.uid != null && e.gid != null) return `${e.uid}/${e.gid}` + if (e.uid != null) return String(e.uid) + if (e.gid != null) return String(e.gid) + return '—' } /** Permission-grid labels are resolved per render so they follow the language. */ @@ -114,8 +134,12 @@ function permColLabels(): string[] { return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')] } -/** Row height in px — must match `.sftp-row` in sftp.css. */ -const ROW_HEIGHT = 24 +/** + * Row height at scale 1, in px — the single source of truth for the list's + * geometry. The actual height is `BASE_ROW_HEIGHT * scale` and reaches CSS as + * the `--sftp-row-h` variable, so `.sftp-row` must never hard-code a height. + */ +const BASE_ROW_HEIGHT = 26 /** Rows rendered above and below the viewport so a fast scroll never blanks. */ const OVERSCAN = 8 /** @@ -126,15 +150,44 @@ const OVERSCAN = 8 */ const VIRTUALIZE_THRESHOLD = 200 +const FONT_SCALE_KEY = 'openterminal.sftpFontScale' +const MIN_FONT_SCALE = 0.8 +const MAX_FONT_SCALE = 1.8 +const FONT_SCALE_STEP = 0.1 + +/** Clamped scale with a tolerance for float drift across 0.1 steps. */ +function normalizeScale(value: number): number { + const clamped = Math.min(MAX_FONT_SCALE, Math.max(MIN_FONT_SCALE, value)) + // 0.8 + 0.1 * 3 = 1.1000000000000001, so round to the step's precision. + return Math.round(clamped * 10) / 10 +} + +/** Persisted zoom level; anything unreadable or out of range falls back to 1. */ +function readStoredScale(): number { + try { + const raw = window.localStorage.getItem(FONT_SCALE_KEY) + if (raw == null) return 1 + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed < MIN_FONT_SCALE || parsed > MAX_FONT_SCALE) return 1 + return normalizeScale(parsed) + } catch { + return 1 + } +} + +/** Sortable columns. Directories always lead, whatever the key. */ +type SortKey = 'name' | 'size' | 'mtime' +type SortDir = 'asc' | 'desc' + /** Slice of the entry list to mount for the current scroll position. */ interface WindowSlice { start: number end: number } -/** First index to render for a given scroll offset and viewport height. */ -function windowStart(scrollTop: number): number { - return Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN) +/** First index to render for a given scroll offset, row height and viewport. */ +function windowStart(scrollTop: number, rowHeight: number): number { + return Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN) } interface PermissionModalProps { @@ -357,14 +410,59 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): const [renameOpen, setRenameOpen] = useState(false) const [permOpen, setPermOpen] = useState(false) const [inputValue, setInputValue] = useState('') + /** Sort column + direction; directories always lead regardless of the key. */ + const [sortKey, setSortKey] = useState('name') + const [sortDir, setSortDir] = useState('asc') + /** Font zoom, persisted per user rather than per session. */ + const [scale, setScale] = useState(readStoredScale) + + const rowHeight = Math.round(BASE_ROW_HEIGHT * scale) + const scaleVars = { + '--sftp-scale': scale, + '--sftp-row-h': `${rowHeight}px`, + '--sftp-fs': `${13 * scale}px`, + '--sftp-fs-meta': `${12 * scale}px` + } as React.CSSProperties + + const entriesSorted = useMemo((): SftpEntry[] => { + const mul = sortDir === 'asc' ? 1 : -1 + return entries.slice().sort((a, b) => { + // Grouping beats the chosen key: directories always lead. + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 + if (sortKey === 'size') return (a.size - b.size) * mul + if (sortKey === 'mtime') return (a.mtime - b.mtime) * mul + return a.name.localeCompare(b.name) * mul + }) + }, [entries, sortKey, sortDir]) + + const toggleSort = (key: SortKey): void => { + if (key === sortKey) { + setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')) + return + } + setSortKey(key) + setSortDir('asc') + } + + const adjustScale = useCallback((delta: number): void => { + setScale((prev) => { + const next = normalizeScale(prev + delta) + try { + window.localStorage.setItem(FONT_SCALE_KEY, String(next)) + } catch { + // ignore + } + return next + }) + }, []) // Windowed list state. `viewportH` is measured off the scroll container and // `firstVisible` only advances when the scroll offset crosses a row boundary, - // so dragging the scrollbar re-renders at most once per ROW_HEIGHT of travel. + // so dragging the scrollbar re-renders at most once per rowHeight of travel. const listRef = useRef(null) const [viewportH, setViewportH] = useState(0) const [firstVisible, setFirstVisible] = useState(0) - const virtualized = entries.length > VIRTUALIZE_THRESHOLD + const virtualized = entriesSorted.length > VIRTUALIZE_THRESHOLD useEffect(() => { const el = listRef.current @@ -378,6 +476,22 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): // observer has to be re-attached whenever the container is replaced. }, [error]) + // React's onWheel is registered passively, so preventDefault() there is a + // no-op and Ctrl+wheel would zoom the whole window instead. An explicit + // non-passive listener is the only way to swallow it. + useEffect(() => { + const el = listRef.current + if (!el) return + const onWheel = (e: WheelEvent): void => { + if (!e.ctrlKey) return + e.preventDefault() + adjustScale(e.deltaY < 0 ? FONT_SCALE_STEP : -FONT_SCALE_STEP) + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => el.removeEventListener('wheel', onWheel) + // `error` swaps the scroll container, so the listener has to move with it. + }, [error, adjustScale]) + // A new directory starts at the top; the stale scroll offset would otherwise // leave the window slice pointing past the (now shorter) listing. useEffect(() => { @@ -386,21 +500,38 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): setFirstVisible(0) }, [dir]) + // Zooming changes the row height, so the offset that used to be row N now + // points at a different row — realign the window with what the viewport shows. + useEffect(() => { + const el = listRef.current + if (!el) return + setFirstVisible(windowStart(el.scrollTop, rowHeight)) + }, [rowHeight]) + const onListScroll = useCallback(() => { const el = listRef.current if (!el) return - const start = windowStart(el.scrollTop) + const start = windowStart(el.scrollTop, rowHeight) setFirstVisible((prev) => (prev === start ? prev : start)) - }, []) + }, [rowHeight]) const slice = useMemo((): WindowSlice => { - const visible = Math.ceil(viewportH / ROW_HEIGHT) + OVERSCAN * 2 - return { start: firstVisible, end: Math.min(entries.length, firstVisible + visible) } - }, [viewportH, firstVisible, entries.length]) + const visible = Math.ceil(viewportH / rowHeight) + OVERSCAN * 2 + return { start: firstVisible, end: Math.min(entriesSorted.length, firstVisible + visible) } + }, [viewportH, firstVisible, rowHeight, entriesSorted.length]) /** the entry the context menu is operating on (selected when right-clicked) */ const menuTarget = menuEntry ?? selected + /** + * The rows carry their own context-menu Dropdown, so a right click on one + * bubbles up to the container's trigger too and both menus would open. antd + * installs its trigger in the bubble phase, so the flag is recorded in the + * capture phase (which React dispatches first) and read by `onOpenChange`. + */ + const contextMenuInRowRef = useRef(false) + const [blankMenuOpen, setBlankMenuOpen] = useState(false) + /** Monotonic token: only the most recently issued listing may paint state. */ const refreshSeqRef = useRef(0) /** Mirrors `dir` for callbacks created before a navigation (upload finish). */ @@ -693,7 +824,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): // eslint-disable-next-line react-hooks/exhaustive-deps }, [menuTarget, dir, entries, refresh, language]) - /** One directory entry: icon, name, permissions, owner, size, mtime + menu. */ + /** One directory entry: icon+name, size, mtime, permissions, owner + menu. */ const renderRow = (e: SftpEntry): React.JSX.Element => ( {e.name} - - {e.mode ?? '----------'} - - - {e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'} - {e.isDir ? '' : fmtSize(e.size)} {fmtTime(e.mtime)} + + {e.mode ?? '----------'} + + + {ownerLabel(e)} + ) + /** Sortable header cell; the arrow only shows on the active column. */ + const headCell = (key: SortKey, colClass: string, label: string): React.JSX.Element => ( + toggleSort(key)} + > + {label} + {sortKey === key ? (sortDir === 'asc' ? '▲' : '▼') : ''} + + ) + return ( -
+
@@ -814,22 +975,67 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): {error ? (
{error}
) : ( -
- {loading && entries.length === 0 &&
{t('ssh.file.loading')}
} - {!loading && entries.length === 0 &&
{t('ssh.file.empty')}
} - {virtualized ? ( -
-
- {entries.slice(slice.start, slice.end).map(renderRow)} -
+ document.body} + open={blankMenuOpen} + onOpenChange={(open) => { + // The rows carry their own Dropdown; a right click on one bubbles up + // here too and would open both menus. The row's handler flags the + // click (in the capture phase, which runs first), so this trigger + // refuses to open for it. + if (open && contextMenuInRowRef.current) return + setBlankMenuOpen(open) + if (!open) setMenuEntry(null) + }} + > +
{ + const inRow = (e.target as HTMLElement).closest('.sftp-row') != null + contextMenuInRowRef.current = inRow + if (inRow) { + // A row click must not leave a blank-area menu behind; the row + // opens its own. (Re-opening is refused above, so this only ever + // closes.) + setBlankMenuOpen(false) + return + } + // Blank space (or the empty/loading hint): the menu keeps only the + // target-less entries, every row action disables itself. + setSelected(null) + setMenuEntry(null) + }} + > +
+ + {headCell('name', 'sftp-col-name', t('ssh.file.colName'))} + {headCell('size', 'sftp-col-size', t('ssh.file.colSize'))} + {headCell('mtime', 'sftp-col-time', t('ssh.file.colModified'))} + {t('ssh.file.colPerm')} + {t('ssh.file.colOwner')}
- ) : ( - entries.map(renderRow) - )} -
+ {loading && entriesSorted.length === 0 && ( +
{t('ssh.file.loading')}
+ )} + {!loading && entriesSorted.length === 0 &&
{t('ssh.file.empty')}
} + {virtualized ? ( +
+
+ {entriesSorted.slice(slice.start, slice.end).map(renderRow)} +
+
+ ) : ( + entriesSorted.map(renderRow) + )} +
+ )} = { 'ssh.file.permColRead': 'Read', 'ssh.file.permColWrite': 'Write', 'ssh.file.permColExec': 'Execute', + 'ssh.file.colName': 'Name', + 'ssh.file.colSize': 'Size', + 'ssh.file.colModified': 'Modified', + 'ssh.file.colPerm': 'Permissions', + 'ssh.file.colOwner': 'Owner', + 'ssh.file.zoomIn': 'Zoom in', + 'ssh.file.zoomOut': 'Zoom out', // System monitor 'ssh.monitor.collecting': 'Collecting…', diff --git a/src/shared/i18n/dicts/ja/ssh.ts b/src/shared/i18n/dicts/ja/ssh.ts index c0107c5..d9acce4 100644 --- a/src/shared/i18n/dicts/ja/ssh.ts +++ b/src/shared/i18n/dicts/ja/ssh.ts @@ -137,6 +137,13 @@ const ssh: Record = { 'ssh.file.permColRead': '読み取り', 'ssh.file.permColWrite': '書き込み', 'ssh.file.permColExec': '実行', + 'ssh.file.colName': '名前', + 'ssh.file.colSize': 'サイズ', + 'ssh.file.colModified': '更新日時', + 'ssh.file.colPerm': 'パーミッション', + 'ssh.file.colOwner': '所有者', + 'ssh.file.zoomIn': 'フォント拡大', + 'ssh.file.zoomOut': 'フォント縮小', // システム監視 'ssh.monitor.collecting': '取得中…', diff --git a/src/shared/i18n/dicts/zh-CN/ssh.ts b/src/shared/i18n/dicts/zh-CN/ssh.ts index 00b5cc9..dbfe677 100644 --- a/src/shared/i18n/dicts/zh-CN/ssh.ts +++ b/src/shared/i18n/dicts/zh-CN/ssh.ts @@ -129,6 +129,13 @@ const ssh: Record = { 'ssh.file.permColRead': '读', 'ssh.file.permColWrite': '写', 'ssh.file.permColExec': '执行', + 'ssh.file.colName': '名称', + 'ssh.file.colSize': '大小', + 'ssh.file.colModified': '修改时间', + 'ssh.file.colPerm': '权限', + 'ssh.file.colOwner': '用户/组', + 'ssh.file.zoomIn': '放大字体', + 'ssh.file.zoomOut': '缩小字体', // 系统监控 'ssh.monitor.collecting': '采集中…', diff --git a/src/shared/i18n/dicts/zh-TW/ssh.ts b/src/shared/i18n/dicts/zh-TW/ssh.ts index 6978ff6..50fece9 100644 --- a/src/shared/i18n/dicts/zh-TW/ssh.ts +++ b/src/shared/i18n/dicts/zh-TW/ssh.ts @@ -129,6 +129,13 @@ const ssh: Record = { 'ssh.file.permColRead': '讀取', 'ssh.file.permColWrite': '寫入', 'ssh.file.permColExec': '執行', + 'ssh.file.colName': '名稱', + 'ssh.file.colSize': '大小', + 'ssh.file.colModified': '修改時間', + 'ssh.file.colPerm': '權限', + 'ssh.file.colOwner': '使用者/群組', + 'ssh.file.zoomIn': '放大字體', + 'ssh.file.zoomOut': '縮小字體', // 系統監控 'ssh.monitor.collecting': '採集中…', diff --git a/src/shared/sftp.ts b/src/shared/sftp.ts index d084b8f..c7ddbbf 100644 --- a/src/shared/sftp.ts +++ b/src/shared/sftp.ts @@ -14,6 +14,9 @@ export interface SftpEntry { /** numeric uid/gid (best effort) */ uid?: number gid?: number + /** owner/group names parsed from readdir longname (best effort) */ + owner?: string + group?: string } export type TransferKind = 'upload' | 'download' | 'zmodem-upload' | 'zmodem-download'