feat(sftp): 文件面板列头/排序、字体缩放与空白处右键菜单

- 列表头改为 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 语言词典补充列名与缩放键
This commit is contained in:
Bill committed 2026-10-09 08:33:06 +08:00
1 parent 5602949a4f
commit 6e7c9d5376
9 files changed
+394 -69

No files matched your search

+1 -1
View File
@@ -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 线程/符号链接逃逸)
+28 -7
View File
@@ -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<string[]> {
return p<Array<string | { filename: string }>>(cb => sftp.readdir(dir, cb)).then(raw =>
raw.map(n => (typeof n === 'string' ? n : n.filename))
function readdirDetailed(sftp: SFTPWrapper, dir: string): Promise<Array<{ name: string; longname?: string }>> {
return p<Array<string | { filename: string; longname?: string }>>(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<string[]> {
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<SftpEntry[]> {
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 })
}
}
+246 -40
View File
@@ -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<SortKey>('name')
const [sortDir, setSortDir] = useState<SortDir>('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<HTMLDivElement | null>(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 => (
<Dropdown
key={e.path}
@@ -717,22 +848,33 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
<span className="sftp-name" title={e.name}>
{e.name}
</span>
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
{e.mode ?? '----------'}
</span>
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
{e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'}
</span>
<span className="sftp-size" title={e.isDir ? '' : `${e.size} bytes`}>
{e.isDir ? '' : fmtSize(e.size)}
</span>
<span className="sftp-time">{fmtTime(e.mtime)}</span>
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
{e.mode ?? '----------'}
</span>
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
{ownerLabel(e)}
</span>
</div>
</Dropdown>
)
/** Sortable header cell; the arrow only shows on the active column. */
const headCell = (key: SortKey, colClass: string, label: string): React.JSX.Element => (
<span
className={`sftp-head-cell ${colClass}${sortKey === key ? ' sftp-head-on' : ''}`}
onClick={() => toggleSort(key)}
>
{label}
<span className="sftp-head-arrow">{sortKey === key ? (sortDir === 'asc' ? '▲' : '▼') : ''}</span>
</span>
)
return (
<div className="sftp-panel">
<div className="sftp-panel" style={scaleVars}>
<div className="sftp-toolbar">
<Tooltip title={t('ssh.file.up')}>
<Button
@@ -798,6 +940,25 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
>
<Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger />
</Popconfirm>
<span className="sftp-toolbar-gap" />
<Tooltip title={t('ssh.file.zoomOut')}>
<Button
type="text"
size="small"
icon={<ZoomOutOutlined />}
disabled={scale <= MIN_FONT_SCALE}
onClick={() => adjustScale(-FONT_SCALE_STEP)}
/>
</Tooltip>
<Tooltip title={t('ssh.file.zoomIn')}>
<Button
type="text"
size="small"
icon={<ZoomInOutlined />}
disabled={scale >= MAX_FONT_SCALE}
onClick={() => adjustScale(FONT_SCALE_STEP)}
/>
</Tooltip>
</div>
<div className="sftp-breadcrumb">
@@ -814,22 +975,67 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
{error ? (
<div className="sftp-error">{error}</div>
) : (
<div className="sftp-list" ref={listRef} onScroll={onListScroll}>
{loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.loading')}</div>}
{!loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
{virtualized ? (
<div className="sftp-list-virtual" style={{ height: entries.length * ROW_HEIGHT }}>
<div
className="sftp-list-window"
style={{ transform: `translateY(${slice.start * ROW_HEIGHT}px)` }}
>
{entries.slice(slice.start, slice.end).map(renderRow)}
</div>
<Dropdown
menu={menu}
trigger={['contextMenu']}
getPopupContainer={() => 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)
}}
>
<div
className="sftp-list"
ref={listRef}
onScroll={onListScroll}
onContextMenuCapture={(e) => {
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)
}}
>
<div className="sftp-head">
<span className="sftp-head-cell" />
{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'))}
<span className="sftp-head-cell sftp-col-mode">{t('ssh.file.colPerm')}</span>
<span className="sftp-head-cell sftp-col-owner">{t('ssh.file.colOwner')}</span>
</div>
) : (
entries.map(renderRow)
)}
</div>
{loading && entriesSorted.length === 0 && (
<div className="sftp-empty">{t('ssh.file.loading')}</div>
)}
{!loading && entriesSorted.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
{virtualized ? (
<div className="sftp-list-virtual" style={{ height: entriesSorted.length * rowHeight }}>
<div
className="sftp-list-window"
style={{ transform: `translateY(${slice.start * rowHeight}px)` }}
>
{entriesSorted.slice(slice.start, slice.end).map(renderRow)}
</div>
</div>
) : (
entriesSorted.map(renderRow)
)}
</div>
</Dropdown>
)}
<PermissionModal
+88 -21
View File
@@ -16,6 +16,11 @@
border-bottom: 1px solid var(--chrome-border);
}
/* Pushes the zoom controls to the far end of the toolbar. */
.sftp-toolbar-gap {
flex: 1;
}
.sftp-breadcrumb {
padding: 3px 8px;
font-size: 12px;
@@ -42,6 +47,79 @@
position: relative;
}
/* ---- header + rows ----------------------------------------------------------
The header and every row share one grid template, so the columns always line
up. Column widths and the header height scale with --sftp-scale; the row
height and font sizes come from FilePanel.tsx (--sftp-row-h / --sftp-fs /
--sftp-fs-meta). Every px below is therefore a scale-1 baseline only. */
.sftp-head,
.sftp-row {
display: grid;
grid-template-columns:
calc(22px * var(--sftp-scale, 1)) minmax(0, 1fr) calc(78px * var(--sftp-scale, 1))
calc(104px * var(--sftp-scale, 1)) calc(88px * var(--sftp-scale, 1))
calc(84px * var(--sftp-scale, 1));
align-items: center;
column-gap: calc(8px * var(--sftp-scale, 1));
padding-inline: calc(8px * var(--sftp-scale, 1));
}
.sftp-head {
position: sticky;
top: 0;
/* Above the rows, including the absolutely positioned windowed slice. */
z-index: 2;
height: var(--sftp-row-h, 26px);
box-sizing: border-box;
background: var(--chrome-bg);
border-bottom: 1px solid var(--chrome-border);
font-size: var(--sftp-fs-meta, 12px);
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent);
/* Header labels must never end up in a text selection with the listing. */
user-select: none;
}
.sftp-head-cell {
display: inline-flex;
align-items: center;
gap: 2px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
font-size: var(--sftp-fs-meta, 12px);
}
.sftp-head-cell.sftp-col-mode {
justify-content: center;
}
.sftp-head-cell.sftp-col-size,
.sftp-head-cell.sftp-col-time,
.sftp-head-cell.sftp-col-owner {
justify-content: flex-end;
}
.sftp-head-cell.sftp-col-name,
.sftp-head-cell.sftp-col-size,
.sftp-head-cell.sftp-col-time {
cursor: pointer;
}
.sftp-head-cell.sftp-col-name:hover,
.sftp-head-cell.sftp-col-size:hover,
.sftp-head-cell.sftp-col-time:hover,
.sftp-head-cell.sftp-head-on {
color: var(--chrome-fg);
}
.sftp-head-arrow {
flex: 0 0 auto;
font-size: calc(9px * var(--sftp-scale, 1));
line-height: 1;
}
/* Windowed list (node_modules-sized directories). Only the visible slice of
rows plus the overscan is mounted; the spacer carries the full scroll height. */
.sftp-list-virtual {
@@ -57,25 +135,14 @@
will-change: transform;
}
/* Right-click menu wrapper: antd wraps each row in a span by default;
force block so rows keep full-width hover/selection and normal flow. */
.sftp-list .ant-dropdown-trigger {
display: block;
}
.sftp-row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) 96px 74px 66px 96px;
align-items: center;
column-gap: 8px;
padding: 3px 8px;
/* Fixed outer height so the windowed list can offset rows arithmetically.
border-box is required — there is no global reset, so content-box would
add the 6px of vertical padding on top. Keep in sync with ROW_HEIGHT in
FilePanel.tsx. */
add any padding on top of the height. The height itself is driven by
--sftp-row-h so row geometry has one source of truth (FilePanel.tsx). */
box-sizing: border-box;
height: 24px;
font-size: 13px;
height: var(--sftp-row-h, 26px);
font-size: var(--sftp-fs, 13px);
color: var(--chrome-fg);
cursor: pointer;
}
@@ -91,7 +158,7 @@
.sftp-icon {
display: inline-flex;
color: #8a8a8a;
font-size: 14px;
font-size: calc(14px * var(--sftp-scale, 1));
}
.sftp-icon-dir {
@@ -103,14 +170,14 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13.5px;
font-size: var(--sftp-fs, 13px);
}
.sftp-mode {
text-align: center;
color: #9a9a9a;
font-family: Consolas, 'Courier New', monospace;
font-size: 11px;
font-size: var(--sftp-fs-meta, 12px);
white-space: nowrap;
overflow: hidden;
}
@@ -118,7 +185,7 @@
.sftp-owner {
text-align: right;
color: #8a8a8a;
font-size: 11px;
font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@@ -126,7 +193,7 @@
.sftp-size {
text-align: right;
color: #8a8a8a;
font-size: 11px;
font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@@ -134,7 +201,7 @@
.sftp-time {
text-align: right;
color: #8a8a8a;
font-size: 11px;
font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
+7
View File
@@ -135,6 +135,13 @@ const ssh: Record<string, string> = {
'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…',
+7
View File
@@ -137,6 +137,13 @@ const ssh: Record<string, string> = {
'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': '取得中…',
+7
View File
@@ -129,6 +129,13 @@ const ssh: Record<string, string> = {
'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': '采集中…',
+7
View File
@@ -129,6 +129,13 @@ const ssh: Record<string, string> = {
'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': '採集中…',
+3
View File
@@ -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'