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
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user