feat(M8): workspace isolation + SSH bottom file panel + context menu + monitor polish

- dual dockview workspaces (terminal/ssh), rail mode entries, sidebar filtered by mode
- SshBottomPanel: file browser below terminal, draggable divider; sftp.css finally imported (was orphaned since M4)
- file context menu: refresh/open/download/upload/rename/mkdir/copy-path/permissions/delete
- monitor restyle: memory amber bar, disk rows, net rx/tx inline (toFixed 4)
- completion fixes: Enter accepts; char-loss fixed by full-line rewrite (Ctrl+U) + echo-suppress ref
- chmod no-op fix: bitsToOctal concatenated decimal (329) instead of octal (511); chown only on change
- popups (layout menu/broadcast popover/file context menu) mount to body - dockview render overlay isolation clipped them under the sidebar
- rail slimmed: split + template actions folded into layout menu; log toolbar hover-only
This commit is contained in:
954801926@qq.com committed 2026-09-07 02:39:33 +08:00
1 parent 54c803091c
commit d3a628f177
20 files changed
+1380 -388

No files matched your search

+1
View File
@@ -14,6 +14,7 @@
| M5 | 命令历史/命令库/补全 + 会话日志 | ✅ 已完成(v0.5) |
| M6 | 广播输入 + ZMODEM + 快捷输入面板 + 快捷键系统 | ✅ 已完成(v0.6) |
| M7 | 打包分发(MSI + NSIS + 自动更新) | ✅ 已完成(v0.7,Release 已发布) |
| M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) |
## M2 — SSH 远程会话 ✅
+16 -2
View File
@@ -4,8 +4,8 @@
## 当前版本与仓库
- v0.7.0(tag v0.7.0 = 2a32ee9),远程 `git.codingplan.site/admin/OpenTerminal.git`(令牌已存于 .git/config)
- Release v0.7.0:MSI + NSIS 安装包 + latest.yml 已上传附件;更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
- v0.8.0,远程 `git.codingplan.site/admin/OpenTerminal.git`(令牌已存于 .git/config)
- 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
- 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder
- 契约先行约定:所有跨进程接口定义在 `src/shared/`,主/预载/渲染各自实现;新功能先改 shared 再派两端
@@ -21,6 +21,20 @@
| M5 命令历史/命令库/补全+会话日志 | ✅ |
| M6 广播输入+ZMODEM+快捷输入面板+快捷键系统 | ✅(v0.6.0,UI 活体验收;ZMODEM 双向 e2e) |
| M7 打包分发(MSI+NSIS+自动更新) | ✅(v0.7.0 已发布 Release) |
| M8 工作区分离+底部文件面板+右键菜单+监控美化+补全修复 | ✅(v0.8.0) |
## M8 实现要点(v0.8.0,已落地)
- **工作区分离**:两个独立 Dockview 实例(terminal/ssh),切换 = 容器 display:none(状态保留);workspaceModeStore(zustand+localStorage);IconRail 双模式入口(选中高亮);ConnectionSidebar 按模式只显示对应区块(终端:终端+命令;SSH:服务器+最近会话);旧的「单 Dockview+marker 面板+setVisible 分组」方案已废弃删除
- **SSH 底部文件面板**(SshBottomPanel):终端在上、文件浏览器在下、4px 可拖分隔条(120px~60%);右侧栏只留监控(300px)
- **sftp.css 从未被 import 的隐藏 bug**:文件面板样式一直裸奔,FilePanel.tsx 补 `import './sftp.css'` 后才真正生效——教训:新增 css 文件必须确认被入口 import
- **文件右键菜单**:刷新/打开·下载/上传/重命名/新建文件夹/复制路径/文件权限/删除(复用现有 handler;「新建文件」因无 touch API 未做)
- **监控美化**:内存橙条、磁盘三列行、网络上下行进区块头(↓蓝 ↑金)、toFixed(4) 精度、tabular-nums
- **补全修复**:①Enter 也接受补全(Tab||Enter);②丢字符根因=differenceWrite 只写差量而 lineBuf 与 shell 行缓冲失同步→改为接受时强制重写(\x15 Ctrl+U 清行+写全命令)+diffRewriteRef 让下一次 onData(shell 重画 echo)重置缓冲
- **chmod 无效 bug**:bitsToOctal 把八进制位按十进制拼数(dr-x--x--x → 329 而非 511),新旧相等致 chmod 根本没发→改字符串拼接;chown 只在 uid/gid 真变化时执行
- **弹层被侧栏盖住**:dockview 渲染层 `contain:layout paint; isolation:isolate` 形成独立层叠上下文,面板内 antd 弹层逃不出→图标栏布局菜单/广播 Popover/文件右键菜单三处 `getPopupContainer={() => document.body}`
- **图标栏瘦身**:分屏两项+模板两项收进「布局」主菜单
- **SSH 标签强化**(SshHostBadge):hostLabel 胶囊+绿 accent;日志工具栏 hover 才显示(录制时常显);侧栏终端列表 max-height 滚动
## M6/M7 实现要点(已落地)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "0.7.0",
"version": "0.8.0",
"description": "Terminal with split panes, themes and fonts (XTerminal-inspired)",
"main": "out/main/index.js",
"author": "Bill",
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button, Modal, Popconfirm, Tabs } from 'antd'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import type { CommandItem } from '@shared/commands'
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './commands.css'
export interface CommandsPanelProps {
@@ -29,6 +30,8 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
const [name, setName] = useState('')
const [command, setCommand] = useState('')
const [note, setNote] = useState('')
/** M6.1: current workspace group — the command panel's 发送目标 belongs to it. */
const workspaceMode = useWorkspaceModeStore((s) => s.mode)
const refresh = useCallback((): void => {
void Promise.all([window.api.listHistory(), window.api.listLibrary()])
@@ -84,6 +87,10 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
return (
<div className="commands-panel">
<div className={`commands-mode-badge commands-mode-badge--${workspaceMode}`}>
<span className="commands-mode-badge-text">发送目标</span>
<span className="commands-mode-badge-tag">{workspaceMode === 'ssh' ? 'SSH 服务器' : '终端'}</span>
</div>
<Tabs
size="small"
centered
@@ -5,7 +5,9 @@ import type { SshConnection } from '@shared/connections'
import { AuthTags } from './Common'
import { ConnectionEditDialog } from './ConnectionEditDialog'
import { CommandsPanel } from '../commands/CommandsPanel'
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './connections.css'
import '../workspace/workspace.css'
export interface ConnectionSidebarProps {
className?: string
@@ -60,6 +62,8 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
}: ConnectionSidebarProps,
ref
): React.JSX.Element {
const mode = useWorkspaceModeStore((s) => s.mode)
const isTerminal = mode === 'terminal'
const [connections, setConnections] = useState<SshConnection[]>([])
const [loaded, setLoaded] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -158,6 +162,9 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
return (
<aside className={className ?? 'connections-sidebar'}>
<div className="connections-mode-title">
{isTerminal ? '终端工作区' : 'SSH 工作区'}
</div>
<div className="connections-header">
<span className="connections-title">连接</span>
<Button
@@ -170,6 +177,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
</div>
<div className="connections-list">
{isTerminal && (
<section className="connections-section">
<div className="connections-section-head">
<span className="connections-section-title">终端</span>
@@ -193,7 +201,9 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
</ul>
)}
</section>
)}
{!isTerminal && (
<section className="connections-section">
<div className="connections-section-head">
<span className="connections-section-title">SSH 服务器</span>
@@ -240,13 +250,16 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
</div>
)}
</section>
)}
{isTerminal && (
<section className="connections-section commands-sidebar-section">
<div className="connections-section-head">
<span className="connections-section-title">命令</span>
</div>
<CommandsPanel onRun={onRunCommand ?? ((): void => undefined)} />
</section>
)}
</div>
<ConnectionEditDialog
+20 -3
View File
@@ -307,18 +307,35 @@
font-size: 12px;
}
/* local terminal list */
/* local terminal list — capped height + inner scroll so many panes never
crowd out the SSH/command sections below. */
.connections-local-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
}
.connections-local-list::-webkit-scrollbar {
width: 8px;
}
.connections-local-list::-webkit-scrollbar-track {
background: transparent;
}
.connections-local-list::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.45);
border-radius: 4px;
}
.connections-local-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px 5px 8px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
user-select: none;
@@ -338,7 +355,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-size: 12px;
color: var(--chrome-fg);
}
+30 -17
View File
@@ -176,6 +176,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
const disks = sample.disks ?? []
const net = sample.net
const failed = Boolean(sample.error)
const memPct = mem.totalMb > 0 ? (mem.usedMb / mem.totalMb) * 100 : 0
return (
<div className={`mm-root${failed ? ' mm-failed' : ''}`}>
@@ -190,11 +191,11 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<div className="mm-block-row">
<span className="mm-label">CPU</span>
<span className="mm-value" style={{ color: cpuColor(cpu.usage) }}>
{cpu.usage.toFixed(0)}%({cpu.cores} 核)
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {cpu.cores} 核</span>
</span>
</div>
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} />
<div className="mm-sub">
<div className="mm-sub mm-load">
负载 {cpu.loadavg.map((v) => v.toFixed(1)).join(' / ')}
</div>
<HistoryChart data={cpuHist} lineColor="#3fb950" fillColor="rgba(63,185,80,0.15)" height={48} maxY={100} />
@@ -203,14 +204,16 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<section className="mm-block" aria-label="内存">
<div className="mm-block-row">
<span className="mm-label">内存</span>
<span className="mm-value">
<span className="mm-mem-size">
{formatSize(mem.usedMb)} / {formatSize(mem.totalMb)}
</span>
</div>
<Bar
pct={mem.totalMb > 0 ? (mem.usedMb / mem.totalMb) * 100 : 0}
color={mem.totalMb > 0 && mem.usedMb / mem.totalMb > 0.8 ? '#f85149' : '#3fb950'}
/>
<div className="mm-mem-row">
<div className="mm-mem-bar">
<Bar pct={memPct} color="#d29922" />
</div>
<span className="mm-mem-pct">{memPct.toFixed(0)}%</span>
</div>
</section>
{disks.length > 0 && (
@@ -223,13 +226,15 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
const pct = d.totalMb > 0 ? (d.usedMb / d.totalMb) * 100 : 0
return (
<div className="mm-disk" key={d.mount}>
<div className="mm-disk-top">
<span className="mm-mount">{d.mount}</span>
<span className="mm-disk-size">
{formatSize(d.usedMb)} / {formatSize(d.totalMb)}
</span>
<span className="mm-mount" title={d.mount}>
{d.mount}
</span>
<div className="mm-disk-bar">
<Bar pct={pct} color={cpuColor(pct)} />
</div>
<Bar pct={pct} color={cpuColor(pct)} />
<span className="mm-disk-size">
{formatSize(d.usedMb)} / {formatSize(d.totalMb)}
</span>
</div>
)
})}
@@ -240,10 +245,18 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<section className="mm-block" aria-label="网络">
<div className="mm-block-row">
<span className="mm-label">网络</span>
</div>
<div className="mm-net">
<span className="mm-net-item"><span className="mm-arrow mm-up">↓</span>{net?.rxKbs ?? 0} KB/s</span>
<span className="mm-net-item"><span className="mm-arrow mm-down">↑</span>{net?.txKbs ?? 0} KB/s</span>
<div className="mm-net">
<span className="mm-net-item mm-rx">
<span className="mm-arrow mm-up">↓</span>
{(net?.rxKbs ?? 0).toFixed(4)}
<span className="mm-net-unit"> KB/s</span>
</span>
<span className="mm-net-item mm-tx">
<span className="mm-arrow mm-down">↑</span>
{(net?.txKbs ?? 0).toFixed(4)}
<span className="mm-net-unit"> KB/s</span>
</span>
</div>
</div>
<div className="mm-net-charts">
<HistoryChart data={rxHist} lineColor="#58a6ff" fillColor="rgba(88,166,255,0.15)" height={40} />
+85 -30
View File
@@ -3,7 +3,7 @@
.mm-root {
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
padding: 10px 12px;
background: #1a1a1a;
color: var(--chrome-fg);
@@ -31,6 +31,9 @@
flex-direction: column;
gap: 1px;
line-height: 1.3;
padding-bottom: 8px;
border-bottom: 1px solid #262626;
margin-bottom: 2px;
}
.mm-host {
@@ -52,12 +55,13 @@
/* ---------- blocks ---------- */
.mm-block {
border: 1px solid #2d2d2d;
border-radius: 6px;
border: 1px solid #2a2a2a;
border-radius: 8px;
background: rgba(255, 255, 255, 0.018);
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 5px;
gap: 6px;
}
.mm-block-row {
@@ -68,16 +72,26 @@
line-height: 1.2;
}
/* weak small section labels */
.mm-label {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
font-weight: 500;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.04em;
user-select: none;
}
.mm-value {
font-size: 12px;
font-size: 13px;
color: #3fb950;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.mm-value-sub {
font-size: 11px;
color: #8a8a8a;
}
.mm-sub {
@@ -85,10 +99,14 @@
color: #8a8a8a;
}
.mm-load {
font-variant-numeric: tabular-nums;
}
/* ---------- bar ---------- */
.mm-bar {
height: 8px;
border-radius: 4px;
height: 5px;
border-radius: 3px;
overflow: hidden;
background: #2c2c2c;
width: 100%;
@@ -96,30 +114,52 @@
.mm-bar-fill {
height: 100%;
border-radius: 4px;
border-radius: 3px;
transition: width 0.3s ease;
}
/* ---------- memory ---------- */
.mm-mem-size {
font-size: 12px;
color: rgba(255, 255, 255, 0.78);
font-variant-numeric: tabular-nums;
}
.mm-mem-row {
display: flex;
align-items: center;
gap: 8px;
}
.mm-mem-bar {
flex: 1;
min-width: 0;
}
.mm-mem-pct {
flex: 0 0 auto;
font-size: 11px;
color: #d29922;
font-variant-numeric: tabular-nums;
min-width: 32px;
text-align: right;
}
/* ---------- disks ---------- */
.mm-disks {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 132px;
gap: 5px;
max-height: 150px;
overflow-y: auto;
}
.mm-disk {
display: flex;
flex-direction: column;
gap: 3px;
}
.mm-disk-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
display: grid;
grid-template-columns: minmax(0, 1fr) 108px 70px;
align-items: center;
column-gap: 8px;
min-height: 16px;
}
.mm-mount {
@@ -129,21 +169,29 @@
white-space: nowrap;
}
.mm-disk-bar {
min-width: 0;
}
.mm-disk-size {
font-size: 11px;
color: #8a8a8a;
flex: 0 0 auto;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ---------- network ---------- */
.mm-net {
display: flex;
gap: 20px;
display: inline-flex;
align-items: center;
gap: 14px;
justify-content: flex-end;
}
.mm-net-charts {
display: flex;
gap: 12px;
gap: 10px;
}
.mm-net-charts .mm-chart {
@@ -163,14 +211,21 @@
.mm-net-item {
display: inline-flex;
align-items: center;
align-items: baseline;
gap: 4px;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.8);
color: rgba(255, 255, 255, 0.82);
}
.mm-net-unit {
font-size: 11px;
color: #8a8a8a;
}
.mm-arrow {
font-size: 12px;
font-size: 13px;
line-height: 1;
align-self: center;
}
.mm-up {
+174 -32
View File
@@ -1,18 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ArrowUpOutlined,
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
FileAddOutlined,
FileOutlined,
FolderAddOutlined,
FolderOpenOutlined,
FolderOutlined,
FormOutlined,
ReloadOutlined,
SafetyOutlined,
UploadOutlined
} from '@ant-design/icons'
import { App, Button, Checkbox, Input, Modal, Popconfirm, Tooltip } from 'antd'
import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import type { SftpEntry, TransferProgressEvent } from '@shared/sftp'
import './sftp.css'
/** Remote file browser for one SSH session (M4). */
export interface FilePanelProps {
@@ -46,8 +51,11 @@ function octalDigit(start: number, bits: PermBits): number {
}
function bitsToOctal(bits: PermBits): string {
const o = octalDigit(0, bits) * 64 + octalDigit(3, bits) * 8 + octalDigit(6, bits)
return String(o)
// Concatenate octal digits as a string — 5,1,1 → "511". (The previous
// arithmetic version computed 5*64+1*8+1 = 329 decimal, which is NOT the
// octal "511" and made chmod a silent no-op when both sides agreed on the
// same wrong value.)
return `${octalDigit(0, bits)}${octalDigit(3, bits)}${octalDigit(6, bits)}`
}
function fmtSize(bytes: number): string {
@@ -106,7 +114,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
const applyOctal = (v: string | null): void => {
const s = (v ?? '').trim()
if (/^\d{1,3}$/.test(s)) {
if (/^[0-7]{1,4}$/.test(s)) {
const num = parseInt(s, 8) || 0
setBits([
(num >> 6) & 4 ? true : false,
@@ -139,18 +147,23 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
try {
const octalStr = bitsToOctal(bits)
const curOctal = entry.mode ? bitsToOctal(parseModeBits(entry.mode)) : null
const applyOwner = uid.trim() !== '' && !Number.isNaN(Number(uid))
const applyGid = gid.trim() !== '' && !Number.isNaN(Number(gid))
// Only chown when the uid/gid actually differ from the entry's current
// owner — the inputs are seeded with the current values, so "untouched"
// would otherwise chown on every save.
const newUid = uid.trim() !== '' && !Number.isNaN(Number(uid)) ? Number(uid) : null
const newGid = gid.trim() !== '' && !Number.isNaN(Number(gid)) ? Number(gid) : null
const ownerChanged =
(newUid !== null && newUid !== entry.uid) || (newGid !== null && newGid !== entry.gid)
if (octalStr !== curOctal) {
await window.api.chmodRemote(sessionId, entry.path, octalStr)
}
if (applyOwner || applyGid) {
const u = applyOwner ? Number(uid) : (entry.uid ?? 0)
const g = applyGid ? Number(gid) : (entry.gid ?? u)
if (ownerChanged) {
const u = newUid ?? entry.uid ?? 0
const g = newGid ?? newUid ?? entry.gid ?? 0
await window.api.chownRemote(sessionId, entry.path, u, g)
}
if (octalStr !== curOctal || applyOwner || applyGid) {
if (octalStr !== curOctal || ownerChanged) {
message.success('已应用权限')
}
onClose()
@@ -229,17 +242,21 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
}
export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
const { message } = App.useApp()
const { message, modal } = App.useApp()
const [dir, setDir] = useState('/')
const [entries, setEntries] = useState<SftpEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [selected, setSelected] = useState<SftpEntry | null>(null)
const [menuEntry, setMenuEntry] = useState<SftpEntry | null>(null)
const [mkdirOpen, setMkdirOpen] = useState(false)
const [renameOpen, setRenameOpen] = useState(false)
const [permOpen, setPermOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
/** the entry the context menu is operating on (selected when right-clicked) */
const menuTarget = menuEntry ?? selected
const refresh = useCallback(
async (target: string): Promise<void> => {
setLoading(true)
@@ -342,6 +359,120 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
})()
}
const doCopyPath = (entry: SftpEntry): void => {
void (async () => {
try {
await navigator.clipboard.writeText(entry.path)
message.success('已复制路径')
} catch {
message.error('复制路径失败')
}
})()
}
/** opens the mkdir modal in create-a-file mode (no-op: new files unsupported) */
const menu: MenuProps = useMemo(() => {
const target = menuTarget
const isDir = target?.isDir
const canAction = target != null
return {
items: [
{ key: 'refresh', label: '刷新', icon: <ReloadOutlined /> },
{ type: 'divider' },
{
key: 'open',
label: isDir ? '打开' : '下载',
icon: isDir ? <FolderOpenOutlined /> : <DownloadOutlined />,
disabled: !canAction
},
{
key: 'download',
label: '下载',
icon: <DownloadOutlined />,
disabled: !canAction || Boolean(isDir)
},
{ key: 'upload', label: '上传…', icon: <UploadOutlined /> },
{
key: 'rename',
label: '重命名',
icon: <FormOutlined />,
disabled: !canAction
},
{
type: 'submenu',
key: 'new',
label: '新建',
icon: <FileAddOutlined />,
children: [
{ key: 'mkdir', label: '新建文件夹', icon: <FolderAddOutlined /> }
// 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做
]
},
{ type: 'divider' },
{
key: 'copyPath',
label: '复制路径',
icon: <CopyOutlined />,
disabled: !canAction
},
{
key: 'permission',
label: '文件权限…',
icon: <SafetyOutlined />,
disabled: !canAction
},
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, disabled: !canAction }
],
onClick: ({ key }) => {
const entry = menuTarget
if (key === 'refresh') {
void refresh(dir)
} else if (key === 'open') {
if (!entry) return
if (entry.isDir) void refresh(entry.path)
else doDownload(entry)
} else if (key === 'download') {
if (entry && !entry.isDir) doDownload(entry)
} else if (key === 'upload') {
doUpload()
} else if (key === 'rename') {
setInputValue(entry?.name ?? '')
setRenameOpen(true)
} else if (key === 'mkdir') {
setInputValue('')
setMkdirOpen(true)
} else if (key === 'copyPath') {
if (entry) doCopyPath(entry)
} else if (key === 'permission') {
setPermOpen(true)
} else if (key === 'delete') {
modal.confirm({
title: '确认删除?',
content: entry ? `${entry.name}(${entry.path})` : undefined,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () =>
new Promise<void>((resolve, reject) => {
if (!entry) {
resolve()
return
}
window.api.deleteRemote(sessionId, [entry.path]).then(
() => {
void refresh(dir)
resolve()
},
(err: unknown) => reject(err)
)
})
})
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [menuTarget, dir, refresh])
return (
<div className="sftp-panel">
<div className="sftp-toolbar">
@@ -424,29 +555,40 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
{loading && entries.length === 0 && <div className="sftp-empty">加载中…</div>}
{!loading && entries.length === 0 && <div className="sftp-empty">空目录</div>}
{entries.map((e) => (
<div
<Dropdown
key={e.path}
className={`sftp-row${selected?.path === e.path ? ' sftp-row-selected' : ''}`}
onClick={() => setSelected(e)}
onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))}
menu={menu}
trigger={['contextMenu']}
getPopupContainer={() => document.body}
onOpenChange={(open) => {
if (!open) setMenuEntry(null)
}}
>
{e.isDir ? (
<FolderOutlined className="sftp-icon sftp-icon-dir" />
) : (
<FileOutlined className="sftp-icon" />
)}
<span className="sftp-name" title={e.name}>
{e.name}
</span>
<span className="sftp-mode" title={e.mode ?? '权限不可用'}>
{e.mode ?? '----------'}
</span>
<span className="sftp-owner">
{e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'}
</span>
<span className="sftp-size">{e.isDir ? '' : fmtSize(e.size)}</span>
<span className="sftp-time">{fmtTime(e.mtime)}</span>
</div>
<div
className={`sftp-row${selected?.path === e.path ? ' sftp-row-selected' : ''}`}
onClick={() => setSelected(e)}
onContextMenu={() => {
setSelected(e)
setMenuEntry(e)
}}
onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))}
>
<span className="sftp-icon">{e.isDir ? <FolderOutlined className="sftp-icon-dir" /> : <FileOutlined />}</span>
<span className="sftp-name" title={e.name}>
{e.name}
</span>
<span className="sftp-mode" title={e.mode ?? '权限不可用'}>
{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>
</div>
</Dropdown>
))}
</div>
)}
+18 -12
View File
@@ -39,12 +39,19 @@
overflow-y: auto;
}
/* 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: flex;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) 96px 74px 66px 96px;
align-items: center;
gap: 6px;
column-gap: 8px;
padding: 3px 8px;
font-size: 12px;
font-size: 13px;
color: var(--chrome-fg);
cursor: pointer;
}
@@ -58,7 +65,9 @@
}
.sftp-icon {
display: inline-flex;
color: #8a8a8a;
font-size: 14px;
}
.sftp-icon-dir {
@@ -66,26 +75,23 @@
}
.sftp-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13.5px;
}
.sftp-mode {
flex: none;
width: 90px;
text-align: center;
color: #9a9a9a;
font-family: Consolas, 'Courier New', monospace;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
}
.sftp-owner {
flex: none;
width: 44px;
text-align: right;
color: #8a8a8a;
font-size: 11px;
@@ -94,19 +100,19 @@
}
.sftp-size {
flex: none;
width: 52px;
text-align: right;
color: #8a8a8a;
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.sftp-time {
flex: none;
width: 78px;
text-align: right;
color: #8a8a8a;
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.sftp-empty,
+188 -40
View File
@@ -69,6 +69,15 @@ interface SearchBarProps {
result: string
}
/** M5: cap for the floating completion popup height in px. */
const SUGGEST_POPUP_H = 200
/** M5: estimated popup height for a given number of suggestion rows. */
function estimatePopupHeight(itemCount: number): number {
// item ≈ 21px, hint row ≈ 18px, popup v-padding + gaps ≈ 10px.
return Math.min(SUGGEST_POPUP_H, Math.max(70, itemCount * 22 + 28))
}
function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }: SearchBarProps): React.JSX.Element {
return (
<div className="term-searchbar">
@@ -100,13 +109,40 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
* buffer is empty (some control data only). Enter/backspace/espace/ctrl-c are
* handled here; escape sequences (\x1b) and the leading non-printable control
* bytes on a chunk — like \x1b[K clear-line — never enter the buffer.
*
* `rewrite` flags a completion acceptance that just rewrote the shell line (a
* Ctrl+U kill + full-command write). The very next onData chunk is the shell's
* redraw echoing the accepted line, so instead of appending onto the stale
* pre-accept buffer we *replace* it with what the pty actually echoed. This is
* the echo-suppression that keeps the local buffer in sync with the shell and
* prevents the accepted text from being duplicated.
*/
function stepLineBuffer(
outBuf: React.MutableRefObject<string>,
data: string
data: string,
rewrite: boolean
): { line: string; submit?: string } {
const b = outBuf.current
if (!data) return { line: b }
// Completion-rewrite redraw: reset to this chunk's visible text verbatim. For
// a shell like PowerShell the redraw is a leading escape/control sequence
// + the line text; strip leading control bytes so only the echoed command
// enters the buffer (matching how a normal chunk is captured). Any trailing
// text on the chunk is appended as usual.
if (rewrite) {
let i = 0
while ((data[i] === '\x1b' && (data[i + 1] === '[' || data[i + 1] === '(')) || data[i] === '\r' || data[i] === '\n') {
i += data[i] === '\x1b' ? 2 : 1
}
const visible = data.slice(i)
if (visible) {
outBuf.current = visible
return { line: visible }
}
// Only control output (e.g. the kill sequence handling) — treat as a reset.
outBuf.current = ''
return { line: '' }
}
// A chunk that begins with escape/control sequences (e.g. the terminal's
// clear-line) only removes buffer content; never appends.
const first = data[0]
@@ -142,14 +178,34 @@ function buildSuggestions(cache: CommandItem[], buffer: string): CommandItem[] {
return out
}
/** M5: send only the part of `full` that isn't already in the pty buffer. */
/**
* [M5] Accept a completion by forcing the pty to rewrite the whole line rather
* than appending only the missing tail.
*
* The previous implementation wrote just `full.slice(buf.length)` when the
* shell line buffer (`buf`) was a prefix of the target (`full`). That delta
* approach is what lost characters: `lineBufRef` is accumulated locally from
* user keystrokes and races the shell's real line buffer. Whenever the shell
* redraws the line (e.g. it emits a clear-line/control sequence, or the pty
* echoes control output back to us) the local buffer drifts out of sync with
* the shell, and the next keystroke overwrites the completion we just wrote.
*
* Rewriting the whole line removes that shared-mutable-buffer dependence:
* 1. `\x15` (Ctrl+U) kills the current line. bash, zsh, fish, and Windows
* PowerShell's PSReadLine all honor it, and — unlike `\x03` (Ctrl+C) —
* it leaves no caret and does not start a fresh prompt, so the shell stays
* on the same line ready for the replacement text. (A control-sequence
* fallback of `\x1b[D` repeated N times + `\x1b[K` would also work but is
* noisier and shell-specific; Ctrl+U is simpler and reliably supported.)
* 2. We then write `full`; the pty echoes it into the session buffer.
*
* `buf` is retained purely for call-site symmetry with the old signature; the
* rewrite always sends the Ctrl+U + full command. Whatever the local buffer
* previously held is irrelevant to the pty after the kill.
*/
function differenceWrite(sessionId: string, full: string, buf: string): void {
if (full.startsWith(buf) && buf) {
writeBroadcast(sessionId, full.slice(buf.length))
} else {
writeBroadcast(sessionId, '\x03')
writeBroadcast(sessionId, full)
}
writeBroadcast(sessionId, '\x15')
writeBroadcast(sessionId, full)
}
export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
@@ -172,6 +228,13 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const streamRef = useRef<HighlightStream | null>(null)
// M5: per-session line-capture buffer + completion candidate cache.
const lineBufRef = useRef<string>('')
// M5: when a completion acceptance rewrites the shell line (Ctrl+U + full
// command via writeBroadcast in `differenceWrite`), the shell answers with a
// redraw that echoes `full`. If that echo were folded into the local buffer
// on top of the pre-accept text it would double the line. `diffRewriteRef`
// flags that the *next* onData chunk is that redraw output, so stepLineBuffer
// replaces the buffer with the freshly-echoed line instead of appending.
const diffRewriteRef = useRef(false)
const completionCacheRef = useRef<CommandItem[]>([])
const completionKeyRef = useRef('')
const recordingRef = useRef(false)
@@ -179,6 +242,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// handler always reads the live values without a stale closure.
const suggestionsRef = useRef<CommandItem[]>([])
const selIndexRef = useRef(0)
// Absolute-positioning hook for the completion popup (the box that wraps the
// xterm surface), so popup coordinates start at the terminal content area.
const suggestHookRef = useRef<HTMLDivElement | null>(null)
// M5: absolute position (view-relative) of the floating completion popup.
const [popupPos, setPopupPos] = useState<{ left: number; top: number; flip: boolean } | null>(null)
// Compile keyword-highlight rules once per rules change; keep them in a ref so
// the (stable, single-construction) pty/term subscriptions always read the
@@ -361,8 +429,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const handleTab = useCallback(
(selected: CommandItem): void => {
const cmd = selected.command
const buf = lineBufRef.current
differenceWrite(sessionId, cmd, buf)
// Rewrite the whole shell line (Ctrl+U then `cmd`) instead of appending a
// delta — the pty redraw, not our local buffer, becomes the source of
// truth for the line (see differenceWrite). The redraw trigger sets the
// rewrite flag so onData resets the line buffer to the echoed line.
differenceWrite(sessionId, cmd, lineBufRef.current)
diffRewriteRef.current = true
lineBufRef.current = cmd
suggestionsRef.current = []
selIndexRef.current = 0
@@ -422,7 +494,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
moveSelection(-1)
return false
}
if (e.key === 'Tab') {
if (e.key === 'Tab' || e.key === 'Enter') {
// Tab and Enter both accept the highlighted suggestion: rewrite the
// whole line. Without the rewrite-on-Enter path, pressing Enter with
// the popup open submitted the raw pre-completion line (dropping the
// suggested text) — this keeps the keyboard affordances symmetric
// with the popup's Hint (↑/↓ 选择 · Tab 接受 · Esc 关闭).
const selected = list[selIndexRef.current] ?? list[0]
if (selected) handleTab(selected)
return false
@@ -464,6 +541,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const fit = new FitAddon()
term.loadAddon(fit)
fitAddonRef.current = fit
// Place any popup that was already showing (e.g. the pty started with a
// visible completion) once the terminal surface has real dimensions.
requestAnimationFrame(() => {
if (termRef.current === term && suggestionsRef.current.length > 0) placeSuggestPopup()
})
const searchAddon = new SearchAddon()
searchAddon.onDidChangeResults((ev) => {
@@ -519,8 +601,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const disposables: { dispose(): void }[] = [
term.onData((data) => {
if (!deadRef.current) writeBroadcast(sessionId, data)
// M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
// now so this redraw chunk replaces (not appends to) the line buffer.
const consumeRewrite = diffRewriteRef.current
diffRewriteRef.current = false
// M5: line capture → recordCommand + inline completion overlay.
const step = stepLineBuffer(lineBufRef, data)
const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
if (typeof step.submit === 'string' && step.submit) {
void window.api.recordCommand(step.submit).catch(() => undefined)
}
@@ -549,8 +635,17 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const sel = term.getSelection()
if (!sel) return
void navigator.clipboard.writeText(sel).catch(() => undefined)
}),
// Keep the floating completion popup pinned to wherever the text cursor
// currently is (repositioned only while suggestions are visible; opening
// the popup also places it once in the effect further below).
term.onCursorMove(() => {
if (suggestionsRef.current.length > 0) placeSuggestPopup()
})
]
// [M5] Placement for a visible popup — one measurement per popup opening,
// so the overlay appears before a subsequent cursor-move event fires.
if (suggestionsRef.current.length > 0) placeSuggestPopup()
// Live data is queued until the replay lands so output keeps its order.
let replayDone = false
const pending: string[] = []
@@ -666,6 +761,53 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const terminalClassName = useMemo(() => ['terminal-view', className].filter(Boolean).join(' '), [className])
/**
* [M5] Compute and store the floating popup position so it hugs the text
* cursor. Called whenever the suggestions become visible (the cursor does not
* move while a completion is open, so one placement per popup is enough) and
* on every cursor move while the popup is open. Coordinates are relative to
* `suggestHookRef` (the container that wraps the xterm surface), so `top`
* starts from the terminal content row area.
*/
/**
* [M5] Compute and store the floating popup position so it hugs the text
* cursor. Called whenever the suggestions become visible (the cursor does not
* move while a completion is open, so one placement per popup is enough) and
* on every cursor move while the popup is open. Coordinates are relative to
* `suggestHookRef` (the container that wraps the xterm surface), so `top`
* starts from the terminal content row area.
*/
const placeSuggestPopup = useCallback((): void => {
const term = termRef.current
if (!term) return
// xterm 6 internal API (`term._core._renderService.dimensions.css.cell`)
// may change across versions — keep the guarded fallback to a
// font-size-based cell estimate.
const ctx = term as unknown as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } } }
const dim = ctx._core?._renderService?.dimensions?.css?.cell
const fontSize = term.options.fontSize ?? 14
const lineHeight = term.options.lineHeight ?? 1
const cellWidth = dim && dim.width > 0 ? dim.width : fontSize * 0.6
const cellHeight = dim && dim.height > 0 ? dim.height : fontSize * lineHeight
const padLeft = 4
const cursorX = term.buffer.active.cursorX
const cursorY = term.buffer.active.cursorY
const left = Math.round(cursorX * cellWidth + padLeft)
const hook = suggestHookRef.current
const roof = hook ? hook.getBoundingClientRect().height : 0
const hookWidth = hook ? hook.getBoundingClientRect().width : 0
// Keep a right-side margin so a wide popup (min 240px) never overflows the
// terminal container — e.g. a narrow split pane with the cursor mid-text.
const leftClamped = Math.min(Math.max(left, 0), Math.max(0, hookWidth - 240))
// Below the cursor row by default; only flip above when there is not enough
// room under the cursor (uses a height estimate keyed off live count).
const popH = estimatePopupHeight(suggestionsRef.current.length)
const belowTop = (cursorY + 1) * cellHeight
const flip = belowTop > Math.max(roof - popH, 100)
const top = Math.round(flip ? Math.max(0, cursorY * cellHeight - popH) : belowTop)
setPopupPos({ left: leftClamped, top, flip })
}, [])
const onSearchChange = useCallback((value: string) => {
setSearchTerm(value)
searchTermRef.current = value
@@ -707,29 +849,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<FolderOpenOutlined />
</button>
</div>
{suggestions.length > 0 && (
<div
className="term-suggestbar"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<div className="term-suggest-list">
{suggestions.map((s, i) => (
<button
key={s.id}
type="button"
className={`term-suggest-item${i === suggestionIndex ? ' is-active' : ''}`}
onClick={() => handleTab(s)}
title={s.command}
>
<span className="term-suggest-cmd">{s.command}</span>
{s.name && <span className="term-suggest-tag">{s.name}</span>}
</button>
))}
</div>
<div className="term-suggest-hint">↑/↓ 选择 · Tab 接受 · Esc 关闭</div>
</div>
)}
{paste !== null && !dead && (
<div className="term-pastebar">
<span className="term-pastebar-text">检测到多行/长文本粘贴</span>
@@ -741,13 +860,42 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
</button>
</div>
)}
<div className="terminal-view-dock" ref={hostRef}>
{dead && (
<div className="term-dead-mask">
<div>进程已退出 (代码 {exitCode})</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>关闭</button>
{/* Relative-positioned wrapper around the xterm surface: the suggestion
popup anchors to this box, so its coordinates start at the terminal
content rows (the search bar above never offsets the top). */}
<div className="term-suggest-hook" ref={suggestHookRef}>
{popupPos !== null && suggestions.length > 0 && (
<div
className={'term-suggestbar' + (popupPos.flip ? ' is-flipped' : '')}
style={{ left: popupPos.left, top: popupPos.top }}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<div className="term-suggest-list">
{suggestions.map((s, i) => (
<button
key={s.id}
type="button"
className={`term-suggest-item${i === suggestionIndex ? ' is-active' : ''}`}
onClick={() => handleTab(s)}
title={s.command}
>
<span className="term-suggest-cmd">{s.command}</span>
{s.name && <span className="term-suggest-tag">{s.name}</span>}
</button>
))}
</div>
<div className="term-suggest-hint">↑/↓ 选择 · Tab 接受 · Esc 关闭</div>
</div>
)}
<div className="terminal-view-dock" ref={hostRef}>
{dead && (
<div className="term-dead-mask">
<div>进程已退出 (代码 {exitCode})</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>关闭</button>
</div>
)}
</div>
</div>
</div>
)
+49 -7
View File
@@ -108,11 +108,25 @@
.term-recbar {
position: absolute;
top: 4px;
left: 4px;
right: 14px;
z-index: 4;
display: flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: 6px;
background: rgba(24, 26, 31, 0.85);
border: 1px solid var(--chrome-border);
/* Hidden until the terminal is hovered — it must not sit over the output. */
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
}
.terminal-view:hover .term-recbar,
.term-recbar:focus-within {
opacity: 1;
pointer-events: auto;
}
.term-rec-btn {
@@ -139,22 +153,46 @@
color: var(--chrome-accent);
}
/* ---- M5 inline command-completion overlay ---- */
/* While a log is recording, keep the bar visible so the state is obvious. */
.term-recbar:has(.term-rec-btn.is-recording) {
opacity: 1;
pointer-events: auto;
}
/* ---- M5 inline command-completion floating popup ----
Absolutely positioned inside .term-suggest-hook (the box that wraps the
terminal surface), so it tracks the text cursor and is never clipped by the
terminal-view's own overflow. The list scrolls internally when too tall. */
.term-suggest-hook {
position: relative;
flex: 1 1 0;
min-height: 0;
min-width: 0;
}
.term-suggestbar {
flex: none;
position: absolute;
z-index: 6;
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
background: var(--chrome-hover);
border-bottom: 1px solid var(--chrome-border);
min-width: 240px;
max-width: 420px;
max-height: 200px;
padding: 5px 6px 6px;
overflow-y: auto;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
user-select: none;
box-sizing: border-box;
}
.term-suggest-list {
display: flex;
flex-direction: column;
gap: 1px;
flex: 0 0 auto;
}
.term-suggest-item {
@@ -199,9 +237,13 @@
}
.term-suggest-hint {
flex: 0 0 auto;
font-size: 11px;
color: #8a8a8a;
opacity: 0.9;
padding-top: 4px;
line-height: 13px;
white-space: nowrap;
}
/* ---- paste risk confirm bar ---- */
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { FilePanel } from '../sftp/FilePanel'
export interface SshBottomPanelProps {
sessionId: string
/** The terminal surface rendered in the flexible top region. */
terminal: ReactNode
}
/** File browser height in px — clamped to [120, 60% of container height]. */
const MIN_HEIGHT = 120
export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element {
/** Current file-browser height in px. */
const [filesHeight, setFilesHeight] = useState<number>(220)
/** Container height captured while a drag is in flight. */
const dragInfoRef = useRef<{ startY: number; startHeight: number; containerH: number } | null>(null)
const onDividerMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>): void => {
if (e.button !== 0) return
const container = (e.currentTarget.parentElement as HTMLElement | null) ?? null
if (!container) return
dragInfoRef.current = {
startY: e.clientY,
startHeight: filesHeight,
containerH: container.clientHeight
}
e.preventDefault()
}, [filesHeight])
useEffect(() => {
const onMove = (e: MouseEvent): void => {
const info = dragInfoRef.current
if (!info) return
const delta = e.clientY - info.startY
const max = Math.round(info.containerH * 0.6)
setFilesHeight(Math.min(max, Math.max(MIN_HEIGHT, info.startHeight - delta)))
}
const onUp = (): void => {
dragInfoRef.current = null
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
return () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
}, [])
return (
<div className="ssh-bottom-panel">
<div className="ssh-bottom-top">{terminal}</div>
<div
className="ssh-bottom-divider"
role="separator"
aria-orientation="horizontal"
aria-label="调整文件面板高度"
onMouseDown={onDividerMouseDown}
/>
<div className="ssh-bottom-files" style={{ height: filesHeight }}>
<FilePanel sessionId={sessionId} />
</div>
</div>
)
}
@@ -0,0 +1,17 @@
/**
* Small capsule badge showing the SSH host label (e.g. `root@36.151.147.123`)
* on an SSH terminal tab. Host labels are often long, so the display string is
* truncated with an ellipsis rather than wrapped; the full label is always
* available via the native `title` tooltip and the tab-level tooltip.
*/
export function SshHostBadge({ label }: { label: string }): React.JSX.Element {
return (
<span
className="ssh-host-badge"
title={label}
aria-label={`SSH 主机 ${label}`}
>
{label}
</span>
)
}
+23 -33
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import type { IDockviewPanelProps } from 'dockview-react'
import { Button, Tabs } from 'antd'
import { AreaChartOutlined, FolderOpenOutlined } from '@ant-design/icons'
import { Button } from 'antd'
import { AreaChartOutlined } from '@ant-design/icons'
import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel'
import { FilePanel } from '../sftp/FilePanel'
import { SshBottomPanel } from './SshBottomPanel'
import { useBroadcastStore } from './broadcastStore'
import type { TerminalParams } from './Workspace'
@@ -28,15 +28,16 @@ export type TerminalPanelProps = IDockviewPanelProps<TerminalParams> & TerminalP
* already-exited process.
*
* For SSH sessions (`sessionKind === 'ssh'`) a collapsible right sidebar with
* [系统监控 | 文件] tabs is docked next to the terminal. Local sessions render
* terminal only.
* [系统监控] is docked next to the terminal, and a resizable file browser
* (SshBottomPanel) sits below the terminal. Local sessions render terminal
* only.
*/
export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element {
const sessionId = params.sessionId ?? ''
const sessionKind = params.sessionKind
const isSsh = sessionKind === 'ssh'
const closedRef = useRef(false)
/** Right sidebar visibility for ssh sessions — expanded by default. */
/** Right sidebar (monitor) visibility for ssh sessions — expanded by default. */
const [sideOpen, setSideOpen] = useState(true)
// M6 broadcast: keep the session in the broadcast registry while mounted so
@@ -55,6 +56,19 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
return () => disposable.dispose()
}, [api, sessionId, isSsh])
const terminal = (
<TerminalView
sessionId={sessionId}
className="workspace-terminal-view"
onClose={() => {
if (closedRef.current) return
closedRef.current = true
onSessionDead?.(sessionId)
api.close()
}}
/>
)
return (
<div className="workspace-terminal-panel">
{isSsh && (
@@ -62,41 +76,17 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
type="text"
size="small"
className="workspace-terminal-monitor-toggle"
icon={sideOpen ? <AreaChartOutlined /> : <FolderOpenOutlined />}
icon={<AreaChartOutlined />}
aria-label={sideOpen ? '收起侧边栏' : '展开侧边栏'}
title={sideOpen ? '收起侧边栏' : '展开侧边栏'}
onClick={() => setSideOpen((prev) => !prev)}
/>
)}
<div className="workspace-terminal-split">
<TerminalView
sessionId={sessionId}
className="workspace-terminal-view"
onClose={() => {
if (closedRef.current) return
closedRef.current = true
onSessionDead?.(sessionId)
api.close()
}}
/>
{isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} /> : terminal}
{isSsh && sideOpen && (
<div className="workspace-terminal-side">
<Tabs
size="small"
centered
items={[
{
key: 'monitor',
label: '监控',
children: <MonitorPanel sessionId={sessionId} />
},
{
key: 'files',
label: '文件',
children: <FilePanel sessionId={sessionId} />
}
]}
/>
<MonitorPanel sessionId={sessionId} />
</div>
)}
</div>
+16 -3
View File
@@ -5,6 +5,7 @@ import { CloudServerOutlined } from '@ant-design/icons'
import type { IDockviewPanelHeaderProps } from 'dockview-react'
import { useBroadcastStore } from './broadcastStore'
import { SshHostBadge } from './SshHostBadge'
import type { LayoutMeta } from '@shared/ipc'
export type TerminalTabProps = IDockviewPanelHeaderProps<{
@@ -29,7 +30,13 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
const selfIndex = panel.panels.findIndex((p) => p.api === api)
const isSsh = params?.sessionKind === 'ssh'
// M6 broadcast: show a dot on tabs that are current broadcast targets.
const hostLabel: string | undefined = isSsh ? (params?.hostLabel || title) : undefined
const tooltip = isSsh
? hostLabel && hostLabel !== title
? `${title} — ${hostLabel}`
: (hostLabel || title)
: '本地终端'
// M6 broadcast: show a badge on tabs that are current broadcast targets.
const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? ''))
// Keep the tab title live (dockview re-renders tab components only on
@@ -68,15 +75,21 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
return (
<Dropdown menu={menu} trigger={['contextMenu']}>
<div className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}>
<div
className={`workspace-terminal-tab${isSsh ? ' workspace-terminal-tab-ssh' : ''}`}
title={tooltip}
>
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span>
{isSsh && hostLabel && <SshHostBadge label={hostLabel} />}
{isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title="广播目标"
aria-label="广播目标"
/>
>
播
</span>
)}
<button
type="button"
+420 -177
View File
@@ -3,8 +3,8 @@ import type { ReactNode } from 'react'
import { App, Button, Checkbox, Dropdown, Popover, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import {
ColumnHeightOutlined,
ColumnWidthOutlined,
CloudServerOutlined,
CodeOutlined,
LayoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
@@ -16,7 +16,6 @@ import {
import { DockviewReact } from 'dockview-react'
import type {
DockviewApi,
DockviewGroupPanel,
DockviewReadyEvent,
IDockviewHeaderActionsProps,
IDockviewPanel,
@@ -34,6 +33,8 @@ import { TerminalPanel } from './TerminalPanel'
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
import type { WorkspaceMode } from './workspaceMode'
import { useWorkspaceModeStore } from './workspaceModeStore'
import './workspace.css'
export interface WorkspaceProps {
@@ -72,26 +73,91 @@ function sessionIdOf(panel: IDockviewPanel): string | undefined {
return (panel.params as TerminalParams | undefined)?.sessionId
}
/** The dockview api that owns a given workspace mode. */
function apiOfMode(mode: WorkspaceMode, terminalApi: DockviewApi | undefined, sshApi: DockviewApi | undefined): DockviewApi | undefined {
return mode === 'terminal' ? terminalApi : sshApi
}
/** Shared per-mode right-header actions (the "+" creates a terminal in the
* terminal dockview and hints from the server list in the ssh dockview). */
function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsProps) => React.JSX.Element {
function TabActions({ containerApi, group }: IDockviewHeaderActionsProps): React.JSX.Element {
// The per-group "+" creates a local terminal — SSH sessions can only be
// opened from the server list, so in ssh mode the button just hints.
const { message } = App.useApp()
const handleAdd = (): void => {
if (mode === 'ssh') {
message.info('SSH 会话请从服务器列表连接')
return
}
void (async (): Promise<void> => {
const result = await window.api.createPty()
containerApi.addPanel<TerminalParams>({
id: result.id,
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(),
params: { sessionId: result.id, sessionKind: 'local' },
position: { referenceGroup: group.id, direction: 'within' }
})
})()
}
return (
<div className="workspace-tabbar-actions">
<AddTerminalGroupButton onClick={handleAdd} />
<BroadcastToggleButton />
<QuickInputToggleButton />
</div>
)
}
return TabActions
}
const TerminalTabActions = createTabActions('terminal')
const SshTabActions = createTabActions('ssh')
/**
* Workspace owns the dockview layout + terminal session lifecycle.
* Workspace owns TWO independent dockviews — one for local terminals, one for
* SSH sessions — so the two session kinds never mix on the same tab strip.
* Switching workspace mode simply toggles `display:none` on the container div
* (React state / panel instances stay mounted, so the switch is free).
*
* Sessions: panels carry a `params.sessionId`. Killing a session is
* idempotent through `deadSessionsRef` so a session is killed at most once —
* either up-front (TerminalView `onClose` after the process exits) or when
* its panel is removed.
* Sessions: panels carry a `params.sessionId`. Killing a session is idempotent
* through `deadSessionsRef` so a session is killed at most once — either
* up-front (TerminalView `onClose` after the process exits) or when its panel
* is removed. Each dockview registers `onDidRemovePanel` so a panel close in
* either workspace kills the bound session.
*/
export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX.Element {
const { message } = App.useApp()
const apiRef = useRef<DockviewApi | undefined>(undefined)
/** Panel used as the anchor for split placements. */
/** Dockview apis for the two isolated workspaces. */
const terminalApiRef = useRef<DockviewApi | undefined>(undefined)
const sshApiRef = useRef<DockviewApi | undefined>(undefined)
/** Currently selected workspace group (terminal / ssh) — drives visibility. */
const workspaceMode = useWorkspaceModeStore((s) => s.mode)
/** session counts per dockview — "empty" means no *session* panel. */
const [modeCounts, setModeCounts] = useState<{ terminal: number; ssh: number }>({
terminal: 0,
ssh: 0
})
const terminalCount = modeCounts.terminal
const sshCount = modeCounts.ssh
/** Per-workspace active panel used as the split anchor. */
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
const activeSshPanelRef = useRef<IDockviewPanel | undefined>(undefined)
/** The currently-visible mode's active panel (broadcast / quick-input target). */
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
/** Sessions we already asked to kill — kill once, never twice. */
const deadSessionsRef = useRef<Set<string>>(new Set())
/** Bind-time disposables for both dockviews, disposed on unmount. */
const disposablesRef = useRef<Array<{ dispose(): void }>>([])
const [panelCount, setPanelCount] = useState(0)
const [saveOpen, setSaveOpen] = useState(false)
const [applyOpen, setApplyOpen] = useState(false)
/** Terminal dockview ready — gates first-run seeding. */
const [terminalReady, setTerminalReady] = useState(false)
/** Local terminal panels surfaced to the sidebar's "终端" section. */
const [localPanels, setLocalPanels] = useState<Array<{ id: string; title: string }>>([])
@@ -137,13 +203,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
return result.id
}, [])
/**
* Rebuild the local-terminal list for the sidebar's "终端" section. Called on
* layout changes (add/remove/renumber) so both the panel set and any title
* updates are reflected.
*/
/** Rebuild the local-terminal list for the sidebar's "终端" section. The
* terminal dockview holds only local sessions, so it is the single source. */
const recomputeLocalPanels = useCallback((): void => {
const current = apiRef.current
const current = terminalApiRef.current
if (!current) {
setLocalPanels([])
return
@@ -154,53 +217,116 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
setLocalPanels(panels)
}, [])
/** Focus a local terminal panel from the sidebar. */
const onFocusPanel = useCallback((panelId: string): void => {
const current = apiRef.current
current?.getPanel(panelId)?.api.setActive()
/** Recount session panels per dockview into local state. */
const recountAll = useCallback((): void => {
const terminal = terminalApiRef.current
const ssh = sshApiRef.current
const t = terminal ? terminal.panels.filter((p) => sessionIdOf(p)).length : 0
const s = ssh ? ssh.panels.filter((p) => sessionIdOf(p)).length : 0
setModeCounts((prev) => (prev.terminal === t && prev.ssh === s ? prev : { terminal: t, ssh: s }))
}, [])
const handleReady = useCallback((event: DockviewReadyEvent): void => {
apiRef.current = event.api
setPanelCount(event.api.panels.length)
/** Focus a local terminal panel from the sidebar (terminal dockview). */
const onFocusPanel = useCallback((panelId: string): void => {
const panel = terminalApiRef.current?.getPanel(panelId)
if (!panel) return
useWorkspaceModeStore.getState().setMode('terminal')
panel.api.setActive()
}, [])
/**
* Subscribe to dockview lifecycle events. Installed once after the first
* `onReady`; `apiRef.current` is set by then.
* Bind a dockview's api: stash it in its ref, subscribe to lifecycle events
* (remove → kill session, active → track panel), and keep indices fresh.
*/
useEffect(() => {
const current = apiRef.current
if (!current) return
const bindApi = useCallback(
(mode: WorkspaceMode, api: DockviewApi): void => {
if (mode === 'terminal') terminalApiRef.current = api
else sshApiRef.current = api
const disposables = [
current.onDidRemovePanel((panel: IDockviewPanel) => {
// Panel vanished (tab close / context menu / layout clear): kill its
// session unless it was already marked dead (process already exited).
killSession(sessionIdOf(panel))
recomputeLocalPanels()
}),
current.onDidActivePanelChange((event) => {
activePanelRef.current = event.panel ?? undefined
setActivePanelId(event.panel?.id ?? null)
}),
current.onDidLayoutChange(() => {
setPanelCount(current.panels.length)
recomputeLocalPanels()
})
]
const cleanups = [
api.onDidRemovePanel((panel: IDockviewPanel) => {
killSession(sessionIdOf(panel))
recomputeLocalPanels()
recountAll()
}),
api.onDidActivePanelChange((event) => {
if (mode === 'terminal') activeTerminalPanelRef.current = event.panel ?? undefined
else activeSshPanelRef.current = event.panel ?? undefined
if (useWorkspaceModeStore.getState().mode === mode) {
activePanelRef.current = event.panel ?? undefined
setActivePanelId(event.panel?.id ?? null)
}
}),
api.onDidLayoutChange(() => {
recomputeLocalPanels()
recountAll()
}),
api.onDidAddPanel(() => recountAll())
]
disposablesRef.current.push(...cleanups)
},
[killSession, recomputeLocalPanels, recountAll]
)
const handleTerminalReady = useCallback(
(event: DockviewReadyEvent): void => {
bindApi('terminal', event.api)
recountAll()
recomputeLocalPanels()
setTerminalReady(true)
},
[bindApi, recountAll, recomputeLocalPanels]
)
const handleSshReady = useCallback(
(event: DockviewReadyEvent): void => {
bindApi('ssh', event.api)
recountAll()
},
[bindApi, recountAll]
)
/** Dispose dockview subscriptions on unmount. */
useEffect(() => {
return () => {
for (const disposable of disposables) disposable.dispose()
for (const disposable of disposablesRef.current) disposable.dispose()
disposablesRef.current = []
}
}, [killSession, recomputeLocalPanels])
}, [])
/** Keep `activePanelRef` pointing at the currently-visible workspace's
* active panel whenever the mode changes (a mode toggle alone doesn't fire
* onDidActivePanelChange). */
useEffect(() => {
const sync = (): void => {
const mode = useWorkspaceModeStore.getState().mode
const api = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)
const active = api?.activePanel
activePanelRef.current = active ?? undefined
setActivePanelId(active?.id ?? null)
}
sync()
return useWorkspaceModeStore.subscribe(sync)
}, [])
/** First run: seed one local terminal so the terminal workspace is never
* empty on launch. Runs once, after the terminal dockview is ready. */
useEffect(() => {
if (!terminalReady) return
const current = terminalApiRef.current
if (current && !current.panels.some((p) => sessionIdOf(p))) void addTerminal()
// seed the terminal workspace exactly once, on first ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminalReady])
/** Kill remaining sessions on window close. */
useEffect(() => {
const handler = (): void => {
const current = apiRef.current
if (!current) return
for (const panel of current.panels) {
killSession(sessionIdOf(panel))
for (const api of [terminalApiRef.current, sshApiRef.current]) {
if (!api) continue
for (const panel of api.panels) {
killSession(sessionIdOf(panel))
}
}
}
window.addEventListener('beforeunload', handler)
@@ -230,27 +356,25 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
}, [])
/**
* M6: Ctrl+PgUp / Ctrl+PgDn switch between terminal tabs. Registered on
* `window` with capture:true so it runs before xterm's own key handling;
* Ctrl+PageDown/PageUp are the OS default terminal-tab chords, which the
* shell rarely needs, so we intercept them and cycle the active panel.
* M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with
* capture:true so it runs before xterm's own key handling; it only cycles the
* currently-visible workspace's session panels.
*/
useEffect(() => {
const handler = (e: KeyboardEvent): void => {
const ctrl = e.ctrlKey
const code = e.code
if (!ctrl || (code !== 'PageUp' && code !== 'PageDown')) return
const current = apiRef.current
const mode = useWorkspaceModeStore.getState().mode
const current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)
if (!current) return
const panels = current.panels
const panels = current.panels.filter((p) => sessionIdOf(p))
if (panels.length < 2) return
e.preventDefault()
e.stopPropagation()
const active = current.activePanel ?? activePanelRef.current
let idx = active ? panels.findIndex((p) => p.id === active.id) : -1
const step = code === 'PageDown' ? 1 : -1
// wrap around the ordered panel list; skip non-terminal panels (unlikely —
// every dockview panel here is a terminal) for robustness.
let next = active
for (let i = 1; i <= panels.length; i++) {
const candidate = panels[(idx + step * i + panels.length * 2) % panels.length]
@@ -265,13 +389,15 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
return () => window.removeEventListener('keydown', handler, true)
}, [])
/** Add a terminal panel; `direction` splits relative to the active panel. */
/** New local terminals always land in the terminal dockview. Ensure it is
* visible first so the new panel shows up. */
const addTerminal = useCallback(
async (direction?: 'right' | 'below'): Promise<void> => {
const current = apiRef.current
const current = terminalApiRef.current
if (!current) return
useWorkspaceModeStore.getState().setMode('terminal')
const sessionId = await createSession()
const reference = activePanelRef.current
const reference = activeTerminalPanelRef.current
current.addPanel<TerminalParams>({
id: sessionId,
@@ -284,7 +410,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
? { referencePanel: reference.id, direction }
: undefined
})
setPanelCount(current.panels.length)
},
[createSession]
)
@@ -292,7 +417,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/**
* SSH connection pipeline. openSession resolves once the session is ready to
* stream; `connectionId` + any staged `secretOverride` are the only inputs
* the main process needs — no extra IPC channel.
* the main process needs — no extra IPC channel. New sessions land in the ssh
* dockview.
*/
const connect = useCallback(
async (conn: SshConnection, secretOverride: SshSecretOverride): Promise<void> => {
@@ -303,8 +429,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
connectionId: conn.id,
secretOverride
})
const current = apiRef.current
const current = sshApiRef.current
if (!current) return
// SSH sessions belong to the ssh workspace — switch to it so the panel
// is visible once added.
useWorkspaceModeStore.getState().setMode('ssh')
current.addPanel<TerminalParams>({
id: result.id,
component: TERMINAL_COMPONENT,
@@ -312,7 +441,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
title: conn.name,
params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host }
})
setPanelCount(current.panels.length)
// B's sidebar refreshes lastConnectedAt on connect.
void sidebarRef.current?.refresh()
} catch (error) {
@@ -360,60 +488,72 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
void addTerminal()
}, [addTerminal])
/** First run: open one terminal so the workspace is never empty on launch. */
const bootstrappedRef = useRef(false)
useEffect(() => {
if (bootstrappedRef.current) return
bootstrappedRef.current = true
const current = apiRef.current
if (current && current.panels.length === 0) void addTerminal()
}, [addTerminal])
/** Icon-rail "+": in terminal mode make a terminal; in ssh mode hand off to
* the server list instead (SSH sessions can't be created on the fly). */
const handleRailNewTerminal = useCallback((): void => {
const mode = useWorkspaceModeStore.getState().mode
if (mode === 'ssh') {
message.info('SSH 会话请从服务器列表连接')
return
}
void addTerminal()
}, [addTerminal, message])
const handleSplit = useCallback(
(direction: 'right' | 'below'): void => {
void addTerminal(direction)
const mode = useWorkspaceModeStore.getState().mode
if (mode === 'terminal') {
// New local terminal split in the terminal workspace.
void addTerminal(direction)
return
}
// ssh mode: split only when there's already a live SSH session to split.
const current = sshApiRef.current
const hasSession = current ? current.panels.some((p) => sessionIdOf(p)) : false
if (!hasSession) {
message.info('请先连接 SSH 服务器')
return
}
const reference = activeSshPanelRef.current
if (!reference || !current) return
const sessionId = sessionIdOf(reference)
if (!sessionId) return
const refParams = reference.params as TerminalParams | undefined
current.addPanel<TerminalParams>({
id: `ssh-${sessionId}-${window.crypto.randomUUID().slice(0, 8)}`,
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: reference.title,
params: { sessionId, sessionKind: 'ssh', hostLabel: refParams?.hostLabel },
position: { referencePanel: reference.id, direction }
})
},
[addTerminal]
[message, addTerminal]
)
const handleSaveTemplate = useCallback(async (name: string): Promise<void> => {
const current = apiRef.current
if (!current) return
await window.api.saveLayout(
{
id: window.crypto.randomUUID(),
name,
createdAt: Date.now()
},
JSON.stringify(current.toJSON())
JSON.stringify({
terminal: terminalApiRef.current?.toJSON(),
ssh: sshApiRef.current?.toJSON()
})
)
}, [])
const handleApplyTemplate = useCallback(
async (meta: LayoutMetaLike): Promise<void> => {
const current = apiRef.current
if (!current) return
const raw = await window.api.getLayout(meta.id)
if (raw == null) return
// Sessions currently bound to panels — the template load replaces them.
const previousSessions = new Set<string>()
for (const panel of current.panels) {
const sid = sessionIdOf(panel)
if (sid) previousSessions.add(sid)
}
let serialized: unknown
try {
serialized = JSON.parse(raw)
} catch {
return
}
// JSON.parse returns `any`; validate above, cast one level down.
current.fromJSON(serialized as never)
// Every restored terminal panel needs a fresh pty bound to its params.
for (const panel of current.panels) {
/**
* Rebind every restored session panel to a fresh local pty. Templates cannot
* carry live session state (SSH needs credentials not stored in the layout),
* so sessions are recreated as local terminals in whichever workspace the
* layout put them.
*/
const rebindRestoredPanels = useCallback(
async (api: DockviewApi): Promise<void> => {
for (const panel of api.panels) {
const id = sessionIdOf(panel)
if (id && panel.view.contentComponent === TERMINAL_COMPONENT) {
const newSessionId = (await window.api.createPty()).id
@@ -421,13 +561,57 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
deadSessionsRef.current.delete(id)
}
}
},
[]
)
const handleApplyTemplate = useCallback(
async (meta: LayoutMetaLike): Promise<void> => {
const raw = await window.api.getLayout(meta.id)
if (raw == null) return
// Sessions currently bound to panels — the template load replaces them.
const previousSessions = new Set<string>()
for (const api of [terminalApiRef.current, sshApiRef.current]) {
if (!api) continue
for (const panel of api.panels) {
const sid = sessionIdOf(panel)
if (sid) previousSessions.add(sid)
}
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return
}
const payload = parsed as { terminal?: unknown; ssh?: unknown }
const hasDualLayout = payload !== null && typeof payload === 'object' && 'terminal' in payload && 'ssh' in payload
// Dual layout (M6.1+): restore each dockview from its own payload.
// Legacy layout (pre-M6.1 single toJSON): restore it wholly into the
// terminal dockview.
if (hasDualLayout) {
terminalApiRef.current?.fromJSON(payload.terminal as never)
sshApiRef.current?.fromJSON(payload.ssh as never)
} else {
// Legacy single-dockview layout → restore into the terminal workspace.
terminalApiRef.current?.fromJSON((payload as unknown) as never)
}
// Fresh sessions for every restored terminal panel in both workspaces.
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
// Old sessions are unreachable after the layout swap — kill them.
for (const sid of previousSessions) killSession(sid)
setPanelCount(current.panels.length)
activePanelRef.current = current.activePanel
recountAll()
recomputeLocalPanels()
const mode = useWorkspaceModeStore.getState().mode
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
},
[killSession]
[killSession, rebindRestoredPanels, recountAll, recomputeLocalPanels]
)
const handleDeleteTemplate = useCallback(async (meta: LayoutMetaLike): Promise<void> => {
@@ -455,16 +639,21 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const layoutMenu: MenuProps = {
items: [
{ key: 'split-right', label: '水平分屏' },
{ key: 'split-below', label: '垂直分屏' },
{ type: 'divider' },
{ key: 'save', label: '保存为模板' },
{ key: 'apply', label: '应用模板' }
],
onClick: ({ key }) => {
if (key === 'save') setSaveOpen(true)
if (key === 'split-right') handleSplit('right')
else if (key === 'split-below') handleSplit('below')
else if (key === 'save') setSaveOpen(true)
else if (key === 'apply') setApplyOpen(true)
}
}
/** Session id of the currently active terminal panel ('' when none). */
/** Session id of the currently active, visible terminal panel ('' when none). */
const resolveActiveSessionId = useCallback((): string => {
const panel = activePanelRef.current
if (!panel) return ''
@@ -475,7 +664,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
<div className="workspace-root">
<IconRail
onOpenSettings={onOpenSettings}
onNewTerminal={handleNewTerminal}
onNewTerminal={handleRailNewTerminal}
onSplit={handleSplit}
onToggleSidebar={toggleSidebar}
sidebarOpen={sidebarOpen}
@@ -495,32 +684,67 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/>
)}
<div className="workspace-dockview">
<DockviewReact
className="dockview-theme-abyss"
components={{
[TERMINAL_COMPONENT]: (props: IDockviewPanelProps<TerminalParams>) => (
<TerminalPanel {...props} onSessionDead={markSessionDead} />
)
}}
tabComponents={{
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
<TerminalTab {...props} />
)
}}
rightHeaderActionsComponent={WorkspaceTabActions}
onReady={handleReady}
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
/>
{panelCount === 0 && (
<div className="workspace-empty-overlay">
<div className="workspace-empty-inner">
<div className="workspace-empty-title">暂无终端</div>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={handleNewTerminal}>
新建终端
</Button>
{/* Local-terminal workspace. */}
<div className={workspaceMode === 'terminal' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
<DockviewReact
className="dockview-theme-abyss"
components={{
[TERMINAL_COMPONENT]: (props: IDockviewPanelProps<TerminalParams>) => (
<TerminalPanel {...props} onSessionDead={markSessionDead} />
)
}}
tabComponents={{
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
<TerminalTab {...props} />
)
}}
rightHeaderActionsComponent={TerminalTabActions}
onReady={handleTerminalReady}
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
/>
{terminalCount === 0 && (
<div className="workspace-empty-overlay">
<div className="workspace-empty-inner">
<div className="workspace-empty-title">暂无终端</div>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleNewTerminal}
>
新建终端
</Button>
</div>
</div>
</div>
)}
)}
</div>
{/* SSH workspace. */}
<div className={workspaceMode === 'ssh' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
<DockviewReact
className="dockview-theme-abyss"
components={{
[TERMINAL_COMPONENT]: (props: IDockviewPanelProps<TerminalParams>) => (
<TerminalPanel {...props} onSessionDead={markSessionDead} />
)
}}
tabComponents={{
[TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps<TerminalParams>) => (
<TerminalTab {...props} />
)
}}
rightHeaderActionsComponent={SshTabActions}
onReady={handleSshReady}
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
/>
{sshCount === 0 && (
<div className="workspace-empty-overlay">
<div className="workspace-empty-inner">
<div className="workspace-empty-title">暂无 SSH 会话</div>
<div className="workspace-empty-hint">请从左侧服务器列表连接</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -560,38 +784,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
)
}
/**
* Right-side actions for every group's tab strip: the "+" new-terminal button
* (per-group), plus two workspace-level M6 toggles — broadcast target picker
* and quick-input panel — so they're visible in the tab bar.
*/
function WorkspaceTabActions({
containerApi,
group
}: IDockviewHeaderActionsProps): React.JSX.Element {
const handleAdd = (): void => {
void (async (): Promise<void> => {
const result = await window.api.createPty()
containerApi.addPanel<TerminalParams>({
id: result.id,
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(),
params: { sessionId: result.id, sessionKind: 'local' },
position: { referenceGroup: group.id, direction: 'within' }
})
})()
}
return (
<div className="workspace-tabbar-actions">
<AddTerminalGroupButton onClick={handleAdd} />
<BroadcastToggleButton />
<QuickInputToggleButton />
</div>
)
}
/** "+" renders the new-terminal button (per-group tab addition). */
function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX.Element {
return (
@@ -614,6 +806,8 @@ function BroadcastToggleButton(): React.JSX.Element {
const sessions = useBroadcastStore((s) => s.sessions)
const toggleTarget = useBroadcastStore((s) => s.toggleTarget)
const setEnabled = useBroadcastStore((s) => s.setEnabled)
/** M6.1: only the current workspace group's sessions are offered as targets. */
const mode = useWorkspaceModeStore((s) => s.mode)
const cls = [
'workspace-tabbar-btn2',
@@ -623,8 +817,10 @@ function BroadcastToggleButton(): React.JSX.Element {
<Popover
trigger="click"
placement="bottomRight"
getPopupContainer={() => document.body}
content={
<WorkspaceBroadcastPopover
mode={mode}
enabled={enabled}
targets={targets}
sessions={sessions}
@@ -647,24 +843,31 @@ function BroadcastToggleButton(): React.JSX.Element {
)
}
/** Popover body: list open sessions with a checkbox each; enable gate >= 2. */
/** Popover body: list the current mode group's open sessions with a checkbox
* each; enable gate >= 2. Hidden-group targets stay in `targets` (the store
* doesn't care about visibility) but are not offered while in the other mode. */
function WorkspaceBroadcastPopover({
mode,
enabled,
targets,
sessions,
onToggleTarget,
onSetEnabled
}: {
mode: WorkspaceMode
enabled: boolean
targets: Set<string>
sessions: { id: string; title: string; isSsh: boolean }[]
onToggleTarget: (id: string) => void
onSetEnabled: (enabled: boolean) => void
}): React.JSX.Element {
const isSsh = (s: { id: string; title: string; isSsh: boolean }): boolean => s.isSsh
const groupSessions = sessions.filter((s) => (mode === 'ssh' ? isSsh(s) : !isSsh(s)))
const label = (s: { id: string; title: string; isSsh: boolean }): string =>
`${s.title}${s.isSsh ? ' (SSH)' : ''}`
return (
<div className="workspace-bcast-pop">
<div className="workspace-bcast-pop-mode">广播输入({mode === 'ssh' ? 'SSH' : '终端'})</div>
<div className="workspace-bcast-pop-switch">
<span className="workspace-bcast-pop-label">广播开关</span>
<Checkbox
@@ -676,10 +879,12 @@ function WorkspaceBroadcastPopover({
</Checkbox>
</div>
<div className="workspace-bcast-pop-list">
{sessions.length === 0 ? (
<div className="workspace-bcast-pop-empty">暂无打开的终端</div>
{groupSessions.length === 0 ? (
<div className="workspace-bcast-pop-empty">
{mode === 'ssh' ? '暂无 SSH 服务器会话' : '暂无终端'}
</div>
) : (
sessions.map((s) => (
groupSessions.map((s) => (
<div key={s.id} className="workspace-bcast-pop-item">
<Checkbox checked={targets.has(s.id)} onChange={() => onToggleTarget(s.id)}>
{label(s)}
@@ -688,7 +893,7 @@ function WorkspaceBroadcastPopover({
))
)}
</div>
<div className="workspace-bcast-pop-hint">选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。</div>
<div className="workspace-bcast-pop-hint">选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。仅当前模式组的会话可被勾选。</div>
</div>
)
}
@@ -742,11 +947,19 @@ function IconRail({
/>
<div className="workspace-rail-divider" />
<RailButton title="新建终端" icon={<PlusOutlined />} onClick={onNewTerminal} />
<RailButton title="水平分屏" icon={<ColumnWidthOutlined />} onClick={() => onSplit('right')} />
<RailButton title="垂直分屏" icon={<ColumnHeightOutlined />} onClick={() => onSplit('below')} />
<Dropdown menu={layoutMenu} trigger={['click']} placement="rightTop">
{/* getPopupContainer=body: dockview's render overlay creates its own
stacking context (contain:layout paint + isolation), which would clip
the dropdown under the sidebar otherwise. */}
<Dropdown
menu={layoutMenu}
trigger={['click']}
placement="rightTop"
getPopupContainer={() => document.body}
>
<RailButton title="布局" icon={<LayoutOutlined />} />
</Dropdown>
<div className="workspace-rail-divider" />
<ModeRailButtons />
<div className="workspace-rail-spacer" />
{connecting && (
<Tooltip title="正在连接…" placement="right">
@@ -758,18 +971,48 @@ function IconRail({
)
}
/** M6.1: terminal / SSH workspace entries in the icon rail. */
function ModeRailButtons(): React.JSX.Element {
const mode = useWorkspaceModeStore((s) => s.mode)
const setMode = useWorkspaceModeStore((s) => s.setMode)
return (
<>
<RailButton
title="终端工作区"
icon={<CodeOutlined />}
active={mode === 'terminal'}
onClick={() => setMode('terminal')}
/>
<RailButton
title="SSH 工作区"
icon={<CloudServerOutlined />}
active={mode === 'ssh'}
onClick={() => setMode('ssh')}
/>
</>
)
}
function RailButton({
title,
icon,
onClick
onClick,
active
}: {
title: string
icon: ReactNode
onClick?: () => void
active?: boolean
}): React.JSX.Element {
return (
<Tooltip title={title} placement="right">
<button type="button" className="workspace-rail-btn" aria-label={title} onClick={onClick}>
<button
type="button"
className={['workspace-rail-btn', active ? ' is-active' : ''].join('')}
aria-label={title}
aria-pressed={active}
onClick={onClick}
>
{icon}
</button>
</Tooltip>
+170 -31
View File
@@ -48,6 +48,13 @@
margin: 2px 0;
}
/* ---- M6.1 rail active state (mode entries) ---- */
.workspace-rail-btn.is-active {
color: var(--dv-active-tab-foreground-color, #e8e8e8);
box-shadow: inset 2px 0 0 #3fb950;
background: rgba(63, 185, 80, 0.10);
}
.workspace-rail-spacer {
flex: 1 1 auto;
}
@@ -92,8 +99,21 @@
min-height: 0;
}
.workspace-dockview .dockview-theme-abyss,
.workspace-dockview .dv-dockview {
/* ---- per-workspace dockview containers (terminal / ssh).
Switching mode toggles `.is-hidden` → display:none. The DockviewReact stays
mounted (state/layout preserved) so the switch is instant and free. */
.workspace-dockview-container {
position: absolute;
inset: 0;
display: flex;
}
.workspace-dockview-container.is-hidden {
display: none;
}
.workspace-dockview-container .dockview-theme-abyss,
.workspace-dockview-container .dv-dockview {
width: 100%;
height: 100%;
}
@@ -182,6 +202,12 @@
user-select: none;
}
.workspace-empty-hint {
color: #6a6a6a;
font-size: 12px;
user-select: none;
}
/* ---- terminal panel ---- */
.workspace-terminal-panel {
position: relative;
@@ -203,41 +229,30 @@
min-width: 0;
}
/* ---- SSH session right dock (M3 monitor / M4 file tabs, collapsible) ---- */
/* ---- SSH session right dock (M3 monitor, collapsible) ---- */
.workspace-terminal-panel > .workspace-terminal-split > .mm-root,
.workspace-terminal-panel > .workspace-terminal-split > .mm-empty,
.workspace-terminal-panel > .workspace-terminal-split > .ant-tabs {
flex: 0 0 260px;
width: 260px;
.workspace-terminal-panel > .workspace-terminal-split > .mm-empty {
flex: 0 0 300px;
width: 300px;
min-width: 0;
}
.workspace-terminal-side {
display: flex;
flex-direction: column;
flex: 0 0 260px;
width: 260px;
flex: 0 0 300px;
width: 300px;
min-width: 0;
border-left: 1px solid var(--chrome-border);
background: var(--chrome-bg);
}
.workspace-terminal-side > .ant-tabs {
.workspace-terminal-side > .mm-root,
.workspace-terminal-side > .mm-empty {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.workspace-terminal-side .ant-tabs-content-holder {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.workspace-terminal-side .ant-tabs-content,
.workspace-terminal-side .ant-tabs-tabpane {
height: 100%;
overflow-y: auto;
border-radius: 0;
}
.workspace-terminal-monitor-toggle {
@@ -254,6 +269,48 @@
background: rgba(255, 255, 255, 0.1);
}
/* ---- SSH bottom file browser (M8, FileZilla-style resizable bottom panel) ----
Flex column: terminal on top (flex 1), 4px drag divider, fixed-height file
browser at the bottom. Height is state-driven from SshBottomPanel. */
.ssh-bottom-panel {
display: flex;
flex-direction: column;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.ssh-bottom-top {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.ssh-bottom-divider {
flex: 0 0 4px;
height: 4px;
background: var(--chrome-border);
cursor: row-resize;
box-sizing: border-box;
touch-action: none;
}
.ssh-bottom-divider:hover,
.ssh-bottom-divider.is-resizing,
body.ssh-resizing .ssh-bottom-divider {
background: var(--chrome-accent);
}
.ssh-bottom-files {
flex: 0 0 auto;
min-height: 0;
overflow: hidden;
border-top: 1px solid var(--chrome-border);
background: var(--chrome-bg);
}
/* ---- term tab: SSH accent ---- */
.workspace-terminal-tab-icon {
flex: 0 0 auto;
@@ -264,6 +321,33 @@
.workspace-dockview .dv-tab .workspace-terminal-tab-ssh {
border-left: 2px solid #3fb950;
padding-left: 5px !important;
gap: 5px;
}
/* Host label pill on SSH tabs: faint green outline, green monospace text. */
.workspace-dockview .dv-tab .workspace-terminal-tab-ssh .ssh-host-badge {
flex: 0 1 auto;
min-width: 0;
max-width: 132px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 1px 6px 2px;
box-sizing: border-box;
border-radius: 9px;
border: 1px solid rgba(63, 185, 80, 0.38);
background: rgba(63, 185, 80, 0.08);
color: #3fb950;
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
font-size: 10.5px;
line-height: 1;
letter-spacing: 0;
}
.workspace-dockview .dv-tab.dv-active-tab .workspace-terminal-tab-ssh .ssh-host-badge {
border-color: rgba(63, 185, 80, 0.6);
background: rgba(63, 185, 80, 0.14);
color: #46c96a;
}
/* ---- per-group "+" right after the last tab ----
@@ -352,6 +436,12 @@
border-bottom: 1px solid var(--chrome-border);
}
.workspace-bcast-pop-mode {
font-size: 12px;
font-weight: 600;
color: var(--chrome-fg);
}
.workspace-bcast-pop-label {
font-size: 12px;
color: var(--chrome-fg);
@@ -389,14 +479,27 @@
padding-top: 6px;
}
/* ---- tab broadcast-target dot ---- */
.workspace-terminal-tab-bcast {
/* ---- tab broadcast-target badge ----
A small "播" pill coordinated with the SSH host-label pill so both can sit
on an SSH tab (SSH left accent + host pill left-ish + bcast pill right)
without visual clutter. */
.workspace-dockview .dv-tab .workspace-terminal-tab-bcast {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
background: #3fb950;
box-shadow: 0 0 4px rgba(63, 185, 80, 0.8);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
box-sizing: border-box;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
line-height: 1;
color: #3fb950;
background: rgba(63, 185, 80, 0.1);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.35);
user-select: none;
}
/* ---- M6 quick-input floating panel (bottom-right) ----
@@ -667,4 +770,40 @@
color: var(--chrome-fg);
word-break: break-all;
user-select: text;
}
}
/* M6.1 sidebar current-mode title: small, quiet, aligned to the sections. */
.connections-mode-title {
font-size: 11px;
letter-spacing: 0.4px;
color: #8a8a8a;
padding: 10px 14px 2px;
user-select: none;
}
/* M6.1 command-panel current-mode badge (shared workspace mode affordance). */
.commands-mode-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px 8px;
}
.commands-mode-badge-text {
font-size: 11px;
color: #8a8a8a;
letter-spacing: 0.4px;
}
.commands-mode-badge-tag {
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 8px;
line-height: 16px;
}
.commands-mode-badge--terminal .commands-mode-badge-tag {
color: #4cc38a;
background: rgba(76, 195, 138, 0.14);
}
.commands-mode-badge--ssh .commands-mode-badge-tag {
color: #6aa9ff;
background: rgba(106, 169, 255, 0.14);
}
@@ -0,0 +1,30 @@
/** M6.1 workspace mode: local terminal vs SSH 服务器 —— 两组面板互不混排。 */
export type WorkspaceMode = 'terminal' | 'ssh'
export const WORKSPACE_MODE_ORDER: readonly WorkspaceMode[] = ['terminal', 'ssh']
/** Dockview panel id 前缀:同组内面板以此为前缀,切换模式时按前缀整体 hide/show。 */
export const WORKSPACE_GROUP_PREFIX: Record<WorkspaceMode, string> = {
terminal: 'terminal:',
ssh: 'ssh:'
}
/** 每个 workspace 组顶部的固定标记面板(不可关闭),作为该组显示/隐藏与激活的锚点。 */
export const WORKSPACE_MARKER_ID: Record<WorkspaceMode, string> = {
terminal: '__workspace-marker-terminal',
ssh: '__workspace-marker-ssh'
}
/** 判断一个面板属于哪个 workspace 组(marker 面板本身不算会话面板)。 */
export function workspaceModeOfPanelId(id: string): WorkspaceMode | null {
if (id.startsWith(WORKSPACE_GROUP_PREFIX.terminal)) return 'terminal'
if (id.startsWith(WORKSPACE_GROUP_PREFIX.ssh)) return 'ssh'
return null
}
/** 从面板 params 判断(新逻辑以此为第一优先,兼容旧无 params 的面板回退到 id 前缀)。 */
export function workspaceModeOfParams(params: { sessionKind?: 'local' | 'ssh' } | undefined): WorkspaceMode | null {
if (params?.sessionKind === 'local') return 'terminal'
if (params?.sessionKind === 'ssh') return 'ssh'
return null
}
@@ -0,0 +1,36 @@
import { create } from 'zustand'
/** 工作区模式:终端(本地)/ SSH(远程服务器)。 */
export type WorkspaceMode = 'terminal' | 'ssh'
interface WorkspaceModeState {
mode: WorkspaceMode
setMode: (mode: WorkspaceMode) => void
}
const LS_KEY = 'ot.workspaceMode'
function readInitial(): WorkspaceMode {
try {
const v = window.localStorage.getItem(LS_KEY)
return v === 'ssh' ? 'ssh' : 'terminal'
} catch {
return 'terminal'
}
}
/**
* 全局工作区模式 store。IconRail 的模式入口、Workspace 的分流、
* ConnectionSidebar 的区块过滤都订阅它。
*/
export const useWorkspaceModeStore = create<WorkspaceModeState>((set) => ({
mode: readInitial(),
setMode: (mode) => {
try {
window.localStorage.setItem(LS_KEY, mode)
} catch {
// ignore
}
set({ mode })
}
}))