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
+1323 -331

No files matched your search

+1
View File
@@ -14,6 +14,7 @@
| M5 | 命令历史/命令库/补全 + 会话日志 | ✅ 已完成(v0.5) | | M5 | 命令历史/命令库/补全 + 会话日志 | ✅ 已完成(v0.5) |
| M6 | 广播输入 + ZMODEM + 快捷输入面板 + 快捷键系统 | ✅ 已完成(v0.6) | | M6 | 广播输入 + ZMODEM + 快捷输入面板 + 快捷键系统 | ✅ 已完成(v0.6) |
| M7 | 打包分发(MSI + NSIS + 自动更新) | ✅ 已完成(v0.7,Release 已发布) | | M7 | 打包分发(MSI + NSIS + 自动更新) | ✅ 已完成(v0.7,Release 已发布) |
| M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) |
## M2 — SSH 远程会话 ✅ ## 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) - v0.8.0,远程 `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) - 更新通道 = `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 - 技术栈: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 再派两端 - 契约先行约定:所有跨进程接口定义在 `src/shared/`,主/预载/渲染各自实现;新功能先改 shared 再派两端
@@ -21,6 +21,20 @@
| M5 命令历史/命令库/补全+会话日志 | ✅ | | M5 命令历史/命令库/补全+会话日志 | ✅ |
| M6 广播输入+ZMODEM+快捷输入面板+快捷键系统 | ✅(v0.6.0,UI 活体验收;ZMODEM 双向 e2e) | | M6 广播输入+ZMODEM+快捷输入面板+快捷键系统 | ✅(v0.6.0,UI 活体验收;ZMODEM 双向 e2e) |
| M7 打包分发(MSI+NSIS+自动更新) | ✅(v0.7.0 已发布 Release) | | 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 实现要点(已落地) ## M6/M7 实现要点(已落地)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"productName": "OpenTerminal", "productName": "OpenTerminal",
"version": "0.7.0", "version": "0.8.0",
"description": "Terminal with split panes, themes and fonts (XTerminal-inspired)", "description": "Terminal with split panes, themes and fonts (XTerminal-inspired)",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "Bill", "author": "Bill",
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Button, Modal, Popconfirm, Tabs } from 'antd' import { Button, Modal, Popconfirm, Tabs } from 'antd'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons' import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import type { CommandItem } from '@shared/commands' import type { CommandItem } from '@shared/commands'
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './commands.css' import './commands.css'
export interface CommandsPanelProps { export interface CommandsPanelProps {
@@ -29,6 +30,8 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
const [name, setName] = useState('') const [name, setName] = useState('')
const [command, setCommand] = useState('') const [command, setCommand] = useState('')
const [note, setNote] = 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 => { const refresh = useCallback((): void => {
void Promise.all([window.api.listHistory(), window.api.listLibrary()]) void Promise.all([window.api.listHistory(), window.api.listLibrary()])
@@ -84,6 +87,10 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
return ( return (
<div className="commands-panel"> <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 <Tabs
size="small" size="small"
centered centered
@@ -5,7 +5,9 @@ import type { SshConnection } from '@shared/connections'
import { AuthTags } from './Common' import { AuthTags } from './Common'
import { ConnectionEditDialog } from './ConnectionEditDialog' import { ConnectionEditDialog } from './ConnectionEditDialog'
import { CommandsPanel } from '../commands/CommandsPanel' import { CommandsPanel } from '../commands/CommandsPanel'
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './connections.css' import './connections.css'
import '../workspace/workspace.css'
export interface ConnectionSidebarProps { export interface ConnectionSidebarProps {
className?: string className?: string
@@ -60,6 +62,8 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
}: ConnectionSidebarProps, }: ConnectionSidebarProps,
ref ref
): React.JSX.Element { ): React.JSX.Element {
const mode = useWorkspaceModeStore((s) => s.mode)
const isTerminal = mode === 'terminal'
const [connections, setConnections] = useState<SshConnection[]>([]) const [connections, setConnections] = useState<SshConnection[]>([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
@@ -158,6 +162,9 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
return ( return (
<aside className={className ?? 'connections-sidebar'}> <aside className={className ?? 'connections-sidebar'}>
<div className="connections-mode-title">
{isTerminal ? '终端工作区' : 'SSH 工作区'}
</div>
<div className="connections-header"> <div className="connections-header">
<span className="connections-title">连接</span> <span className="connections-title">连接</span>
<Button <Button
@@ -170,6 +177,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
</div> </div>
<div className="connections-list"> <div className="connections-list">
{isTerminal && (
<section className="connections-section"> <section className="connections-section">
<div className="connections-section-head"> <div className="connections-section-head">
<span className="connections-section-title">终端</span> <span className="connections-section-title">终端</span>
@@ -193,7 +201,9 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
</ul> </ul>
)} )}
</section> </section>
)}
{!isTerminal && (
<section className="connections-section"> <section className="connections-section">
<div className="connections-section-head"> <div className="connections-section-head">
<span className="connections-section-title">SSH 服务器</span> <span className="connections-section-title">SSH 服务器</span>
@@ -240,13 +250,16 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
</div> </div>
)} )}
</section> </section>
)}
{isTerminal && (
<section className="connections-section commands-sidebar-section"> <section className="connections-section commands-sidebar-section">
<div className="connections-section-head"> <div className="connections-section-head">
<span className="connections-section-title">命令</span> <span className="connections-section-title">命令</span>
</div> </div>
<CommandsPanel onRun={onRunCommand ?? ((): void => undefined)} /> <CommandsPanel onRun={onRunCommand ?? ((): void => undefined)} />
</section> </section>
)}
</div> </div>
<ConnectionEditDialog <ConnectionEditDialog
+20 -3
View File
@@ -307,18 +307,35 @@
font-size: 12px; 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 { .connections-local-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 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 { .connections-local-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 5px 6px 5px 8px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -338,7 +355,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 13px; font-size: 12px;
color: var(--chrome-fg); color: var(--chrome-fg);
} }
+27 -14
View File
@@ -176,6 +176,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
const disks = sample.disks ?? [] const disks = sample.disks ?? []
const net = sample.net const net = sample.net
const failed = Boolean(sample.error) const failed = Boolean(sample.error)
const memPct = mem.totalMb > 0 ? (mem.usedMb / mem.totalMb) * 100 : 0
return ( return (
<div className={`mm-root${failed ? ' mm-failed' : ''}`}> <div className={`mm-root${failed ? ' mm-failed' : ''}`}>
@@ -190,11 +191,11 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<div className="mm-block-row"> <div className="mm-block-row">
<span className="mm-label">CPU</span> <span className="mm-label">CPU</span>
<span className="mm-value" style={{ color: cpuColor(cpu.usage) }}> <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> </span>
</div> </div>
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} /> <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(' / ')} 负载 {cpu.loadavg.map((v) => v.toFixed(1)).join(' / ')}
</div> </div>
<HistoryChart data={cpuHist} lineColor="#3fb950" fillColor="rgba(63,185,80,0.15)" height={48} maxY={100} /> <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="内存"> <section className="mm-block" aria-label="内存">
<div className="mm-block-row"> <div className="mm-block-row">
<span className="mm-label">内存</span> <span className="mm-label">内存</span>
<span className="mm-value"> <span className="mm-mem-size">
{formatSize(mem.usedMb)} / {formatSize(mem.totalMb)} {formatSize(mem.usedMb)} / {formatSize(mem.totalMb)}
</span> </span>
</div> </div>
<Bar <div className="mm-mem-row">
pct={mem.totalMb > 0 ? (mem.usedMb / mem.totalMb) * 100 : 0} <div className="mm-mem-bar">
color={mem.totalMb > 0 && mem.usedMb / mem.totalMb > 0.8 ? '#f85149' : '#3fb950'} <Bar pct={memPct} color="#d29922" />
/> </div>
<span className="mm-mem-pct">{memPct.toFixed(0)}%</span>
</div>
</section> </section>
{disks.length > 0 && ( {disks.length > 0 && (
@@ -223,14 +226,16 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
const pct = d.totalMb > 0 ? (d.usedMb / d.totalMb) * 100 : 0 const pct = d.totalMb > 0 ? (d.usedMb / d.totalMb) * 100 : 0
return ( return (
<div className="mm-disk" key={d.mount}> <div className="mm-disk" key={d.mount}>
<div className="mm-disk-top"> <span className="mm-mount" title={d.mount}>
<span className="mm-mount">{d.mount}</span> {d.mount}
</span>
<div className="mm-disk-bar">
<Bar pct={pct} color={cpuColor(pct)} />
</div>
<span className="mm-disk-size"> <span className="mm-disk-size">
{formatSize(d.usedMb)} / {formatSize(d.totalMb)} {formatSize(d.usedMb)} / {formatSize(d.totalMb)}
</span> </span>
</div> </div>
<Bar pct={pct} color={cpuColor(pct)} />
</div>
) )
})} })}
</div> </div>
@@ -240,10 +245,18 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<section className="mm-block" aria-label="网络"> <section className="mm-block" aria-label="网络">
<div className="mm-block-row"> <div className="mm-block-row">
<span className="mm-label">网络</span> <span className="mm-label">网络</span>
</div>
<div className="mm-net"> <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 mm-rx">
<span className="mm-net-item"><span className="mm-arrow mm-down">↑</span>{net?.txKbs ?? 0} KB/s</span> <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>
<div className="mm-net-charts"> <div className="mm-net-charts">
<HistoryChart data={rxHist} lineColor="#58a6ff" fillColor="rgba(88,166,255,0.15)" height={40} /> <HistoryChart data={rxHist} lineColor="#58a6ff" fillColor="rgba(88,166,255,0.15)" height={40} />
+85 -30
View File
@@ -3,7 +3,7 @@
.mm-root { .mm-root {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 6px;
padding: 10px 12px; padding: 10px 12px;
background: #1a1a1a; background: #1a1a1a;
color: var(--chrome-fg); color: var(--chrome-fg);
@@ -31,6 +31,9 @@
flex-direction: column; flex-direction: column;
gap: 1px; gap: 1px;
line-height: 1.3; line-height: 1.3;
padding-bottom: 8px;
border-bottom: 1px solid #262626;
margin-bottom: 2px;
} }
.mm-host { .mm-host {
@@ -52,12 +55,13 @@
/* ---------- blocks ---------- */ /* ---------- blocks ---------- */
.mm-block { .mm-block {
border: 1px solid #2d2d2d; border: 1px solid #2a2a2a;
border-radius: 6px; border-radius: 8px;
background: rgba(255, 255, 255, 0.018);
padding: 8px 10px; padding: 8px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 5px; gap: 6px;
} }
.mm-block-row { .mm-block-row {
@@ -68,16 +72,26 @@
line-height: 1.2; line-height: 1.2;
} }
/* weak small section labels */
.mm-label { .mm-label {
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 500;
color: rgba(255, 255, 255, 0.85); color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.04em;
user-select: none;
} }
.mm-value { .mm-value {
font-size: 12px; font-size: 13px;
color: #3fb950; color: #3fb950;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.mm-value-sub {
font-size: 11px;
color: #8a8a8a;
} }
.mm-sub { .mm-sub {
@@ -85,10 +99,14 @@
color: #8a8a8a; color: #8a8a8a;
} }
.mm-load {
font-variant-numeric: tabular-nums;
}
/* ---------- bar ---------- */ /* ---------- bar ---------- */
.mm-bar { .mm-bar {
height: 8px; height: 5px;
border-radius: 4px; border-radius: 3px;
overflow: hidden; overflow: hidden;
background: #2c2c2c; background: #2c2c2c;
width: 100%; width: 100%;
@@ -96,30 +114,52 @@
.mm-bar-fill { .mm-bar-fill {
height: 100%; height: 100%;
border-radius: 4px; border-radius: 3px;
transition: width 0.3s ease; 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 ---------- */ /* ---------- disks ---------- */
.mm-disks { .mm-disks {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 5px;
max-height: 132px; max-height: 150px;
overflow-y: auto; overflow-y: auto;
} }
.mm-disk { .mm-disk {
display: flex; display: grid;
flex-direction: column; grid-template-columns: minmax(0, 1fr) 108px 70px;
gap: 3px; align-items: center;
} column-gap: 8px;
min-height: 16px;
.mm-disk-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
} }
.mm-mount { .mm-mount {
@@ -129,21 +169,29 @@
white-space: nowrap; white-space: nowrap;
} }
.mm-disk-bar {
min-width: 0;
}
.mm-disk-size { .mm-disk-size {
font-size: 11px; font-size: 11px;
color: #8a8a8a; color: #8a8a8a;
flex: 0 0 auto; text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
/* ---------- network ---------- */ /* ---------- network ---------- */
.mm-net { .mm-net {
display: flex; display: inline-flex;
gap: 20px; align-items: center;
gap: 14px;
justify-content: flex-end;
} }
.mm-net-charts { .mm-net-charts {
display: flex; display: flex;
gap: 12px; gap: 10px;
} }
.mm-net-charts .mm-chart { .mm-net-charts .mm-chart {
@@ -163,14 +211,21 @@
.mm-net-item { .mm-net-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: baseline;
gap: 4px; gap: 4px;
font-variant-numeric: tabular-nums; 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 { .mm-arrow {
font-size: 12px; font-size: 13px;
line-height: 1;
align-self: center;
} }
.mm-up { .mm-up {
+161 -19
View File
@@ -1,18 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
ArrowUpOutlined, ArrowUpOutlined,
CopyOutlined,
DeleteOutlined, DeleteOutlined,
DownloadOutlined, DownloadOutlined,
FileAddOutlined,
FileOutlined, FileOutlined,
FolderAddOutlined, FolderAddOutlined,
FolderOpenOutlined,
FolderOutlined, FolderOutlined,
FormOutlined, FormOutlined,
ReloadOutlined, ReloadOutlined,
SafetyOutlined, SafetyOutlined,
UploadOutlined UploadOutlined
} from '@ant-design/icons' } 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 type { SftpEntry, TransferProgressEvent } from '@shared/sftp'
import './sftp.css'
/** Remote file browser for one SSH session (M4). */ /** Remote file browser for one SSH session (M4). */
export interface FilePanelProps { export interface FilePanelProps {
@@ -46,8 +51,11 @@ function octalDigit(start: number, bits: PermBits): number {
} }
function bitsToOctal(bits: PermBits): string { function bitsToOctal(bits: PermBits): string {
const o = octalDigit(0, bits) * 64 + octalDigit(3, bits) * 8 + octalDigit(6, bits) // Concatenate octal digits as a string — 5,1,1 → "511". (The previous
return String(o) // 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 { function fmtSize(bytes: number): string {
@@ -106,7 +114,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
const applyOctal = (v: string | null): void => { const applyOctal = (v: string | null): void => {
const s = (v ?? '').trim() const s = (v ?? '').trim()
if (/^\d{1,3}$/.test(s)) { if (/^[0-7]{1,4}$/.test(s)) {
const num = parseInt(s, 8) || 0 const num = parseInt(s, 8) || 0
setBits([ setBits([
(num >> 6) & 4 ? true : false, (num >> 6) & 4 ? true : false,
@@ -139,18 +147,23 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
try { try {
const octalStr = bitsToOctal(bits) const octalStr = bitsToOctal(bits)
const curOctal = entry.mode ? bitsToOctal(parseModeBits(entry.mode)) : null const curOctal = entry.mode ? bitsToOctal(parseModeBits(entry.mode)) : null
const applyOwner = uid.trim() !== '' && !Number.isNaN(Number(uid)) // Only chown when the uid/gid actually differ from the entry's current
const applyGid = gid.trim() !== '' && !Number.isNaN(Number(gid)) // 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) { if (octalStr !== curOctal) {
await window.api.chmodRemote(sessionId, entry.path, octalStr) await window.api.chmodRemote(sessionId, entry.path, octalStr)
} }
if (applyOwner || applyGid) { if (ownerChanged) {
const u = applyOwner ? Number(uid) : (entry.uid ?? 0) const u = newUid ?? entry.uid ?? 0
const g = applyGid ? Number(gid) : (entry.gid ?? u) const g = newGid ?? newUid ?? entry.gid ?? 0
await window.api.chownRemote(sessionId, entry.path, u, g) await window.api.chownRemote(sessionId, entry.path, u, g)
} }
if (octalStr !== curOctal || applyOwner || applyGid) { if (octalStr !== curOctal || ownerChanged) {
message.success('已应用权限') message.success('已应用权限')
} }
onClose() onClose()
@@ -229,17 +242,21 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
} }
export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
const { message } = App.useApp() const { message, modal } = App.useApp()
const [dir, setDir] = useState('/') const [dir, setDir] = useState('/')
const [entries, setEntries] = useState<SftpEntry[]>([]) const [entries, setEntries] = useState<SftpEntry[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [selected, setSelected] = useState<SftpEntry | null>(null) const [selected, setSelected] = useState<SftpEntry | null>(null)
const [menuEntry, setMenuEntry] = useState<SftpEntry | null>(null)
const [mkdirOpen, setMkdirOpen] = useState(false) const [mkdirOpen, setMkdirOpen] = useState(false)
const [renameOpen, setRenameOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false)
const [permOpen, setPermOpen] = useState(false) const [permOpen, setPermOpen] = useState(false)
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
/** the entry the context menu is operating on (selected when right-clicked) */
const menuTarget = menuEntry ?? selected
const refresh = useCallback( const refresh = useCallback(
async (target: string): Promise<void> => { async (target: string): Promise<void> => {
setLoading(true) 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 ( return (
<div className="sftp-panel"> <div className="sftp-panel">
<div className="sftp-toolbar"> <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>}
{!loading && entries.length === 0 && <div className="sftp-empty">空目录</div>} {!loading && entries.length === 0 && <div className="sftp-empty">空目录</div>}
{entries.map((e) => ( {entries.map((e) => (
<div <Dropdown
key={e.path} key={e.path}
menu={menu}
trigger={['contextMenu']}
getPopupContainer={() => document.body}
onOpenChange={(open) => {
if (!open) setMenuEntry(null)
}}
>
<div
className={`sftp-row${selected?.path === e.path ? ' sftp-row-selected' : ''}`} className={`sftp-row${selected?.path === e.path ? ' sftp-row-selected' : ''}`}
onClick={() => setSelected(e)} onClick={() => setSelected(e)}
onContextMenu={() => {
setSelected(e)
setMenuEntry(e)
}}
onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))} onDoubleClick={() => (e.isDir ? void refresh(e.path) : doDownload(e))}
> >
{e.isDir ? ( <span className="sftp-icon">{e.isDir ? <FolderOutlined className="sftp-icon-dir" /> : <FileOutlined />}</span>
<FolderOutlined className="sftp-icon sftp-icon-dir" />
) : (
<FileOutlined className="sftp-icon" />
)}
<span className="sftp-name" title={e.name}> <span className="sftp-name" title={e.name}>
{e.name} {e.name}
</span> </span>
<span className="sftp-mode" title={e.mode ?? '权限不可用'}> <span className="sftp-mode" title={e.mode ?? '权限不可用'}>
{e.mode ?? '----------'} {e.mode ?? '----------'}
</span> </span>
<span className="sftp-owner"> <span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
{e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'} {e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'}
</span> </span>
<span className="sftp-size">{e.isDir ? '' : fmtSize(e.size)}</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-time">{fmtTime(e.mtime)}</span>
</div> </div>
</Dropdown>
))} ))}
</div> </div>
)} )}
+18 -12
View File
@@ -39,12 +39,19 @@
overflow-y: auto; 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 { .sftp-row {
display: flex; display: grid;
grid-template-columns: 22px minmax(0, 1fr) 96px 74px 66px 96px;
align-items: center; align-items: center;
gap: 6px; column-gap: 8px;
padding: 3px 8px; padding: 3px 8px;
font-size: 12px; font-size: 13px;
color: var(--chrome-fg); color: var(--chrome-fg);
cursor: pointer; cursor: pointer;
} }
@@ -58,7 +65,9 @@
} }
.sftp-icon { .sftp-icon {
display: inline-flex;
color: #8a8a8a; color: #8a8a8a;
font-size: 14px;
} }
.sftp-icon-dir { .sftp-icon-dir {
@@ -66,26 +75,23 @@
} }
.sftp-name { .sftp-name {
flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 13.5px;
} }
.sftp-mode { .sftp-mode {
flex: none;
width: 90px;
text-align: center; text-align: center;
color: #9a9a9a; color: #9a9a9a;
font-family: Consolas, 'Courier New', monospace; font-family: Consolas, 'Courier New', monospace;
font-size: 11px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
} }
.sftp-owner { .sftp-owner {
flex: none;
width: 44px;
text-align: right; text-align: right;
color: #8a8a8a; color: #8a8a8a;
font-size: 11px; font-size: 11px;
@@ -94,19 +100,19 @@
} }
.sftp-size { .sftp-size {
flex: none;
width: 52px;
text-align: right; text-align: right;
color: #8a8a8a; color: #8a8a8a;
font-size: 11px; font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.sftp-time { .sftp-time {
flex: none;
width: 78px;
text-align: right; text-align: right;
color: #8a8a8a; color: #8a8a8a;
font-size: 11px; font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.sftp-empty, .sftp-empty,
+172 -24
View File
@@ -69,6 +69,15 @@ interface SearchBarProps {
result: string 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 { function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }: SearchBarProps): React.JSX.Element {
return ( return (
<div className="term-searchbar"> <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 * buffer is empty (some control data only). Enter/backspace/espace/ctrl-c are
* handled here; escape sequences (\x1b) and the leading non-printable control * handled here; escape sequences (\x1b) and the leading non-printable control
* bytes on a chunk — like \x1b[K clear-line — never enter the buffer. * 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( function stepLineBuffer(
outBuf: React.MutableRefObject<string>, outBuf: React.MutableRefObject<string>,
data: string data: string,
rewrite: boolean
): { line: string; submit?: string } { ): { line: string; submit?: string } {
const b = outBuf.current const b = outBuf.current
if (!data) return { line: b } 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 // A chunk that begins with escape/control sequences (e.g. the terminal's
// clear-line) only removes buffer content; never appends. // clear-line) only removes buffer content; never appends.
const first = data[0] const first = data[0]
@@ -142,14 +178,34 @@ function buildSuggestions(cache: CommandItem[], buffer: string): CommandItem[] {
return out 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 { function differenceWrite(sessionId: string, full: string, buf: string): void {
if (full.startsWith(buf) && buf) { writeBroadcast(sessionId, '\x15')
writeBroadcast(sessionId, full.slice(buf.length))
} else {
writeBroadcast(sessionId, '\x03')
writeBroadcast(sessionId, full) writeBroadcast(sessionId, full)
}
} }
export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> = export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
@@ -172,6 +228,13 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const streamRef = useRef<HighlightStream | null>(null) const streamRef = useRef<HighlightStream | null>(null)
// M5: per-session line-capture buffer + completion candidate cache. // M5: per-session line-capture buffer + completion candidate cache.
const lineBufRef = useRef<string>('') 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 completionCacheRef = useRef<CommandItem[]>([])
const completionKeyRef = useRef('') const completionKeyRef = useRef('')
const recordingRef = useRef(false) const recordingRef = useRef(false)
@@ -179,6 +242,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// handler always reads the live values without a stale closure. // handler always reads the live values without a stale closure.
const suggestionsRef = useRef<CommandItem[]>([]) const suggestionsRef = useRef<CommandItem[]>([])
const selIndexRef = useRef(0) 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 // Compile keyword-highlight rules once per rules change; keep them in a ref so
// the (stable, single-construction) pty/term subscriptions always read the // the (stable, single-construction) pty/term subscriptions always read the
@@ -361,8 +429,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const handleTab = useCallback( const handleTab = useCallback(
(selected: CommandItem): void => { (selected: CommandItem): void => {
const cmd = selected.command const cmd = selected.command
const buf = lineBufRef.current // Rewrite the whole shell line (Ctrl+U then `cmd`) instead of appending a
differenceWrite(sessionId, cmd, buf) // 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 lineBufRef.current = cmd
suggestionsRef.current = [] suggestionsRef.current = []
selIndexRef.current = 0 selIndexRef.current = 0
@@ -422,7 +494,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
moveSelection(-1) moveSelection(-1)
return false 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] const selected = list[selIndexRef.current] ?? list[0]
if (selected) handleTab(selected) if (selected) handleTab(selected)
return false return false
@@ -464,6 +541,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const fit = new FitAddon() const fit = new FitAddon()
term.loadAddon(fit) term.loadAddon(fit)
fitAddonRef.current = 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() const searchAddon = new SearchAddon()
searchAddon.onDidChangeResults((ev) => { searchAddon.onDidChangeResults((ev) => {
@@ -519,8 +601,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const disposables: { dispose(): void }[] = [ const disposables: { dispose(): void }[] = [
term.onData((data) => { term.onData((data) => {
if (!deadRef.current) writeBroadcast(sessionId, 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. // 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) { if (typeof step.submit === 'string' && step.submit) {
void window.api.recordCommand(step.submit).catch(() => undefined) void window.api.recordCommand(step.submit).catch(() => undefined)
} }
@@ -549,8 +635,17 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const sel = term.getSelection() const sel = term.getSelection()
if (!sel) return if (!sel) return
void navigator.clipboard.writeText(sel).catch(() => undefined) 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. // Live data is queued until the replay lands so output keeps its order.
let replayDone = false let replayDone = false
const pending: string[] = [] const pending: string[] = []
@@ -666,6 +761,53 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const terminalClassName = useMemo(() => ['terminal-view', className].filter(Boolean).join(' '), [className]) 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) => { const onSearchChange = useCallback((value: string) => {
setSearchTerm(value) setSearchTerm(value)
searchTermRef.current = value searchTermRef.current = value
@@ -707,9 +849,25 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<FolderOpenOutlined /> <FolderOpenOutlined />
</button> </button>
</div> </div>
{suggestions.length > 0 && ( {paste !== null && !dead && (
<div className="term-pastebar">
<span className="term-pastebar-text">检测到多行/长文本粘贴</span>
<button type="button" className="term-pastebar-btn term-pastebar-btn-ok" onClick={() => confirmPaste(paste)}>
粘贴
</button>
<button type="button" className="term-pastebar-btn term-pastebar-btn-cancel" onClick={cancelPaste}>
取消
</button>
</div>
)}
{/* 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 <div
className="term-suggestbar" className={'term-suggestbar' + (popupPos.flip ? ' is-flipped' : '')}
style={{ left: popupPos.left, top: popupPos.top }}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -730,17 +888,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<div className="term-suggest-hint">↑/↓ 选择 · Tab 接受 · Esc 关闭</div> <div className="term-suggest-hint">↑/↓ 选择 · Tab 接受 · Esc 关闭</div>
</div> </div>
)} )}
{paste !== null && !dead && (
<div className="term-pastebar">
<span className="term-pastebar-text">检测到多行/长文本粘贴</span>
<button type="button" className="term-pastebar-btn term-pastebar-btn-ok" onClick={() => confirmPaste(paste)}>
粘贴
</button>
<button type="button" className="term-pastebar-btn term-pastebar-btn-cancel" onClick={cancelPaste}>
取消
</button>
</div>
)}
<div className="terminal-view-dock" ref={hostRef}> <div className="terminal-view-dock" ref={hostRef}>
{dead && ( {dead && (
<div className="term-dead-mask"> <div className="term-dead-mask">
@@ -750,5 +897,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
)} )}
</div> </div>
</div> </div>
</div>
) )
}) })
+49 -7
View File
@@ -108,11 +108,25 @@
.term-recbar { .term-recbar {
position: absolute; position: absolute;
top: 4px; top: 4px;
left: 4px; right: 14px;
z-index: 4; z-index: 4;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; 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 { .term-rec-btn {
@@ -139,22 +153,46 @@
color: var(--chrome-accent); 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 { .term-suggestbar {
flex: none; position: absolute;
z-index: 6;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; min-width: 240px;
padding: 4px 6px; max-width: 420px;
background: var(--chrome-hover); max-height: 200px;
border-bottom: 1px solid var(--chrome-border); 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; user-select: none;
box-sizing: border-box;
} }
.term-suggest-list { .term-suggest-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 1px;
flex: 0 0 auto;
} }
.term-suggest-item { .term-suggest-item {
@@ -199,9 +237,13 @@
} }
.term-suggest-hint { .term-suggest-hint {
flex: 0 0 auto;
font-size: 11px; font-size: 11px;
color: #8a8a8a; color: #8a8a8a;
opacity: 0.9; opacity: 0.9;
padding-top: 4px;
line-height: 13px;
white-space: nowrap;
} }
/* ---- paste risk confirm bar ---- */ /* ---- 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>
)
}
+26 -36
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { IDockviewPanelProps } from 'dockview-react' import type { IDockviewPanelProps } from 'dockview-react'
import { Button, Tabs } from 'antd' import { Button } from 'antd'
import { AreaChartOutlined, FolderOpenOutlined } from '@ant-design/icons' import { AreaChartOutlined } from '@ant-design/icons'
import { TerminalView } from '../terminal/TerminalView' import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel' import { MonitorPanel } from '../monitor/MonitorPanel'
import { FilePanel } from '../sftp/FilePanel' import { SshBottomPanel } from './SshBottomPanel'
import { useBroadcastStore } from './broadcastStore' import { useBroadcastStore } from './broadcastStore'
import type { TerminalParams } from './Workspace' import type { TerminalParams } from './Workspace'
@@ -28,15 +28,16 @@ export type TerminalPanelProps = IDockviewPanelProps<TerminalParams> & TerminalP
* already-exited process. * already-exited process.
* *
* For SSH sessions (`sessionKind === 'ssh'`) a collapsible right sidebar with * For SSH sessions (`sessionKind === 'ssh'`) a collapsible right sidebar with
* [系统监控 | 文件] tabs is docked next to the terminal. Local sessions render * [系统监控] is docked next to the terminal, and a resizable file browser
* terminal only. * (SshBottomPanel) sits below the terminal. Local sessions render terminal
* only.
*/ */
export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element { export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element {
const sessionId = params.sessionId ?? '' const sessionId = params.sessionId ?? ''
const sessionKind = params.sessionKind const sessionKind = params.sessionKind
const isSsh = sessionKind === 'ssh' const isSsh = sessionKind === 'ssh'
const closedRef = useRef(false) 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) const [sideOpen, setSideOpen] = useState(true)
// M6 broadcast: keep the session in the broadcast registry while mounted so // M6 broadcast: keep the session in the broadcast registry while mounted so
@@ -55,20 +56,7 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
return () => disposable.dispose() return () => disposable.dispose()
}, [api, sessionId, isSsh]) }, [api, sessionId, isSsh])
return ( const terminal = (
<div className="workspace-terminal-panel">
{isSsh && (
<Button
type="text"
size="small"
className="workspace-terminal-monitor-toggle"
icon={sideOpen ? <AreaChartOutlined /> : <FolderOpenOutlined />}
aria-label={sideOpen ? '收起侧边栏' : '展开侧边栏'}
title={sideOpen ? '收起侧边栏' : '展开侧边栏'}
onClick={() => setSideOpen((prev) => !prev)}
/>
)}
<div className="workspace-terminal-split">
<TerminalView <TerminalView
sessionId={sessionId} sessionId={sessionId}
className="workspace-terminal-view" className="workspace-terminal-view"
@@ -79,24 +67,26 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
api.close() api.close()
}} }}
/> />
)
return (
<div className="workspace-terminal-panel">
{isSsh && (
<Button
type="text"
size="small"
className="workspace-terminal-monitor-toggle"
icon={<AreaChartOutlined />}
aria-label={sideOpen ? '收起侧边栏' : '展开侧边栏'}
title={sideOpen ? '收起侧边栏' : '展开侧边栏'}
onClick={() => setSideOpen((prev) => !prev)}
/>
)}
<div className="workspace-terminal-split">
{isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} /> : terminal}
{isSsh && sideOpen && ( {isSsh && sideOpen && (
<div className="workspace-terminal-side"> <div className="workspace-terminal-side">
<Tabs <MonitorPanel sessionId={sessionId} />
size="small"
centered
items={[
{
key: 'monitor',
label: '监控',
children: <MonitorPanel sessionId={sessionId} />
},
{
key: 'files',
label: '文件',
children: <FilePanel sessionId={sessionId} />
}
]}
/>
</div> </div>
)} )}
</div> </div>
+16 -3
View File
@@ -5,6 +5,7 @@ import { CloudServerOutlined } from '@ant-design/icons'
import type { IDockviewPanelHeaderProps } from 'dockview-react' import type { IDockviewPanelHeaderProps } from 'dockview-react'
import { useBroadcastStore } from './broadcastStore' import { useBroadcastStore } from './broadcastStore'
import { SshHostBadge } from './SshHostBadge'
import type { LayoutMeta } from '@shared/ipc' import type { LayoutMeta } from '@shared/ipc'
export type TerminalTabProps = IDockviewPanelHeaderProps<{ 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 selfIndex = panel.panels.findIndex((p) => p.api === api)
const isSsh = params?.sessionKind === 'ssh' 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 ?? '')) const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? ''))
// Keep the tab title live (dockview re-renders tab components only on // 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 ( return (
<Dropdown menu={menu} trigger={['contextMenu']}> <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" />} {isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span> <span className="workspace-terminal-tab-title">{title}</span>
{isSsh && hostLabel && <SshHostBadge label={hostLabel} />}
{isBroadcastTarget && ( {isBroadcastTarget && (
<span <span
className="workspace-terminal-tab-bcast" className="workspace-terminal-tab-bcast"
title="广播目标" title="广播目标"
aria-label="广播目标" aria-label="广播目标"
/> >
播
</span>
)} )}
<button <button
type="button" type="button"
+393 -150
View File
@@ -3,8 +3,8 @@ import type { ReactNode } from 'react'
import { App, Button, Checkbox, Dropdown, Popover, Tooltip } from 'antd' import { App, Button, Checkbox, Dropdown, Popover, Tooltip } from 'antd'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
import { import {
ColumnHeightOutlined, CloudServerOutlined,
ColumnWidthOutlined, CodeOutlined,
LayoutOutlined, LayoutOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
@@ -16,7 +16,6 @@ import {
import { DockviewReact } from 'dockview-react' import { DockviewReact } from 'dockview-react'
import type { import type {
DockviewApi, DockviewApi,
DockviewGroupPanel,
DockviewReadyEvent, DockviewReadyEvent,
IDockviewHeaderActionsProps, IDockviewHeaderActionsProps,
IDockviewPanel, IDockviewPanel,
@@ -34,6 +33,8 @@ import { TerminalPanel } from './TerminalPanel'
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab' import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
import { useBroadcastStore, writeBroadcast } from './broadcastStore' import { useBroadcastStore, writeBroadcast } from './broadcastStore'
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel' import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
import type { WorkspaceMode } from './workspaceMode'
import { useWorkspaceModeStore } from './workspaceModeStore'
import './workspace.css' import './workspace.css'
export interface WorkspaceProps { export interface WorkspaceProps {
@@ -72,26 +73,91 @@ function sessionIdOf(panel: IDockviewPanel): string | undefined {
return (panel.params as TerminalParams | undefined)?.sessionId 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 * Sessions: panels carry a `params.sessionId`. Killing a session is idempotent
* idempotent through `deadSessionsRef` so a session is killed at most once — * through `deadSessionsRef` so a session is killed at most once — either
* either up-front (TerminalView `onClose` after the process exits) or when * up-front (TerminalView `onClose` after the process exits) or when its panel
* its panel is removed. * 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 { export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX.Element {
const { message } = App.useApp() const { message } = App.useApp()
const apiRef = useRef<DockviewApi | undefined>(undefined) /** Dockview apis for the two isolated workspaces. */
/** Panel used as the anchor for split placements. */ 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) const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
/** Sessions we already asked to kill — kill once, never twice. */ /** Sessions we already asked to kill — kill once, never twice. */
const deadSessionsRef = useRef<Set<string>>(new Set()) 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 [saveOpen, setSaveOpen] = useState(false)
const [applyOpen, setApplyOpen] = 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. */ /** Local terminal panels surfaced to the sidebar's "终端" section. */
const [localPanels, setLocalPanels] = useState<Array<{ id: string; title: string }>>([]) const [localPanels, setLocalPanels] = useState<Array<{ id: string; title: string }>>([])
@@ -137,13 +203,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
return result.id return result.id
}, []) }, [])
/** /** Rebuild the local-terminal list for the sidebar's "终端" section. The
* Rebuild the local-terminal list for the sidebar's "终端" section. Called on * terminal dockview holds only local sessions, so it is the single source. */
* layout changes (add/remove/renumber) so both the panel set and any title
* updates are reflected.
*/
const recomputeLocalPanels = useCallback((): void => { const recomputeLocalPanels = useCallback((): void => {
const current = apiRef.current const current = terminalApiRef.current
if (!current) { if (!current) {
setLocalPanels([]) setLocalPanels([])
return return
@@ -154,55 +217,118 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
setLocalPanels(panels) setLocalPanels(panels)
}, []) }, [])
/** Focus a local terminal panel from the sidebar. */ /** Recount session panels per dockview into local state. */
const onFocusPanel = useCallback((panelId: string): void => { const recountAll = useCallback((): void => {
const current = apiRef.current const terminal = terminalApiRef.current
current?.getPanel(panelId)?.api.setActive() 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 => { /** Focus a local terminal panel from the sidebar (terminal dockview). */
apiRef.current = event.api const onFocusPanel = useCallback((panelId: string): void => {
setPanelCount(event.api.panels.length) 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 * Bind a dockview's api: stash it in its ref, subscribe to lifecycle events
* `onReady`; `apiRef.current` is set by then. * (remove → kill session, active → track panel), and keep indices fresh.
*/ */
useEffect(() => { const bindApi = useCallback(
const current = apiRef.current (mode: WorkspaceMode, api: DockviewApi): void => {
if (!current) return if (mode === 'terminal') terminalApiRef.current = api
else sshApiRef.current = api
const disposables = [ const cleanups = [
current.onDidRemovePanel((panel: IDockviewPanel) => { api.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)) killSession(sessionIdOf(panel))
recomputeLocalPanels() recomputeLocalPanels()
recountAll()
}), }),
current.onDidActivePanelChange((event) => { 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 activePanelRef.current = event.panel ?? undefined
setActivePanelId(event.panel?.id ?? null) setActivePanelId(event.panel?.id ?? null)
}),
current.onDidLayoutChange(() => {
setPanelCount(current.panels.length)
recomputeLocalPanels()
})
]
return () => {
for (const disposable of disposables) disposable.dispose()
} }
}, [killSession, recomputeLocalPanels]) }),
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 disposablesRef.current) disposable.dispose()
disposablesRef.current = []
}
}, [])
/** 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. */ /** Kill remaining sessions on window close. */
useEffect(() => { useEffect(() => {
const handler = (): void => { const handler = (): void => {
const current = apiRef.current for (const api of [terminalApiRef.current, sshApiRef.current]) {
if (!current) return if (!api) continue
for (const panel of current.panels) { for (const panel of api.panels) {
killSession(sessionIdOf(panel)) killSession(sessionIdOf(panel))
} }
} }
}
window.addEventListener('beforeunload', handler) window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler) return () => window.removeEventListener('beforeunload', handler)
}, [killSession]) }, [killSession])
@@ -230,27 +356,25 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
}, []) }, [])
/** /**
* M6: Ctrl+PgUp / Ctrl+PgDn switch between terminal tabs. Registered on * M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with
* `window` with capture:true so it runs before xterm's own key handling; * capture:true so it runs before xterm's own key handling; it only cycles the
* Ctrl+PageDown/PageUp are the OS default terminal-tab chords, which the * currently-visible workspace's session panels.
* shell rarely needs, so we intercept them and cycle the active panel.
*/ */
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent): void => { const handler = (e: KeyboardEvent): void => {
const ctrl = e.ctrlKey const ctrl = e.ctrlKey
const code = e.code const code = e.code
if (!ctrl || (code !== 'PageUp' && code !== 'PageDown')) return 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 if (!current) return
const panels = current.panels const panels = current.panels.filter((p) => sessionIdOf(p))
if (panels.length < 2) return if (panels.length < 2) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const active = current.activePanel ?? activePanelRef.current const active = current.activePanel ?? activePanelRef.current
let idx = active ? panels.findIndex((p) => p.id === active.id) : -1 let idx = active ? panels.findIndex((p) => p.id === active.id) : -1
const step = code === 'PageDown' ? 1 : -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 let next = active
for (let i = 1; i <= panels.length; i++) { for (let i = 1; i <= panels.length; i++) {
const candidate = panels[(idx + step * i + panels.length * 2) % panels.length] 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) 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( const addTerminal = useCallback(
async (direction?: 'right' | 'below'): Promise<void> => { async (direction?: 'right' | 'below'): Promise<void> => {
const current = apiRef.current const current = terminalApiRef.current
if (!current) return if (!current) return
useWorkspaceModeStore.getState().setMode('terminal')
const sessionId = await createSession() const sessionId = await createSession()
const reference = activePanelRef.current const reference = activeTerminalPanelRef.current
current.addPanel<TerminalParams>({ current.addPanel<TerminalParams>({
id: sessionId, id: sessionId,
@@ -284,7 +410,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
? { referencePanel: reference.id, direction } ? { referencePanel: reference.id, direction }
: undefined : undefined
}) })
setPanelCount(current.panels.length)
}, },
[createSession] [createSession]
) )
@@ -292,7 +417,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/** /**
* SSH connection pipeline. openSession resolves once the session is ready to * SSH connection pipeline. openSession resolves once the session is ready to
* stream; `connectionId` + any staged `secretOverride` are the only inputs * 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( const connect = useCallback(
async (conn: SshConnection, secretOverride: SshSecretOverride): Promise<void> => { async (conn: SshConnection, secretOverride: SshSecretOverride): Promise<void> => {
@@ -303,8 +429,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
connectionId: conn.id, connectionId: conn.id,
secretOverride secretOverride
}) })
const current = apiRef.current const current = sshApiRef.current
if (!current) return 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>({ current.addPanel<TerminalParams>({
id: result.id, id: result.id,
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
@@ -312,7 +441,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
title: conn.name, title: conn.name,
params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host } params: { sessionId: result.id, sessionKind: 'ssh', hostLabel: conn.host }
}) })
setPanelCount(current.panels.length)
// B's sidebar refreshes lastConnectedAt on connect. // B's sidebar refreshes lastConnectedAt on connect.
void sidebarRef.current?.refresh() void sidebarRef.current?.refresh()
} catch (error) { } catch (error) {
@@ -360,60 +488,72 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
void addTerminal() void addTerminal()
}, [addTerminal]) }, [addTerminal])
/** First run: open one terminal so the workspace is never empty on launch. */ /** Icon-rail "+": in terminal mode make a terminal; in ssh mode hand off to
const bootstrappedRef = useRef(false) * the server list instead (SSH sessions can't be created on the fly). */
useEffect(() => { const handleRailNewTerminal = useCallback((): void => {
if (bootstrappedRef.current) return const mode = useWorkspaceModeStore.getState().mode
bootstrappedRef.current = true if (mode === 'ssh') {
const current = apiRef.current message.info('SSH 会话请从服务器列表连接')
if (current && current.panels.length === 0) void addTerminal() return
}, [addTerminal]) }
void addTerminal()
}, [addTerminal, message])
const handleSplit = useCallback( const handleSplit = useCallback(
(direction: 'right' | 'below'): void => { (direction: 'right' | 'below'): void => {
const mode = useWorkspaceModeStore.getState().mode
if (mode === 'terminal') {
// New local terminal split in the terminal workspace.
void addTerminal(direction) 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 handleSaveTemplate = useCallback(async (name: string): Promise<void> => {
const current = apiRef.current
if (!current) return
await window.api.saveLayout( await window.api.saveLayout(
{ {
id: window.crypto.randomUUID(), id: window.crypto.randomUUID(),
name, name,
createdAt: Date.now() 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> => { * Rebind every restored session panel to a fresh local pty. Templates cannot
const current = apiRef.current * carry live session state (SSH needs credentials not stored in the layout),
if (!current) return * so sessions are recreated as local terminals in whichever workspace the
const raw = await window.api.getLayout(meta.id) * layout put them.
if (raw == null) return */
const rebindRestoredPanels = useCallback(
// Sessions currently bound to panels — the template load replaces them. async (api: DockviewApi): Promise<void> => {
const previousSessions = new Set<string>() for (const panel of api.panels) {
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) {
const id = sessionIdOf(panel) const id = sessionIdOf(panel)
if (id && panel.view.contentComponent === TERMINAL_COMPONENT) { if (id && panel.view.contentComponent === TERMINAL_COMPONENT) {
const newSessionId = (await window.api.createPty()).id const newSessionId = (await window.api.createPty()).id
@@ -421,13 +561,57 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
deadSessionsRef.current.delete(id) 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. // Old sessions are unreachable after the layout swap — kill them.
for (const sid of previousSessions) killSession(sid) for (const sid of previousSessions) killSession(sid)
setPanelCount(current.panels.length) recountAll()
activePanelRef.current = current.activePanel 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> => { const handleDeleteTemplate = useCallback(async (meta: LayoutMetaLike): Promise<void> => {
@@ -455,16 +639,21 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const layoutMenu: MenuProps = { const layoutMenu: MenuProps = {
items: [ items: [
{ key: 'split-right', label: '水平分屏' },
{ key: 'split-below', label: '垂直分屏' },
{ type: 'divider' },
{ key: 'save', label: '保存为模板' }, { key: 'save', label: '保存为模板' },
{ key: 'apply', label: '应用模板' } { key: 'apply', label: '应用模板' }
], ],
onClick: ({ key }) => { 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) 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 resolveActiveSessionId = useCallback((): string => {
const panel = activePanelRef.current const panel = activePanelRef.current
if (!panel) return '' if (!panel) return ''
@@ -475,7 +664,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
<div className="workspace-root"> <div className="workspace-root">
<IconRail <IconRail
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
onNewTerminal={handleNewTerminal} onNewTerminal={handleRailNewTerminal}
onSplit={handleSplit} onSplit={handleSplit}
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
sidebarOpen={sidebarOpen} sidebarOpen={sidebarOpen}
@@ -495,6 +684,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/> />
)} )}
<div className="workspace-dockview"> <div className="workspace-dockview">
{/* Local-terminal workspace. */}
<div className={workspaceMode === 'terminal' ? 'workspace-dockview-container' : 'workspace-dockview-container is-hidden'}>
<DockviewReact <DockviewReact
className="dockview-theme-abyss" className="dockview-theme-abyss"
components={{ components={{
@@ -507,21 +698,54 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
<TerminalTab {...props} /> <TerminalTab {...props} />
) )
}} }}
rightHeaderActionsComponent={WorkspaceTabActions} rightHeaderActionsComponent={TerminalTabActions}
onReady={handleReady} onReady={handleTerminalReady}
theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }} theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }}
/> />
{panelCount === 0 && ( {terminalCount === 0 && (
<div className="workspace-empty-overlay"> <div className="workspace-empty-overlay">
<div className="workspace-empty-inner"> <div className="workspace-empty-inner">
<div className="workspace-empty-title">暂无终端</div> <div className="workspace-empty-title">暂无终端</div>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={handleNewTerminal}> <Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={handleNewTerminal}
>
新建终端 新建终端
</Button> </Button>
</div> </div>
</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> </div>
{/* M6: floating quick-input panel (bottom-right). Sends to the active {/* M6: floating quick-input panel (bottom-right). Sends to the active
@@ -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). */ /** "+" renders the new-terminal button (per-group tab addition). */
function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX.Element { function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX.Element {
return ( return (
@@ -614,6 +806,8 @@ function BroadcastToggleButton(): React.JSX.Element {
const sessions = useBroadcastStore((s) => s.sessions) const sessions = useBroadcastStore((s) => s.sessions)
const toggleTarget = useBroadcastStore((s) => s.toggleTarget) const toggleTarget = useBroadcastStore((s) => s.toggleTarget)
const setEnabled = useBroadcastStore((s) => s.setEnabled) 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 = [ const cls = [
'workspace-tabbar-btn2', 'workspace-tabbar-btn2',
@@ -623,8 +817,10 @@ function BroadcastToggleButton(): React.JSX.Element {
<Popover <Popover
trigger="click" trigger="click"
placement="bottomRight" placement="bottomRight"
getPopupContainer={() => document.body}
content={ content={
<WorkspaceBroadcastPopover <WorkspaceBroadcastPopover
mode={mode}
enabled={enabled} enabled={enabled}
targets={targets} targets={targets}
sessions={sessions} 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({ function WorkspaceBroadcastPopover({
mode,
enabled, enabled,
targets, targets,
sessions, sessions,
onToggleTarget, onToggleTarget,
onSetEnabled onSetEnabled
}: { }: {
mode: WorkspaceMode
enabled: boolean enabled: boolean
targets: Set<string> targets: Set<string>
sessions: { id: string; title: string; isSsh: boolean }[] sessions: { id: string; title: string; isSsh: boolean }[]
onToggleTarget: (id: string) => void onToggleTarget: (id: string) => void
onSetEnabled: (enabled: boolean) => void onSetEnabled: (enabled: boolean) => void
}): React.JSX.Element { }): 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 => const label = (s: { id: string; title: string; isSsh: boolean }): string =>
`${s.title}${s.isSsh ? ' (SSH)' : ''}` `${s.title}${s.isSsh ? ' (SSH)' : ''}`
return ( return (
<div className="workspace-bcast-pop"> <div className="workspace-bcast-pop">
<div className="workspace-bcast-pop-mode">广播输入({mode === 'ssh' ? 'SSH' : '终端'})</div>
<div className="workspace-bcast-pop-switch"> <div className="workspace-bcast-pop-switch">
<span className="workspace-bcast-pop-label">广播开关</span> <span className="workspace-bcast-pop-label">广播开关</span>
<Checkbox <Checkbox
@@ -676,10 +879,12 @@ function WorkspaceBroadcastPopover({
</Checkbox> </Checkbox>
</div> </div>
<div className="workspace-bcast-pop-list"> <div className="workspace-bcast-pop-list">
{sessions.length === 0 ? ( {groupSessions.length === 0 ? (
<div className="workspace-bcast-pop-empty">暂无打开的终端</div> <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"> <div key={s.id} className="workspace-bcast-pop-item">
<Checkbox checked={targets.has(s.id)} onChange={() => onToggleTarget(s.id)}> <Checkbox checked={targets.has(s.id)} onChange={() => onToggleTarget(s.id)}>
{label(s)} {label(s)}
@@ -688,7 +893,7 @@ function WorkspaceBroadcastPopover({
)) ))
)} )}
</div> </div>
<div className="workspace-bcast-pop-hint">选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。</div> <div className="workspace-bcast-pop-hint">选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。仅当前模式组的会话可被勾选。</div>
</div> </div>
) )
} }
@@ -742,11 +947,19 @@ function IconRail({
/> />
<div className="workspace-rail-divider" /> <div className="workspace-rail-divider" />
<RailButton title="新建终端" icon={<PlusOutlined />} onClick={onNewTerminal} /> <RailButton title="新建终端" icon={<PlusOutlined />} onClick={onNewTerminal} />
<RailButton title="水平分屏" icon={<ColumnWidthOutlined />} onClick={() => onSplit('right')} /> {/* getPopupContainer=body: dockview's render overlay creates its own
<RailButton title="垂直分屏" icon={<ColumnHeightOutlined />} onClick={() => onSplit('below')} /> stacking context (contain:layout paint + isolation), which would clip
<Dropdown menu={layoutMenu} trigger={['click']} placement="rightTop"> the dropdown under the sidebar otherwise. */}
<Dropdown
menu={layoutMenu}
trigger={['click']}
placement="rightTop"
getPopupContainer={() => document.body}
>
<RailButton title="布局" icon={<LayoutOutlined />} /> <RailButton title="布局" icon={<LayoutOutlined />} />
</Dropdown> </Dropdown>
<div className="workspace-rail-divider" />
<ModeRailButtons />
<div className="workspace-rail-spacer" /> <div className="workspace-rail-spacer" />
{connecting && ( {connecting && (
<Tooltip title="正在连接…" placement="right"> <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({ function RailButton({
title, title,
icon, icon,
onClick onClick,
active
}: { }: {
title: string title: string
icon: ReactNode icon: ReactNode
onClick?: () => void onClick?: () => void
active?: boolean
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
<Tooltip title={title} placement="right"> <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} {icon}
</button> </button>
</Tooltip> </Tooltip>
+169 -30
View File
@@ -48,6 +48,13 @@
margin: 2px 0; 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 { .workspace-rail-spacer {
flex: 1 1 auto; flex: 1 1 auto;
} }
@@ -92,8 +99,21 @@
min-height: 0; min-height: 0;
} }
.workspace-dockview .dockview-theme-abyss, /* ---- per-workspace dockview containers (terminal / ssh).
.workspace-dockview .dv-dockview { 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%; width: 100%;
height: 100%; height: 100%;
} }
@@ -182,6 +202,12 @@
user-select: none; user-select: none;
} }
.workspace-empty-hint {
color: #6a6a6a;
font-size: 12px;
user-select: none;
}
/* ---- terminal panel ---- */ /* ---- terminal panel ---- */
.workspace-terminal-panel { .workspace-terminal-panel {
position: relative; position: relative;
@@ -203,41 +229,30 @@
min-width: 0; 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-root,
.workspace-terminal-panel > .workspace-terminal-split > .mm-empty, .workspace-terminal-panel > .workspace-terminal-split > .mm-empty {
.workspace-terminal-panel > .workspace-terminal-split > .ant-tabs { flex: 0 0 300px;
flex: 0 0 260px; width: 300px;
width: 260px;
min-width: 0; min-width: 0;
} }
.workspace-terminal-side { .workspace-terminal-side {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 0 0 260px; flex: 0 0 300px;
width: 260px; width: 300px;
min-width: 0; min-width: 0;
border-left: 1px solid var(--chrome-border); border-left: 1px solid var(--chrome-border);
background: var(--chrome-bg); background: var(--chrome-bg);
} }
.workspace-terminal-side > .ant-tabs { .workspace-terminal-side > .mm-root,
.workspace-terminal-side > .mm-empty {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
display: flex; overflow-y: auto;
flex-direction: column; border-radius: 0;
}
.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%;
} }
.workspace-terminal-monitor-toggle { .workspace-terminal-monitor-toggle {
@@ -254,6 +269,48 @@
background: rgba(255, 255, 255, 0.1); 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 ---- */ /* ---- term tab: SSH accent ---- */
.workspace-terminal-tab-icon { .workspace-terminal-tab-icon {
flex: 0 0 auto; flex: 0 0 auto;
@@ -264,6 +321,33 @@
.workspace-dockview .dv-tab .workspace-terminal-tab-ssh { .workspace-dockview .dv-tab .workspace-terminal-tab-ssh {
border-left: 2px solid #3fb950; border-left: 2px solid #3fb950;
padding-left: 5px !important; 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 ---- /* ---- per-group "+" right after the last tab ----
@@ -352,6 +436,12 @@
border-bottom: 1px solid var(--chrome-border); 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 { .workspace-bcast-pop-label {
font-size: 12px; font-size: 12px;
color: var(--chrome-fg); color: var(--chrome-fg);
@@ -389,14 +479,27 @@
padding-top: 6px; padding-top: 6px;
} }
/* ---- tab broadcast-target dot ---- */ /* ---- tab broadcast-target badge ----
.workspace-terminal-tab-bcast { 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; flex: 0 0 auto;
width: 6px; display: inline-flex;
height: 6px; align-items: center;
border-radius: 50%; justify-content: center;
background: #3fb950; min-width: 16px;
box-shadow: 0 0 4px rgba(63, 185, 80, 0.8); 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) ---- /* ---- M6 quick-input floating panel (bottom-right) ----
@@ -668,3 +771,39 @@
word-break: break-all; word-break: break-all;
user-select: text; 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 })
}
}))