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