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:
1 parent
5602949a4f
commit
6e7c9d5376
9 files changed
+394
-69
No files matched your search
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': '取得中…',
|
||||
|
||||
@@ -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': '采集中…',
|
||||
|
||||
@@ -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': '採集中…',
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user