chore: 保存 Agent 安全优化前基线

This commit is contained in:
Logan.Li committed 2026-09-02 23:31:38 +08:00
1 parent 08696896f9
commit ff016347cd
116 files changed
+3858 -2485

No files matched your search

+126 -23
View File
@@ -7,9 +7,11 @@ import {
Loader2,
Plus,
Send,
ShieldAlert,
Sparkles,
Square,
TerminalSquare,
TextSelect,
X,
} from 'lucide-react'
import { useAiProviderStore } from '../../stores/aiProviderStore'
@@ -25,6 +27,7 @@ import {
type AgentSafetyMode,
} from '../../utils/agentCommandRisk'
import { useConfirmDialog } from '../common/ConfirmDialog'
import Button from '../common/Button'
import { useSettingsStore } from '../../stores/settingsStore'
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
import i18n from '../../i18n'
@@ -41,6 +44,8 @@ interface AiTerminalPanelProps {
connectionId?: string | null
targetLabel?: string
isLocal?: boolean
/** 将命令文本写入当前焦点终端输入行(不带回车);返回 false 表示当前不可写。 */
insertToTerminal?: (text: string) => boolean
}
interface AgentStep {
@@ -146,12 +151,12 @@ export default function AiTerminalPanel({
connectionId = null,
targetLabel = '当前 SSH 会话',
isLocal = false,
insertToTerminal,
}: AiTerminalPanelProps) {
const { t } = useTranslation('ai')
const store = useAiProviderStore()
const safetyMode = normalizeAgentSafetyMode(
useSettingsStore((state) => state.settings.agentSafetyMode)
)
const safetyMode = normalizeAgentSafetyMode(useSettingsStore((state) => state.settings.agentSafetyMode))
const confirmAllCommands = useSettingsStore((state) => state.settings.agentConfirmAllCommands)
const setSetting = useSettingsStore((state) => state.setSetting)
const [prompt, setPrompt] = useState('')
const [messages, setMessages] = useState<ThreadMessage[]>([])
@@ -231,6 +236,7 @@ export default function AiTerminalPanel({
setRunning(true)
setLiveSteps([])
const runSafetyMode = safetyMode
const runConfirmAll = confirmAllCommands
const completedSteps: AgentStep[] = []
const commandCounts = new Map<string, number>()
@@ -293,7 +299,7 @@ export default function AiTerminalPanel({
throw new Error(i18n.t('ai:error.criticalBlocked', { command }))
}
const proposed: AgentStep = { round, command, risk, status: 'running' }
if (agentCommandNeedsConfirmation(risk, runSafetyMode)) {
if (agentCommandNeedsConfirmation(risk, runSafetyMode, runConfirmAll)) {
const fingerprint = agentCommandAllowFingerprint(command, risk)
if (!sessionAllowRef.current.has(fingerprint)) {
const awaiting = { ...proposed, status: 'awaiting' as const }
@@ -308,11 +314,33 @@ export default function AiTerminalPanel({
danger: risk === 'high',
})
if (runIdRef.current !== runId) return
if (choice === 'insert') {
const inserted = insertToTerminal?.(command) || false
if (!inserted) {
await writeClipboardText(command)
}
setLiveSteps([])
setMessages((prev) => [
...prev,
{
id: messageId(),
role: 'assistant',
content: inserted ? t('step.inserted', { round }) : t('step.insertedClipboard', { round }),
steps: [...completedSteps],
},
])
return
}
if (choice === 'reject') {
setLiveSteps([])
setMessages((prev) => [
...prev,
{ id: messageId(), role: 'assistant', content: t('step.rejected', { round }), steps: [...completedSteps] },
{
id: messageId(),
role: 'assistant',
content: t('step.rejected', { round }),
steps: [...completedSteps],
},
])
return
}
@@ -323,7 +351,7 @@ export default function AiTerminalPanel({
}
setLiveSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command)
const executed = await window.opsxApi.sshExec(connectionId, command, { fromAgent: true })
if (runIdRef.current !== runId) return
if (!executed.success && /已终止/.test(executed.error || '')) {
return
@@ -371,6 +399,50 @@ export default function AiTerminalPanel({
window.setTimeout(() => setCopiedId(''), 1200)
}
/** ④ 对当前终端可见输出做一次只读总结(不进入 Agent 循环、不执行命令)。 */
const summarizeViewport = async () => {
setError('')
if (!provider || !model) {
setError(t('error.noProvider'))
return
}
if (!provider.hasApiKey && provider.authStyle !== 'none') {
setError(t('error.noApiKey', { provider: provider.name }))
return
}
const context = capture().selection.trim() || capture().viewport.trim()
if (!context) {
setError(t('error.noOutput'))
return
}
setMessages((prev) => [...prev, { id: messageId(), role: 'user', content: t('panel.summarizeRequest') }])
const runId = runIdRef.current + 1
runIdRef.current = runId
setRunning(true)
try {
const completion = await window.opsxApi.aiComplete({
provider,
model,
action: 'explain',
prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`,
context: truncateMiddle(context, MAX_TOOL_OUTPUT_CHARS),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
throw new Error(completion.error || i18n.t('ai:error.modelEmpty'))
}
setMessages((prev) => [...prev, { id: messageId(), role: 'assistant', content: completion.text!.trim() }])
} catch (caught) {
const message = translateErrorMessage(caught instanceof Error ? caught.message : String(caught))
setError(message)
} finally {
if (runIdRef.current === runId) {
setRunning(false)
}
}
}
const stopAgent = () => {
runIdRef.current += 1
dismissAgentRisk()
@@ -398,7 +470,7 @@ export default function AiTerminalPanel({
}
return (
<aside className="border-dark-700/50 bg-dark-850 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l">
<aside className="border-dark-700/50 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l bg-dark-850">
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2.5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent-blue" />
@@ -433,6 +505,19 @@ export default function AiTerminalPanel({
</button>
)
})}
<button
type="button"
aria-pressed={confirmAllCommands}
disabled={running}
title={t('safety.confirmAllTitle')}
aria-label={t('safety.confirmAllTitle')}
onClick={() => void setSetting('agentConfirmAllCommands', !confirmAllCommands)}
className={`ml-1 rounded p-1 disabled:opacity-50 ${
confirmAllCommands ? 'text-amber-300' : 'text-txt-faint hover:text-content-secondary'
}`}
>
<ShieldAlert className="h-3.5 w-3.5" />
</button>
</div>
<button
type="button"
@@ -444,7 +529,12 @@ export default function AiTerminalPanel({
>
<Plus className="h-4 w-4" />
</button>
<button type="button" title={t('panel.close')} onClick={onClose} className="rounded p-1 text-txt-muted hover:bg-dark-700">
<button
type="button"
title={t('panel.close')}
onClick={onClose}
className="rounded p-1 text-txt-muted hover:bg-dark-700"
>
<X className="h-4 w-4" />
</button>
</div>
@@ -452,9 +542,7 @@ export default function AiTerminalPanel({
<div ref={timelineRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 py-3">
{messages.length === 0 && liveSteps.length === 0 && !running && (
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">
{t('panel.emptyHint')}
</p>
<p className="px-1 py-8 text-center text-xs leading-relaxed text-txt-faint">{t('panel.emptyHint')}</p>
)}
{messages.map((message) => (
@@ -504,7 +592,10 @@ export default function AiTerminalPanel({
)}
{error && (
<div role="alert" className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300">
<div
role="alert"
className="rounded-lg border border-red-500/25 bg-red-500/10 px-3 py-2 text-xs text-red-300"
>
{error}
</div>
)}
@@ -534,13 +625,29 @@ export default function AiTerminalPanel({
: t('panel.hintSteps', { max: MAX_AGENT_STEPS })}
</span>
{running ? (
<button type="button" onClick={stopAgent} className="ui-btn-secondary flex items-center gap-1.5">
<Square className="h-3.5 w-3.5" /> {t('panel.stop')}
</button>
<Button variant="secondary" size="sm" icon={<Square className="h-3.5 w-3.5" />} onClick={stopAgent}>
{t('panel.stop')}
</Button>
) : (
<button type="button" onClick={() => void runAgent()} className="ui-btn-primary flex items-center gap-1.5">
<Send className="h-3.5 w-3.5" /> {t('panel.send')}
</button>
<>
<Button
variant="ghost"
size="sm"
icon={<TextSelect className="h-3.5 w-3.5" />}
title={t('panel.summarizeTitle')}
onClick={() => void summarizeViewport()}
>
{t('panel.summarize')}
</Button>
<Button
variant="primary"
size="sm"
icon={<Send className="h-3.5 w-3.5" />}
onClick={() => void runAgent()}
>
{t('panel.send')}
</Button>
</>
)}
</div>
<p className="mt-2 text-[10px] text-txt-faint">{t('panel.footerNote')}</p>
@@ -553,11 +660,7 @@ export default function AiTerminalPanel({
function StepCard({ step }: { step: AgentStep }) {
const { t } = useTranslation('ai')
const statusLabel =
step.status === 'awaiting'
? t('step.awaiting')
: step.status === 'running'
? t('step.running')
: t('step.done')
step.status === 'awaiting' ? t('step.awaiting') : step.status === 'running' ? t('step.running') : t('step.done')
return (
<div className="border-dark-700/50 bg-dark-900/45 rounded-lg border p-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-[10px] text-txt-faint">
+49 -65
View File
@@ -8,20 +8,15 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Search,
Globe,
Code2,
LogIn,
Plus,
X,
ChevronRight,
Settings,
Laptop,
PanelLeft,
} from 'lucide-react'
import { Search, Globe, Code2, LogIn, Plus, X, ChevronRight, Settings, Laptop, PanelLeft } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore, useSnippetStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../../stores'
import {
useSessionStore,
useSnippetStore,
useCommandPaletteStore,
useEntitlementStore,
type Session,
} from '../../stores'
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
@@ -62,12 +57,7 @@ const TYPE_BADGE_KEY: Record<CommandPaletteItemType, string> = {
// ─── 组件 ──────────────────────────────────────────────────────────────
export function CommandPalette({
onNewSession,
onQuickConnect,
onOpenSettings,
onEditSession,
}: CommandPaletteProps) {
export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, onEditSession }: CommandPaletteProps) {
const { t } = useTranslation('common')
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -210,9 +200,7 @@ export function CommandPalette({
id: `snippet:${snippet.id}`,
type: 'snippet',
label: snippet.name,
description: snippet.content.length > 60
? snippet.content.slice(0, 60) + '…'
: snippet.content,
description: snippet.content.length > 60 ? snippet.content.slice(0, 60) + '…' : snippet.content,
icon: <Code2 size={16} />,
action: () => {
// 片段插入到当前终端的逻辑(后续可通过 dedicated event 扩展)
@@ -270,15 +258,11 @@ export function CommandPalette({
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) =>
prev < filteredItems.length - 1 ? prev + 1 : 0
)
setSelectedIndex((prev) => (prev < filteredItems.length - 1 ? prev + 1 : 0))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredItems.length - 1
)
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : filteredItems.length - 1))
break
case 'Enter':
e.preventDefault()
@@ -331,40 +315,50 @@ export function CommandPalette({
onKeyDown={handleKeyDown}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" />
<div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" />
{/* Palette container */}
<div
className="relative w-[600px] max-h-[460px] bg-dark-800 border border-dark-700 rounded-lg shadow-2xl overflow-hidden flex flex-col animate-scale-in"
className="relative flex max-h-[460px] w-[600px] animate-scale-in flex-col overflow-hidden rounded-lg border border-dark-700 bg-dark-800 shadow-2xl"
role="dialog"
aria-modal="true"
aria-label={t('palette.aria')}
>
{/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-dark-700">
<Search size={18} className="text-txt-muted flex-shrink-0" />
<div className="flex items-center gap-3 border-b border-dark-700 px-4 py-3">
<Search size={18} className="flex-shrink-0 text-txt-muted" />
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="palette-listbox"
aria-activedescendant={filteredItems.length > 0 ? `palette-item-${selectedIndex}` : undefined}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('palette.placeholder')}
className="flex-1 bg-transparent text-content-primary placeholder-ph outline-none text-sm"
className="flex-1 bg-transparent text-sm text-content-primary placeholder-ph outline-none"
autoComplete="off"
spellCheck={false}
/>
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] text-txt-muted bg-dark-700 rounded border border-dark-600">
<kbd className="hidden items-center gap-0.5 rounded border border-dark-600 bg-dark-700 px-1.5 py-0.5 text-[10px] text-txt-muted sm:inline-flex">
ESC
</kbd>
</div>
{/* Results list */}
<div ref={listRef} className="flex-1 overflow-y-auto py-1">
<div
ref={listRef}
id="palette-listbox"
role="listbox"
aria-label={t('palette.aria')}
className="flex-1 overflow-y-auto py-1"
>
{filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-txt-muted">
<Search size={32} className="mb-2 opacity-30" />
<p className="text-sm">{t('palette.empty')}</p>
<p className="text-xs text-txt-faint mt-1">{t('palette.emptyHint')}</p>
<p className="mt-1 text-xs text-txt-faint">{t('palette.emptyHint')}</p>
</div>
) : (
groupedItems.map((group) => {
@@ -374,13 +368,13 @@ export function CommandPalette({
return (
<div key={group.type}>
{/* Group header */}
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px] font-medium text-txt-muted uppercase tracking-wider">
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px] font-medium uppercase tracking-wider text-txt-muted">
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE_CLASS[group.type]}`}
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold ${TYPE_BADGE_CLASS[group.type]}`}
>
{t(TYPE_BADGE_KEY[group.type])}
</span>
<span className="flex-1 h-px bg-dark-700" />
<span className="h-px flex-1 bg-dark-700" />
<span className="text-txt-faint">{group.items.length}</span>
</div>
@@ -391,14 +385,15 @@ export function CommandPalette({
return (
<div
key={item.id}
id={`palette-item-${globalIndex}`}
role="option"
aria-selected={isSelected}
data-index={globalIndex}
className={`
flex items-center gap-3 px-4 py-2 cursor-pointer transition-colors duration-75
${isSelected
className={`flex cursor-pointer items-center gap-3 px-4 py-2 transition-colors duration-75 ${
isSelected
? 'bg-accent-blue/15 text-content-primary'
: 'text-content-secondary hover:bg-dark-700'
}
`}
} `}
onClick={() => {
item.action()
close()
@@ -407,7 +402,7 @@ export function CommandPalette({
>
{/* Icon */}
<span
className={`flex-shrink-0 w-5 h-5 flex items-center justify-center ${
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center ${
isSelected ? 'text-accent-blue' : 'text-txt-muted'
}`}
>
@@ -415,28 +410,19 @@ export function CommandPalette({
</span>
{/* Label + description */}
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{item.label}</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{item.label}</div>
{item.description && (
<div className="text-[11px] text-txt-muted truncate">
{item.description}
</div>
<div className="truncate text-[11px] text-txt-muted">{item.description}</div>
)}
</div>
{/* Shortcut */}
{item.shortcut && (
<kbd className="flex-shrink-0 text-[11px] text-txt-muted bg-dark-700 px-1.5 py-0.5 rounded border border-dark-600">
<kbd className="flex-shrink-0 rounded border border-dark-600 bg-dark-700 px-1.5 py-0.5 text-[11px] text-txt-muted">
{item.shortcut}
</kbd>
)}
{/* Type badge */}
<span
className={`flex-shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${TYPE_BADGE_CLASS[item.type]}`}
>
{t(TYPE_BADGE_KEY[item.type])}
</span>
</div>
)
})}
@@ -447,24 +433,22 @@ export function CommandPalette({
</div>
{/* Footer hint */}
<div className="flex items-center justify-between px-4 py-2 border-t border-dark-700 text-[11px] text-txt-faint">
<div className="flex items-center justify-between border-t border-dark-700 px-4 py-2 text-[11px] text-txt-faint">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↑↓</kbd>
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">↑↓</kbd>
{t('palette.hint.nav')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">↵</kbd>
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">↵</kbd>
{t('palette.hint.run')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-dark-700 rounded border border-dark-600">esc</kbd>
<kbd className="rounded border border-dark-600 bg-dark-700 px-1 py-0.5">esc</kbd>
{t('palette.hint.close')}
</span>
</div>
<span>
{t('palette.resultCount', { count: filteredItems.length })}
</span>
<span>{t('palette.resultCount', { count: filteredItems.length })}</span>
</div>
</div>
</div>
+7 -8
View File
@@ -31,8 +31,8 @@ export interface ConfirmOptions {
cancelText?: string
}
/** Agent 高风险确认:Reject / Once / Always(Always 仅会话内存) */
export type AgentRiskChoice = 'reject' | 'once' | 'always'
/** Agent 高风险确认:Reject / Insert(只插入不执行)/ Once / Always(Always 仅会话内存) */
export type AgentRiskChoice = 'reject' | 'once' | 'always' | 'insert'
export interface AgentRiskConfirmOptions {
title: string
@@ -155,11 +155,7 @@ export function useConfirmDialog() {
cancelText={state.cancelText}
isDanger={state.danger}
size="sm"
icon={
state.danger ? (
<AlertTriangle className="w-5 h-5 text-red-400" />
) : undefined
}
icon={state.danger ? <AlertTriangle className="h-5 w-5 text-red-400" /> : undefined}
>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-content-secondary">{state.message}</p>
</Modal>
@@ -171,12 +167,15 @@ export function useConfirmDialog() {
showConfirm={false}
size="sm"
disableBackdropClose
icon={<AlertTriangle className="w-5 h-5 text-red-400" />}
icon={<AlertTriangle className="h-5 w-5 text-red-400" />}
footer={
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => resolveAgentRisk('reject')}>
{t('ai:confirm.reject')}
</Button>
<Button variant="secondary" size="sm" onClick={() => resolveAgentRisk('insert')}>
{t('ai:confirm.insert')}
</Button>
<Button
variant={agentRisk.danger ? 'danger' : 'primary'}
size="sm"
+25
View File
@@ -0,0 +1,25 @@
import type { ConnectionState } from '../../../shared/types/connection.types'
const STATUS_DOT: Record<ConnectionState, string> = {
connected: 'bg-feedback-success',
connecting: 'bg-feedback-warning animate-pulse',
reconnecting: 'bg-feedback-warning animate-pulse',
error: 'bg-feedback-error',
suspended: 'bg-surface-active',
disconnected: 'bg-surface-active',
background: 'bg-feedback-success opacity-60',
}
export default function StatusDot({
state,
className = 'h-1.5 w-1.5',
}: {
state: ConnectionState
className?: string
}) {
return (
<span
className={`inline-block flex-shrink-0 rounded-full ${STATUS_DOT[state] ?? 'bg-surface-active'} ${className}`}
/>
)
}
+31 -79
View File
@@ -6,20 +6,13 @@
import { useState, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Wifi,
WifiOff,
RefreshCw,
X,
Clock,
HardDrive,
Activity,
} from 'lucide-react'
import { Wifi, WifiOff, RefreshCw, X, Clock, HardDrive, Activity } from 'lucide-react'
import { useConnectionStore } from '../../stores/connection'
import { usePoolStore } from '../../stores/connection/poolStore'
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
import type { PooledConnection, ConnectionState } from '../../stores/connection/types'
import type { PooledConnection } from '../../stores/connection/types'
import EmptyState from '../common/EmptyState'
import StatusDot from '../common/StatusDot'
// ============== Helpers ==============
@@ -43,30 +36,6 @@ function formatDuration(ms: number): string {
return `${hours}h ${minutes % 60}m`
}
// ============== Status Dot ==============
interface StatusDotProps {
state: ConnectionState
}
const STATUS_DOT_COLORS: Record<ConnectionState, string> = {
connected: 'bg-green-400',
connecting: 'bg-yellow-400 animate-pulse',
background: 'bg-green-400 opacity-60',
suspended: 'bg-surface-active',
error: 'bg-red-400',
disconnected: 'bg-surface-active',
reconnecting: 'bg-yellow-400 animate-pulse',
}
function StatusDot({ state }: StatusDotProps) {
return (
<span
className={`inline-block w-2 h-2 rounded-full ${STATUS_DOT_COLORS[state] ?? 'bg-surface-active'}`}
/>
)
}
// ============== Connection Card ==============
interface ConnectionCardProps {
@@ -79,26 +48,24 @@ interface ConnectionCardProps {
function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: ConnectionCardProps) {
const { t } = useTranslation('common')
const isReconnecting = reconnectingId === conn.connectionId
const canReconnect =
conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
const canDisconnect =
conn.state !== 'disconnected' && conn.state !== 'error'
const canReconnect = conn.state === 'suspended' || conn.state === 'error' || conn.state === 'disconnected'
const canDisconnect = conn.state !== 'disconnected' && conn.state !== 'error'
const durationMs = Date.now() - conn.stats.connectedAt
const sessionCount = conn.shellIds.size + conn.sftpIds.size
return (
<div className="bg-dark-900 rounded-lg p-3 space-y-2.5">
<div className="space-y-2.5 rounded-lg bg-dark-900 p-3">
{/* Header: host + status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<StatusDot state={conn.state} />
<span className="text-sm text-content-primary truncate font-medium">
<div className="flex min-w-0 items-center gap-2">
<StatusDot state={conn.state} className="h-2 w-2" />
<span className="truncate text-sm font-medium text-content-primary">
{conn.config.username}@{conn.config.host}
</span>
</div>
<span
className={`text-xs px-1.5 py-0.5 rounded ${
className={`rounded px-1.5 py-0.5 text-xs ${
conn.state === 'connected'
? 'bg-feedback-success/10 text-feedback-success'
: conn.state === 'connecting' || conn.state === 'reconnecting'
@@ -120,20 +87,19 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-txt-muted">
{/* Port */}
<span className="flex items-center gap-1">
<Activity className="w-3 h-3" />
:{conn.config.port ?? 22}
<Activity className="h-3 w-3" />:{conn.config.port ?? 22}
</span>
{/* Duration */}
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<Clock className="h-3 w-3" />
{formatDuration(durationMs)}
</span>
{/* Transfer */}
{conn.stats.bytesTransferred > 0 && (
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
<HardDrive className="h-3 w-3" />
{formatBytes(conn.stats.bytesTransferred)}
</span>
)}
@@ -156,11 +122,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
type="button"
onClick={() => onReconnect(conn.connectionId)}
disabled={isReconnecting}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
bg-accent-blue/10 text-accent-blue hover:bg-accent-blue/20
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center gap-1 rounded bg-accent-blue/10 px-2 py-1 text-xs text-accent-blue transition-colors hover:bg-accent-blue/20 disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
<RefreshCw className={`h-3 w-3 ${isReconnecting ? 'animate-spin' : ''}`} />
{isReconnecting ? t('connectionPanel.reconnecting') : t('connectionPanel.reconnect')}
</button>
)}
@@ -168,10 +132,9 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
<button
type="button"
onClick={() => onDisconnect(conn.connectionId)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs
bg-red-400/10 text-red-400 hover:bg-red-400/20 transition-colors"
className="flex items-center gap-1 rounded bg-red-400/10 px-2 py-1 text-xs text-red-400 transition-colors hover:bg-red-400/20"
>
<WifiOff className="w-3 h-3" />
<WifiOff className="h-3 w-3" />
{t('connectionPanel.disconnect')}
</button>
)}
@@ -179,7 +142,7 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
{/* Error message */}
{conn.error && (
<p className="text-xs text-red-400 truncate" title={conn.error}>
<p className="truncate text-xs text-red-400" title={conn.error}>
{conn.error}
</p>
)}
@@ -208,7 +171,7 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
return (
<div className="grid grid-cols-4 gap-2">
{items.map((item) => (
<div key={item.label} className="bg-dark-900 rounded-lg p-2 text-center">
<div key={item.label} className="rounded-lg bg-dark-900 p-2 text-center">
<div className={`text-lg font-semibold ${item.color}`}>{item.value}</div>
<div className="text-[10px] text-txt-muted">{item.label}</div>
</div>
@@ -225,11 +188,7 @@ export default function ConnectionPanel() {
const close = useConnectionPanelStore((s) => s.close)
const connections = usePoolStore((s) => s.connections)
const {
resumeConnection,
disconnectConnection,
getStats,
} = useConnectionStore()
const { resumeConnection, disconnectConnection, getStats } = useConnectionStore()
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
@@ -250,14 +209,14 @@ export default function ConnectionPanel() {
setReconnectingId(null)
}
},
[resumeConnection],
[resumeConnection]
)
const handleDisconnect = useCallback(
async (connectionId: string) => {
await disconnectConnection(connectionId)
},
[disconnectConnection],
[disconnectConnection]
)
if (!isOpen) return null
@@ -265,34 +224,27 @@ export default function ConnectionPanel() {
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/30 z-40 animate-fade-in"
onClick={close}
/>
<div className="fixed inset-0 z-40 animate-fade-in bg-black/30" onClick={close} />
{/* Panel */}
<div
className="fixed top-0 right-0 bottom-0 w-[360px] z-50
bg-dark-800/95 backdrop-blur border-l border-dark-700/30
flex flex-col shadow-xl animate-slide-in-right"
>
<div className="bg-dark-800/95 border-dark-700/30 animate-slide-in-right fixed bottom-0 right-0 top-0 z-50 flex w-[360px] flex-col border-l shadow-xl backdrop-blur">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-dark-700/30 shrink-0">
<h2 className="text-sm font-semibold text-content-primary flex items-center gap-2">
<Wifi className="w-4 h-4 text-accent-blue" />
<div className="border-dark-700/30 flex h-12 shrink-0 items-center justify-between border-b px-4">
<h2 className="flex items-center gap-2 text-sm font-semibold text-content-primary">
<Wifi className="h-4 w-4 text-accent-blue" />
{t('connectionPanel.title')}
</h2>
<button
type="button"
onClick={close}
className="p-1 rounded hover:bg-dark-700 text-txt-muted hover:text-content-primary transition-colors"
className="rounded p-1 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</button>
</div>
{/* Stats Overview */}
<div className="px-4 py-3 border-b border-dark-700/30 shrink-0">
<div className="border-dark-700/30 shrink-0 border-b px-4 py-3">
<StatsOverview
total={stats.total}
connected={stats.connected}
@@ -302,7 +254,7 @@ export default function ConnectionPanel() {
</div>
{/* Connection List */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-3">
{connList.length === 0 ? (
<EmptyState
icon={WifiOff}
+11 -36
View File
@@ -1,18 +1,10 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import {
Cast,
ClipboardPaste,
FolderInput,
FolderOpen,
Maximize2,
Minimize2,
Monitor,
RefreshCw,
} from 'lucide-react'
import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
import StatusDot from '../common/StatusDot'
import type {
DesktopFrameEvent,
DesktopPointerButton,
@@ -96,9 +88,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
const sessions = useSessionStore((state) => state.sessions)
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
const fullscreenActive = useDesktopFullscreenStore(
(state) => state.active && state.tabId === tabId
)
const fullscreenActive = useDesktopFullscreenStore((state) => state.active && state.tabId === tabId)
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
@@ -126,18 +116,14 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const local = sessions.find((item) => item.id === tab.sessionId)
if (local) {
if (!cancelled) {
setResolvedConfig(
protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local)
)
setResolvedConfig(protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local))
}
return
}
const response = await window.opsxApi?.sessionGetById?.(tab.sessionId)
if (!cancelled && response?.success && response.session) {
setResolvedConfig(
protocol === 'rdp'
? sessionToRdpConfig(response.session)
: sessionToVncConfig(response.session)
protocol === 'rdp' ? sessionToRdpConfig(response.session) : sessionToVncConfig(response.session)
)
}
}
@@ -548,11 +534,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
event.preventDefault()
const isPasteChord =
pressed &&
(event.metaKey || event.ctrlKey) &&
event.code === 'KeyV' &&
!event.altKey &&
!event.shiftKey
pressed && (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
if (isPasteChord) {
// Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V.
@@ -597,10 +579,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
}
const Icon = protocol === 'rdp' ? Monitor : Cast
const hostLabel =
resolvedConfig && 'host' in resolvedConfig
? resolvedConfig.host
: tab?.title
const hostLabel = resolvedConfig && 'host' in resolvedConfig ? resolvedConfig.host : tab?.title
const connected = tab?.connectionStatus === 'connected'
const toolBtn =
@@ -610,8 +589,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
<div
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
fullscreenActive
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
: 'border-b border-dark-700/50 bg-dark-800/80'
? 'border-dark-700/60 bg-dark-900/95 rounded-b-lg border border-t-0 shadow-lg shadow-black/40 backdrop-blur'
: 'border-dark-700/50 bg-dark-800/80 border-b'
}`}
onMouseEnter={() => {
if (fullscreenActive) {
@@ -636,11 +615,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
{tab?.connectionStatus === 'connecting' ? (
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
) : (
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
}`}
/>
<StatusDot state={connected ? 'connected' : tab?.connectionStatus === 'error' ? 'error' : 'disconnected'} />
)}
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
@@ -739,7 +714,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
<canvas
ref={canvasRef}
tabIndex={0}
className="cursor-default outline-none shadow-lg shadow-black/40"
className="cursor-default shadow-lg shadow-black/40 outline-none"
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => mapPointer(event)}
onMouseDown={(event) => mapPointer(event, true)}
+280 -188
View File
@@ -3,7 +3,8 @@
* 支持宽度拖拽调整
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { Plus, LogIn, Settings, ListChecks, X, TerminalSquare, ChevronsLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
@@ -18,8 +19,11 @@ import { buildOpenTabPayload, isSshSession } from '../../utils/sessionProtocol'
import {
DEFAULT_SESSION_GROUP,
groupDisplayName,
groupSessionsInDisplayOrder,
normalizeGroupId,
} from '../../utils/sessionGroups'
import { localeCompareText } from '../../i18n/format'
import type { SessionReorderEntry } from '../../../shared/types/session.types'
const MIN_SIDEBAR_WIDTH = 180
const MAX_SIDEBAR_WIDTH = 400
@@ -33,12 +37,12 @@ interface SidebarProps {
export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, onCollapse }: SidebarProps) {
const { t } = useTranslation(['sessions', 'common'])
const { sessions, setSessions, addSession, removeSession, updateSession } = useSessionStore()
const { sessions, setSessions, adoptSession, removeSession, updateSession } = useSessionStore()
const { settings, setSetting } = useSettingsStore()
const { addTab } = useTabStore()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const { prompt, PromptDialogComponent } = usePromptDialog()
const [searchTerm, setSearchTerm] = useState('')
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['default']))
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
@@ -54,7 +58,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
const [batchMode, setBatchMode] = useState(false)
const [batchSelection, setBatchSelection] = useState<Set<string>>(new Set())
const requirePro = useEntitlementStore((s) => s.requirePro)
const contextMenuRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null)
@@ -67,7 +71,8 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
// Load sessions
useEffect(() => {
window.opsxApi?.sessionGetAll()
window.opsxApi
?.sessionGetAll()
.then((result) => {
if (result.success && result.sessions) setSessions(result.sessions)
})
@@ -94,36 +99,58 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
}
}, [contextMenu])
// Hug content (`w-max`); keep the compact menu inside the viewport.
useLayoutEffect(() => {
const el = contextMenuRef.current
if (!el || !contextMenu) return
const margin = 8
const width = el.offsetWidth
const height = el.offsetHeight
let left = contextMenu.x
let top = contextMenu.y
if (left + width + margin > window.innerWidth) {
left = Math.max(margin, window.innerWidth - width - margin)
}
if (top + height + margin > window.innerHeight) {
top = Math.max(margin, window.innerHeight - height - margin)
}
el.style.left = `${left}px`
el.style.top = `${top}px`
}, [contextMenu])
// Resize handler
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
const handleResizeStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
const startX = e.clientX
const startWidth = sidebarWidth
const startX = e.clientX
const startWidth = sidebarWidth
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
setSidebarWidth(newWidth)
}
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX
const newWidth = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + delta))
setSidebarWidth(newWidth)
}
const handleMouseUp = () => {
setIsResizing(false)
document.body.style.userSelect = ''
document.body.style.cursor = ''
// Save to settings on drag end
setSetting('sidebarWidth', sidebarWidth)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const handleMouseUp = () => {
setIsResizing(false)
document.body.style.userSelect = ''
document.body.style.cursor = ''
// Save to settings on drag end
setSetting('sidebarWidth', sidebarWidth)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// Prevent text selection during drag
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [sidebarWidth, setSetting])
// Prevent text selection during drag
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
},
[sidebarWidth, setSetting]
)
// Save final width on unmount or when resizing ends (we save in handleMouseUp)
useEffect(() => {
@@ -171,16 +198,16 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
}, [])
/** Batch mode only lists SSH targets (RDP / VNC are unsupported). */
const batchableSessions = useMemo(
() => sessions.filter((session) => isSshSession(session)),
[sessions]
)
const batchableSessions = useMemo(() => sessions.filter((session) => isSshSession(session)), [sessions])
const visibleSessions = batchableSessions.filter((session) => {
const query = searchTerm.trim().toLowerCase()
return !query || session.name.toLowerCase().includes(query) ||
return (
!query ||
session.name.toLowerCase().includes(query) ||
session.host.toLowerCase().includes(query) ||
session.username?.toLowerCase().includes(query)
)
})
const toggleSelectVisible = useCallback(() => {
@@ -244,9 +271,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
const persistSessionGroups = useCallback(
async (next: string[]) => {
const cleaned = next
.map((item) => normalizeGroupId(item))
.filter((item) => item !== DEFAULT_SESSION_GROUP)
const cleaned = next.map((item) => normalizeGroupId(item)).filter((item) => item !== DEFAULT_SESSION_GROUP)
const unique = Array.from(new Set(cleaned))
await setSetting('sessionGroups', unique)
},
@@ -317,6 +342,71 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
[confirm, persistSessionGroups, sessions, settings.sessionGroups, t]
)
/**
* Persist a batch of sidebar order entries optimistically. On a failed write,
* reload from storage so the tree reflects the real persisted order.
*/
const applyReorder = useCallback(
async (entries: SessionReorderEntry[]) => {
const all = useSessionStore.getState().sessions
const byId = new Map(entries.map((entry) => [entry.id, entry]))
setSessions(
all.map((session) => {
const entry = byId.get(session.id)
if (!entry) return session
return {
...session,
sortOrder: entry.sortOrder,
...(entry.group !== undefined ? { group: entry.group === null ? undefined : entry.group } : {}),
}
})
)
const result = await window.opsxApi?.sessionReorder(entries)
if (result && !result.success) {
const fresh = await window.opsxApi?.sessionGetAll()
if (fresh?.success && fresh.sessions) setSessions(fresh.sessions)
}
},
[setSessions]
)
/** Drag commit: place `dragId` at `targetIndex` (display order) of `targetGroup`. */
const handleReorderSessions = useCallback(
(dragId: string, targetGroup: string, targetIndex: number) => {
const all = useSessionStore.getState().sessions
const dragged = all.find((session) => session.id === dragId)
if (!dragged) return
const members = groupSessionsInDisplayOrder(all, targetGroup).filter((session) => session.id !== dragId)
const index = Math.max(0, Math.min(targetIndex, members.length))
members.splice(index, 0, dragged)
const nextGroup = targetGroup === DEFAULT_SESSION_GROUP ? null : targetGroup
const changedGroup = (dragged.group || DEFAULT_SESSION_GROUP) !== targetGroup
// Skip the write when the drop didn't change the visible order.
const visibleIds = (list: Session[]) => list.map((session) => session.id).join('\u0000')
if (!changedGroup && visibleIds(members) === visibleIds(groupSessionsInDisplayOrder(all, targetGroup))) {
return
}
const entries: SessionReorderEntry[] = members.map((session, order) => ({
id: session.id,
sortOrder: order,
...(session.id === dragId && changedGroup ? { group: nextGroup } : {}),
}))
void applyReorder(entries)
},
[applyReorder]
)
/** One-shot alphabetical rewrite of one group's manual order. */
const sortGroupByName = useCallback(
(group: string) => {
const all = useSessionStore.getState().sessions
const members = groupSessionsInDisplayOrder(all, group).sort((a, b) => localeCompareText(a.name, b.name))
if (members.length < 2) return
void applyReorder(members.map((session, order) => ({ id: session.id, sortOrder: order })))
},
[applyReorder]
)
const handleContextMenuAction = async (action: string) => {
if (!contextMenu) return
@@ -331,6 +421,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
const group = contextMenu.group
if (action === 'new-session') onNewSession(group)
if (action === 'new-group') await createGroup()
if (action === 'sort-by-name') sortGroupByName(group)
if (action === 'rename-group') await renameGroup(group)
if (action === 'delete-group') await deleteEmptyGroup(group)
setContextMenu(null)
@@ -350,14 +441,16 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
break
case 'duplicate': {
const { id: _id, ...sessionWithoutId } = session
window.opsxApi?.sessionCreate({
...sessionWithoutId,
name: `${session.name} (${t('common:action.clone')})`,
}).then((result) => {
if (result?.success && result.session) {
addSession(result.session as Session)
}
})
window.opsxApi
?.sessionCreate({
...sessionWithoutId,
name: `${session.name} (${t('common:action.clone')})`,
})
.then((result) => {
if (result?.success && result.session) {
adoptSession(result.session as Session)
}
})
break
}
case 'delete': {
@@ -379,79 +472,76 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
return (
<>
<div
ref={sidebarRef}
className="relative flex flex-col h-full bg-dark-800/95 border-r border-dark-700/40"
style={{ width: sidebarWidth }}
>
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
{/* Header */}
<div className="p-3.5 border-b border-dark-700/30">
<div className="flex items-center gap-2.5 mb-3.5 px-0.5">
<img
src={appIconUrl}
alt="OpsXShell"
width={32}
height={32}
className="h-8 w-8 flex-shrink-0 rounded-[9px] object-cover"
draggable={false}
/>
<div className="min-w-0 flex-1">
<div className="font-semibold text-sm text-content-primary tracking-tight leading-none">
OpsXShell
<div
ref={sidebarRef}
className="bg-dark-800/95 border-dark-700/40 relative flex h-full flex-col border-r"
style={{ width: sidebarWidth }}
>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="border-dark-700/30 border-b p-3.5">
<div className="mb-3.5 flex items-center gap-2.5 px-0.5">
<img
src={appIconUrl}
alt="OpsXShell"
width={32}
height={32}
className="h-8 w-8 flex-shrink-0 rounded-[9px] object-cover"
draggable={false}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold leading-none tracking-tight text-content-primary">OpsXShell</div>
<div className="mt-1 text-[10px] text-txt-faint">{t('common:sidebar.brandSub')}</div>
</div>
<div className="text-[10px] text-txt-faint mt-1">{t('common:sidebar.brandSub')}</div>
{onCollapse && (
<button
type="button"
onClick={onCollapse}
title={t('common:sidebar.collapse')}
aria-label={t('common:sidebar.collapseAria')}
className="hover:bg-dark-700/70 group flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-txt-faint transition-colors hover:text-txt-muted"
>
<ChevronsLeft
className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100"
strokeWidth={1.75}
/>
</button>
)}
</div>
{onCollapse && (
<div className="flex gap-1.5">
<button
type="button"
onClick={onCollapse}
title={t('common:sidebar.collapse')}
aria-label={t('common:sidebar.collapseAria')}
className="group flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-txt-faint transition-colors hover:bg-dark-700/70 hover:text-txt-muted"
onClick={onQuickConnect}
title={t('sidebar.quickConnect')}
className="border-dark-600/60 flex flex-1 items-center justify-center gap-1.5 rounded-lg border bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-blue/40 hover:bg-dark-600"
>
<ChevronsLeft className="h-3.5 w-3.5 opacity-70 transition-opacity group-hover:opacity-100" strokeWidth={1.75} />
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
{t('sidebar.quickConnectShort')}
</button>
)}
<button
type="button"
onClick={() => onNewSession()}
title={t('sidebar.newSession')}
className="border-dark-600/60 flex flex-1 items-center justify-center gap-1.5 rounded-lg border bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
{t('sidebar.newSessionShort')}
</button>
</div>
</div>
<div className="flex gap-1.5">
<button
type="button"
onClick={onQuickConnect}
title={t('sidebar.quickConnect')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-blue/40 hover:bg-dark-600"
>
<LogIn className="h-3.5 w-3.5 text-accent-blue" />
{t('sidebar.quickConnectShort')}
</button>
<button
type="button"
onClick={() => onNewSession()}
title={t('sidebar.newSession')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
{t('sidebar.newSessionShort')}
</button>
</div>
</div>
<>
<div className="p-3 border-b border-dark-700/20 space-y-2">
<>
<div className="border-dark-700/20 space-y-2 border-b p-3">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<SearchBox
value={searchTerm}
onChange={setSearchTerm}
placeholder={t('sidebar.searchPlaceholder')}
/>
<SearchBox value={searchTerm} onChange={setSearchTerm} placeholder={t('sidebar.searchPlaceholder')} />
</div>
<button
type="button"
title={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
aria-label={batchMode ? t('sidebar.exitBatchMode') : t('sidebar.batchMode')}
onClick={() => batchMode ? finishBatchMode() : setBatchMode(true)}
onClick={() => (batchMode ? finishBatchMode() : setBatchMode(true))}
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded border transition-colors ${
batchMode
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
@@ -463,11 +553,15 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
</div>
{batchMode && (
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="text-txt-muted truncate">
<span className="truncate text-txt-muted">
{t('sidebar.batchSelected', { count: batchSelection.size })}
{sessions.length !== batchableSessions.length ? t('sidebar.batchExcludedDesktop') : ''}
</span>
<button type="button" onClick={toggleSelectVisible} className="flex-shrink-0 text-accent-blue hover:underline">
<button
type="button"
onClick={toggleSelectVisible}
className="flex-shrink-0 text-accent-blue hover:underline"
>
{visibleSessions.length > 0 && visibleSessions.every((s) => batchSelection.has(s.id))
? t('sidebar.deselectAllVisible')
: t('sidebar.selectAllVisible')}
@@ -486,6 +580,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
onContextMenu={handleContextMenu}
onGroupContextMenu={handleGroupContextMenu}
onBlankContextMenu={handleBlankContextMenu}
onReorderSessions={handleReorderSessions}
searchTerm={searchTerm}
loading={sessionsLoading}
onNewSession={() => onNewSession()}
@@ -494,7 +589,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
onToggleSelection={toggleBatchSelection}
/>
{batchMode && (
<div className="grid grid-cols-1 gap-2 border-t border-dark-700/30 p-3">
<div className="border-dark-700/30 grid grid-cols-1 gap-2 border-t p-3">
<button
type="button"
disabled={selectedSessions.length === 0}
@@ -506,91 +601,88 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
</button>
</div>
)}
</>
</>
{/* Footer */}
<div className="p-3 border-t border-dark-700/20">
<button
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-txt-muted hover:bg-dark-700 rounded transition-colors"
>
<Settings className="w-4 h-4" />
<span>{t('sidebar.settings')}</span>
</button>
{/* Footer */}
<div className="border-dark-700/20 border-t p-3">
<button
onClick={() => addTab({ title: t('common:tab.settings'), type: 'settings' })}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm text-txt-muted transition-colors hover:bg-dark-700"
>
<Settings className="h-4 w-4" />
<span>{t('sidebar.settings')}</span>
</button>
</div>
{/* Context Menu — portaled so sidebar overflow cannot stretch/clip it */}
{contextMenu &&
createPortal(
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-[200] w-max rounded-md border border-dark-700 bg-dark-800 py-0.5 shadow-xl"
onPointerDown={(e) => e.stopPropagation()}
>
{(contextMenu.kind === 'blank'
? [
{ label: t('contextMenu.blank.newGroup'), action: 'new-group' },
{ label: t('contextMenu.blank.newSession'), action: 'new-session' },
]
: contextMenu.kind === 'group'
? [
{ label: t('contextMenu.group.newSessionHere'), action: 'new-session' },
{ label: t('contextMenu.group.newGroup'), action: 'new-group' },
{ label: t('contextMenu.group.sortByName'), action: 'sort-by-name' },
...(contextMenu.group !== DEFAULT_SESSION_GROUP
? [
{ label: t('contextMenu.group.renameGroup'), action: 'rename-group' },
{
label: t('contextMenu.group.deleteEmptyGroup'),
action: 'delete-group',
danger: true,
},
]
: []),
]
: [
{ label: t('contextMenu.session.open'), action: 'connect' },
...(isSshSession(contextMenu.session)
? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }]
: []),
{ label: t('contextMenu.session.edit'), action: 'edit' },
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
]
).map((item) => (
<button
key={item.action}
type="button"
onClick={() => handleContextMenuAction(item.action)}
className={`block w-full whitespace-nowrap px-2.5 py-1.5 text-left text-xs transition-colors ${
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-content-secondary hover:bg-dark-700'
}`}
>
{item.label}
</button>
))}
</div>,
document.body
)}
</div>
{/* Context Menu */}
{contextMenu && (
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
onPointerDown={(e) => e.stopPropagation()}
>
{(contextMenu.kind === 'blank'
? [
{ label: t('contextMenu.blank.newGroup'), action: 'new-group' },
{ label: t('contextMenu.blank.newSession'), action: 'new-session' },
]
: contextMenu.kind === 'group'
? [
{ label: t('contextMenu.group.newSessionHere'), action: 'new-session' },
{ label: t('contextMenu.group.newGroup'), action: 'new-group' },
...(contextMenu.group !== DEFAULT_SESSION_GROUP
? [
{ label: t('contextMenu.group.renameGroup'), action: 'rename-group' },
{ label: t('contextMenu.group.deleteEmptyGroup'), action: 'delete-group', danger: true },
]
: []),
]
: [
{ label: t('contextMenu.session.open'), action: 'connect' },
...(isSshSession(contextMenu.session)
? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }]
: []),
{ label: t('contextMenu.session.edit'), action: 'edit' },
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
]
).map((item) => (
<button
key={item.action}
onClick={() => handleContextMenuAction(item.action)}
className={`
w-full px-4 py-2 text-left text-sm transition-colors
${
item.danger
? 'text-red-400 hover:bg-red-500/20'
: 'text-content-secondary hover:bg-dark-700'
}
`}
>
{item.label}
</button>
))}
</div>
)}
{/* Resize Handle — 双击也可折叠 */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={(e) => {
e.preventDefault()
onCollapse?.()
}}
title={t('sidebar.resizeHint')}
className={`absolute bottom-0 right-0 top-0 w-1 cursor-col-resize transition-all duration-150 hover:w-1.5 hover:bg-accent-blue/50 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)] active:bg-accent-blue/70 ${isResizing ? 'w-1.5 bg-accent-blue/70 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''} `}
/>
</div>
{/* Resize Handle — 双击也可折叠 */}
<div
onMouseDown={handleResizeStart}
onDoubleClick={(e) => {
e.preventDefault()
onCollapse?.()
}}
title={t('sidebar.resizeHint')}
className={`
absolute top-0 right-0 bottom-0 w-1 cursor-col-resize
hover:bg-accent-blue/50 active:bg-accent-blue/70
hover:w-1.5 hover:shadow-[0_0_6px_rgba(59,130,246,0.4)]
transition-all duration-150
${isResizing ? 'bg-accent-blue/70 w-1.5 shadow-[0_0_6px_rgba(59,130,246,0.5)]' : ''}
`}
/>
</div>
{ConfirmDialogComponent}
{PromptDialogComponent}
{ConfirmDialogComponent}
{PromptDialogComponent}
</>
)
}
+21 -12
View File
@@ -22,13 +22,7 @@ interface TabBarProps {
function TabBar({ onOpenLauncher }: TabBarProps) {
const { t } = useTranslation('common')
const {
tabs,
activeTabId,
activateTab,
addTab,
moveTab,
} = useTabStore()
const { tabs, activeTabId, activateTab, addTab, moveTab, updateTabTitle } = useTabStore()
// Drag state
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
@@ -115,16 +109,28 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null
return (
<div
data-tauri-drag-region
className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b"
>
<div data-tauri-drag-region className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b">
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
<div
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
role="tablist"
aria-label={t('tab.listAria')}
onKeyDown={(event) => {
if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return
const index = tabs.findIndex((tab) => tab.id === activeTabId)
if (index < 0 || tabs.length === 0) return
event.preventDefault()
let next = index
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
if (event.key === 'Home') next = 0
if (event.key === 'End') next = tabs.length - 1
activateTab(tabs[next].id)
requestAnimationFrame(() => {
document.querySelector<HTMLElement>('[data-tab-item][aria-selected="true"]')?.focus()
})
}}
>
{tabs.map((tab, index) => (
<TabItem
@@ -144,6 +150,9 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onRename={
tab.type !== 'welcome' && tab.type !== 'settings' ? (title) => updateTabTitle(tab.id, title) : undefined
}
/>
))}
</div>
@@ -151,7 +160,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
<button
type="button"
onClick={onOpenLauncher}
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-dark-600 text-white transition-colors hover:bg-accent-blue"
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-surface-overlay text-content-secondary transition-colors hover:bg-accent-blue hover:text-white"
title={t('launcher.title')}
aria-label={t('launcher.title')}
>
+9 -15
View File
@@ -17,15 +17,10 @@ interface TabContentProps {
onQuickConnect?: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
onImportSessions?: () => void
}
function ProGate({
feature,
children,
}: {
feature: ProFeature
children: React.ReactNode
}) {
function ProGate({ feature, children }: { feature: ProFeature; children: React.ReactNode }) {
const canUse = useEntitlementStore((s) => s.canUse(feature))
if (!canUse) {
return <UpgradeView feature={feature} />
@@ -39,11 +34,12 @@ function TabContent({
onQuickConnect,
onRdpConnect,
onVncConnect,
onImportSessions,
}: TabContentProps) {
const tabs = useTabStore((state) => state.tabs)
return (
<div className="relative w-full h-full">
<div className="relative h-full w-full">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId
@@ -89,10 +85,7 @@ function TabContent({
break
case 'upgrade':
content = (
<UpgradeView
feature={tab.upgradeFeature as ProFeature | undefined}
comingSoon={tab.upgradeComingSoon}
/>
<UpgradeView feature={tab.upgradeFeature as ProFeature | undefined} comingSoon={tab.upgradeComingSoon} />
)
break
case 'batch':
@@ -110,6 +103,7 @@ function TabContent({
onQuickConnect={onQuickConnect}
onRdpConnect={onRdpConnect}
onVncConnect={onVncConnect}
onImportSessions={onImportSessions}
/>
)
}
@@ -117,12 +111,12 @@ function TabContent({
return (
<div
key={tab.id}
id={`tabpanel-${tab.id}`}
role="tabpanel"
aria-hidden={!isActive}
{...(!isActive ? { inert: '' } : {})}
className={`absolute inset-0 transition-opacity duration-150 ${
isActive
? 'visible opacity-100 z-10'
: 'invisible opacity-0 z-0 pointer-events-none'
isActive ? 'visible z-10 opacity-100' : 'pointer-events-none invisible z-0 opacity-0'
}`}
>
{content}
+265 -33
View File
@@ -1,17 +1,25 @@
/**
* SessionTree — 分组会话列表
* 单击选中 · 双击打开终端 · 右键更多操作
* 单击选中 · 双击打开终端 · 右键更多操作 · 拖拽调整组内顺序或移动分组
*
* macOS WKWebView 的 HTML5 drop 不可靠(SFTP 已改指针拖),侧栏同样用 pointer 拖拽。
*/
import { Check, ChevronRight, Folder, ServerOff } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { SessionTreeSkeleton } from '../../common/Skeleton'
import HostDistroIcon from '../../common/HostDistroIcon'
import type { Session } from '../../../stores/sessionStore'
import EmptyState from '../../common/EmptyState'
import { collectSessionGroups, groupDisplayName } from '../../../utils/sessionGroups'
import { collectSessionGroups, compareBySortOrder, groupDisplayName } from '../../../utils/sessionGroups'
import { localeCompareText } from '../../../i18n/format'
const POINTER_DRAG_THRESHOLD = 6
const AUTO_EXPAND_MS = 400
const DRAG_BODY_CLASS = 'session-tree-dragging'
interface SessionTreeProps {
sessions: Session[]
/** Persisted empty groups + any extras to always show. */
@@ -24,6 +32,8 @@ interface SessionTreeProps {
onContextMenu: (e: React.MouseEvent, session: Session) => void
onGroupContextMenu?: (e: React.MouseEvent, group: string) => void
onBlankContextMenu?: (e: React.MouseEvent) => void
/** Commit a drag: place `dragId` at `targetIndex` (display order) of `targetGroup`. */
onReorderSessions: (dragId: string, targetGroup: string, targetIndex: number) => void
searchTerm: string
loading?: boolean
onNewSession?: () => void
@@ -32,6 +42,68 @@ interface SessionTreeProps {
onToggleSelection?: (session: Session) => void
}
interface DropTarget {
group: string
index: number
}
interface PointerDragState {
pointerId: number
sessionId: string
sessionName: string
startX: number
startY: number
active: boolean
}
function insertIndexFromY(clientY: number, row: HTMLElement): number {
const rect = row.getBoundingClientRect()
return clientY < rect.top + rect.height / 2 ? 0 : 1
}
/** Skip the insert line when the drop would leave the row where it already is. */
function quietIfNoOp(
group: string,
insertAt: number,
dragId: string,
grouped: Record<string, Session[]>
): DropTarget | null {
const list = grouped[group] || []
const dragIndex = list.findIndex((session) => session.id === dragId)
if (dragIndex >= 0 && (insertAt === dragIndex || insertAt === dragIndex + 1)) {
return null
}
return { group, index: insertAt }
}
export function resolveSessionDropTarget(
clientX: number,
clientY: number,
dragId: string,
grouped: Record<string, Session[]>
): DropTarget | null {
const hit = document.elementFromPoint?.(clientX, clientY)
if (!hit || !(hit instanceof Element)) return null
const row = hit.closest('[data-session-row]') as HTMLElement | null
if (row?.dataset.group != null && row.dataset.index != null) {
const group = row.dataset.group
const index = Number(row.dataset.index)
if (Number.isNaN(index)) return null
return quietIfNoOp(group, index + insertIndexFromY(clientY, row), dragId, grouped)
}
const zone = (hit.closest('[data-group-row]') ||
hit.closest('[data-group-empty]') ||
hit.closest('[data-group-body]')) as HTMLElement | null
if (zone?.dataset.group) {
const group = zone.dataset.group
return quietIfNoOp(group, grouped[group]?.length ?? 0, dragId, grouped)
}
return null
}
export function SessionTree({
sessions,
extraGroups = [],
@@ -43,6 +115,7 @@ export function SessionTree({
onContextMenu,
onGroupContextMenu,
onBlankContextMenu,
onReorderSessions,
searchTerm,
loading = false,
onNewSession,
@@ -51,6 +124,26 @@ export function SessionTree({
onToggleSelection,
}: SessionTreeProps) {
const { t } = useTranslation('sessions')
const [dragId, setDragId] = useState<string | null>(null)
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null)
const [preview, setPreview] = useState<{ x: number; y: number; name: string } | null>(null)
const dragRef = useRef<PointerDragState | null>(null)
const groupedRef = useRef<Record<string, Session[]>>({})
const expandedRef = useRef(expandedGroups)
const scrollerRef = useRef<HTMLDivElement>(null)
const suppressClickRef = useRef(false)
const expandTimerRef = useRef<number | null>(null)
const hoverGroupRef = useRef<string | null>(null)
expandedRef.current = expandedGroups
useEffect(() => {
return () => {
document.body.classList.remove(DRAG_BODY_CLASS)
if (expandTimerRef.current != null) window.clearTimeout(expandTimerRef.current)
}
}, [])
if (loading) {
return (
@@ -86,14 +179,123 @@ export function SessionTree({
}
}
for (const key of Object.keys(grouped)) {
grouped[key].sort(compareBySortOrder)
}
groupedRef.current = grouped
const groupKeys = Object.keys(grouped).sort((a, b) => {
if (a === 'default') return -1
if (b === 'default') return 1
return localeCompareText(a, b)
})
// Dragging is a manual-order gesture: disabled while filtering (the visible
// list is not the persisted order) and in batch multi-select mode.
const dragEnabled = !multiSelect && !query
const clearExpandTimer = () => {
hoverGroupRef.current = null
if (expandTimerRef.current != null) {
window.clearTimeout(expandTimerRef.current)
expandTimerRef.current = null
}
}
const maybeExpandGroup = (group: string) => {
if (hoverGroupRef.current === group) return
if (expandTimerRef.current != null) window.clearTimeout(expandTimerRef.current)
hoverGroupRef.current = group
if (expandedRef.current.has(group)) {
expandTimerRef.current = null
return
}
expandTimerRef.current = window.setTimeout(() => {
if (!expandedRef.current.has(group)) onToggleGroup(group)
}, AUTO_EXPAND_MS)
}
const resetDrag = () => {
dragRef.current = null
setDragId(null)
setDropTarget(null)
setPreview(null)
clearExpandTimer()
document.body.classList.remove(DRAG_BODY_CLASS)
}
const autoScroll = (clientY: number) => {
const scroller = scrollerRef.current
if (!scroller) return
const rect = scroller.getBoundingClientRect()
const edge = 28
if (clientY < rect.top + edge) scroller.scrollTop -= 10
else if (clientY > rect.bottom - edge) scroller.scrollTop += 10
}
const applyPointerHover = (clientX: number, clientY: number, sessionId: string) => {
const target = resolveSessionDropTarget(clientX, clientY, sessionId, groupedRef.current)
setDropTarget(target)
if (target) maybeExpandGroup(target.group)
else clearExpandTimer()
autoScroll(clientY)
}
const startPointerDrag = (session: Session, event: React.PointerEvent<HTMLElement>) => {
if (!dragEnabled || event.button !== 0) return
dragRef.current = {
pointerId: event.pointerId,
sessionId: session.id,
sessionName: session.name,
startX: event.clientX,
startY: event.clientY,
active: false,
}
event.currentTarget.setPointerCapture?.(event.pointerId)
}
const movePointerDrag = (event: React.PointerEvent<HTMLElement>) => {
const state = dragRef.current
if (!state || state.pointerId !== event.pointerId) return
if (!state.active) {
const distance = Math.hypot(event.clientX - state.startX, event.clientY - state.startY)
if (distance < POINTER_DRAG_THRESHOLD) return
state.active = true
suppressClickRef.current = true
setDragId(state.sessionId)
document.body.classList.add(DRAG_BODY_CLASS)
window.getSelection()?.removeAllRanges()
}
event.preventDefault()
setPreview({ x: event.clientX, y: event.clientY, name: state.sessionName })
applyPointerHover(event.clientX, event.clientY, state.sessionId)
}
const finishPointerDrag = (event: React.PointerEvent<HTMLElement>) => {
const state = dragRef.current
if (!state || state.pointerId !== event.pointerId) return
event.currentTarget.releasePointerCapture?.(event.pointerId)
if (state.active) {
event.preventDefault()
const target = resolveSessionDropTarget(event.clientX, event.clientY, state.sessionId, groupedRef.current)
if (target) {
onReorderSessions(state.sessionId, target.group, target.index)
if (!expandedRef.current.has(target.group)) onToggleGroup(target.group)
}
window.setTimeout(() => {
suppressClickRef.current = false
}, 0)
}
resetDrag()
}
return (
<div
ref={scrollerRef}
className="flex-1 overflow-y-auto"
onContextMenu={(e) => {
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
@@ -103,11 +305,13 @@ export function SessionTree({
{groupKeys.map((group) => {
const groupSessions = grouped[group]
const isExpanded = expandedGroups.has(group)
const groupDropActive = dropTarget?.group === group && dropTarget.index === groupSessions.length
return (
<div key={group} className="border-b border-dark-700/20 last:border-b-0">
<div key={group} className="border-dark-700/20 border-b last:border-b-0">
<button
type="button"
data-group-row
data-group={group}
aria-expanded={isExpanded}
aria-controls={`session-group-${group}`}
onClick={() => onToggleGroup(group)}
@@ -116,15 +320,17 @@ export function SessionTree({
e.stopPropagation()
onGroupContextMenu?.(e, group)
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-txt-muted hover:bg-dark-700/50 transition-colors"
className={`hover:bg-dark-700/50 flex w-full items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${
groupDropActive ? 'bg-accent-blue/10 text-accent-blue' : ''
}`}
>
<ChevronRight
className="w-4 h-4 transition-transform duration-200 ease-in-out"
className="h-4 w-4 transition-transform duration-200 ease-in-out"
style={{ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
<Folder className="w-4 h-4" />
<Folder className="h-4 w-4" />
<span className="flex-1 text-left">{groupDisplayName(group)}</span>
<span className="text-xs text-txt-faint tabular-nums">{groupSessions.length}</span>
<span className="text-xs tabular-nums text-txt-faint">{groupSessions.length}</span>
</button>
<div
@@ -133,26 +339,44 @@ export function SessionTree({
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
aria-hidden={!isExpanded}
>
<div className="overflow-hidden" {...(!isExpanded ? { inert: '' } : {})}>
<div
className="overflow-hidden"
data-group-body={group}
data-group={group}
{...(!isExpanded ? { inert: '' } : {})}
>
{groupSessions.length === 0 ? (
<div className="px-9 py-2 text-[11px] text-txt-faint">{t('tree.emptyTitle')}</div>
<div className="px-9 py-2 text-[11px] text-txt-faint" data-group-empty data-group={group}>
{t('tree.emptyTitle')}
</div>
) : (
groupSessions.map((session) => {
const selected = multiSelect
? selectedSessionIds.has(session.id)
: selectedSessionId === session.id
groupSessions.map((session, index) => {
const selected = multiSelect ? selectedSessionIds.has(session.id) : selectedSessionId === session.id
const showInsertBefore = dropTarget?.group === group && dropTarget.index === index
const showInsertAfter =
dropTarget?.group === group &&
dropTarget.index === index + 1 &&
index === groupSessions.length - 1
return (
<div
key={session.id}
data-session-row
data-group={group}
data-index={index}
role="button"
tabIndex={isExpanded ? 0 : -1}
onPointerDown={(e) => startPointerDrag(session, e)}
onPointerMove={movePointerDrag}
onPointerUp={finishPointerDrag}
onPointerCancel={finishPointerDrag}
onClick={() => {
if (suppressClickRef.current) return
if (multiSelect) onToggleSelection?.(session)
else onSelectSession(session)
}}
onDoubleClick={() => {
if (!multiSelect) onConnectSession(session)
if (suppressClickRef.current || multiSelect) return
onConnectSession(session)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || (multiSelect && e.key === ' ')) {
@@ -167,22 +391,24 @@ export function SessionTree({
? t('tree.selectSession')
: `${session.username}@${session.host}:${session.port || 22}`
}
className={`
group flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer
transition-colors duration-150 pl-9 pr-2
${
className={`group relative flex touch-none select-none items-center gap-1.5 px-3 py-1.5 pl-9 pr-2 text-sm transition-colors duration-150 ${dragEnabled ? 'cursor-grab' : 'cursor-pointer'} ${dragId === session.id ? 'opacity-40' : ''} ${
selected
? 'bg-accent-blue/10 border-l-2 border-accent-blue'
: 'border-l-2 border-transparent hover:bg-dark-700/40'
}
`}
? 'border-l-2 border-accent-blue bg-accent-blue/10'
: 'hover:bg-dark-700/40 border-l-2 border-transparent'
} `}
>
{(showInsertBefore || showInsertAfter) && (
<span
aria-hidden
className={`pointer-events-none absolute inset-x-1 ${
showInsertBefore ? 'top-0' : 'bottom-0'
} h-0.5 rounded-full bg-accent-blue`}
/>
)}
{multiSelect ? (
<span
className={`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-900'
selected ? 'border-accent-blue bg-accent-blue text-white' : 'border-dark-500 bg-dark-900'
}`}
aria-hidden
>
@@ -210,11 +436,7 @@ export function SessionTree({
})}
{filteredSessions.length === 0 && query && (
<EmptyState
icon={ServerOff}
title={t('tree.noMatchTitle')}
description={t('tree.noMatchDesc')}
/>
<EmptyState icon={ServerOff} title={t('tree.noMatchTitle')} description={t('tree.noMatchDesc')} />
)}
{sessions.length === 0 && !query && groupKeys.every((g) => (grouped[g] || []).length === 0) && (
@@ -222,11 +444,21 @@ export function SessionTree({
icon={ServerOff}
title={t('tree.emptyTitle')}
description={t('tree.emptyDesc')}
action={
onNewSession ? { label: t('tree.emptyAction'), onClick: onNewSession } : undefined
}
action={onNewSession ? { label: t('tree.emptyAction'), onClick: onNewSession } : undefined}
/>
)}
{preview &&
createPortal(
<div
aria-hidden
className="bg-dark-800/95 pointer-events-none fixed z-[180] max-w-[12rem] truncate rounded-md border border-accent-blue/40 px-2 py-1 text-xs text-content-primary shadow-lg"
style={{ left: preview.x + 12, top: preview.y + 12 }}
>
{preview.name}
</div>,
document.body
)}
</div>
)
}
+81 -43
View File
@@ -2,12 +2,14 @@
* TabItem — local shell uses the same Terminal icon as the launcher (+)
*/
import { useEffect, useRef, useState } from 'react'
import { X, Terminal, FolderSync, Cable, Crown, TerminalSquare } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Tab } from '../../../stores/tabStore'
import { useSessionStore } from '../../../stores/sessionStore'
import HostDistroIcon from '../../common/HostDistroIcon'
import { resolveTabTitle } from '../../../utils/tabTitle'
import StatusDot from '../../common/StatusDot'
interface TabItemProps {
tab: Tab
@@ -22,6 +24,8 @@ interface TabItemProps {
onDragOver: (e: React.DragEvent, index: number) => void
onDrop: (e: React.DragEvent, index: number) => void
onDragEnd: () => void
/** 双击标签内联重命名;welcome/settings 等固定标签不传。 */
onRename?: (title: string) => void
}
export function TabItem({
@@ -37,6 +41,7 @@ export function TabItem({
onDragOver,
onDrop,
onDragEnd,
onRename,
}: TabItemProps) {
const { t } = useTranslation(['terminal', 'common'])
const displayTitle = resolveTabTitle(tab)
@@ -44,6 +49,29 @@ export function TabItem({
const session = useSessionStore((state) =>
tab.sessionId ? state.sessions.find((item) => item.id === tab.sessionId) : undefined
)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
const renameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editing) {
renameInputRef.current?.focus()
renameInputRef.current?.select()
}
}, [editing])
const startRename = () => {
setDraft(tab.title)
setEditing(true)
}
const commitRename = () => {
setEditing(false)
const next = draft.trim()
if (next && next !== tab.title) {
onRename?.(next)
}
}
const getIcon = () => {
if (isLocalTerm) {
@@ -57,13 +85,8 @@ export function TabItem({
if (tab.type === 'rdp' || tab.type === 'vnc' || (tab.type === 'terminal' && session)) {
return (
<HostDistroIcon
distro={
session?.distro ??
(tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)
}
protocol={
tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : session?.protocol ?? 'ssh'
}
distro={session?.distro ?? (tab.type === 'rdp' ? 'windows' : tab.type === 'vnc' ? 'vnc' : undefined)}
protocol={tab.type === 'rdp' ? 'rdp' : tab.type === 'vnc' ? 'vnc' : (session?.protocol ?? 'ssh')}
size="xs"
/>
)
@@ -88,12 +111,10 @@ export function TabItem({
const showRightIndicator = dragOverIndex === index + 1 && !isDragging
const statusDot =
tab.connectionStatus === 'connecting' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-yellow-400" />
) : tab.connectionStatus === 'connected' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500" />
) : tab.connectionStatus === 'error' ? (
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-red-500" />
tab.connectionStatus === 'connecting' ||
tab.connectionStatus === 'connected' ||
tab.connectionStatus === 'error' ? (
<StatusDot state={tab.connectionStatus} />
) : null
return (
@@ -101,7 +122,8 @@ export function TabItem({
data-tab-item
role="tab"
aria-selected={isActive}
tabIndex={0}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
draggable
onClick={onActivate}
onAuxClick={(e) => {
@@ -126,50 +148,66 @@ export function TabItem({
onDragOver={(e) => onDragOver(e, index)}
onDrop={(e) => onDrop(e, index)}
onDragEnd={onDragEnd}
className={`
group relative flex min-w-[120px] max-w-[220px] cursor-pointer select-none items-center gap-2
border-r border-dark-700/20 px-3 py-2 titlebar-no-drag transition-all duration-150
${
isActive
? 'bg-dark-900 text-content-primary'
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
}
${isDragging ? 'opacity-50' : 'opacity-100'}
`}
className={`border-dark-700/20 titlebar-no-drag group relative flex min-w-[120px] max-w-[220px] cursor-pointer select-none items-center gap-2 border-r px-3 py-2 transition-all duration-150 ${
isActive
? 'bg-dark-900 text-content-primary'
: 'bg-dark-800 text-txt-muted hover:bg-dark-700 hover:text-txt-muted'
} ${isDragging ? 'opacity-50' : 'opacity-100'} `}
>
{showLeftIndicator && (
<div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />
)}
{showRightIndicator && (
<div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />
)}
{showLeftIndicator && <div className="absolute bottom-0 left-0 top-0 z-10 w-0.5 bg-accent-blue" />}
{showRightIndicator && <div className="absolute bottom-0 right-0 top-0 z-10 w-0.5 bg-accent-blue" />}
{statusDot}
{getIcon()}
<span className="flex-1 truncate text-sm font-medium" title={displayTitle}>
{displayTitle}
</span>
{editing ? (
<input
ref={renameInputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === 'Enter') {
e.preventDefault()
commitRename()
}
if (e.key === 'Escape') {
e.preventDefault()
setEditing(false)
}
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
aria-label={t('common:tab.renameAria', { title: displayTitle })}
className="ui-input-sm min-w-0 flex-1 px-1 py-0 text-sm"
/>
) : (
<span
className="flex-1 truncate text-sm font-medium"
title={onRename ? t('common:tab.renameHint') : displayTitle}
onDoubleClick={(e) => {
if (!onRename) return
e.stopPropagation()
startRename()
}}
>
{displayTitle}
</span>
)}
<button
type="button"
onClick={onClose}
aria-label={t('common:tab.closeAria', { title: displayTitle })}
className={`
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600
group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100
${isActive ? 'opacity-100' : ''}
`}
className={`rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 ${isActive ? 'opacity-100' : ''} `}
>
<X className="h-3.5 w-3.5" />
</button>
<div
className={`
absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-accent-blue
transition-[opacity,transform] duration-150
${isActive ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0'}
`}
className={`absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-accent-blue transition-[opacity,transform] duration-150 ${isActive ? 'scale-x-100 opacity-100' : 'scale-x-0 opacity-0'} `}
/>
</div>
)
File diff suppressed because it is too large. Load diff
+25 -38
View File
@@ -10,22 +10,19 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { shellDisplayFromPath } from '../../utils/shellName'
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
import {
buildOpenTabPayload,
defaultPortForProtocol,
sessionProtocol,
} from '../../utils/sessionProtocol'
import { buildOpenTabPayload, defaultPortForProtocol, sessionProtocol } from '../../utils/sessionProtocol'
interface WelcomeViewProps {
onNewSession?: () => void
onQuickConnect?: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
onImportSessions?: () => void
}
function Kbd({ children }: { children: string }) {
return (
<kbd className="rounded border border-dark-600/80 bg-dark-900/80 px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
<kbd className="border-dark-600/80 bg-dark-900/80 rounded border px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
{children}
</kbd>
)
@@ -36,6 +33,7 @@ export default function WelcomeView({
onQuickConnect,
onRdpConnect,
onVncConnect,
onImportSessions,
}: WelcomeViewProps) {
const { t } = useTranslation('sessions')
const { addTab } = useTabStore()
@@ -71,15 +69,11 @@ export default function WelcomeView({
}, [shellMenuOpen])
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
const defaultShellLabel = defaultShell
? shellDisplayFromPath(defaultShell.path || defaultShell.name).title
: 'Shell'
const defaultShellLabel = defaultShell ? shellDisplayFromPath(defaultShell.path || defaultShell.name).title : 'Shell'
const openDefaultLocal = useCallback(() => {
openLocalShellTab(
defaultShell
? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name }
: undefined
defaultShell ? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name } : undefined
)
}, [defaultShell])
@@ -112,7 +106,7 @@ export default function WelcomeView({
<button
type="button"
onClick={() => onQuickConnect?.()}
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
className="border-dark-700/60 bg-dark-800/50 group flex flex-col gap-4 rounded-xl border p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
@@ -129,7 +123,7 @@ export default function WelcomeView({
<button
type="button"
onClick={() => onNewSession?.()}
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
className="border-dark-700/60 bg-dark-800/50 group flex flex-col gap-4 rounded-xl border p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-green/15 text-accent-green">
@@ -144,7 +138,7 @@ export default function WelcomeView({
</button>
<div ref={shellMenuRef} className="relative">
<div className="flex h-full overflow-hidden rounded-xl border border-dark-700/60 bg-dark-800/50 transition-colors hover:border-dark-600 hover:bg-dark-800">
<div className="border-dark-700/60 bg-dark-800/50 flex h-full overflow-hidden rounded-xl border transition-colors hover:border-dark-600 hover:bg-dark-800">
<button
type="button"
onClick={openDefaultLocal}
@@ -158,9 +152,7 @@ export default function WelcomeView({
</div>
<div>
<div className="text-sm font-medium text-content-primary">{t('welcome.localShell.title')}</div>
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">
{defaultShellLabel}
</div>
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">{defaultShellLabel}</div>
</div>
</button>
<button
@@ -168,14 +160,14 @@ export default function WelcomeView({
aria-label={t('welcome.localShell.selectShell')}
aria-expanded={shellMenuOpen}
onClick={() => setShellMenuOpen((v) => !v)}
className="flex w-9 flex-shrink-0 items-center justify-center border-l border-dark-700/60 text-txt-faint transition-colors hover:bg-dark-700/40 hover:text-content-secondary"
className="border-dark-700/60 hover:bg-dark-700/40 flex w-9 flex-shrink-0 items-center justify-center border-l text-txt-faint transition-colors hover:text-content-secondary"
>
<ChevronRight className={`h-4 w-4 transition-transform ${shellMenuOpen ? 'rotate-90' : ''}`} />
</button>
</div>
{shellMenuOpen && shells.length > 0 && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-dark-600/80 bg-dark-800 py-1 shadow-xl">
<div className="border-dark-600/80 absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border bg-dark-800 py-1 shadow-xl">
{shells.map((s) => (
<button
key={s.path}
@@ -184,7 +176,7 @@ export default function WelcomeView({
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
setShellMenuOpen(false)
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-dark-700/70"
className="hover:bg-dark-700/70 flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors"
>
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
@@ -201,7 +193,9 @@ export default function WelcomeView({
<section aria-label={t('welcome.sectionDesktop')} className="mb-10">
<div className="mb-3 flex items-center gap-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">{t('welcome.sectionDesktop')}</h2>
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('welcome.sectionDesktop')}
</h2>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
@@ -210,7 +204,7 @@ export default function WelcomeView({
if (!requirePro('rdp')) return
onRdpConnect?.()
}}
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
className="border-dark-700/50 bg-dark-800/30 flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Monitor className="h-4 w-4" />
@@ -226,7 +220,7 @@ export default function WelcomeView({
if (!requirePro('vnc')) return
onVncConnect?.()
}}
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
className="border-dark-700/50 bg-dark-800/30 flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Cast className="h-4 w-4" />
@@ -245,34 +239,29 @@ export default function WelcomeView({
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('welcome.sectionRecent')}
</h2>
{recentSessions.length > 0 && (
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
)}
{recentSessions.length > 0 && <span className="text-[11px] text-txt-dim">{recentSessions.length}</span>}
</div>
{recentSessions.length === 0 ? (
<div className="rounded-xl border border-dashed border-dark-700/70 px-4 py-10">
<div className="border-dark-700/70 rounded-xl border border-dashed px-4 py-10">
<EmptyState
icon={Server}
title={t('welcome.recentEmpty.title')}
description={t('welcome.recentEmpty.desc')}
action={onImportSessions ? { label: t('welcome.import.title'), onClick: onImportSessions } : undefined}
/>
</div>
) : (
<ul className="divide-y divide-dark-700/40 overflow-hidden rounded-xl border border-dark-700/50">
<ul className="divide-dark-700/40 border-dark-700/50 divide-y overflow-hidden rounded-xl border">
{recentSessions.map((session) => (
<li key={session.id}>
<button
type="button"
onClick={() => handleSessionClick(session)}
className="group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-dark-800/80"
className="hover:bg-dark-800/80 group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors"
>
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
<HostDistroIcon
distro={session.distro}
protocol={session.protocol}
size="md"
/>
<HostDistroIcon distro={session.distro} protocol={session.protocol} size="md" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content-primary">{session.name}</span>
@@ -282,9 +271,7 @@ export default function WelcomeView({
</span>
</span>
{session.group ? (
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">
{session.group}
</span>
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">{session.group}</span>
) : null}
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
</button>
+68 -25
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, CheckCircle2, ChevronDown, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
import { Check, CheckCircle2, ChevronDown, Plus, Save, Trash2, Wifi } from 'lucide-react'
import type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
import {
AI_PROVIDER_FAMILY_ORDER,
@@ -10,6 +10,7 @@ import {
resolveProviderFamily,
} from '../../ai/providerPresets'
import { useAiProviderStore } from '../../stores/aiProviderStore'
import Button from '../common/Button'
type TestState = 'idle' | 'testing' | 'success' | 'error'
@@ -133,7 +134,15 @@ export default function AiProviderSettings() {
}
const id = `manual-${Date.now().toString(36)}`
updateDraft({
models: [{ id, model: modelId, label: modelId || t('ai.customModel'), capability: 'chat', billing: defaultManualBilling() }],
models: [
{
id,
model: modelId,
label: modelId || t('ai.customModel'),
capability: 'chat',
billing: defaultManualBilling(),
},
],
defaultModelId: id,
})
}
@@ -208,13 +217,15 @@ export default function AiProviderSettings() {
</optgroup>
))}
</select>
<button
<Button
type="button"
variant="primary"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
className="ui-btn-primary flex items-center gap-1.5"
>
<Plus className="h-3.5 w-3.5" /> {t('ai.add')}
</button>
{t('ai.add')}
</Button>
</div>
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
@@ -233,7 +244,7 @@ export default function AiProviderSettings() {
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
selectedId === provider.id
? 'bg-accent-blue/12 text-accent-blue'
: 'text-content-secondary hover:bg-dark-700/50'
: 'hover:bg-dark-700/50 text-content-secondary'
}`}
>
<div className="flex items-center gap-1.5">
@@ -256,7 +267,11 @@ export default function AiProviderSettings() {
<div className="max-w-xl space-y-3.5">
<div className="grid grid-cols-2 gap-3">
<Field label={t('ai.name')}>
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
<input
value={draft.name}
onChange={(event) => updateDraft({ name: event.target.value })}
className="ui-input-sm"
/>
</Field>
<Field label={t('ai.model')}>
{manualModel || draft.models.length === 0 ? (
@@ -286,7 +301,11 @@ export default function AiProviderSettings() {
</div>
{manualModel && draft.models.length > 0 && (
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
<button
type="button"
className="text-xs text-accent-blue hover:underline"
onClick={() => setManualModel(false)}
>
{t('ai.backToList')}
</button>
)}
@@ -298,7 +317,13 @@ export default function AiProviderSettings() {
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={draft.hasApiKey ? t('ai.savedKeepBlank') : draft.authStyle === 'none' ? t('ai.notRequired') : 'sk-...'}
placeholder={
draft.hasApiKey
? t('ai.savedKeepBlank')
: draft.authStyle === 'none'
? t('ai.notRequired')
: 'sk-...'
}
className="ui-input-sm"
/>
</Field>
@@ -330,7 +355,9 @@ export default function AiProviderSettings() {
<Field label={t('ai.protocol')}>
<select
value={draft.protocol}
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
onChange={(event) =>
updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })
}
className="ui-input-sm"
>
<option value="openai-chat">OpenAI Chat</option>
@@ -341,7 +368,9 @@ export default function AiProviderSettings() {
<Field label={t('ai.auth')}>
<select
value={draft.authStyle}
onChange={(event) => updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })}
onChange={(event) =>
updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })
}
className="ui-input-sm"
>
<option value="bearer">Bearer</option>
@@ -362,31 +391,45 @@ export default function AiProviderSettings() {
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
<button
<Button
type="button"
onClick={() => void testConnection()}
variant="secondary"
size="sm"
disabled={!canTest}
className="ui-btn-secondary flex items-center gap-1.5"
loading={testState === 'testing'}
icon={testState === 'testing' ? undefined : <Wifi className="h-3.5 w-3.5" />}
onClick={() => void testConnection()}
>
{testState === 'testing' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
</button>
<button
</Button>
<Button
type="button"
onClick={() => void save()}
variant="primary"
size="sm"
disabled={!verified || saving}
className="ui-btn-primary flex items-center gap-1.5"
loading={saving}
icon={saving ? undefined : <Save className="h-3.5 w-3.5" />}
onClick={() => void save()}
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{t('common:action.save')}
</button>
</Button>
{isStored && store.activeProviderId !== draft.id && (
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void store.setActiveProvider(draft.id)}
>
{t('ai.setActive')}
</button>
</Button>
)}
<div className="flex-1" />
<button type="button" title={t('ai.deleteTitle')} onClick={() => void remove()} className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300">
<button
type="button"
title={t('ai.deleteTitle')}
onClick={() => void remove()}
className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover:text-red-300"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
+4 -6
View File
@@ -177,7 +177,7 @@ export default function RemoteTextEditor({
aria-modal="true"
aria-label={t('editor.title', { name: fileName })}
>
<header className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/60 bg-dark-800 px-4 py-2.5">
<header className="border-dark-700/60 flex flex-shrink-0 items-center gap-3 border-b bg-dark-800 px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
<FileCode className="h-4 w-4" />
@@ -216,9 +216,7 @@ export default function RemoteTextEditor({
disabled={!dirty || saving || loading}
onClick={() => void handleSave()}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors disabled:opacity-40 ${
dirty
? 'bg-accent-blue text-white hover:bg-blue-500'
: 'bg-dark-700 text-txt-muted'
dirty ? 'bg-accent-blue text-white hover:bg-blue-500' : 'bg-dark-700 text-txt-muted'
}`}
>
<Save className="h-3.5 w-3.5" />
@@ -242,7 +240,7 @@ export default function RemoteTextEditor({
</div>
)}
<div className="min-h-0 flex-1 bg-[#1e1e1e]">
<div className={`min-h-0 flex-1 ${effective === 'light' ? 'bg-white' : 'bg-[#1e1e1e]'}`}>
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
{t('common:status.loading')}
@@ -271,7 +269,7 @@ export default function RemoteTextEditor({
)}
</div>
<footer className="flex flex-shrink-0 items-center justify-between gap-3 border-t border-dark-700/60 bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
<footer className="border-dark-700/60 flex flex-shrink-0 items-center justify-between gap-3 border-t bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
<span>{t('editor.footer')}</span>
<span>
{t('editor.charCount', { count: content.length.toLocaleString() })}
+24 -11
View File
@@ -7,16 +7,23 @@ export interface ShellClosedOverlayProps {
reason: string
idleTimeout: boolean
restarting?: boolean
isLocal?: boolean
onReopen: () => void
}
export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, onReopen }: ShellClosedOverlayProps) {
export function ShellClosedOverlay({
reason,
idleTimeout,
restarting = false,
isLocal = false,
onReopen,
}: ShellClosedOverlayProps) {
const { t } = useTranslation('terminal')
return (
<div className="terminal-overlay-scrim absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-5 text-center">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15">
{idleTimeout ? (
<Clock3 className="h-6 w-6 text-accent-blue" />
) : (
@@ -27,7 +34,9 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
</div>
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div>
<div className="mb-4 text-[11px] text-accent-green">{t('overlay.shellClosedHint')}</div>
<div className="mb-4 text-[11px] text-accent-green">
{t(isLocal ? 'overlay.localShellClosedHint' : 'overlay.shellClosedHint')}
</div>
<button
type="button"
disabled={restarting}
@@ -48,6 +57,7 @@ export interface TerminalOverlayProps {
showSuspendedOverlay: boolean
needsReconnect: boolean
reconnectAttempt?: number
isLocal?: boolean
onReconnect: () => void
onResume: () => void
}
@@ -58,6 +68,7 @@ function TerminalOverlay({
showSuspendedOverlay,
needsReconnect,
reconnectAttempt = 0,
isLocal = false,
onReconnect,
onResume,
}: TerminalOverlayProps) {
@@ -69,17 +80,21 @@ function TerminalOverlay({
{showDisconnectBanner && (
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-6 text-center">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15">
<WifiOff className="h-7 w-7 text-accent-blue" />
</div>
<div className="mb-1.5 text-base font-medium text-slate-100">{t('overlay.disconnected')}</div>
<div className="mb-1.5 text-base font-medium text-slate-100">
{t(isLocal ? 'overlay.localDisconnected' : 'overlay.disconnected')}
</div>
<div className="mb-6 text-sm leading-relaxed text-slate-400">
{statusMessage?.trim() ? statusMessage : t('overlay.disconnectedHint')}
{statusMessage?.trim()
? statusMessage
: t(isLocal ? 'overlay.localDisconnectedHint' : 'overlay.disconnectedHint')}
</div>
<button
type="button"
onClick={onReconnect}
className="shadow-accent-blue/20 inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-blue-hover"
className="inline-flex items-center gap-2 rounded-xl bg-accent-blue px-6 py-2.5 text-sm font-medium text-white shadow-sm shadow-accent-blue/20 transition-colors hover:bg-accent-blue-hover"
>
<RefreshCw className="h-4 w-4" />
{t('overlay.reconnect')}
@@ -121,7 +136,7 @@ function TerminalOverlay({
</div>
<div className="mb-1.5 font-medium text-red-300">{t('overlay.connectFailed')}</div>
<div className="mb-5 text-sm leading-relaxed text-slate-400">
{statusMessage || t('overlay.connectFailedHint')}
{statusMessage || t(isLocal ? 'overlay.localConnectFailedHint' : 'overlay.connectFailedHint')}
</div>
<button
type="button"
@@ -142,9 +157,7 @@ function TerminalOverlay({
<TerminalIcon className="h-4 w-4" />
{connectionState === 'reconnecting'
? t('overlay.reconnecting', {
attempt: reconnectAttempt
? t('overlay.reconnectingAttempt', { count: reconnectAttempt })
: '',
attempt: reconnectAttempt ? t('overlay.reconnectingAttempt', { count: reconnectAttempt }) : '',
})
: t('overlay.connecting')}
</div>
+3 -5
View File
@@ -15,10 +15,7 @@ import { createLogger } from '../../utils/logger'
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import {
ZmodemSftpBridge,
type ZmodemBridgeStatus,
} from '../../utils/zmodemSftpBridge'
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
import TerminalOverlay from './TerminalOverlay'
@@ -311,7 +308,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
<div ref={terminalRef} className="terminal-surface h-full w-full rounded" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
<div className="bg-dark-800/95 pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
{zmodemBanner}
</div>
)}
@@ -337,6 +334,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
}
reconnectAttempt={reconnectAttempt}
isLocal={!!currentTab?.isLocal}
onReconnect={handleReconnect}
onResume={resumeConnection}
/>
+205 -171
View File
@@ -36,7 +36,7 @@ import { useEntitlementStore } from '../../stores/entitlementStore'
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
import { StreamingKeywordHighlight } from '../../utils/streamingKeywordHighlight'
import { shellPayloadByteLength } from '../../utils/shellData'
import { shellWrite, shellResize, shellCreate, shellKill, flushSshShellBootstrap } from '../../utils/shellIO'
import { shellWrite, shellResize, shellCreate, shellKill, flushShellBootstrap } from '../../utils/shellIO'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
@@ -48,10 +48,7 @@ import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import {
ZmodemSftpBridge,
type ZmodemBridgeStatus,
} from '../../utils/zmodemSftpBridge'
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
import TerminalContextMenu from './TerminalContextMenu'
import i18n from '../../i18n'
@@ -78,6 +75,8 @@ interface PaneRuntime {
pasteClipboard: () => void
selectAll: () => void
clear: () => void
/** 写入命令文本但不带回车:插入到当前输入行,由用户自行检查后执行。 */
insertText: (text: string) => boolean
}
function captureTerminal(term: Terminal): TerminalAiContext {
@@ -220,11 +219,7 @@ function PaneTerminal({
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
onContextMenuRef.current(
paneId,
{ x: event.clientX, y: event.clientY },
term.hasSelection()
)
onContextMenuRef.current(paneId, { x: event.clientX, y: event.clientY }, term.hasSelection())
}
container.addEventListener('contextmenu', handleContextMenu)
@@ -250,22 +245,21 @@ function PaneTerminal({
},
onError: (error) => logger.warn('终端输入失败', error),
})
const zmodem =
isLocal
? null
: new ZmodemSftpBridge({
writeToTerminal: (data) => {
if (disposed) return
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
},
sendToPeer: (data) => {
const sid = shellIdRef.current
if (!sid) return
return shellWrite(sid, data)
},
getConnectionId: () => connectionIdRef.current,
onStatus: setZmodemStatus,
})
const zmodem = isLocal
? null
: new ZmodemSftpBridge({
writeToTerminal: (data) => {
if (disposed) return
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
},
sendToPeer: (data) => {
const sid = shellIdRef.current
if (!sid) return
return shellWrite(sid, data)
},
getConnectionId: () => connectionIdRef.current,
onStatus: setZmodemStatus,
})
term.onData((data) => {
if (disposed) return
zmodem?.noteInput(data)
@@ -305,9 +299,17 @@ function PaneTerminal({
getTerminal: () => xtermRef.current,
getSearchAddon: () => searchAddonRef.current,
copySelection,
pasteClipboard: () => { void pasteClipboard() },
pasteClipboard: () => {
void pasteClipboard()
},
selectAll: () => term.selectAll(),
clear: () => safeTerminalClear(term),
insertText: (text) => {
if (!shellIdRef.current || !text) return false
// xterm input() 走 onData → pipeline,与用户键入同一条路径且不带回车。
term.input(text, true)
return true
},
})
return () => {
@@ -356,6 +358,7 @@ function PaneTerminal({
const pendingId = isLocal
? `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
: `shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
shellIdRef.current = pendingId
void (async () => {
try {
const cols = xtermRef.current?.cols || 80
@@ -377,18 +380,17 @@ function PaneTerminal({
}
shellIdRef.current = result.shellId
onShellReady(paneId, result.shellId)
// Secondary panes must open output or MOTD stays buffered and the
// reader never emits ssh:shell-data (blank cursor forever).
if (!isLocal) {
try {
const boot = await flushSshShellBootstrap(result.shellId)
if (boot && xtermRef.current) xtermRef.current.write(boot)
} catch (err) {
logger.warn('Failed to flush split-pane shell bootstrap', err)
}
// Open buffered PTY output or the first PS1 / MOTD is dropped.
try {
const boot = await flushShellBootstrap(result.shellId)
if (boot && xtermRef.current) xtermRef.current.write(boot)
} catch (err) {
logger.warn('Failed to flush split-pane shell bootstrap', err)
}
} else {
xtermRef.current?.writeln(`\r\n\x1b[31m${t('zmodem.createShellFailed', { error: result?.error || 'unknown' })}\x1b[0m`)
xtermRef.current?.writeln(
`\r\n\x1b[31m${t('zmodem.createShellFailed', { error: result?.error || 'unknown' })}\x1b[0m`
)
}
} catch (e) {
logger.error('create shell failed', e)
@@ -419,10 +421,10 @@ function PaneTerminal({
: null
return (
<div className={`relative h-full w-full ${isFocused ? 'ring-accent-blue/40 ring-1' : ''}`} onMouseDown={onFocus}>
<div className={`relative h-full w-full ${isFocused ? 'ring-1 ring-accent-blue/40' : ''}`} onMouseDown={onFocus}>
<div ref={containerRef} className="terminal-surface h-full w-full" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
<div className="bg-dark-800/95 pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
{zmodemBanner}
</div>
)}
@@ -461,15 +463,10 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const broadcastTargetCount = useTerminalBroadcastStore(
useCallback((state) => selectBroadcastTargetCount(tabId)(state), [tabId])
)
const readyPaneCount = useMemo(
() => panes.filter((pane) => !!pane.shellId).length,
[panes]
)
const readyPaneCount = useMemo(() => panes.filter((pane) => !!pane.shellId).length, [panes])
// current-tab: prefer live pane shells so the button enables as soon as splits are ready
const effectiveBroadcastCount =
broadcastScope === 'current-tab'
? Math.max(broadcastTargetCount, readyPaneCount)
: broadcastTargetCount
broadcastScope === 'current-tab' ? Math.max(broadcastTargetCount, readyPaneCount) : broadcastTargetCount
const canEnableBroadcast = effectiveBroadcastCount >= 2
const requirePro = useEntitlementStore((state) => state.requirePro)
const [aiPanelOpen, setAiPanelOpen] = useState(false)
@@ -546,34 +543,52 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
})
}, [tabId, direction, panes.length, ratio, updateTab])
// Sync primary shell from connection hook → first pane
// Sync primary shell from connection hook → first pane.
// Also adopt the pending id while connecting so bash/sh PS1 is not dropped.
useEffect(() => {
if (connectionState === 'connected' && shellIdRef.current) {
setPanes((prev) => {
if (prev.length === 0) return prev
if (prev[0].shellId === shellIdRef.current) return prev
return prev.map((p, i) => (i === 0 ? { ...p, shellId: shellIdRef.current } : p))
})
}
}, [connectionState, shellIdRef])
const sid = shellIdRef.current || currentTab?.connectionInstance?.shellId
if (!sid) return
if (connectionState !== 'connected' && connectionState !== 'connecting') return
setPanes((prev) => {
if (prev.length === 0) return prev
if (prev[0].shellId === sid) return prev
if (prev[0].shellId) return prev
return prev.map((p, i) => (i === 0 ? { ...p, shellId: sid } : p))
})
}, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef])
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
useEffect(() => {
const connectionId = connectionIdRef.current
const targets = connectionState === 'connected' && connectionId
? panes.flatMap((pane) => pane.shellId
? [{
tabId,
paneId: pane.id,
shellId: pane.shellId,
connectionId,
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
sessionId: currentTab?.sessionId,
}]
: [])
: []
const targets =
connectionState === 'connected' && connectionId
? panes.flatMap((pane) =>
pane.shellId
? [
{
tabId,
paneId: pane.id,
shellId: pane.shellId,
connectionId,
label: currentTab?.title || (currentTab?.isLocal ? t('toolbar.labelLocal') : t('toolbar.labelSsh')),
sessionId: currentTab?.sessionId,
},
]
: []
)
: []
registerBroadcastWorkspace({ tabId, targets })
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId, t])
}, [
connectionIdRef,
connectionState,
currentTab?.isLocal,
currentTab?.sessionId,
currentTab?.title,
panes,
registerBroadcastWorkspace,
tabId,
t,
])
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
@@ -721,17 +736,20 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const unregisterShellHandler = useCallback((paneId: string) => {
shellHandlersRef.current.delete(paneId)
}, [])
const registerRuntime = useCallback((paneId: string, runtime: PaneRuntime) => {
paneRuntimesRef.current.set(paneId, runtime)
if (panesRef.current[0]?.id === paneId) {
primaryXtermRef.current = runtime.getTerminal()
armInitialConnect()
}
if (paneId === focusedPaneId) {
focusedXtermRef.current = runtime.getTerminal()
focusedSearchAddonRef.current = runtime.getSearchAddon()
}
}, [armInitialConnect, focusedPaneId])
const registerRuntime = useCallback(
(paneId: string, runtime: PaneRuntime) => {
paneRuntimesRef.current.set(paneId, runtime)
if (panesRef.current[0]?.id === paneId) {
primaryXtermRef.current = runtime.getTerminal()
armInitialConnect()
}
if (paneId === focusedPaneId) {
focusedXtermRef.current = runtime.getTerminal()
focusedSearchAddonRef.current = runtime.getSearchAddon()
}
},
[armInitialConnect, focusedPaneId]
)
const unregisterRuntime = useCallback((paneId: string) => {
paneRuntimesRef.current.delete(paneId)
if (panesRef.current[0]?.id === paneId) {
@@ -802,7 +820,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
preferredId
)
if (!result.success || !result.shellId) {
throw new Error(result.error || (isLocal ? i18n.t('terminal:zmodem.createLocalShellFailed') : i18n.t('terminal:zmodem.createRemoteShellFailed')))
throw new Error(
result.error ||
(isLocal
? i18n.t('terminal:zmodem.createLocalShellFailed')
: i18n.t('terminal:zmodem.createRemoteShellFailed'))
)
}
if (!isLocal) {
getConnectionStore().addShell(connectionId, result.shellId)
@@ -819,14 +842,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
shellId: result.shellId,
})
}
if (!isLocal && result.shellId) {
try {
const boot = await flushSshShellBootstrap(result.shellId)
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
if (boot && term) term.write(boot)
} catch (err) {
logger.warn('Failed to flush reopened shell bootstrap', err)
}
try {
const boot = await flushShellBootstrap(result.shellId)
const term = paneRuntimesRef.current.get(paneId)?.getTerminal?.()
if (boot && term) term.write(boot)
} catch (err) {
logger.warn('Failed to flush reopened shell bootstrap', err)
}
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
@@ -839,16 +860,21 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
)
}
},
[connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.localShellPath, focusedPaneId, shellIdRef, tabId, updateTabConnectionInstance]
[
connectionIdRef,
connectionState,
currentTab?.isLocal,
currentTab?.localShellPath,
focusedPaneId,
shellIdRef,
tabId,
updateTabConnectionInstance,
]
)
const handlePaneInput = useCallback(
(sourcePaneId: string, data: string) => {
const shouldBroadcast = shouldBroadcastTerminalInput(
broadcastInput,
currentTab?.isLocal,
connectionIdRef.current
)
const shouldBroadcast = shouldBroadcastTerminalInput(broadcastInput, currentTab?.isLocal, connectionIdRef.current)
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets(tabId) : []
const targets = shouldBroadcast
? broadcastTargets.map((target) => target.shellId)
@@ -919,87 +945,92 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
return () => window.removeEventListener('keydown', handleSplitShortcut, true)
}, [addPane, direction, isActive, setSplitDirection])
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) => {
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 (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
}
const onlyTab = tabs.length === 1 ? tabs[0] : null
if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) 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: closingShellId,
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]
)
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,
})
.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 (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
}
const onlyTab = tabs.length === 1 ? tabs[0] : null
if (tabs.length <= 1 && (!onlyTab || onlyTab.type === 'welcome' || onlyTab.id !== tabId)) 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])
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])
setContextMenu({ paneId, position, hasSelection })
},
[connectionIdRef, shellIdRef, tabId, updateTabConnectionInstance]
)
useEffect(() => {
if (maximizedPaneId && !panes.some((pane) => pane.id === maximizedPaneId)) {
@@ -1183,7 +1214,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (!requirePro('ai')) return
setAiPanelOpen(true)
}}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 text-accent-blue hover:border-accent-blue/40 hover:bg-accent-blue/15'}`}
>
<Sparkles className="h-3.5 w-3.5" /> {t('toolbar.aiAssistant')}
</button>
@@ -1256,6 +1287,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
reason={pane.closed.reason}
idleTimeout={pane.closed.idleTimeout}
restarting={pane.closed.restarting}
isLocal={isLocal}
onReopen={() => void reopenPaneShell(pane.id)}
/>
)}
@@ -1281,6 +1313,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
}
reconnectAttempt={reconnectAttempt}
isLocal={isLocal}
onReconnect={() => {
const count = Math.min(MAX_PANES, Math.max(1, panes.length))
setPanes(Array.from({ length: count }, () => ({ id: newPaneId(), shellId: null })))
@@ -1296,6 +1329,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
targetLabel={currentTab?.title || t('toolbar.targetSshSession')}
isLocal={false}
insertToTerminal={(text) => paneRuntimesRef.current.get(focusedPaneId)?.insertText(text) ?? false}
/>
)}
</div>
@@ -1315,7 +1349,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
onClear={() => paneRuntimesRef.current.get(contextMenu.paneId)?.clear()}
onSplitRight={() => setSplitDirection('horizontal')}
onSplitDown={() => setSplitDirection('vertical')}
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
onToggleMaximize={() => setMaximizedPaneId((current) => (current ? null : contextMenu.paneId))}
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
onOpenSftp={
!isLocal && currentTab?.sessionId