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:
Logan.LiandCursor committed 2026-08-05 19:53:39 +08:00
1 parent 494ef6b9d0
commit 9ce71795b8
38 files changed
+1437 -186

No files matched your search

+186 -60
View File
@@ -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 () => {