feat: add terminal broadcast, context menu, and shell close
Complete multi-pane broadcast, standard terminal right-click actions, batch host selection with progress, and native ssh_close_shell so closing a pane tears down the remote shell. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
494ef6b9d0
commit
9ce71795b8
38 files changed
+1437
-186
No files matched your search
@@ -7,7 +7,9 @@ import {
|
||||
Play,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Square,
|
||||
TerminalSquare,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -15,8 +17,16 @@ import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../utils/aiResponse'
|
||||
import { writeClipboardText } from '../../platform/clipboard'
|
||||
import { analyzeAgentOutput } from '../../utils/agentOutput'
|
||||
import { AGENT_RISK_LABEL, assessAgentCommandRisk, type AgentCommandRisk } from '../../utils/agentCommandRisk'
|
||||
import {
|
||||
AGENT_RISK_LABEL,
|
||||
AGENT_SAFETY_MODES,
|
||||
agentCommandNeedsConfirmation,
|
||||
assessAgentCommandRisk,
|
||||
type AgentCommandRisk,
|
||||
type AgentSafetyMode,
|
||||
} from '../../utils/agentCommandRisk'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
export interface TerminalAiContext {
|
||||
selection: string
|
||||
@@ -43,6 +53,7 @@ interface AgentStep {
|
||||
const MAX_AGENT_STEPS = 8
|
||||
const MAX_TOOL_OUTPUT_CHARS = 12_000
|
||||
const MAX_AGENT_CONTEXT_CHARS = 48_000
|
||||
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'balanced', 'permissive']
|
||||
|
||||
function truncateMiddle(value: string, limit: number): string {
|
||||
if (value.length <= limit) return value
|
||||
@@ -102,6 +113,8 @@ export default function AiTerminalPanel({
|
||||
isLocal = false,
|
||||
}: AiTerminalPanelProps) {
|
||||
const store = useAiProviderStore()
|
||||
const safetyMode = useSettingsStore((state) => state.settings.agentSafetyMode)
|
||||
const setSetting = useSettingsStore((state) => state.setSetting)
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [contextNote, setContextNote] = useState('仅在开始时读取当前选中内容')
|
||||
@@ -150,6 +163,7 @@ export default function AiTerminalPanel({
|
||||
: '未附带终端内容 · Agent 将通过 ssh-exec 自主检查'
|
||||
)
|
||||
setRunning(true)
|
||||
const runSafetyMode = safetyMode
|
||||
|
||||
const completedSteps: AgentStep[] = []
|
||||
const commandCounts = new Map<string, number>()
|
||||
@@ -204,21 +218,24 @@ export default function AiTerminalPanel({
|
||||
if (risk === 'critical') {
|
||||
throw new Error(`命令命中破坏性操作硬红线,不提供确认放行:${command}`)
|
||||
}
|
||||
const awaiting: AgentStep = { round, command, risk, status: 'awaiting' }
|
||||
setSteps([...completedSteps, awaiting])
|
||||
const approved = await confirm({
|
||||
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
|
||||
message: `目标:SSH · ${targetLabel}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
|
||||
danger: risk === 'high',
|
||||
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
|
||||
})
|
||||
if (!approved) {
|
||||
setSteps([...completedSteps])
|
||||
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
|
||||
return
|
||||
const proposed: AgentStep = { round, command, risk, status: 'running' }
|
||||
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
|
||||
const awaiting = { ...proposed, status: 'awaiting' as const }
|
||||
setSteps([...completedSteps, awaiting])
|
||||
const approved = await confirm({
|
||||
title: `第 ${round} 步:确认远程执行 · ${AGENT_RISK_LABEL[risk]}`,
|
||||
message: `目标:SSH · ${targetLabel}\n安全模式:${AGENT_SAFETY_MODES[runSafetyMode].label}\n风险:${AGENT_RISK_LABEL[risk]}\n\n命令:${command}\n\n执行结果将交给 AI 判断下一步;命令和输出不会写入历史。`,
|
||||
danger: risk === 'high',
|
||||
confirmText: risk === 'high' ? '确认风险并执行' : '执行',
|
||||
})
|
||||
if (!approved) {
|
||||
setSteps([...completedSteps])
|
||||
setResult(`任务已停止:你取消了第 ${round} 步命令。`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSteps([...completedSteps, { ...awaiting, status: 'running' }])
|
||||
setSteps([...completedSteps, proposed])
|
||||
const executed = await window.opsxApi.sshExec(connectionId, command)
|
||||
if (runIdRef.current !== runId) return
|
||||
const rawOutput = executed.success
|
||||
@@ -259,8 +276,14 @@ export default function AiTerminalPanel({
|
||||
window.setTimeout(() => setCopied(false), 1200)
|
||||
}
|
||||
|
||||
const stopAgent = () => {
|
||||
runIdRef.current += 1
|
||||
setRunning(false)
|
||||
setResult('任务已停止;正在执行的单条 SSH 命令不会被强制中断,但不会再继续下一步。')
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="border-dark-700/50 bg-dark-850 flex w-[390px] flex-shrink-0 flex-col border-l">
|
||||
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
|
||||
<header className="border-dark-700/50 flex items-center gap-2 border-b px-3 py-2.5">
|
||||
<Sparkles className="h-4 w-4 text-accent-blue" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -269,6 +292,9 @@ export default function AiTerminalPanel({
|
||||
{provider && model ? `LLM: ${provider.name} · ${model.label}` : '尚未配置模型'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md border border-dark-600 bg-dark-900/70 px-2 py-1 text-[10px] text-content-secondary">
|
||||
{AGENT_SAFETY_MODES[safetyMode].label}
|
||||
</span>
|
||||
<button type="button" title="关闭" onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -282,7 +308,40 @@ export default function AiTerminalPanel({
|
||||
<span className="truncate">已连接主机:{connectionId && !isLocal ? targetLabel : '尚未连接远程 SSH'}</span>
|
||||
</div>
|
||||
<p className="text-[10px] leading-relaxed text-txt-faint">
|
||||
Agent 通过当前连接执行、观察再决定下一步;每条真实命令都需要你确认。
|
||||
Agent 只使用当前 SSH 连接执行、观察并继续处理,不切换目标主机。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dark-700/60 bg-dark-900/45 p-2.5">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-medium text-content-secondary">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-accent-green" /> 执行安全模式
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1" role="radiogroup" aria-label="Agent 安全模式">
|
||||
{SAFETY_MODE_ORDER.map((mode) => {
|
||||
const option = AGENT_SAFETY_MODES[mode]
|
||||
const selected = safetyMode === mode
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={running}
|
||||
title={`${option.short}:${option.description}`}
|
||||
onClick={() => void setSetting('agentSafetyMode', mode)}
|
||||
className={`rounded-md border px-2 py-1.5 text-[11px] transition-colors disabled:opacity-50 ${
|
||||
selected
|
||||
? 'border-accent-blue/50 bg-accent-blue/15 text-accent-blue'
|
||||
: 'border-dark-700 bg-dark-800 text-txt-muted hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-txt-faint">
|
||||
{AGENT_SAFETY_MODES[safetyMode].short} · {AGENT_SAFETY_MODES[safetyMode].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -302,15 +361,15 @@ export default function AiTerminalPanel({
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-txt-faint">Shift+回车换行 · 回车发送</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runAgent()}
|
||||
disabled={running}
|
||||
className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5"
|
||||
>
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{running ? '执行中…' : '执行'}
|
||||
</button>
|
||||
{running ? (
|
||||
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex min-w-20 items-center justify-center gap-1.5">
|
||||
<Square className="h-3.5 w-3.5" /> 停止
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex min-w-20 items-center justify-center gap-1.5">
|
||||
<Play className="h-3.5 w-3.5" /> 执行
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-txt-faint">{contextNote}</div>
|
||||
|
||||
@@ -377,7 +436,7 @@ export default function AiTerminalPanel({
|
||||
|
||||
<footer className="border-dark-700/40 flex items-start gap-2 border-t px-3 py-2 text-[10px] leading-relaxed text-txt-faint">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
最多 {MAX_AGENT_STEPS} 步,重复命令自动拦截;命令、AI 对话和工具输出只存在于当前面板内存。
|
||||
默认均衡模式仅高风险确认;critical 永久拒绝。最多 {MAX_AGENT_STEPS} 步,命令、对话和输出不落盘。
|
||||
</footer>
|
||||
{ConfirmDialogComponent}
|
||||
</aside>
|
||||
|
||||
@@ -429,7 +429,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession }:
|
||||
{ConfirmDialogComponent}
|
||||
<BatchCommandDialog
|
||||
isOpen={batchCommandOpen}
|
||||
sessions={batchCommandSessions}
|
||||
sessions={sessions}
|
||||
initialSelectedIds={batchCommandSessions.map((session) => session.id)}
|
||||
onClose={() => setBatchCommandOpen(false)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePoolStore } from '../../stores/connection/poolStore'
|
||||
import type { ConnectionState } from '../../stores/connection'
|
||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||
import { useSessionStore } from '../../stores'
|
||||
import packageJson from '../../../package.json'
|
||||
|
||||
// ============== Status Display Config ==============
|
||||
|
||||
@@ -84,7 +85,7 @@ function mapTabStatusToConnectionState(status?: ConnectionStatus): ConnectionSta
|
||||
|
||||
export default function StatusBar() {
|
||||
const togglePanel = useConnectionPanelStore((s) => s.toggle)
|
||||
const [version, setVersion] = useState('1.0.0')
|
||||
const [version, setVersion] = useState(packageJson.version)
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
|
||||
// Tab store
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Clock3, Loader2, Play, TerminalSquare } from 'lucide-react'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Loader2,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
TerminalSquare,
|
||||
} from 'lucide-react'
|
||||
import type { Session } from '../../stores/sessionStore'
|
||||
import { getSessionConfig } from '../../stores/connection'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
@@ -19,21 +29,51 @@ interface BatchCommandResult {
|
||||
interface BatchCommandDialogProps {
|
||||
isOpen: boolean
|
||||
sessions: Session[]
|
||||
initialSelectedIds?: string[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENCY = 5
|
||||
const EMPTY_SELECTION: string[] = []
|
||||
|
||||
export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchCommandDialogProps) {
|
||||
export default function BatchCommandDialog({
|
||||
isOpen,
|
||||
sessions,
|
||||
initialSelectedIds = EMPTY_SELECTION,
|
||||
onClose,
|
||||
}: BatchCommandDialogProps) {
|
||||
const [command, setCommand] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [running, setRunning] = useState(false)
|
||||
const [results, setResults] = useState<BatchCommandResult[]>([])
|
||||
const timeout = useSettingsStore((state) => state.settings.timeout)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
}, [isOpen, sessions])
|
||||
const available = new Set(sessions.map((session) => session.id))
|
||||
const requested = initialSelectedIds.filter((id) => available.has(id))
|
||||
const initial = requested.length > 0 ? requested : sessions.map((session) => session.id)
|
||||
setSelectedIds(new Set(initial))
|
||||
setResults(initial.map((sessionId) => ({ sessionId, status: 'idle' })))
|
||||
setCommand('')
|
||||
setQuery('')
|
||||
}, [initialSelectedIds, isOpen, sessions])
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return sessions
|
||||
return sessions.filter((session) =>
|
||||
[session.name, session.host, session.username, session.group, ...(session.tags || [])]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(normalized))
|
||||
)
|
||||
}, [query, sessions])
|
||||
|
||||
const selectedSessions = useMemo(
|
||||
() => sessions.filter((session) => selectedIds.has(session.id)),
|
||||
[selectedIds, sessions]
|
||||
)
|
||||
|
||||
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
|
||||
setResults((current) => current.map((result) =>
|
||||
@@ -75,23 +115,22 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} finally {
|
||||
if (connectionId) {
|
||||
await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||
}
|
||||
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
|
||||
}
|
||||
}, [timeout, updateResult])
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
const commandText = command.trim()
|
||||
if (!commandText || running || sessions.length === 0) return
|
||||
if (!commandText || running || selectedSessions.length === 0) return
|
||||
|
||||
const targets = [...selectedSessions]
|
||||
setRunning(true)
|
||||
setResults(sessions.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
setResults(targets.map((session) => ({ sessionId: session.id, status: 'idle' })))
|
||||
|
||||
let nextIndex = 0
|
||||
const worker = async () => {
|
||||
while (nextIndex < sessions.length) {
|
||||
const session = sessions[nextIndex]
|
||||
while (nextIndex < targets.length) {
|
||||
const session = targets[nextIndex]
|
||||
nextIndex += 1
|
||||
await executeForSession(session, commandText)
|
||||
}
|
||||
@@ -99,40 +138,66 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(MAX_CONCURRENCY, sessions.length) }, () => worker())
|
||||
Array.from({ length: Math.min(MAX_CONCURRENCY, targets.length) }, () => worker())
|
||||
)
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}, [command, executeForSession, running, sessions])
|
||||
}, [command, executeForSession, running, selectedSessions])
|
||||
|
||||
const toggleSession = (sessionId: string) => {
|
||||
if (running) return
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(sessionId)) next.delete(sessionId)
|
||||
else next.add(sessionId)
|
||||
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const allFilteredSelected = filteredSessions.length > 0 && filteredSessions.every((session) => selectedIds.has(session.id))
|
||||
const toggleFiltered = () => {
|
||||
if (running) return
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const session of filteredSessions) {
|
||||
if (allFilteredSelected) next.delete(session.id)
|
||||
else next.add(session.id)
|
||||
}
|
||||
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const summary = useMemo(() => ({
|
||||
success: results.filter((result) => result.status === 'success').length,
|
||||
error: results.filter((result) => result.status === 'error').length,
|
||||
running: results.filter((result) => result.status === 'running').length,
|
||||
}), [results])
|
||||
|
||||
const handleClose = () => {
|
||||
if (!running) onClose()
|
||||
}
|
||||
const completed = summary.success + summary.error
|
||||
const progress = selectedSessions.length > 0 ? Math.round((completed / selectedSessions.length) * 100) : 0
|
||||
const handleClose = () => { if (!running) onClose() }
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title="批量命令"
|
||||
description={`${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
|
||||
description={`已选 ${selectedSessions.length}/${sessions.length} 台主机 · 最大并发 ${MAX_CONCURRENCY}`}
|
||||
icon={<TerminalSquare className="h-5 w-5" />}
|
||||
onClose={handleClose}
|
||||
showFooter
|
||||
showConfirm={false}
|
||||
disableBackdropClose={running}
|
||||
size="full"
|
||||
className="max-w-5xl"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-xs text-txt-muted">
|
||||
{summary.success > 0 && <span className="text-accent-green">成功 {summary.success}</span>}
|
||||
{summary.success > 0 && summary.error > 0 && ' · '}
|
||||
{summary.error > 0 && <span className="text-red-400">失败 {summary.error}</span>}
|
||||
</span>
|
||||
<div className="min-w-0 text-xs text-txt-muted">
|
||||
{running ? `执行中 ${completed}/${selectedSessions.length}` : `目标 ${selectedSessions.length} 台`}
|
||||
{summary.success > 0 && <span className="ml-2 text-accent-green">成功 {summary.success}</span>}
|
||||
{summary.error > 0 && <span className="ml-2 text-red-400">失败 {summary.error}</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose} disabled={running}>关闭</Button>
|
||||
<Button
|
||||
@@ -140,22 +205,21 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
|
||||
size="sm"
|
||||
icon={<Play className="h-3.5 w-3.5" />}
|
||||
onClick={() => void execute()}
|
||||
disabled={!command.trim()}
|
||||
disabled={!command.trim() || selectedSessions.length === 0}
|
||||
loading={running}
|
||||
>
|
||||
{results.some((result) => result.status === 'success' || result.status === 'error')
|
||||
? '再次执行'
|
||||
: '执行命令'}
|
||||
{completed > 0 ? '再次执行' : `执行到 ${selectedSessions.length} 台`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="batch-command" className="mb-1.5 block text-xs font-medium text-content-secondary">
|
||||
远程命令
|
||||
</label>
|
||||
<section className="rounded-xl border border-dark-700/70 bg-dark-900/55 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<label htmlFor="batch-command" className="text-xs font-medium text-content-secondary">远程命令</label>
|
||||
<span className="text-[10px] text-txt-faint">Ctrl/⌘ + Enter 执行</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="batch-command"
|
||||
value={command}
|
||||
@@ -171,39 +235,101 @@ export default function BatchCommandDialog({ isOpen, sessions, onClose }: BatchC
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder="例如:date"
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
|
||||
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-950 px-3 py-2.5 font-mono text-sm text-content-primary outline-none transition-colors focus:border-accent-blue disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
{(running || completed > 0) && (
|
||||
<div className="mt-2 h-1 overflow-hidden rounded-full bg-dark-700">
|
||||
<div className="h-full rounded-full bg-accent-blue transition-[width]" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="max-h-[48vh] space-y-2 overflow-y-auto pr-1">
|
||||
{sessions.map((session) => {
|
||||
const result = results.find((item) => item.sessionId === session.id)
|
||||
const status = result?.status ?? 'idle'
|
||||
return (
|
||||
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-900/65">
|
||||
<div className="flex min-h-10 items-center gap-2 border-b border-dark-700/70 px-3 py-2">
|
||||
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
|
||||
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
|
||||
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
|
||||
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content-primary">{session.name}</span>
|
||||
<span className="truncate font-mono text-[11px] text-txt-muted">
|
||||
{session.username}@{session.host}:{session.port || 22}
|
||||
</span>
|
||||
{result?.durationMs !== undefined && (
|
||||
<span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>
|
||||
)}
|
||||
<div className="grid min-h-[390px] grid-cols-[300px_minmax(0,1fr)] gap-3">
|
||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-900/45">
|
||||
<div className="space-y-2 border-b border-dark-700/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
|
||||
<Server className="h-3.5 w-3.5" /> 目标主机
|
||||
</div>
|
||||
{(result?.output || result?.error) && (
|
||||
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 text-xs leading-relaxed ${
|
||||
result.error ? 'text-red-300' : 'text-content-secondary'
|
||||
}`}>
|
||||
{result.error || result.output}
|
||||
</pre>
|
||||
)}
|
||||
<button type="button" disabled={running || filteredSessions.length === 0} onClick={toggleFiltered} className="text-[11px] text-accent-blue disabled:opacity-40">
|
||||
{allFilteredSelected ? '取消当前结果' : '全选当前结果'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<label className="relative block">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-faint" />
|
||||
<input
|
||||
aria-label="筛选主机"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
disabled={running}
|
||||
placeholder="名称、IP、分组或标签"
|
||||
className="ui-input-sm w-full pl-8"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{filteredSessions.map((session) => {
|
||||
const selected = selectedIds.has(session.id)
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
disabled={running}
|
||||
onClick={() => toggleSession(session.id)}
|
||||
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors ${
|
||||
selected
|
||||
? 'border-accent-blue/40 bg-accent-blue/10'
|
||||
: 'border-transparent hover:border-dark-700 hover:bg-dark-800'
|
||||
}`}
|
||||
>
|
||||
<span className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${selected ? 'border-accent-blue bg-accent-blue text-white' : 'border-dark-500 bg-dark-950'}`}>
|
||||
{selected && <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-content-primary">{session.name}</span>
|
||||
<span className="block truncate font-mono text-[10px] text-txt-faint">{session.username}@{session.host}:{session.port || 22}</span>
|
||||
</span>
|
||||
<span className="max-w-20 truncate rounded bg-dark-700/70 px-1.5 py-0.5 text-[9px] text-txt-muted">{session.group || '默认'}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredSessions.length === 0 && <div className="py-12 text-center text-xs text-txt-faint">没有匹配的主机</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-900/45">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/60 px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-content-secondary">执行结果</span>
|
||||
<span className="text-[10px] text-txt-faint">输出仅保留在当前弹框内</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2.5">
|
||||
{selectedSessions.map((session) => {
|
||||
const result = results.find((item) => item.sessionId === session.id)
|
||||
const status = result?.status ?? 'idle'
|
||||
return (
|
||||
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-950/70">
|
||||
<div className="flex min-h-10 items-center gap-2 px-3 py-2">
|
||||
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
|
||||
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
|
||||
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
|
||||
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-content-primary">{session.name}</span>
|
||||
<span className="truncate font-mono text-[10px] text-txt-muted">{session.host}</span>
|
||||
{result?.durationMs !== undefined && <span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>}
|
||||
</div>
|
||||
{(result?.output || result?.error) && (
|
||||
<pre className={`max-h-40 overflow-auto whitespace-pre-wrap break-words border-t border-dark-700/70 px-3 py-2 text-[11px] leading-relaxed ${result.error ? 'text-red-300' : 'text-content-secondary'}`}>
|
||||
{result.error || result.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{selectedSessions.length === 0 && <div className="flex h-full min-h-64 items-center justify-center text-xs text-txt-faint">请从左侧选择目标主机</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -69,10 +69,11 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialTarget) {
|
||||
setTarget(initialTarget)
|
||||
setError(null)
|
||||
}
|
||||
if (!isOpen) return
|
||||
setTarget(initialTarget)
|
||||
setPassword('')
|
||||
setError(null)
|
||||
setIsConnecting(false)
|
||||
}, [initialTarget, isOpen])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, type ComponentType } from 'react'
|
||||
import {
|
||||
Clipboard,
|
||||
ClipboardPaste,
|
||||
Eraser,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MousePointer2,
|
||||
SeparatorHorizontal,
|
||||
SplitSquareHorizontal,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface MenuAction {
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
onSelect: () => void
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
dividerBefore?: boolean
|
||||
}
|
||||
|
||||
interface TerminalContextMenuProps {
|
||||
position: { x: number; y: number }
|
||||
hasSelection: boolean
|
||||
canSplit: boolean
|
||||
canClosePane: boolean
|
||||
canCloseTerminal: boolean
|
||||
closeLabel: string
|
||||
maximized: boolean
|
||||
onClose: () => void
|
||||
onCopy: () => void
|
||||
onPaste: () => void
|
||||
onSelectAll: () => void
|
||||
onClear: () => void
|
||||
onSplitRight: () => void
|
||||
onSplitDown: () => void
|
||||
onToggleMaximize: () => void
|
||||
onClosePane: () => void
|
||||
}
|
||||
|
||||
export default function TerminalContextMenu({
|
||||
position,
|
||||
hasSelection,
|
||||
canSplit,
|
||||
canClosePane,
|
||||
canCloseTerminal,
|
||||
closeLabel,
|
||||
maximized,
|
||||
onClose,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
onToggleMaximize,
|
||||
onClosePane,
|
||||
}: TerminalContextMenuProps) {
|
||||
useEffect(() => {
|
||||
const dismiss = () => onClose()
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
|
||||
window.addEventListener('mousedown', dismiss)
|
||||
window.addEventListener('blur', dismiss)
|
||||
window.addEventListener('resize', dismiss)
|
||||
window.addEventListener('keydown', escape)
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', dismiss)
|
||||
window.removeEventListener('blur', dismiss)
|
||||
window.removeEventListener('resize', dismiss)
|
||||
window.removeEventListener('keydown', escape)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const actions: MenuAction[] = [
|
||||
{ label: '复制', icon: Clipboard, onSelect: onCopy, disabled: !hasSelection },
|
||||
{ label: '粘贴', icon: ClipboardPaste, onSelect: onPaste },
|
||||
{ label: '全选终端内容', icon: MousePointer2, onSelect: onSelectAll },
|
||||
{ label: '清空显示', icon: Eraser, onSelect: onClear },
|
||||
{ label: '向右分屏', icon: SplitSquareHorizontal, onSelect: onSplitRight, disabled: !canSplit, dividerBefore: true },
|
||||
{ label: '向下分屏', icon: SeparatorHorizontal, onSelect: onSplitDown, disabled: !canSplit },
|
||||
{
|
||||
label: maximized ? '恢复分屏布局' : '最大化当前分屏',
|
||||
icon: maximized ? Minimize2 : Maximize2,
|
||||
onSelect: onToggleMaximize,
|
||||
disabled: !canClosePane,
|
||||
},
|
||||
{ label: closeLabel, icon: X, onSelect: onClosePane, disabled: !canCloseTerminal, danger: true, dividerBefore: true },
|
||||
]
|
||||
|
||||
const left = Math.min(position.x, Math.max(8, window.innerWidth - 220))
|
||||
const top = Math.min(position.y, Math.max(8, window.innerHeight - 330))
|
||||
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="终端操作"
|
||||
style={{ left, top }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
className="fixed z-[140] w-52 overflow-hidden rounded-lg border border-dark-600/90 bg-dark-800 py-1.5 shadow-2xl shadow-black/45"
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<div key={action.label}>
|
||||
{action.dividerBefore && <div className="my-1 border-t border-dark-700/70" />}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={action.disabled}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
action.onSelect()
|
||||
}}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-35 ${
|
||||
action.danger
|
||||
? 'text-red-300 hover:bg-red-500/10'
|
||||
: 'text-content-secondary hover:bg-dark-700 hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
<action.icon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="flex-1">{action.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,11 @@ import { themes } from '../../themes/terminal'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { getConnectionStore } from '../../stores/connection'
|
||||
import {
|
||||
getTerminalBroadcastTargets,
|
||||
terminalBroadcastTargetCount,
|
||||
useTerminalBroadcastStore,
|
||||
} from '../../stores/terminalBroadcastStore'
|
||||
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
|
||||
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
|
||||
import { shellPayloadByteLength } from '../../utils/shellData'
|
||||
@@ -35,9 +40,10 @@ import { createLogger } from '../../utils/logger'
|
||||
import type { TerminalViewProps } from './types'
|
||||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
|
||||
import { resolveTerminalInputTargets } from '../../utils/terminalPanes'
|
||||
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
|
||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
|
||||
import TerminalContextMenu from './TerminalContextMenu'
|
||||
|
||||
const logger = createLogger('TerminalWorkspace')
|
||||
const MAX_PANES = 4
|
||||
@@ -55,6 +61,10 @@ interface PaneState {
|
||||
interface PaneRuntime {
|
||||
capture: () => TerminalAiContext
|
||||
getSize: () => { cols: number; rows: number }
|
||||
copySelection: () => void
|
||||
pasteClipboard: () => void
|
||||
selectAll: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
function captureTerminal(term: Terminal): TerminalAiContext {
|
||||
@@ -83,6 +93,7 @@ interface PaneTerminalProps {
|
||||
isActive: boolean
|
||||
onFocus: () => void
|
||||
onInput: (paneId: string, data: string) => void
|
||||
onContextMenu: (paneId: string, position: { x: number; y: number }, hasSelection: boolean) => void
|
||||
onShellReady: (paneId: string, shellId: string) => void
|
||||
registerShellHandler: (paneId: string, handler: (shellId: string, data: unknown) => void) => void
|
||||
unregisterShellHandler: (paneId: string) => void
|
||||
@@ -101,6 +112,7 @@ function PaneTerminal({
|
||||
isActive,
|
||||
onFocus,
|
||||
onInput,
|
||||
onContextMenu,
|
||||
onShellReady,
|
||||
registerShellHandler,
|
||||
unregisterShellHandler,
|
||||
@@ -112,12 +124,14 @@ function PaneTerminal({
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const shellIdRef = useRef(shellId)
|
||||
const onInputRef = useRef(onInput)
|
||||
const onContextMenuRef = useRef(onContextMenu)
|
||||
const creatingRef = useRef(false)
|
||||
const { settings } = useSettingsStore()
|
||||
const settingsRef = useRef(settings)
|
||||
settingsRef.current = settings
|
||||
shellIdRef.current = shellId
|
||||
onInputRef.current = onInput
|
||||
onContextMenuRef.current = onContextMenu
|
||||
|
||||
const currentTheme = themes[settings.terminalTheme] || themes['dracula']
|
||||
|
||||
@@ -176,16 +190,19 @@ function PaneTerminal({
|
||||
}
|
||||
}
|
||||
|
||||
const copySelection = () => {
|
||||
const selection = term.getSelection()
|
||||
if (!selection) return
|
||||
void writeClipboardText(selection).catch((error) => {
|
||||
logger.warn('写入剪贴板失败', error)
|
||||
})
|
||||
}
|
||||
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== 'keydown') return true
|
||||
if (isTerminalCopyShortcut(event)) {
|
||||
event.preventDefault()
|
||||
const selection = term.getSelection()
|
||||
if (selection) {
|
||||
void writeClipboardText(selection).catch((error) => {
|
||||
logger.warn('写入剪贴板失败', error)
|
||||
})
|
||||
}
|
||||
copySelection()
|
||||
return false
|
||||
}
|
||||
if (isTerminalPasteShortcut(event)) {
|
||||
@@ -198,14 +215,12 @@ function PaneTerminal({
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
const selection = term.getSelection()
|
||||
if (selection) {
|
||||
void writeClipboardText(selection).catch((error) => {
|
||||
logger.warn('写入剪贴板失败', error)
|
||||
})
|
||||
} else {
|
||||
void pasteClipboard()
|
||||
}
|
||||
event.stopPropagation()
|
||||
onContextMenuRef.current(
|
||||
paneId,
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
term.hasSelection()
|
||||
)
|
||||
}
|
||||
container.addEventListener('contextmenu', handleContextMenu)
|
||||
|
||||
@@ -245,6 +260,10 @@ function PaneTerminal({
|
||||
registerRuntime(paneId, {
|
||||
capture: () => captureTerminal(term),
|
||||
getSize: () => ({ cols: term.cols, rows: term.rows }),
|
||||
copySelection,
|
||||
pasteClipboard: () => { void pasteClipboard() },
|
||||
selectAll: () => term.selectAll(),
|
||||
clear: () => term.clear(),
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -324,7 +343,7 @@ function PaneTerminal({
|
||||
|
||||
export default function TerminalWorkspace({ tabId, isActive = true }: TerminalViewProps) {
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { getTabById, updateTab, updateTabConnectionInstance } = useTabStore()
|
||||
const { getTabById, updateTab, updateTabConnectionInstance, closeTab, tabs } = useTabStore()
|
||||
const snapshotStore = useTerminalSnapshotStore()
|
||||
const currentTab = getTabById(tabId)
|
||||
const savedLayout = currentTab?.splitLayout
|
||||
@@ -342,8 +361,17 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
const [direction, setDirection] = useState<'horizontal' | 'vertical'>(savedLayout?.direction || 'horizontal')
|
||||
const [ratio, setRatio] = useState(savedLayout?.ratio ?? 0.5)
|
||||
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null)
|
||||
const [broadcastInput, setBroadcastInput] = useState(false)
|
||||
const broadcastInput = useTerminalBroadcastStore((state) => state.enabled)
|
||||
const setBroadcastInput = useTerminalBroadcastStore((state) => state.setEnabled)
|
||||
const registerBroadcastWorkspace = useTerminalBroadcastStore((state) => state.registerWorkspace)
|
||||
const unregisterBroadcastWorkspace = useTerminalBroadcastStore((state) => state.unregisterWorkspace)
|
||||
const broadcastTargetCount = useTerminalBroadcastStore(terminalBroadcastTargetCount)
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
paneId: string
|
||||
position: { x: number; y: number }
|
||||
hasSelection: boolean
|
||||
} | null>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const layoutHydrated = useRef(false)
|
||||
|
||||
@@ -392,6 +420,30 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
}
|
||||
}, [connectionState, shellIdRef])
|
||||
|
||||
// A single global broadcast scope covers every ready SSH shell in every
|
||||
// currently open terminal tab. Local host shells are intentionally excluded.
|
||||
useEffect(() => {
|
||||
const connectionId = connectionIdRef.current
|
||||
const targets = connectionState === 'connected' && connectionId && connectionId !== 'local' && !currentTab?.isLocal
|
||||
? panes.flatMap((pane) => pane.shellId
|
||||
? [{
|
||||
tabId,
|
||||
paneId: pane.id,
|
||||
shellId: pane.shellId,
|
||||
connectionId,
|
||||
label: currentTab?.title || 'SSH 终端',
|
||||
}]
|
||||
: [])
|
||||
: []
|
||||
registerBroadcastWorkspace({ tabId, targets })
|
||||
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
|
||||
|
||||
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
|
||||
|
||||
useEffect(() => {
|
||||
if (broadcastInput && broadcastTargetCount < 2) setBroadcastInput(false)
|
||||
}, [broadcastInput, broadcastTargetCount, setBroadcastInput])
|
||||
|
||||
// Fan-out shell data to panes (SSH + local)
|
||||
useEffect(() => {
|
||||
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
|
||||
@@ -571,17 +623,31 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
|
||||
const handlePaneInput = useCallback(
|
||||
(sourcePaneId: string, data: string) => {
|
||||
const targets = resolveTerminalInputTargets(panesRef.current, sourcePaneId, broadcastInput)
|
||||
for (const targetShellId of targets) {
|
||||
const shouldBroadcast = shouldBroadcastTerminalInput(
|
||||
broadcastInput,
|
||||
currentTab?.isLocal,
|
||||
connectionIdRef.current
|
||||
)
|
||||
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets() : []
|
||||
const targets = shouldBroadcast
|
||||
? broadcastTargets.map((target) => target.shellId)
|
||||
: resolveTerminalInputTargets(panesRef.current, sourcePaneId, false)
|
||||
for (const targetShellId of new Set(targets)) {
|
||||
void shellWrite(targetShellId, data)
|
||||
}
|
||||
|
||||
const connectionId = connectionIdRef.current
|
||||
if (targets.length > 0 && connectionId && connectionId !== 'local') {
|
||||
getConnectionStore().updateActivity(connectionId)
|
||||
if (shouldBroadcast) {
|
||||
for (const connectionId of new Set(broadcastTargets.map((target) => target.connectionId))) {
|
||||
getConnectionStore().updateActivity(connectionId)
|
||||
}
|
||||
} else {
|
||||
const connectionId = connectionIdRef.current
|
||||
if (targets.length > 0 && connectionId && connectionId !== 'local') {
|
||||
getConnectionStore().updateActivity(connectionId)
|
||||
}
|
||||
}
|
||||
},
|
||||
[broadcastInput, connectionIdRef]
|
||||
[broadcastInput, connectionIdRef, currentTab?.isLocal]
|
||||
)
|
||||
|
||||
const split = useCallback(
|
||||
@@ -614,27 +680,92 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
|
||||
}, [isActive, split])
|
||||
|
||||
const closePane = (paneId: string) => {
|
||||
if (panes.length <= 1) return
|
||||
const pane = panes.find((item) => item.id === paneId)
|
||||
if (currentTab?.isLocal && pane?.shellId) void shellKill(pane.shellId)
|
||||
const closePane = useCallback((paneId: string) => {
|
||||
if (panesRef.current.length <= 1) return
|
||||
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||
const connectionId = connectionIdRef.current
|
||||
if (pane?.shellId) {
|
||||
const closingShellId = pane.shellId
|
||||
void shellKill(closingShellId)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
logger.warn('关闭分屏 Shell 失败', {
|
||||
shellId: closingShellId,
|
||||
error: result.error || 'unknown',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => logger.warn('关闭分屏 Shell 失败', { shellId: closingShellId, error }))
|
||||
if (connectionId && connectionId !== 'local') {
|
||||
getConnectionStore().removeShell(connectionId, pane.shellId)
|
||||
}
|
||||
}
|
||||
if (maximizedPaneId === paneId) setMaximizedPaneId(null)
|
||||
setPanes((prev) => {
|
||||
const next = prev.filter((p) => p.id !== paneId)
|
||||
if (!next.find((p) => p.id === focusedPaneId) && next[0]) {
|
||||
setFocusedPaneId(next[0].id)
|
||||
if (next[0].shellId) shellIdRef.current = next[0].shellId
|
||||
if (next[0].shellId) {
|
||||
shellIdRef.current = next[0].shellId
|
||||
if (connectionId) updateTabConnectionInstance(tabId, { connectionId, shellId: next[0].shellId })
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [connectionIdRef, focusedPaneId, maximizedPaneId, shellIdRef, tabId, updateTabConnectionInstance])
|
||||
|
||||
const closePaneOrTab = useCallback((paneId: string) => {
|
||||
if (panesRef.current.length > 1) {
|
||||
closePane(paneId)
|
||||
return
|
||||
}
|
||||
if (tabs.length <= 1) return
|
||||
|
||||
const connectionId = connectionIdRef.current
|
||||
const paneShells = panesRef.current.flatMap((pane) => pane.shellId ? [pane.shellId] : [])
|
||||
void (async () => {
|
||||
for (const paneShellId of paneShells) {
|
||||
const result = await shellKill(paneShellId).catch((error) => ({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}))
|
||||
if (!result.success) {
|
||||
logger.warn('关闭标签 Shell 失败', {
|
||||
shellId: paneShellId,
|
||||
error: result.error || 'unknown',
|
||||
})
|
||||
}
|
||||
if (connectionId && connectionId !== 'local') getConnectionStore().removeShell(connectionId, paneShellId)
|
||||
}
|
||||
if (connectionId && connectionId !== 'local') {
|
||||
await getConnectionStore().releaseConnection(connectionId, tabId)
|
||||
}
|
||||
closeTab(tabId)
|
||||
})()
|
||||
}, [closePane, closeTab, connectionIdRef, tabId, tabs.length])
|
||||
|
||||
const openTerminalContextMenu = useCallback((
|
||||
paneId: string,
|
||||
position: { x: number; y: number },
|
||||
hasSelection: boolean
|
||||
) => {
|
||||
setFocusedPaneId(paneId)
|
||||
const pane = panesRef.current.find((item) => item.id === paneId)
|
||||
if (pane?.shellId && connectionIdRef.current) {
|
||||
shellIdRef.current = pane.shellId
|
||||
updateTabConnectionInstance(tabId, {
|
||||
connectionId: connectionIdRef.current,
|
||||
shellId: pane.shellId,
|
||||
})
|
||||
}
|
||||
setContextMenu({ paneId, position, hasSelection })
|
||||
}, [connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance])
|
||||
|
||||
useEffect(() => {
|
||||
if (panes.length <= 1 && broadcastInput) setBroadcastInput(false)
|
||||
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
|
||||
setMaximizedPaneId(null)
|
||||
}
|
||||
}, [broadcastInput, maximizedPaneId, panes])
|
||||
}, [maximizedPaneId, panes])
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
@@ -745,11 +876,13 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
<div className="bg-dark-600/70 mx-1 h-4 w-px" />
|
||||
<button
|
||||
type="button"
|
||||
title={broadcastInput ? '广播输入已开启:键盘输入将发送到所有分屏' : '广播输入到所有分屏'}
|
||||
title={broadcastInput
|
||||
? `广播已开启:键盘输入将发送到全部 ${broadcastTargetCount} 个已连接 SSH 终端`
|
||||
: `广播输入到所有打开的 SSH 终端(${broadcastTargetCount})`}
|
||||
aria-label="广播输入"
|
||||
aria-pressed={broadcastInput}
|
||||
disabled={!multi || connectionState !== 'connected'}
|
||||
onClick={() => setBroadcastInput((enabled) => !enabled)}
|
||||
disabled={isLocal || connectionState !== 'connected' || broadcastTargetCount < 2}
|
||||
onClick={() => setBroadcastInput(!broadcastInput)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
|
||||
broadcastInput
|
||||
? 'bg-accent-orange/15 text-accent-orange'
|
||||
@@ -771,7 +904,11 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> AI 助手
|
||||
</button>
|
||||
{broadcastInput && <div className="ui-chip border-accent-orange/30 text-accent-orange">广播中</div>}
|
||||
{broadcastInput && (
|
||||
<div className="ui-chip border-accent-orange/30 text-accent-orange">
|
||||
广播中 · {broadcastTargetCount}
|
||||
</div>
|
||||
)}
|
||||
<div className="ui-chip tabular-nums">
|
||||
<Square className="h-2.5 w-2.5" />
|
||||
{panes.length}/{MAX_PANES}
|
||||
@@ -801,6 +938,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
isFocused={focusedPaneId === pane.id}
|
||||
isActive={isActive}
|
||||
onInput={handlePaneInput}
|
||||
onContextMenu={openTerminalContextMenu}
|
||||
onFocus={() => {
|
||||
setFocusedPaneId(pane.id)
|
||||
if (pane.shellId && connectionIdRef.current) {
|
||||
@@ -865,6 +1003,26 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<TerminalContextMenu
|
||||
position={contextMenu.position}
|
||||
hasSelection={contextMenu.hasSelection}
|
||||
canSplit={connectionState === 'connected' && panes.length < MAX_PANES}
|
||||
canClosePane={panes.length > 1}
|
||||
canCloseTerminal={panes.length > 1 || tabs.length > 1}
|
||||
closeLabel={panes.length > 1 ? '关闭当前分屏' : '关闭当前终端标签'}
|
||||
maximized={maximizedPaneId === contextMenu.paneId}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onCopy={() => paneRuntimesRef.current.get(contextMenu.paneId)?.copySelection()}
|
||||
onPaste={() => paneRuntimesRef.current.get(contextMenu.paneId)?.pasteClipboard()}
|
||||
onSelectAll={() => paneRuntimesRef.current.get(contextMenu.paneId)?.selectAll()}
|
||||
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
|
||||
onSplitRight={() => split('horizontal')}
|
||||
onSplitDown={() => split('vertical')}
|
||||
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
|
||||
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user