From d3a628f177d0dd299a688152753bd0cb65a3f1c9 Mon Sep 17 00:00:00 2001 From: "954801926@qq.com" Date: Mon, 7 Sep 2026 02:39:33 +0800 Subject: [PATCH] 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 --- docs/ROADMAP.md | 1 + docs/STATE.md | 18 +- package.json | 2 +- src/renderer/src/commands/CommandsPanel.tsx | 7 + .../src/connections/ConnectionSidebar.tsx | 13 + src/renderer/src/connections/connections.css | 23 +- src/renderer/src/monitor/MonitorPanel.tsx | 47 +- src/renderer/src/monitor/monitor.css | 115 +++- src/renderer/src/sftp/FilePanel.tsx | 206 +++++- src/renderer/src/sftp/sftp.css | 30 +- src/renderer/src/terminal/TerminalView.tsx | 228 +++++-- src/renderer/src/terminal/terminal.css | 56 +- src/renderer/src/workspace/SshBottomPanel.tsx | 66 ++ src/renderer/src/workspace/SshHostBadge.tsx | 17 + src/renderer/src/workspace/TerminalPanel.tsx | 56 +- src/renderer/src/workspace/TerminalTab.tsx | 19 +- src/renderer/src/workspace/Workspace.tsx | 597 ++++++++++++------ src/renderer/src/workspace/workspace.css | 201 +++++- src/renderer/src/workspace/workspaceMode.ts | 30 + .../src/workspace/workspaceModeStore.ts | 36 ++ 20 files changed, 1380 insertions(+), 388 deletions(-) create mode 100644 src/renderer/src/workspace/SshBottomPanel.tsx create mode 100644 src/renderer/src/workspace/SshHostBadge.tsx create mode 100644 src/renderer/src/workspace/workspaceMode.ts create mode 100644 src/renderer/src/workspace/workspaceModeStore.ts diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 30efb7f..8231284 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 远程会话 ✅ diff --git a/docs/STATE.md b/docs/STATE.md index f10300e..bb41482 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -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 实现要点(已落地) diff --git a/package.json b/package.json index 6c02356..c957a4c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/commands/CommandsPanel.tsx b/src/renderer/src/commands/CommandsPanel.tsx index 3f6513c..81d4527 100644 --- a/src/renderer/src/commands/CommandsPanel.tsx +++ b/src/renderer/src/commands/CommandsPanel.tsx @@ -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 (
+
+ 发送目标 + {workspaceMode === 'ssh' ? 'SSH 服务器' : '终端'} +
s.mode) + const isTerminal = mode === 'terminal' const [connections, setConnections] = useState([]) const [loaded, setLoaded] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) @@ -158,6 +162,9 @@ export const ConnectionSidebar = forwardRef +
+ {isTerminal ? '终端工作区' : 'SSH 工作区'} +
连接
- {suggestions.length > 0 && ( -
e.preventDefault()} - onClick={(e) => e.stopPropagation()} - > -
- {suggestions.map((s, i) => ( - - ))} -
-
↑/↓ 选择 · Tab 接受 · Esc 关闭
-
- )} {paste !== null && !dead && (
检测到多行/长文本粘贴 @@ -741,13 +860,42 @@ export const TerminalView: ForwardRefExoticComponent
)} -
- {dead && ( -
-
进程已退出 (代码 {exitCode})
- + {/* 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). */} +
+ {popupPos !== null && suggestions.length > 0 && ( +
e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > +
+ {suggestions.map((s, i) => ( + + ))} +
+
↑/↓ 选择 · Tab 接受 · Esc 关闭
)} +
+ {dead && ( +
+
进程已退出 (代码 {exitCode})
+ +
+ )} +
) diff --git a/src/renderer/src/terminal/terminal.css b/src/renderer/src/terminal/terminal.css index 3195aac..223ca92 100644 --- a/src/renderer/src/terminal/terminal.css +++ b/src/renderer/src/terminal/terminal.css @@ -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 ---- */ diff --git a/src/renderer/src/workspace/SshBottomPanel.tsx b/src/renderer/src/workspace/SshBottomPanel.tsx new file mode 100644 index 0000000..6d2c834 --- /dev/null +++ b/src/renderer/src/workspace/SshBottomPanel.tsx @@ -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(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): 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 ( +
+
{terminal}
+
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/SshHostBadge.tsx b/src/renderer/src/workspace/SshHostBadge.tsx new file mode 100644 index 0000000..adb6411 --- /dev/null +++ b/src/renderer/src/workspace/SshHostBadge.tsx @@ -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 ( + + {label} + + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/TerminalPanel.tsx b/src/renderer/src/workspace/TerminalPanel.tsx index 734acdf..4d6258a 100644 --- a/src/renderer/src/workspace/TerminalPanel.tsx +++ b/src/renderer/src/workspace/TerminalPanel.tsx @@ -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 & TerminalP * already-exited process. * * For SSH sessions (`sessionKind === 'ssh'`) a collapsible right sidebar with - * [系统监控 | 文件] tabs is docked next to the terminal. Local sessions render - * terminal only. + * [系统监控] is docked next to the terminal, and a resizable file browser + * (SshBottomPanel) sits below the terminal. Local sessions render terminal + * only. */ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element { const sessionId = params.sessionId ?? '' const sessionKind = params.sessionKind const isSsh = sessionKind === 'ssh' const closedRef = useRef(false) - /** Right sidebar visibility for ssh sessions — expanded by default. */ + /** Right sidebar (monitor) visibility for ssh sessions — expanded by default. */ const [sideOpen, setSideOpen] = useState(true) // M6 broadcast: keep the session in the broadcast registry while mounted so @@ -55,6 +56,19 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps return () => disposable.dispose() }, [api, sessionId, isSsh]) + const terminal = ( + { + if (closedRef.current) return + closedRef.current = true + onSessionDead?.(sessionId) + api.close() + }} + /> + ) + return (
{isSsh && ( @@ -62,41 +76,17 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps type="text" size="small" className="workspace-terminal-monitor-toggle" - icon={sideOpen ? : } + icon={} aria-label={sideOpen ? '收起侧边栏' : '展开侧边栏'} title={sideOpen ? '收起侧边栏' : '展开侧边栏'} onClick={() => setSideOpen((prev) => !prev)} /> )}
- { - if (closedRef.current) return - closedRef.current = true - onSessionDead?.(sessionId) - api.close() - }} - /> + {isSsh ? : terminal} {isSsh && sideOpen && (
- - }, - { - key: 'files', - label: '文件', - children: - } - ]} - /> +
)}
diff --git a/src/renderer/src/workspace/TerminalTab.tsx b/src/renderer/src/workspace/TerminalTab.tsx index 367c482..3c587aa 100644 --- a/src/renderer/src/workspace/TerminalTab.tsx +++ b/src/renderer/src/workspace/TerminalTab.tsx @@ -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 ( -
+
{isSsh && } {title} + {isSsh && hostLabel && } {isBroadcastTarget && ( + > + 播 + )} + {/* Local-terminal workspace. */} +
+ ) => ( + + ) + }} + tabComponents={{ + [TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps) => ( + + ) + }} + rightHeaderActionsComponent={TerminalTabActions} + onReady={handleTerminalReady} + theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }} + /> + {terminalCount === 0 && ( +
+
+
暂无终端
+ +
-
- )} + )} +
+ {/* SSH workspace. */} +
+ ) => ( + + ) + }} + tabComponents={{ + [TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps) => ( + + ) + }} + rightHeaderActionsComponent={SshTabActions} + onReady={handleSshReady} + theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }} + /> + {sshCount === 0 && ( +
+
+
暂无 SSH 会话
+
请从左侧服务器列表连接
+
+
+ )} +
@@ -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 => { - const result = await window.api.createPty() - containerApi.addPanel({ - 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 ( -
- - - -
- ) -} - /** "+" 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 { document.body} content={ = 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 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 (
+
广播输入({mode === 'ssh' ? 'SSH' : '终端'})
广播开关
- {sessions.length === 0 ? ( -
暂无打开的终端
+ {groupSessions.length === 0 ? ( +
+ {mode === 'ssh' ? '暂无 SSH 服务器会话' : '暂无终端'} +
) : ( - sessions.map((s) => ( + groupSessions.map((s) => (
onToggleTarget(s.id)}> {label(s)} @@ -688,7 +893,7 @@ function WorkspaceBroadcastPopover({ )) )}
-
选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。
+
选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。仅当前模式组的会话可被勾选。
) } @@ -742,11 +947,19 @@ function IconRail({ />
} onClick={onNewTerminal} /> - } onClick={() => onSplit('right')} /> - } onClick={() => onSplit('below')} /> - + {/* getPopupContainer=body: dockview's render overlay creates its own + stacking context (contain:layout paint + isolation), which would clip + the dropdown under the sidebar otherwise. */} + document.body} + > } /> +
+
{connecting && ( @@ -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 ( + <> + } + active={mode === 'terminal'} + onClick={() => setMode('terminal')} + /> + } + 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 ( - diff --git a/src/renderer/src/workspace/workspace.css b/src/renderer/src/workspace/workspace.css index f82a240..d2b776d 100644 --- a/src/renderer/src/workspace/workspace.css +++ b/src/renderer/src/workspace/workspace.css @@ -48,6 +48,13 @@ margin: 2px 0; } +/* ---- M6.1 rail active state (mode entries) ---- */ +.workspace-rail-btn.is-active { + color: var(--dv-active-tab-foreground-color, #e8e8e8); + box-shadow: inset 2px 0 0 #3fb950; + background: rgba(63, 185, 80, 0.10); +} + .workspace-rail-spacer { flex: 1 1 auto; } @@ -92,8 +99,21 @@ min-height: 0; } -.workspace-dockview .dockview-theme-abyss, -.workspace-dockview .dv-dockview { +/* ---- per-workspace dockview containers (terminal / ssh). + Switching mode toggles `.is-hidden` → display:none. The DockviewReact stays + mounted (state/layout preserved) so the switch is instant and free. */ +.workspace-dockview-container { + position: absolute; + inset: 0; + display: flex; +} + +.workspace-dockview-container.is-hidden { + display: none; +} + +.workspace-dockview-container .dockview-theme-abyss, +.workspace-dockview-container .dv-dockview { width: 100%; height: 100%; } @@ -182,6 +202,12 @@ user-select: none; } +.workspace-empty-hint { + color: #6a6a6a; + font-size: 12px; + user-select: none; +} + /* ---- terminal panel ---- */ .workspace-terminal-panel { position: relative; @@ -203,41 +229,30 @@ min-width: 0; } -/* ---- SSH session right dock (M3 monitor / M4 file tabs, collapsible) ---- */ +/* ---- SSH session right dock (M3 monitor, collapsible) ---- */ .workspace-terminal-panel > .workspace-terminal-split > .mm-root, -.workspace-terminal-panel > .workspace-terminal-split > .mm-empty, -.workspace-terminal-panel > .workspace-terminal-split > .ant-tabs { - flex: 0 0 260px; - width: 260px; +.workspace-terminal-panel > .workspace-terminal-split > .mm-empty { + flex: 0 0 300px; + width: 300px; min-width: 0; } .workspace-terminal-side { display: flex; flex-direction: column; - flex: 0 0 260px; - width: 260px; + flex: 0 0 300px; + width: 300px; min-width: 0; border-left: 1px solid var(--chrome-border); background: var(--chrome-bg); } -.workspace-terminal-side > .ant-tabs { +.workspace-terminal-side > .mm-root, +.workspace-terminal-side > .mm-empty { flex: 1 1 auto; min-height: 0; - display: flex; - flex-direction: column; -} - -.workspace-terminal-side .ant-tabs-content-holder { - flex: 1 1 auto; - min-height: 0; - overflow: hidden; -} - -.workspace-terminal-side .ant-tabs-content, -.workspace-terminal-side .ant-tabs-tabpane { - height: 100%; + overflow-y: auto; + border-radius: 0; } .workspace-terminal-monitor-toggle { @@ -254,6 +269,48 @@ background: rgba(255, 255, 255, 0.1); } +/* ---- SSH bottom file browser (M8, FileZilla-style resizable bottom panel) ---- + Flex column: terminal on top (flex 1), 4px drag divider, fixed-height file + browser at the bottom. Height is state-driven from SshBottomPanel. */ +.ssh-bottom-panel { + display: flex; + flex-direction: column; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.ssh-bottom-top { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.ssh-bottom-divider { + flex: 0 0 4px; + height: 4px; + background: var(--chrome-border); + cursor: row-resize; + box-sizing: border-box; + touch-action: none; +} + +.ssh-bottom-divider:hover, +.ssh-bottom-divider.is-resizing, +body.ssh-resizing .ssh-bottom-divider { + background: var(--chrome-accent); +} + +.ssh-bottom-files { + flex: 0 0 auto; + min-height: 0; + overflow: hidden; + border-top: 1px solid var(--chrome-border); + background: var(--chrome-bg); +} + /* ---- term tab: SSH accent ---- */ .workspace-terminal-tab-icon { flex: 0 0 auto; @@ -264,6 +321,33 @@ .workspace-dockview .dv-tab .workspace-terminal-tab-ssh { border-left: 2px solid #3fb950; padding-left: 5px !important; + gap: 5px; +} + +/* Host label pill on SSH tabs: faint green outline, green monospace text. */ +.workspace-dockview .dv-tab .workspace-terminal-tab-ssh .ssh-host-badge { + flex: 0 1 auto; + min-width: 0; + max-width: 132px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 1px 6px 2px; + box-sizing: border-box; + border-radius: 9px; + border: 1px solid rgba(63, 185, 80, 0.38); + background: rgba(63, 185, 80, 0.08); + color: #3fb950; + font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace; + font-size: 10.5px; + line-height: 1; + letter-spacing: 0; +} + +.workspace-dockview .dv-tab.dv-active-tab .workspace-terminal-tab-ssh .ssh-host-badge { + border-color: rgba(63, 185, 80, 0.6); + background: rgba(63, 185, 80, 0.14); + color: #46c96a; } /* ---- per-group "+" right after the last tab ---- @@ -352,6 +436,12 @@ border-bottom: 1px solid var(--chrome-border); } +.workspace-bcast-pop-mode { + font-size: 12px; + font-weight: 600; + color: var(--chrome-fg); +} + .workspace-bcast-pop-label { font-size: 12px; color: var(--chrome-fg); @@ -389,14 +479,27 @@ padding-top: 6px; } -/* ---- tab broadcast-target dot ---- */ -.workspace-terminal-tab-bcast { +/* ---- tab broadcast-target badge ---- + A small "播" pill coordinated with the SSH host-label pill so both can sit + on an SSH tab (SSH left accent + host pill left-ish + bcast pill right) + without visual clutter. */ +.workspace-dockview .dv-tab .workspace-terminal-tab-bcast { flex: 0 0 auto; - width: 6px; - height: 6px; - border-radius: 50%; - background: #3fb950; - box-shadow: 0 0 4px rgba(63, 185, 80, 0.8); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + box-sizing: border-box; + border-radius: 8px; + font-size: 10px; + font-weight: 600; + line-height: 1; + color: #3fb950; + background: rgba(63, 185, 80, 0.1); + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.35); + user-select: none; } /* ---- M6 quick-input floating panel (bottom-right) ---- @@ -667,4 +770,40 @@ color: var(--chrome-fg); word-break: break-all; user-select: text; -} \ No newline at end of file +} +/* 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); +} diff --git a/src/renderer/src/workspace/workspaceMode.ts b/src/renderer/src/workspace/workspaceMode.ts new file mode 100644 index 0000000..8aad828 --- /dev/null +++ b/src/renderer/src/workspace/workspaceMode.ts @@ -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 = { + terminal: 'terminal:', + ssh: 'ssh:' +} + +/** 每个 workspace 组顶部的固定标记面板(不可关闭),作为该组显示/隐藏与激活的锚点。 */ +export const WORKSPACE_MARKER_ID: Record = { + 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 +} diff --git a/src/renderer/src/workspace/workspaceModeStore.ts b/src/renderer/src/workspace/workspaceModeStore.ts new file mode 100644 index 0000000..17863df --- /dev/null +++ b/src/renderer/src/workspace/workspaceModeStore.ts @@ -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((set) => ({ + mode: readInitial(), + setMode: (mode) => { + try { + window.localStorage.setItem(LS_KEY, mode) + } catch { + // ignore + } + set({ mode }) + } +}))