chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+93 -73
View File
@@ -7,7 +7,6 @@ import {
Loader2,
Plus,
Send,
ShieldAlert,
Sparkles,
Square,
TerminalSquare,
@@ -31,7 +30,7 @@ import Button from '../common/Button'
import { useSettingsStore } from '../../stores/settingsStore'
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
import i18n from '../../i18n'
import { translateErrorMessage } from '../../i18n/format'
import { isSshExecCancelled, translateErrorMessage } from '../../i18n/format'
export interface TerminalAiContext {
selection: string
@@ -64,9 +63,10 @@ interface ThreadMessage {
steps?: AgentStep[]
}
const MAX_AGENT_STEPS = 8
const MAX_TOOL_OUTPUT_CHARS = 12_000
const MAX_AGENT_CONTEXT_CHARS = 48_000
const MAX_AGENT_STEPS = 12
const MAX_TOOL_OUTPUT_CHARS = 8_000
const MAX_SUMMARIZE_CHARS = 6_000
const MAX_AGENT_CONTEXT_CHARS = 32_000
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'relaxed']
function truncateMiddle(value: string, limit: number): string {
@@ -80,6 +80,27 @@ function messageId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
}
/** Collapse "DeepSeek · DeepSeek V4 Pro" when the model label already names the vendor. */
function modelSubtitle(providerName: string, modelLabel: string): string {
const provider = providerName.trim()
const model = modelLabel.trim()
if (!provider) return model
if (!model) return provider
if (model.toLowerCase().startsWith(provider.toLowerCase())) return model
return `${provider} · ${model}`
}
/** Long command / viewport dumps: keep errors + head/tail instead of the raw wall of text. */
function compactToolOutput(raw: string, limit = MAX_TOOL_OUTPUT_CHARS): string {
const text = raw.trim()
if (!text) return text
const lines = text.split('\n').length
if (text.length > Math.floor(limit / 2) || lines > 80) {
return truncateMiddle(analyzeAgentOutput(text).text, limit)
}
return text
}
function agentPrompt(
task: string,
targetLabel: string,
@@ -91,7 +112,7 @@ function agentPrompt(
const checkpoint =
consecutiveFailures >= 2
? `最近已连续失败 ${consecutiveFailures} 次,必须更换排查路径,不得只换参数重试相同操作。`
: round === 4
: round === Math.floor(MAX_AGENT_STEPS / 2)
? '请检查已有证据是否足够;如果足够应立即收敛,不要为了调用工具而调用。'
: round === MAX_AGENT_STEPS
? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。'
@@ -165,6 +186,7 @@ export default function AiTerminalPanel({
const [running, setRunning] = useState(false)
const [copiedId, setCopiedId] = useState('')
const runIdRef = useRef(0)
const runConnectionIdRef = useRef<string | null>(null)
const sessionAllowRef = useRef(new Set<string>())
const timelineRef = useRef<HTMLDivElement>(null)
const initialSelectionRef = useRef('')
@@ -229,6 +251,8 @@ export default function AiTerminalPanel({
const runId = runIdRef.current + 1
runIdRef.current = runId
const runConnectionId = connectionId
runConnectionIdRef.current = runConnectionId
if (!isFollowUp) {
initialSelectionRef.current = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
}
@@ -351,18 +375,15 @@ export default function AiTerminalPanel({
}
setLiveSteps([...completedSteps, proposed])
const executed = await window.opsxApi.sshExec(connectionId, command, { fromAgent: true })
const executed = await window.opsxApi.sshExec(runConnectionId, command, { fromAgent: true })
if (runIdRef.current !== runId) return
if (!executed.success && /已终止/.test(executed.error || '')) {
if (!executed.success && isSshExecCancelled(executed.error)) {
return
}
const rawOutput = executed.success
? executed.result?.trim() || i18n.t('ai:error.execNoOutput')
: i18n.t('ai:error.execFailed', { error: executed.error || i18n.t('ai:error.execUnknown') })
const output =
rawOutput.length > MAX_TOOL_OUTPUT_CHARS / 2
? truncateMiddle(analyzeAgentOutput(rawOutput).text, MAX_TOOL_OUTPUT_CHARS)
: rawOutput
const output = compactToolOutput(rawOutput)
const step: AgentStep = {
round,
command,
@@ -388,6 +409,7 @@ export default function AiTerminalPanel({
}
} finally {
if (runIdRef.current === runId) {
runConnectionIdRef.current = null
setRunning(false)
}
}
@@ -426,7 +448,7 @@ export default function AiTerminalPanel({
model,
action: 'explain',
prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`,
context: truncateMiddle(context, MAX_TOOL_OUTPUT_CHARS),
context: compactToolOutput(context, MAX_SUMMARIZE_CHARS),
})
if (runIdRef.current !== runId) return
if (!completion.success || !completion.text) {
@@ -446,8 +468,10 @@ export default function AiTerminalPanel({
const stopAgent = () => {
runIdRef.current += 1
dismissAgentRisk()
if (connectionId) {
void window.opsxApi.sshExecCancel(connectionId)
const runConnectionId = runConnectionIdRef.current
runConnectionIdRef.current = null
if (runConnectionId) {
void window.opsxApi.sshExecCancel(runConnectionId)
}
setRunning(false)
const snapped = liveStepsRef.current
@@ -470,73 +494,69 @@ export default function AiTerminalPanel({
}
return (
<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" />
<aside className="app-agent-panel">
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2">
<div className="flex items-start gap-2">
<Sparkles className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent-blue" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{t('panel.title')}</div>
<div className="truncate text-[10px] text-txt-faint">
{provider && model
? t('panel.modelConfigured', { provider: provider.name, model: model.label })
: t('panel.modelNotConfigured')}
{' · '}
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
<div className="mt-0.5 text-[10px] leading-snug text-txt-faint">
<div
className="truncate"
title={provider && model ? `${provider.name} · ${model.label}` : t('panel.modelNotConfigured')}
>
{provider && model ? modelSubtitle(provider.name, model.label) : t('panel.modelNotConfigured')}
</div>
<div className="break-all" title={sshReady ? targetLabel : undefined}>
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
</div>
</div>
</div>
<div className="flex items-center gap-0.5" role="radiogroup" aria-label={t('safety.aria')}>
{SAFETY_MODE_ORDER.map((mode) => {
const selected = safetyMode === mode
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`safety.${mode}.label`)}
disabled={running}
title={t(`safety.${mode}.description`)}
onClick={() => void setSetting('agentSafetyMode', mode)}
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
}`}
>
{t(`safety.${mode}.label`)}
</button>
)
})}
<div className="flex flex-shrink-0 items-center gap-0.5">
<div role="radiogroup" aria-label={t('safety.aria')} className="flex items-center">
{SAFETY_MODE_ORDER.map((mode) => {
const selected = safetyMode === mode
return (
<button
key={mode}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`safety.${mode}.label`)}
disabled={running}
title={t(`safety.${mode}.description`)}
onClick={() => void setSetting('agentSafetyMode', mode)}
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
}`}
>
{t(`safety.${mode}.label`)}
</button>
)
})}
</div>
{(messages.length > 0 || liveSteps.length > 0) && (
<button
type="button"
title={t('panel.newTopic')}
aria-label={t('panel.newTopic')}
disabled={running}
onClick={startNewTopic}
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</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'
}`}
title={t('panel.close')}
aria-label={t('panel.close')}
onClick={onClose}
className="rounded p-1 text-txt-muted hover:bg-dark-700"
>
<ShieldAlert className="h-3.5 w-3.5" />
<X className="h-4 w-4" />
</button>
</div>
<button
type="button"
title={t('panel.newTopic')}
aria-label={t('panel.newTopic')}
disabled={running}
onClick={startNewTopic}
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
>
<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"
>
<X className="h-4 w-4" />
</button>
</div>
</header>
+26
View File
@@ -0,0 +1,26 @@
import { type MouseEvent, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
/**
* Full-window layer (modal, palette, drawer). Portaled to document.body so
* TabContent overflow / containing blocks cannot clip it or steal title-bar
* hit testing. Only mount when `open` — a leftover inset-0 node blocks
* terminal input and window dragging.
*/
export interface AppOverlayProps {
open: boolean
children: ReactNode
className?: string
onMouseDown?: (event: MouseEvent<HTMLDivElement>) => void
}
export default function AppOverlay({ open, children, className = '', onMouseDown }: AppOverlayProps) {
if (!open || typeof document === 'undefined') return null
return createPortal(
<div className={`app-overlay ${className}`.trim()} data-app-overlay="" onMouseDown={onMouseDown}>
{children}
</div>,
document.body
)
}
+5 -8
View File
@@ -19,6 +19,7 @@ import {
} from '../../stores'
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
import AppOverlay from './AppOverlay'
// ─── 类型定义 ────────────────────────────────────────────────────────
@@ -309,13 +310,8 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
>
{/* Backdrop */}
<div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" />
<AppOverlay open className="z-[100] flex items-start justify-center pt-[15vh]" onMouseDown={handleBackdropClick}>
<div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" onClick={close} />
{/* Palette container */}
<div
@@ -323,6 +319,7 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
role="dialog"
aria-modal="true"
aria-label={t('palette.aria')}
onKeyDown={handleKeyDown}
>
{/* Search input */}
<div className="flex items-center gap-3 border-b border-dark-700 px-4 py-3">
@@ -451,7 +448,7 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
<span>{t('palette.resultCount', { count: filteredItems.length })}</span>
</div>
</div>
</div>
</AppOverlay>
)
}
+23 -19
View File
@@ -4,52 +4,56 @@ import { handleError } from '../../utils/error-handler'
interface DefaultFallbackProps {
error: Error
onReset: () => void
compact?: boolean
}
export default function DefaultFallback({ error, onReset }: DefaultFallbackProps) {
export default function DefaultFallback({ error, onReset, compact = false }: DefaultFallbackProps) {
const { t } = useTranslation('common')
const appError = handleError(error)
return (
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<div
className={`flex items-center justify-center bg-dark-900 p-4 ${compact ? 'h-full min-h-[12rem]' : 'min-h-screen'}`}
>
<div className="w-full max-w-md rounded-xl border border-dark-700 bg-dark-800 p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/20">
<svg className="h-6 w-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-content-primary">{t('error.title')}</h2>
<p className="text-sm text-txt-muted">{t('error.body')}</p>
<p className="text-sm text-txt-muted">{compact ? t('error.bodyPane') : t('error.body')}</p>
</div>
</div>
<div className="bg-dark-900 rounded-lg p-4 mb-4">
<p className="text-sm text-content-secondary mb-2">{appError.message}</p>
{appError.recoverable && (
<p className="text-xs text-green-400">{t('error.recoverable')}</p>
)}
<div className="mb-4 rounded-lg bg-dark-900 p-4">
<p className="mb-2 text-sm text-content-secondary">{appError.message}</p>
{appError.recoverable && <p className="text-xs text-green-400">{t('error.recoverable')}</p>}
</div>
<div className="flex gap-3">
<button
onClick={onReset}
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-content-primary rounded-lg transition-colors text-sm font-medium"
className="flex-1 rounded-lg bg-accent-blue px-4 py-2 text-sm font-medium text-content-primary transition-colors hover:bg-blue-600"
>
{t('error.refreshPage')}
{compact ? t('error.retryPane') : t('error.refreshPage')}
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-content-secondary rounded-lg transition-colors text-sm"
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-content-secondary transition-colors hover:bg-dark-600"
>
{t('error.restartApp')}
</button>
</div>
<p className="mt-4 text-xs text-txt-faint text-center">
{t('error.supportHint')}
</p>
<p className="mt-4 text-center text-xs text-txt-faint">{t('error.supportHint')}</p>
</div>
</div>
)
+16 -30
View File
@@ -1,10 +1,3 @@
/**
* 错误边界组件
*
* 捕获子组件树中的 JavaScript 错误,防止整个应用崩溃
* 体现 "安全可靠" 和 "操作便捷人性化" 理念
*/
import { Component, type ReactNode } from 'react'
import { createLogger } from '../../utils/logger'
import DefaultFallback from './DefaultFallback'
@@ -14,6 +7,10 @@ const logger = createLogger('ErrorBoundary')
interface Props {
children: ReactNode
fallback?: ReactNode
/** In-pane fallback so one tab crash does not look like a full-app failure. */
compact?: boolean
/** Changing this key clears a previously captured error. */
resetKey?: string | number
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
}
@@ -22,14 +19,6 @@ interface State {
error: Error | null
}
/**
* 错误边界组件
*
* @example
* <ErrorBoundary>
* <MyComponent />
* </ErrorBoundary>
*/
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
@@ -40,15 +29,18 @@ export default class ErrorBoundary extends Component<Props, State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// 记录错误日志
logger.error('组件错误边界捕获到异常', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
})
componentDidUpdate(prevProps: Props) {
if (this.props.resetKey !== prevProps.resetKey && this.state.hasError) {
this.setState({ hasError: false, error: null })
}
}
// 调用外部错误处理回调
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
logger.error('组件错误边界捕获到异常', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
})
this.props.onError?.(error, errorInfo)
}
@@ -58,16 +50,10 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError && this.state.error) {
// 使用自定义 fallback 或默认 fallback
if (this.props.fallback) {
return this.props.fallback
}
return (
<DefaultFallback
error={this.state.error}
onReset={this.handleReset}
/>
)
return <DefaultFallback error={this.state.error} onReset={this.handleReset} compact={this.props.compact} />
}
return this.props.children
+26 -28
View File
@@ -5,6 +5,7 @@
import { type ReactNode, useEffect, useId, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from 'lucide-react'
import AppOverlay from './AppOverlay'
import Button from './Button'
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
@@ -69,9 +70,7 @@ export default function Modal({
useEffect(() => {
if (!isOpen) return
restoreFocusRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
const focusables = () =>
Array.from(
@@ -125,59 +124,58 @@ export default function Modal({
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
<AppOverlay
open
className="z-[100] flex items-center justify-center p-4 sm:p-6"
onMouseDown={
disableBackdropClose
? undefined
: (event) => {
if (event.target === event.currentTarget) onClose()
}
}
>
<div
className="absolute inset-0 bg-black/55 backdrop-blur-[2px] animate-fade-in"
className="absolute inset-0 animate-fade-in bg-black/55 backdrop-blur-[2px]"
onClick={disableBackdropClose ? undefined : onClose}
/>
<div
ref={panelRef}
className={`
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
flex flex-col
bg-dark-800 border border-dark-700/80 rounded-2xl
shadow-[0_25px_50px_-12px_rgba(0,0,0,0.55)]
animate-scale-in
${className}
`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`relative w-full ${sizeClass[size]} border-dark-700/80 flex max-h-[min(90vh,880px)] animate-scale-in flex-col rounded-2xl border bg-dark-800 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.55)] ${className} `}
>
{/* Header */}
<div className="flex items-start gap-3 px-5 sm:px-6 py-4 border-b border-dark-700/60 flex-shrink-0">
<div className="border-dark-700/60 flex flex-shrink-0 items-start gap-3 border-b px-5 py-4 sm:px-6">
{icon && (
<div className="w-10 h-10 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center flex-shrink-0 text-accent-blue">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
{icon}
</div>
)}
<div className="min-w-0 flex-1 pt-0.5">
<h3 id={titleId} className="text-base sm:text-lg font-semibold text-content-primary tracking-tight">
<h3 id={titleId} className="text-base font-semibold tracking-tight text-content-primary sm:text-lg">
{title}
</h3>
{description && (
<p className="text-xs sm:text-sm text-txt-muted mt-0.5 leading-relaxed">{description}</p>
)}
{description && <p className="mt-0.5 text-xs leading-relaxed text-txt-muted sm:text-sm">{description}</p>}
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 -mr-1 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700 transition-colors flex-shrink-0"
className="-mr-1 flex-shrink-0 rounded-lg p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
aria-label={t('action.close')}
>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="px-5 sm:px-6 py-4 overflow-y-auto flex-1 min-h-0">{children}</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 sm:px-6">{children}</div>
{/* Footer */}
{showFooter && (
<div className="flex items-center justify-end gap-2.5 px-5 sm:px-6 py-3.5 border-t border-dark-700/60 bg-dark-900/30 rounded-b-2xl flex-shrink-0">
<div className="border-dark-700/60 bg-dark-900/30 flex flex-shrink-0 items-center justify-end gap-2.5 rounded-b-2xl border-t px-5 py-3.5 sm:px-6">
{footer ?? (
<>
<Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>
@@ -199,6 +197,6 @@ export default function Modal({
</div>
)}
</div>
</div>
</AppOverlay>
)
}
@@ -13,6 +13,7 @@ import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
import type { PooledConnection } from '../../stores/connection/types'
import EmptyState from '../common/EmptyState'
import StatusDot from '../common/StatusDot'
import AppOverlay from '../common/AppOverlay'
// ============== Helpers ==============
@@ -222,12 +223,9 @@ export default function ConnectionPanel() {
if (!isOpen) return null
return (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-40 animate-fade-in bg-black/30" onClick={close} />
{/* Panel */}
<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">
<AppOverlay open className="z-40">
<div className="absolute inset-0 animate-fade-in bg-black/30" onClick={close} />
<div className="bg-dark-800/95 border-dark-700/30 animate-slide-in-right absolute bottom-0 right-0 top-0 flex w-[360px] flex-col border-l shadow-xl backdrop-blur">
{/* Header */}
<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">
@@ -274,6 +272,6 @@ export default function ConnectionPanel() {
)}
</div>
</div>
</>
</AppOverlay>
)
}
+33 -76
View File
@@ -1,22 +1,24 @@
/**
* Right drawer for SSH port forwarding (SessionDialog-style).
* Portaled to document.body so TabContent overflow-hidden cannot clip it.
* Uses AppOverlay (portaled to document.body) so TabContent overflow cannot clip it.
*
* Dynamic is a single SOCKS5 on 127.0.0.1 (default port 1080; port set in the form below).
* Local / Remote rules appear in the list and can be added repeatedly.
*/
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
import AppOverlay from '../common/AppOverlay'
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
import {
DEFAULT_DYNAMIC_PORT,
DEFAULT_LOCAL_BIND_HOST,
DEFAULT_REMOTE_BIND_HOST,
formatHostPort,
isAllInterfacesBind,
parseHostPort,
resolveDynamicPort,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
@@ -24,15 +26,6 @@ import {
type ForwardRuleType,
} from './forwardRules'
export const DEFAULT_DYNAMIC_PORT = 1080
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function resolveDynamicPort(port: unknown): number {
const value = Number(port)
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
return DEFAULT_DYNAMIC_PORT
}
export interface ForwardRuleDraft {
type: ForwardRuleType
name: string
@@ -164,9 +157,7 @@ export default function ForwardRuleDrawer({
const localRemoteRules = (rules || []).filter((rule) => rule.type !== 'dynamic')
const dynamicRuntime = dynamicRule ? runtimes?.get(dynamicRule.id) : undefined
const dynamicRunning = Boolean(dynamicRuntime?.running)
const dynamicPort = resolveDynamicPort(
dynamicRuntime?.boundPort || dynamicRule?.localPort || draft.localPort
)
const dynamicPort = resolveDynamicPort(dynamicRuntime?.boundPort || dynamicRule?.localPort || draft.localPort)
useEffect(() => {
if (!open) return
@@ -190,16 +181,9 @@ export default function ForwardRuleDrawer({
const localName = t('portForward.defaultLocalName')
const remoteName = t('portForward.defaultRemoteName')
const current = (draft.name || '').trim()
const isPreset =
!current || current === socksName || current === localName || current === remoteName
const isPreset = !current || current === socksName || current === localName || current === remoteName
const nextName = !isPreset
? current
: next === 'dynamic'
? socksName
: next === 'remote'
? remoteName
: localName
const nextName = !isPreset ? current : next === 'dynamic' ? socksName : next === 'remote' ? remoteName : localName
if (next === 'dynamic') {
onChange({
@@ -217,21 +201,15 @@ export default function ForwardRuleDrawer({
const nextLocalPort = draft.type === 'dynamic' ? 8080 : draft.localPort || 8080
const nextRemotePort =
draft.type === 'dynamic'
? next === 'remote'
? 18080
: 80
: draft.remotePort || (next === 'remote' ? 18080 : 80)
draft.type === 'dynamic' ? (next === 'remote' ? 18080 : 80) : draft.remotePort || (next === 'remote' ? 18080 : 80)
onChange({
...draft,
type: next,
name: nextName,
localPort: nextLocalPort,
localBindHost:
next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
remoteBindHost:
next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
localBindHost: next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
remoteBindHost: next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
remoteHost: resolveForwardTargetHost(draft.remoteHost) || '127.0.0.1',
remotePort: nextRemotePort,
})
@@ -246,16 +224,16 @@ export default function ForwardRuleDrawer({
? t('action.save')
: t('portForward.addRule')
return createPortal(
<div className="fixed inset-0 z-[100] flex justify-end">
<div className="absolute inset-0 bg-black/45 backdrop-blur-[1px] animate-fade-in" onClick={onClose} />
return (
<AppOverlay open className="z-[100] flex justify-end">
<div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} />
<aside
className="relative flex h-full w-full max-w-[420px] flex-col border-l border-dark-700/70 bg-dark-800 shadow-2xl animate-slide-in-right"
className="border-dark-700/70 animate-slide-in-right relative flex h-full w-full max-w-[420px] flex-col border-l bg-dark-800 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="forward-drawer-title"
>
<div className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/50 px-4 py-3.5">
<div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
<ArrowRightLeft className="h-4 w-4" />
</div>
@@ -286,9 +264,7 @@ export default function ForwardRuleDrawer({
{/* Fixed Dynamic slot */}
<div
className={`rounded-xl border px-3 py-2.5 ${
dynamicRunning
? 'border-accent-blue/40 bg-accent-blue/10'
: 'border-dark-700/60 bg-dark-900/30'
dynamicRunning ? 'border-accent-blue/40 bg-accent-blue/10' : 'border-dark-700/60 bg-dark-900/30'
}`}
>
<div className="flex items-start gap-2.5">
@@ -297,21 +273,15 @@ export default function ForwardRuleDrawer({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-content-primary">
{t('portForward.typeDynamic')}
</span>
<span className="rounded bg-orange-500/15 px-1.5 py-px text-[10px] text-orange-300">
SOCKS5
</span>
<span className="text-xs font-medium text-content-primary">{t('portForward.typeDynamic')}</span>
<span className="rounded bg-orange-500/15 px-1.5 py-px text-[10px] text-orange-300">SOCKS5</span>
{dynamicRunning && (
<span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400">
{t('portForward.running')}
</span>
)}
</div>
<p className="mt-0.5 font-mono text-[11px] text-txt-muted">
127.0.0.1:{dynamicPort}
</p>
<p className="mt-0.5 font-mono text-[11px] text-txt-muted">127.0.0.1:{dynamicPort}</p>
</div>
<div className="flex shrink-0 gap-1">
{dynamicRule && dynamicRunning && onStopRule && (
@@ -369,9 +339,7 @@ export default function ForwardRuleDrawer({
<div
key={rule.id}
className={`rounded-xl border px-3 py-2 ${
running
? 'border-green-500/30 bg-green-500/10'
: 'border-dark-700/60 bg-dark-900/30'
running ? 'border-green-500/30 bg-green-500/10' : 'border-dark-700/60 bg-dark-900/30'
}`}
>
<div className="flex items-start gap-2.5">
@@ -390,9 +358,7 @@ export default function ForwardRuleDrawer({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-xs font-medium text-content-primary">
{rule.name}
</span>
<span className="truncate text-xs font-medium text-content-primary">{rule.name}</span>
<span
className={`rounded px-1.5 py-px text-[10px] ${
rule.type === 'local'
@@ -400,9 +366,7 @@ export default function ForwardRuleDrawer({
: 'bg-purple-500/15 text-purple-300'
}`}
>
{rule.type === 'local'
? t('portForward.typeLocal')
: t('portForward.typeRemote')}
{rule.type === 'local' ? t('portForward.typeLocal') : t('portForward.typeRemote')}
</span>
{running && (
<span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400">
@@ -463,7 +427,7 @@ export default function ForwardRuleDrawer({
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{showList ? t('portForward.addSection') : t('portForward.selectType')}
</div>
<div className="grid grid-cols-3 gap-0 overflow-hidden rounded-lg border border-dark-600 bg-dark-900/40 p-0.5">
<div className="bg-dark-900/40 grid grid-cols-3 gap-0 overflow-hidden rounded-lg border border-dark-600 p-0.5">
{types.map((item) => (
<button
key={item.type}
@@ -487,7 +451,7 @@ export default function ForwardRuleDrawer({
<ForwardArchitectureDiagram type={type} />
{type === 'dynamic' ? (
<section className="space-y-2 rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5">
<section className="border-dark-700/50 bg-dark-900/30 space-y-2 rounded-xl border p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('portForward.typeDynamic')}
</div>
@@ -511,7 +475,7 @@ export default function ForwardRuleDrawer({
</p>
</section>
) : (
<section className="space-y-3 rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5">
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('portForward.ruleFields')}
</div>
@@ -531,7 +495,7 @@ export default function ForwardRuleDrawer({
{type === 'local' ? (
<>
{viaSession ? (
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
<p className="border-dark-700/50 bg-dark-900/40 rounded-lg border px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
{t('portForward.viaSession', { target: viaSession })}
</p>
) : null}
@@ -561,10 +525,7 @@ export default function ForwardRuleDrawer({
<label className="ui-label">{t('portForward.targetAddress')}</label>
<input
type="text"
value={formatHostPort(
resolveForwardTargetHost(draft.remoteHost),
draft.remotePort
)}
value={formatHostPort(resolveForwardTargetHost(draft.remoteHost), draft.remotePort)}
onChange={(e) => {
const raw = e.target.value.trim()
const parsed = parseHostPort(raw)
@@ -600,7 +561,7 @@ export default function ForwardRuleDrawer({
: t('portForward.targetAddressHint')}
</p>
</div>
<details className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2">
<details className="border-dark-700/40 bg-dark-900/20 rounded-lg border px-2.5 py-2">
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
{t('portForward.localBindAdvanced')}
</summary>
@@ -628,7 +589,7 @@ export default function ForwardRuleDrawer({
) : (
<>
{viaSession ? (
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
<p className="border-dark-700/50 bg-dark-900/40 rounded-lg border px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
{t('portForward.viaSessionRemote', { target: viaSession })}
</p>
) : null}
@@ -658,10 +619,7 @@ export default function ForwardRuleDrawer({
<label className="ui-label">{t('portForward.localTargetAddress')}</label>
<input
type="text"
value={formatHostPort(
resolveForwardTargetHost(draft.remoteHost),
draft.localPort
)}
value={formatHostPort(resolveForwardTargetHost(draft.remoteHost), draft.localPort)}
onChange={(e) => {
const raw = e.target.value.trim()
const parsed = parseHostPort(raw)
@@ -696,7 +654,7 @@ export default function ForwardRuleDrawer({
</p>
</div>
<details
className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2"
className="border-dark-700/40 bg-dark-900/20 rounded-lg border px-2.5 py-2"
open={isAllInterfacesBind(resolveRemoteBindHost(draft.remoteBindHost))}
>
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
@@ -734,7 +692,7 @@ export default function ForwardRuleDrawer({
)}
</div>
<div className="flex flex-shrink-0 gap-2 border-t border-dark-700/50 px-4 py-3">
<div className="border-dark-700/50 flex flex-shrink-0 gap-2 border-t px-4 py-3">
<button
type="button"
onClick={onClose}
@@ -753,7 +711,6 @@ export default function ForwardRuleDrawer({
</button>
</div>
</aside>
</div>,
document.body
</AppOverlay>
)
}
+20 -63
View File
@@ -10,18 +10,17 @@ import { useTabStore } from '../../stores/tabStore'
import { useConnectionPool } from '../../stores/connection'
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { translateErrorMessage } from '../../i18n/format'
import { translateErrorMessage, translateIpcError } from '../../i18n/format'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
emptyForwardDraft,
resolveDynamicPort,
} from './ForwardRuleDrawer'
import ForwardRuleDrawer, { emptyForwardDraft } from './ForwardRuleDrawer'
import { startNativeForward, stopNativeForward } from './forwardRuntime'
import {
DEFAULT_DYNAMIC_PORT,
formatTransferBytes,
duplicateForwardRule,
parseForwardRules,
portForwardStorageKey,
resolveDynamicPort,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
@@ -156,11 +155,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
)
return
}
if (
newRule.type === 'dynamic' &&
!editingRuleId &&
rules.some((item) => item.type === 'dynamic')
) {
if (newRule.type === 'dynamic' && !editingRuleId && rules.some((item) => item.type === 'dynamic')) {
setDrawerError(t('common:portForward.errorDynamicExists'))
return
}
@@ -170,12 +165,9 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
type: newRule.type as 'local' | 'remote' | 'dynamic',
name: newRule.name.trim(),
localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!,
localBindHost:
newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined,
remoteBindHost:
newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
remoteHost:
newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
localBindHost: newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined,
remoteBindHost: newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
remoteHost: newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
remotePort: newRule.remotePort,
}
@@ -194,43 +186,13 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
setError(null)
try {
let result
if (rule.type === 'local') {
result = await window.opsxApi?.sshForwardLocal(
connectionId,
rule.localPort,
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi?.sshForwardRemote(
connectionId,
rule.remotePort || 0,
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi?.sshForwardDynamic(
connectionId,
resolveDynamicPort(rule.localPort),
rule.id
)
}
if (result?.success) {
const result = await startNativeForward(connectionId, rule)
if (result.success) {
rememberForwardIntent(intentKey, rule)
await refreshTunnels()
return true
}
setError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
)
setError(translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStartFailed'))
} catch (err: unknown) {
const raw = err instanceof Error ? err.message : String(err)
setError(raw ? translateErrorMessage(raw) : t('common:portForward.errorStartFailed'))
@@ -284,23 +246,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
setIsLoading(true)
setError(null)
try {
let result = await window.opsxApi?.sshForwardStop(rule.id)
if (!result?.success && connectionId) {
const listed = await window.opsxApi?.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
if (match) {
result = await window.opsxApi?.sshForwardStop(match.forwardId)
}
}
if (!result?.success) {
setError(result?.error || t('common:portForward.errorStopFailed'))
const result = await stopNativeForward(connectionId, rule)
if (!result.success) {
setError(translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStopFailed'))
return false
}
forgetForwardIntent(intentKey, rule.id)
await refreshTunnels()
return true
} catch (err: unknown) {
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStopFailed'))
setError(
translateErrorMessage(err instanceof Error ? err.message : String(err)) ||
t('common:portForward.errorStopFailed')
)
return false
} finally {
setIsLoading(false)
@@ -351,7 +309,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
{needsReconnect && currentTab?.sessionId && (
<div className="bg-dark-900/90 absolute inset-0 z-50 flex items-center justify-center">
<div className="text-center">
<div className="bg-accent-blue/20 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent-blue/20">
<ArrowRightLeft className="h-8 w-8 text-accent-blue" />
</div>
<div className="mb-2 text-lg text-content-primary">{t('common:portForward.sessionRestoredTitle')}</div>
@@ -597,7 +555,6 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<p className="text-sm text-red-400">{error}</p>
</div>
)}
</div>
<ForwardRuleDrawer
+10 -5
View File
@@ -3,6 +3,15 @@ import i18n from '../../i18n'
export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
/** Default SOCKS5 listen port (`-D`). */
export const DEFAULT_DYNAMIC_PORT = 1080
export function resolveDynamicPort(port: unknown): number {
const value = Number(port)
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
return DEFAULT_DYNAMIC_PORT
}
/** Default local listen bind for `-L` (this machine only). */
export const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'
@@ -93,11 +102,7 @@ export function isInvalidForwardTargetHost(host: string): boolean {
return isListenOnlyAddress(host)
}
export function portForwardStorageKey(
sessionId: string | undefined,
tabId: string,
connectionId?: string
): string {
export function portForwardStorageKey(sessionId: string | undefined, tabId: string, connectionId?: string): string {
if (sessionId) return `portForwards_session_${sessionId}`
if (connectionId) return `portForwards_conn_${connectionId}`
return `portForwards_tab_${tabId}`
@@ -0,0 +1,71 @@
import type { SSHForwardResponse } from '../../../shared/types/ipc.types'
import { tunnelCoversRule } from './forwardIntent'
import {
resolveDynamicPort,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
type ForwardRule,
} from './forwardRules'
export type ForwardCommandResult = Pick<SSHForwardResponse, 'success' | 'error' | 'errorCode'>
function asResult(result?: SSHForwardResponse | null): ForwardCommandResult {
return {
success: Boolean(result?.success),
error: result?.error,
errorCode: result?.errorCode,
}
}
/** Start the native listener for a persisted rule. UI layers own toast / intent / refresh. */
export async function startNativeForward(connectionId: string, rule: ForwardRule): Promise<ForwardCommandResult> {
if (!window.opsxApi) return { success: false }
if (rule.type === 'local') {
return asResult(
await window.opsxApi.sshForwardLocal(
connectionId,
rule.localPort,
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
)
}
if (rule.type === 'remote') {
return asResult(
await window.opsxApi.sshForwardRemote(
connectionId,
rule.remotePort || 0,
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
)
}
return asResult(await window.opsxApi.sshForwardDynamic(connectionId, resolveDynamicPort(rule.localPort), rule.id))
}
/**
* Stop the real native listener. After reconnect the runtime id may differ from
* the persisted rule id — fall back to a tunnel that still covers the rule.
*/
export async function stopNativeForward(
connectionId: string | undefined,
rule: ForwardRule
): Promise<ForwardCommandResult> {
if (!window.opsxApi?.sshForwardStop) return { success: false }
let result = await window.opsxApi.sshForwardStop(rule.id)
if (!result?.success && connectionId) {
const listed = await window.opsxApi.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
if (match) {
result = await window.opsxApi.sshForwardStop(match.forwardId)
}
}
return asResult(result)
}
+169 -28
View File
@@ -1,8 +1,17 @@
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import {
useCallback,
useEffect,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
type MouseEvent,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
import StatusDot from '../common/StatusDot'
import type {
@@ -35,8 +44,17 @@ import {
vncInputKey,
vncInputPointer,
} from '../../platform/vnc'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
import { clampDesktopResolution } from '../../utils/desktopResolution'
import {
clampDesktopResolution,
desktopCanvasDisplaySize,
isUsableDesktopPane,
resolutionFromPane,
type DesktopResolution,
} from '../../utils/desktopResolution'
import { reconnectDelayMs } from '../../utils/disconnectReason'
import { subscribeTabReconnect } from '../../utils/tabReconnect'
interface DesktopSessionViewProps {
tabId: string
@@ -97,6 +115,12 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
const [chromeVisible, setChromeVisible] = useState(true)
const [reconnectGeneration, setReconnectGeneration] = useState(0)
const reconnectAttemptRef = useRef(0)
const reconnectTimerRef = useRef<number | null>(null)
const intentionalDisconnectRef = useRef(false)
const hadConnectedRef = useRef(false)
const pasteLockRef = useRef(0)
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
isActiveRef.current = isActive
@@ -134,6 +158,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
}
}, [protocol, sessions, tab?.rdpConfig, tab?.sessionId, tab?.vncConfig])
const clearDesktopReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current != null) {
window.clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
}, [])
const scheduleDesktopReconnect = useCallback(() => {
const { settings } = useSettingsStore.getState()
if (!settings.autoReconnect) return
if (intentionalDisconnectRef.current) return
if (!hadConnectedRef.current) return
const maxAttempts = settings.maxReconnectAttempts ?? 5
const baseDelay = settings.reconnectBaseDelayMs ?? 1500
if (reconnectAttemptRef.current >= maxAttempts) {
setError(t('status.reconnectLimit', { max: maxAttempts }))
updateTabStatus(tabId, 'error')
return
}
reconnectAttemptRef.current += 1
const attempt = reconnectAttemptRef.current
const delay = reconnectDelayMs(attempt, baseDelay)
updateTabStatus(tabId, 'connecting')
setStatusMessage(t('status.reconnecting', { attempt, max: maxAttempts }))
clearDesktopReconnectTimer()
reconnectTimerRef.current = window.setTimeout(() => {
reconnectTimerRef.current = null
setReconnectGeneration((value) => value + 1)
}, delay)
}, [clearDesktopReconnectTimer, t, tabId, updateTabStatus])
const handleDesktopReconnect = useCallback(() => {
clearDesktopReconnectTimer()
reconnectAttemptRef.current = 0
intentionalDisconnectRef.current = false
setError(null)
setReconnectGeneration((value) => value + 1)
}, [clearDesktopReconnectTimer])
useEffect(() => {
return () => clearDesktopReconnectTimer()
}, [clearDesktopReconnectTimer])
useEffect(() => subscribeTabReconnect(tabId, handleDesktopReconnect), [tabId, handleDesktopReconnect])
useEffect(() => {
if (!resolvedConfig) return
let disposed = false
@@ -208,28 +277,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
connectedRef.current = false
lastRemoteResizeRef.current = null
const waitForPaneResolution = async (fallback: DesktopResolution): Promise<DesktopResolution> => {
for (let i = 0; i < 24; i++) {
const pane = paneRef.current
const width = pane?.clientWidth ?? 0
const height = pane?.clientHeight ?? 0
if (pane && isUsableDesktopPane(width, height)) {
return resolutionFromPane(width, height, fallback)
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve())
})
}
const pane = paneRef.current
return resolutionFromPane(pane?.clientWidth ?? 0, pane?.clientHeight ?? 0, fallback)
}
void (async () => {
try {
if (protocol === 'rdp') {
unlistenStatus = await onRdpStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
if (!nativeSessionIdRef.current) {
if (payload.status === 'disconnected' || payload.status === 'error') return
nativeSessionIdRef.current = payload.sessionId
}
if (payload.width && payload.height) {
setFbSize({ width: payload.width, height: payload.height })
lastRemoteResizeRef.current = { width: payload.width, height: payload.height }
}
if (payload.status === 'connected') {
connectedRef.current = true
hadConnectedRef.current = true
reconnectAttemptRef.current = 0
updateTabStatus(tabId, 'connected')
setStatusMessage(payload.message || t('status.connected'))
} else if (payload.status === 'error') {
connectedRef.current = false
updateTabStatus(tabId, 'error')
setError(payload.message || t('status.connectFailed'))
if (!disposed) scheduleDesktopReconnect()
} else if (payload.status === 'disconnected') {
connectedRef.current = false
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
if (!disposed) scheduleDesktopReconnect()
} else if (payload.message) {
setStatusMessage(payload.message)
}
@@ -241,7 +333,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
unlistenClipboard = await onRdpClipboard((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return
if (payload.text) {
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
void writeClipboardText(payload.text).catch(() => undefined)
setStatusMessage(t('status.clipboardSynced'))
}
})
@@ -249,7 +341,15 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
if (payload.sessionId !== nativeSessionIdRef.current) return
setStatusMessage(t('status.savedToDownloads', { name: payload.name }))
})
const sessionId = await rdpConnect(resolvedConfig as RdpConfig)
const rdpConfig = resolvedConfig as RdpConfig
const paneSize = await waitForPaneResolution(clampDesktopResolution(rdpConfig.width, rdpConfig.height))
if (disposed) return
lastRemoteResizeRef.current = paneSize
const sessionId = await rdpConnect({
...rdpConfig,
width: paneSize.width,
height: paneSize.height,
})
if (disposed) {
await rdpDisconnect(sessionId)
return
@@ -258,16 +358,26 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
} else {
unlistenStatus = await onVncStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
if (!nativeSessionIdRef.current) {
if (payload.status === 'disconnected' || payload.status === 'error') return
nativeSessionIdRef.current = payload.sessionId
}
if (payload.status === 'connected') {
connectedRef.current = true
hadConnectedRef.current = true
reconnectAttemptRef.current = 0
updateTabStatus(tabId, 'connected')
setStatusMessage(payload.message || t('status.connected'))
} else if (payload.status === 'error') {
connectedRef.current = false
updateTabStatus(tabId, 'error')
setError(payload.message || t('status.connectFailed'))
if (!disposed) scheduleDesktopReconnect()
} else if (payload.status === 'disconnected') {
connectedRef.current = false
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
if (!disposed) scheduleDesktopReconnect()
} else if (payload.message) {
setStatusMessage(payload.message)
}
@@ -279,7 +389,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
unlistenClipboard = await onVncClipboard((payload) => {
if (payload.sessionId !== nativeSessionIdRef.current) return
if (payload.text) {
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
void writeClipboardText(payload.text).catch(() => undefined)
setStatusMessage(t('status.clipboardSynced'))
}
})
@@ -320,7 +430,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
else void vncDisconnect(sessionId)
}
}
}, [protocol, resolvedConfig, tabId, updateTabStatus, t])
}, [protocol, resolvedConfig, tabId, updateTabStatus, t, reconnectGeneration, scheduleDesktopReconnect])
useEffect(() => {
if (!isActive) return
@@ -334,13 +444,15 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
const updateFit = () => {
const availW = Math.max(1, pane.clientWidth)
const availH = Math.max(1, pane.clientHeight)
const scale = Math.min(availW / fbSize.width, availH / fbSize.height)
const displayW = Math.max(1, Math.floor(fbSize.width * scale))
const displayH = Math.max(1, Math.floor(fbSize.height * scale))
canvas.style.width = `${displayW}px`
canvas.style.height = `${displayH}px`
const display = desktopCanvasDisplaySize(
protocol,
pane.clientWidth,
pane.clientHeight,
fbSize.width,
fbSize.height
)
canvas.style.width = `${display.width}px`
canvas.style.height = `${display.height}px`
}
const requestRemoteResize = () => {
@@ -503,14 +615,28 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
}
}
const pasteToRemote = async () => {
const pasteToRemote = async (textFromEvent?: string) => {
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
const text = await navigator.clipboard.readText()
if (!text) return
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text)
else await vncClipboardSet(sessionId, text)
setStatusMessage(t('status.pastedToRemote'))
const now = Date.now()
if (now - pasteLockRef.current < 400) return
pasteLockRef.current = now
try {
const text = textFromEvent ?? (await readClipboardText())
if (!text) return
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text)
else await vncClipboardSet(sessionId, text)
setStatusMessage(t('status.pastedToRemote'))
} catch (reason) {
setStatusMessage(reason instanceof Error ? reason.message : String(reason))
}
}
const onPasteClipboard = (event: ClipboardEvent<HTMLCanvasElement>) => {
event.preventDefault()
event.stopPropagation()
const text = event.clipboardData?.getData('text/plain')
void pasteToRemote(text || undefined)
}
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
@@ -533,12 +659,12 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
event.preventDefault()
const isPasteChord =
pressed && (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
const isPasteChord = (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.
void pasteToRemote()
// Swallow Cmd/Ctrl+V (keydown and keyup). Native VNC types the text as
// keysyms so remote terminals do not wrap it in bracketed paste.
if (pressed) void pasteToRemote()
return
}
@@ -624,6 +750,16 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
)}
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className={toolBtn}
disabled={connected || tab?.connectionStatus === 'connecting'}
onClick={() => handleDesktopReconnect()}
title={t('toolbar.reconnect')}
aria-label={t('toolbar.reconnectAria')}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={toolBtn}
@@ -707,14 +843,18 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
<div
ref={paneRef}
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${
fullscreenActive ? 'p-0' : 'p-2'
className={`flex min-h-0 flex-1 overflow-hidden bg-black ${
protocol === 'rdp' || fullscreenActive
? 'items-stretch justify-stretch p-0'
: 'items-center justify-center p-2'
}`}
>
<canvas
ref={canvasRef}
tabIndex={0}
className="cursor-default shadow-lg shadow-black/40 outline-none"
className={`cursor-default outline-none ${
protocol === 'rdp' ? 'h-full w-full' : 'shadow-lg shadow-black/40'
}`}
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => mapPointer(event)}
onMouseDown={(event) => mapPointer(event, true)}
@@ -722,6 +862,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
onMouseLeave={() => {
buttonsRef.current = { left: false, middle: false, right: false }
}}
onPaste={onPasteClipboard}
onKeyDown={(event) => onKey(event, true)}
onKeyUp={(event) => onKey(event, false)}
/>
+49 -128
View File
@@ -7,21 +7,20 @@ import type { ConnectionState } from '../../stores/connection'
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
import { useSessionStore, useEntitlementStore } from '../../stores'
import type { HostMetrics } from '../../../shared/types/host-metrics.types'
import { formatLocaleTime, translateErrorMessage } from '../../i18n/format'
import { formatLocaleTime, translateErrorMessage, translateIpcError } from '../../i18n/format'
import { useToast } from '../../hooks/useToast'
import ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
emptyForwardDraft,
resolveDynamicPort,
} from '../connection/ForwardRuleDrawer'
import ForwardRuleDrawer, { emptyForwardDraft } from '../connection/ForwardRuleDrawer'
import {
DEFAULT_DYNAMIC_PORT,
parseForwardRules,
portForwardStorageKey,
resolveDynamicPort,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
type ForwardRule,
} from '../connection/forwardRules'
import { startNativeForward, stopNativeForward } from '../connection/forwardRuntime'
import {
forgetForwardIntent,
forwardIntentKey,
@@ -42,43 +41,43 @@ interface StatusDisplay {
const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey: string }> = {
connected: {
icon: <Wifi className="w-3 h-3" />,
icon: <Wifi className="h-3 w-3" />,
labelKey: 'status.connected',
colorClass: 'text-feedback-success',
animateClass: '',
},
connecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
icon: <Loader2 className="h-3 w-3 animate-spin" />,
labelKey: 'status.connecting',
colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse',
},
background: {
icon: <Wifi className="w-3 h-3" />,
icon: <Wifi className="h-3 w-3" />,
labelKey: 'status.background',
colorClass: 'text-feedback-success',
animateClass: '',
},
suspended: {
icon: <WifiOff className="w-3 h-3" />,
icon: <WifiOff className="h-3 w-3" />,
labelKey: 'status.suspended',
colorClass: 'text-feedback-warning',
animateClass: '',
},
reconnecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
icon: <Loader2 className="h-3 w-3 animate-spin" />,
labelKey: 'status.reconnecting',
colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse',
},
error: {
icon: <AlertCircle className="w-3 h-3" />,
icon: <AlertCircle className="h-3 w-3" />,
labelKey: 'status.error',
colorClass: 'text-feedback-error',
animateClass: '',
},
disconnected: {
icon: <WifiOff className="w-3 h-3" />,
icon: <WifiOff className="h-3 w-3" />,
labelKey: 'status.disconnected',
colorClass: 'text-txt-muted',
animateClass: '',
@@ -276,8 +275,7 @@ function RemoteHostMetricsStrip({
metrics?.memPercent != null ? t('metrics.memory', { percent: metrics.memPercent, detail: memDetail }) : null,
transferLabel ? t('metrics.sessionTransfer', { value: transferLabel.replace(/^↑↓\s*/, '') }) : null,
...rankedDisks.map(
(d) =>
`${d.mount} ${d.percent}% (${formatBytes(d.usedKb * 1024)}/${formatBytes(d.totalKb * 1024)})`
(d) => `${d.mount} ${d.percent}% (${formatBytes(d.usedKb * 1024)}/${formatBytes(d.totalKb * 1024)})`
),
].filter(Boolean)
@@ -333,21 +331,19 @@ function RemoteHostMetricsStrip({
title={tipParts.join(' · ')}
>
{osLabel && (
<span className="text-content-secondary max-w-[10rem] shrink truncate" title={osLabel}>
<span className="max-w-[10rem] shrink truncate text-content-secondary" title={osLabel}>
{osLabel}
</span>
)}
{tz && <span className="text-txt-faint shrink-0">{tz}</span>}
{tz && <span className="shrink-0 text-txt-faint">{tz}</span>}
{metrics?.cpuPercent != null && (
<span className={`shrink-0 ${pctClass(metrics.cpuPercent)}`}>
CPU {formatCpuPercent(metrics.cpuPercent)}
</span>
<span className={`shrink-0 ${pctClass(metrics.cpuPercent)}`}>CPU {formatCpuPercent(metrics.cpuPercent)}</span>
)}
{metrics?.memPercent != null && (
<span className={`shrink-0 ${pctClass(metrics.memPercent)}`}>MEM {metrics.memPercent}%</span>
)}
{transferLabel && (
<span className="text-txt-muted flex shrink-0 items-center gap-1">
<span className="flex shrink-0 items-center gap-1 text-txt-muted">
<HardDrive className="h-3 w-3" />
{transferLabel}
</span>
@@ -385,8 +381,7 @@ export default function StatusBar() {
const activeTab = useMemo(() => tabs.find((t) => t.id === activeTabId), [tabs, activeTabId])
const { connectionState, hostLabel, stats, connectionId, isRemoteSsh, sessionOsPretty } =
useMemo(() => {
const { connectionState, hostLabel, stats, connectionId, isRemoteSsh, sessionOsPretty } = useMemo(() => {
if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') {
return {
connectionState: undefined,
@@ -425,9 +420,7 @@ export default function StatusBar() {
}
}
const session = activeTab.sessionId
? sessions.find((s) => s.id === activeTab.sessionId)
: undefined
const session = activeTab.sessionId ? sessions.find((s) => s.id === activeTab.sessionId) : undefined
const sessionOsPretty = session?.osPrettyName
const instance = activeTab.connectionInstance
@@ -458,9 +451,7 @@ export default function StatusBar() {
const state = mapTabStatusToConnectionState(activeTab.connectionStatus)
const config = activeTab.connectionConfig
const label = config
? `${config.username}@${config.host}:${config.port || 22}`
: undefined
const label = config ? `${config.username}@${config.host}:${config.port || 22}` : undefined
return {
connectionState: state,
@@ -473,9 +464,7 @@ export default function StatusBar() {
}, [activeTab, connections, sessions, t])
const metricsEnabled =
isRemoteSsh &&
Boolean(connectionId) &&
(connectionState === 'connected' || connectionState === 'background')
isRemoteSsh && Boolean(connectionId) && (connectionState === 'connected' || connectionState === 'background')
const socksAvailable = metricsEnabled
const { metrics, stale } = useRemoteHostMetrics(connectionId, metricsEnabled)
@@ -632,38 +621,11 @@ export default function StatusBar() {
const startForwardRule = useCallback(
async (rule: ForwardRule): Promise<boolean> => {
if (!connectionId || !window.opsxApi) return false
let result
if (rule.type === 'local') {
result = await window.opsxApi.sshForwardLocal(
connectionId,
rule.localPort,
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi.sshForwardRemote(
connectionId,
rule.remotePort || 0,
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi.sshForwardDynamic(
connectionId,
resolveDynamicPort(rule.localPort),
rule.id
)
}
if (!result?.success) {
if (!connectionId) return false
const result = await startNativeForward(connectionId, rule)
if (!result.success) {
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStartFailed')
)
return false
}
@@ -695,21 +657,10 @@ export default function StatusBar() {
const stopForwardRule = useCallback(
async (rule: ForwardRule): Promise<boolean> => {
if (!window.opsxApi?.sshForwardStop) return false
let result = await window.opsxApi.sshForwardStop(rule.id)
if (!result?.success && connectionId) {
// Runtime id may differ from persisted rule id after reconnect.
const listed = await window.opsxApi.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
if (match) {
result = await window.opsxApi.sshForwardStop(match.forwardId)
}
}
if (!result?.success) {
const result = await stopNativeForward(connectionId, rule)
if (!result.success) {
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStopFailed')
translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStopFailed')
)
return false
}
@@ -767,16 +718,7 @@ export default function StatusBar() {
setForwardSaving(false)
}
},
[
connectionId,
forwardRules,
forwardRuntimes,
forwardSaving,
persistForwardRules,
startForwardRule,
t,
toast,
]
[connectionId, forwardRules, forwardRuntimes, forwardSaving, persistForwardRules, startForwardRule, t, toast]
)
const handleStopRule = useCallback(
@@ -788,8 +730,7 @@ export default function StatusBar() {
await stopForwardRule(rule)
} catch (error) {
setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) ||
t('common:portForward.errorStopFailed')
(error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStopFailed')
)
} finally {
setForwardSaving(false)
@@ -811,8 +752,7 @@ export default function StatusBar() {
await persistForwardRules(forwardRules.filter((item) => item.id !== rule.id))
} catch (error) {
setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) ||
t('common:portForward.errorStopFailed')
(error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStopFailed')
)
} finally {
setForwardSaving(false)
@@ -872,14 +812,8 @@ export default function StatusBar() {
type: forwardDraft.type || 'local',
name: forwardDraft.name.trim(),
localPort: forwardDraft.localPort,
localBindHost:
forwardDraft.type === 'local'
? resolveLocalBindHost(forwardDraft.localBindHost)
: undefined,
remoteBindHost:
forwardDraft.type === 'remote'
? resolveRemoteBindHost(forwardDraft.remoteBindHost)
: undefined,
localBindHost: forwardDraft.type === 'local' ? resolveLocalBindHost(forwardDraft.localBindHost) : undefined,
remoteBindHost: forwardDraft.type === 'remote' ? resolveRemoteBindHost(forwardDraft.remoteBindHost) : undefined,
remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost),
remotePort: forwardDraft.remotePort,
}
@@ -959,35 +893,32 @@ export default function StatusBar() {
const statusCfg = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.disconnected
const statusLabel = t(statusCfg.labelKey)
const transferLabel =
stats && stats.bytesTransferred > 0 ? `↑↓ ${formatBytes(stats.bytesTransferred)}` : null
const transferLabel = stats && stats.bytesTransferred > 0 ? `↑↓ ${formatBytes(stats.bytesTransferred)}` : null
return (
<div className="border-dark-700/40 bg-dark-800/95 flex h-7 select-none items-center px-3 text-[11px] text-content-primary">
<div className="app-statusbar border-dark-700/40 bg-dark-800/95 flex h-7 select-none items-center px-3 text-[11px] text-content-primary">
<button
type="button"
onClick={togglePanel}
className={`-ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors
hover:bg-dark-700/70 group
${statusCfg.colorClass} ${statusCfg.animateClass}`}
className={`hover:bg-dark-700/70 group -ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors ${statusCfg.colorClass} ${statusCfg.animateClass}`}
title={t('common:connectionPanel.title')}
>
{statusCfg.icon && <span className="transition-colors duration-300">{statusCfg.icon}</span>}
<span className="font-medium transition-colors duration-300">{statusLabel}</span>
{hostLabel && (
<>
<span className="text-txt-faint mx-0.5">·</span>
<span className="text-content-secondary max-w-[280px] truncate font-mono" title={hostLabel}>
<span className="mx-0.5 text-txt-faint">·</span>
<span className="max-w-[280px] truncate font-mono text-content-secondary" title={hostLabel}>
{hostLabel}
</span>
</>
)}
{totalConnections > 0 && (
<span className="bg-accent-blue/20 ml-1 rounded-full px-1.5 py-px text-[10px] font-medium tabular-nums text-accent-blue">
<span className="ml-1 rounded-full bg-accent-blue/20 px-1.5 py-px text-[10px] font-medium tabular-nums text-accent-blue">
{totalConnections}
</span>
)}
<ChevronUp className="text-txt-faint h-3 w-3 transition-transform duration-200 group-hover:rotate-180" />
<ChevronUp className="h-3 w-3 text-txt-faint transition-transform duration-200 group-hover:rotate-180" />
</button>
{metricsEnabled && (
@@ -1014,12 +945,11 @@ export default function StatusBar() {
}
aria-label={t('common:statusBar.forwardOpenIdle', { port: dynamicListenPort })}
aria-pressed={socksRunning}
className={`mr-2 flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors disabled:opacity-50
${
socksRunning
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
: 'text-txt-muted hover:bg-dark-700/70 hover:text-content-secondary'
}`}
className={`mr-2 flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors disabled:opacity-50 ${
socksRunning
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
: 'hover:bg-dark-700/70 text-txt-muted hover:text-content-secondary'
}`}
>
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
<span className="tabular-nums">
@@ -1028,7 +958,7 @@ export default function StatusBar() {
</button>
)}
<div className="text-txt-faint flex items-center gap-1 tabular-nums">
<div className="flex items-center gap-1 tabular-nums text-txt-faint">
<Clock className="h-3 w-3" />
<span>{formatTime(currentTime)}</span>
</div>
@@ -1046,18 +976,9 @@ export default function StatusBar() {
setForwardDraft({
type: next.type || 'local',
name: next.name || '',
localPort:
next.type === 'dynamic'
? resolveDynamicPort(next.localPort)
: next.localPort || 0,
localBindHost:
next.type === 'local'
? resolveLocalBindHost(next.localBindHost)
: '',
remoteBindHost:
next.type === 'remote'
? resolveRemoteBindHost(next.remoteBindHost)
: '',
localPort: next.type === 'dynamic' ? resolveDynamicPort(next.localPort) : next.localPort || 0,
localBindHost: next.type === 'local' ? resolveLocalBindHost(next.localBindHost) : '',
remoteBindHost: next.type === 'remote' ? resolveRemoteBindHost(next.remoteBindHost) : '',
remoteHost: next.remoteHost || '',
remotePort: next.remotePort || 0,
})
+12 -2
View File
@@ -15,6 +15,7 @@ import {
} from '../../utils/disposeTab'
import { TabItem } from './tabbar/TabItem'
import { TabContextMenu } from './tabbar/TabContextMenu'
import { requestTabReconnect, tabSupportsReconnect } from '../../utils/tabReconnect'
interface TabBarProps {
onOpenLauncher?: () => void
@@ -109,9 +110,9 @@ 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 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="app-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"
@@ -209,6 +210,15 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
}
: undefined
}
onReconnect={
tabSupportsReconnect(currentTab)
? () => {
if (!menuState.tabId) return
activateTab(menuState.tabId)
requestTabReconnect(menuState.tabId)
}
: undefined
}
/>
</div>
)
+5 -2
View File
@@ -1,4 +1,5 @@
import React from 'react'
import ErrorBoundary from '../common/ErrorBoundary'
import { useTabStore } from '../../stores/tabStore'
import WelcomeView from '../sessions/WelcomeView'
import TerminalWorkspace from '../terminal/TerminalWorkspace'
@@ -39,7 +40,7 @@ function TabContent({
const tabs = useTabStore((state) => state.tabs)
return (
<div className="relative h-full w-full">
<div className="relative h-full min-h-0 w-full overflow-hidden">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId
@@ -119,7 +120,9 @@ function TabContent({
isActive ? 'visible z-10 opacity-100' : 'pointer-events-none invisible z-0 opacity-0'
}`}
>
{content}
<ErrorBoundary compact resetKey={tab.id}>
{content}
</ErrorBoundary>
</div>
)
})}
@@ -76,7 +76,7 @@ function quietIfNoOp(
return { group, index: insertAt }
}
export function resolveSessionDropTarget(
function resolveSessionDropTarget(
clientX: number,
clientY: number,
dragId: string,
@@ -296,7 +296,7 @@ export function SessionTree({
return (
<div
ref={scrollerRef}
className="flex-1 overflow-y-auto"
className="session-tree flex-1 overflow-y-auto"
onContextMenu={(e) => {
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
onBlankContextMenu?.(e)
@@ -320,7 +320,7 @@ export function SessionTree({
e.stopPropagation()
onGroupContextMenu?.(e, group)
}}
className={`hover:bg-dark-700/50 flex w-full items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${
className={`hover:bg-dark-700/50 flex w-full cursor-default items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${
groupDropActive ? 'bg-accent-blue/10 text-accent-blue' : ''
}`}
>
@@ -391,7 +391,7 @@ export function SessionTree({
? t('tree.selectSession')
: `${session.username}@${session.host}:${session.port || 22}`
}
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' : ''} ${
className={`group relative flex cursor-default touch-none select-none items-center gap-1.5 px-3 py-1.5 pl-9 pr-2 text-sm transition-colors duration-150 ${dragId === session.id ? 'opacity-40' : ''} ${
selected
? 'border-l-2 border-accent-blue bg-accent-blue/10'
: 'hover:bg-dark-700/40 border-l-2 border-transparent'
+32 -12
View File
@@ -2,14 +2,14 @@
* TabContextMenu - 标签右键菜单
*/
import { useRef, useEffect } from 'react'
import { useRef, useEffect, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
interface MenuItem {
label: string
action: () => void
danger?: boolean
separator?: boolean
}
interface TabContextMenuProps {
@@ -23,6 +23,7 @@ interface TabContextMenuProps {
onCloseAll: () => void
onClone?: () => void
onOpenSftp?: () => void
onReconnect?: () => void
}
export function TabContextMenu({
@@ -36,6 +37,7 @@ export function TabContextMenu({
onCloseAll,
onClone,
onOpenSftp,
onReconnect,
}: TabContextMenuProps) {
const { t } = useTranslation('common')
const menuRef = useRef<HTMLDivElement>(null)
@@ -60,6 +62,24 @@ export function TabContextMenu({
}
}, [isOpen])
useLayoutEffect(() => {
const el = menuRef.current
if (!el || !isOpen) return
const margin = 8
const width = el.offsetWidth
const height = el.offsetHeight
let left = position.x
let top = position.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`
}, [isOpen, position.x, position.y])
if (!isOpen) return null
const items: MenuItem[] = [
@@ -70,6 +90,9 @@ export function TabContextMenu({
{ label: t('tab.menu.closeAll'), action: onCloseAll, danger: true },
]
if (onReconnect) {
items.unshift({ label: t('tab.menu.reconnect'), action: onReconnect })
}
if (onClone) {
items.unshift({ label: t('tab.menu.clone'), action: onClone })
}
@@ -77,11 +100,11 @@ export function TabContextMenu({
items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp })
}
return (
return createPortal(
<div
ref={menuRef}
style={{ left: position.x, top: position.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[160px]"
className="fixed z-[200] w-max min-w-[8.5rem] rounded-md border border-dark-700 bg-dark-800 py-0.5 shadow-xl"
onPointerDown={(e) => e.stopPropagation()}
>
{items.map((item, index) => (
@@ -92,17 +115,14 @@ export function TabContextMenu({
item.action()
onClose()
}}
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'
}
`}
className={`block w-full whitespace-nowrap px-3 py-1.5 text-left text-[13px] transition-colors ${
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-content-secondary hover:bg-dark-700'
}`}
>
{item.label}
</button>
))}
</div>
</div>,
document.body
)
}
+15 -14
View File
@@ -4,11 +4,8 @@
import { useTranslation } from 'react-i18next'
import { Crown, Sparkles, X } from 'lucide-react'
import {
proFeatureLabel,
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
import { proFeatureLabel, useEntitlementStore, type ProFeature } from '../../stores/entitlementStore'
import AppOverlay from '../common/AppOverlay'
export default function UpgradeWall() {
const { t } = useTranslation('common')
@@ -23,16 +20,20 @@ export default function UpgradeWall() {
const comingSoon = wall.comingSoon
return (
<div
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="upgrade-wall-title"
onClick={closeWall}
<AppOverlay
open
className="z-[120] flex items-center justify-center bg-black/65 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) closeWall()
}}
>
<div
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-dark-600/80 bg-dark-800 shadow-2xl"
className="border-dark-600/80 relative w-full max-w-md overflow-hidden rounded-2xl border bg-dark-800 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="upgrade-wall-title"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<button
type="button"
@@ -43,7 +44,7 @@ export default function UpgradeWall() {
<X className="h-4 w-4" />
</button>
<div className="border-b border-dark-700/50 bg-gradient-to-br from-accent-blue/15 to-transparent px-6 pb-5 pt-6">
<div className="border-dark-700/50 border-b bg-gradient-to-br from-accent-blue/15 to-transparent px-6 pb-5 pt-6">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl bg-accent-blue/20 text-accent-blue">
<Crown className="h-5 w-5" />
</div>
@@ -80,6 +81,6 @@ export default function UpgradeWall() {
</button>
</div>
</div>
</div>
</AppOverlay>
)
}
+341 -319
View File
@@ -22,6 +22,7 @@ import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
import { getDisplayResolution } from '../../utils/desktopResolution'
import { useToast } from '../../hooks/useToast'
import { translateErrorMessage } from '../../i18n/format'
import AppOverlay from '../common/AppOverlay'
const logger = createLogger('SessionDialog')
@@ -45,6 +46,18 @@ type KeyInputMode = 'file' | 'text'
const DEFAULT_KEY_PATH = '~/.ssh/id_ed25519'
function defaultDisplayName(protocol: SessionProtocol, host: string, username: string): string {
if (!host) return ''
return protocol === 'vnc' ? host : `${username}@${host}`
}
function isGeneratedDisplayName(name: string, host: string, previousHost = ''): boolean {
const trimmed = name.trim()
if (!trimmed) return true
if (trimmed.includes('@')) return true
return [host, previousHost].filter(Boolean).some((value) => trimmed === value || trimmed === `vnc:${value}`)
}
function isPemPrivateKey(value: string | undefined | null): boolean {
return Boolean(value && value.includes('-----BEGIN'))
}
@@ -220,10 +233,9 @@ export default function SessionDialog({
host: parsed.host,
port: targetIncludesPort(trimmed) ? parsed.port : prev.port,
username: parsed.username || prev.username,
name:
prev.name.trim() && !prev.name.includes('@')
? prev.name
: `${parsed.username || prev.username}@${parsed.host}`,
name: isGeneratedDisplayName(prev.name, parsed.host, prev.host)
? `${parsed.username || prev.username}@${parsed.host}`
: prev.name,
}))
}
return
@@ -235,12 +247,13 @@ export default function SessionDialog({
...prev,
host,
port: portMatch ? Number(portMatch[1]) : prev.port,
name:
prev.name.trim() && !prev.name.includes('@') && !prev.name.startsWith('vnc:')
? prev.name
: formData.protocol === 'vnc'
? `vnc:${host}`
: `${prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')}@${host}`,
name: isGeneratedDisplayName(prev.name, host, prev.host)
? defaultDisplayName(
formData.protocol,
host,
prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')
)
: prev.name,
}))
}
@@ -320,7 +333,7 @@ export default function SessionDialog({
: undefined
const sessionData = {
name: formData.name.trim() || (formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`),
name: formData.name.trim() || defaultDisplayName(formData.protocol, host, username),
host,
port,
username,
@@ -436,8 +449,7 @@ export default function SessionDialog({
return (
<>
<div className="fixed inset-0 z-[100] flex justify-end">
{/* Backdrop */}
<AppOverlay open className="z-[100] flex justify-end">
<div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} />
{/* Drawer */}
@@ -450,7 +462,7 @@ export default function SessionDialog({
{/* Header */}
<div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
<Server className="w-4.5 h-4.5 h-4 w-4" />
<Server className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
@@ -507,7 +519,11 @@ export default function SessionDialog({
autoFocus={!isEditing}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t('dialog.displayNamePlaceholder')}
placeholder={
formData.protocol === 'vnc'
? t('dialog.displayNamePlaceholderVnc')
: t('dialog.displayNamePlaceholder')
}
className="ui-input"
/>
</div>
@@ -576,12 +592,13 @@ export default function SessionDialog({
setFormData((prev) => ({
...prev,
username,
name:
prev.name.trim() && !prev.name.includes('@')
? prev.name
: prev.host
? `${username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')}@${prev.host}`
: prev.name,
name: isGeneratedDisplayName(prev.name, prev.host)
? defaultDisplayName(
prev.protocol,
prev.host,
username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')
)
: prev.name,
}))
}}
placeholder={
@@ -595,20 +612,34 @@ export default function SessionDialog({
</div>
)}
{formData.protocol === 'rdp' && (
<div>
<label htmlFor="session-password" className="ui-label">
{t('dialog.password')}
</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
<>
<div>
<label htmlFor="session-password" className="ui-label">
{t('dialog.password')}
</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
<div>
<label className="ui-label" htmlFor="session-domain">
{t('dialog.domain')}
</label>
<input
id="session-domain"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
className="ui-input font-mono"
placeholder={t('dialog.domainPlaceholder')}
/>
</div>
</>
)}
{formData.protocol !== 'ssh' && (
<DesktopResolutionPicker
@@ -622,168 +653,155 @@ export default function SessionDialog({
{formData.protocol === 'rdp' && groupField}
</section>
{/* 凭据 */}
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('dialog.login')}
</div>
{isSsh && (
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{authTypes.map((type) => {
const Icon = type.icon
const active = formData.authType === type.value
return (
<button
key={type.value}
type="button"
onClick={() => setFormData({ ...formData, authType: type.value })}
className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
<Icon className="h-3.5 w-3.5" />
{type.label}
</button>
)
})}
{/* 凭据:SSH / VNC。RDP 的密码和域已在基本区,避免重复。 */}
{(isSsh || formData.protocol === 'vnc') && (
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('dialog.login')}
</div>
)}
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
<div>
<label htmlFor="session-password" className="ui-label">
{t('dialog.password')}
</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
)}
{formData.protocol === 'rdp' && (
<div>
<label className="ui-label" htmlFor="session-domain">
{t('dialog.domain')}
</label>
<input
id="session-domain"
value={formData.domain}
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
className="ui-input font-mono"
placeholder={t('dialog.domainPlaceholder')}
/>
</div>
)}
{isSsh && formData.authType === 'key' && (
<div className="space-y-2.5">
<div>
<div className="ui-label">{t('dialog.keySource')}</div>
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{(
[
['file', t('dialog.keyFile')],
['text', t('dialog.keyText')],
] as const
).map(([value, label]) => {
const active = formData.keyInputMode === value
return (
<button
key={value}
type="button"
onClick={() => setFormData({ ...formData, keyInputMode: value })}
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
{label}
</button>
)
})}
</div>
</div>
{formData.keyInputMode === 'file' ? (
<div>
<label htmlFor="session-private-key" className="ui-label">
{t('dialog.privateKeyPath')}
</label>
<div className="flex gap-1.5">
<input
id="session-private-key"
type="text"
value={formData.privateKeyPath}
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
placeholder={DEFAULT_KEY_PATH}
className="ui-input flex-1"
/>
{isSsh && (
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{authTypes.map((type) => {
const Icon = type.icon
const active = formData.authType === type.value
return (
<button
key={type.value}
type="button"
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600"
onClick={async () => {
const result = await window.opsxApi?.showOpenDialog({
title: t('dialog.pickKeyDialogTitle'),
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }],
})
if (!result?.canceled && result?.filePaths?.[0]) {
setFormData({
...formData,
privateKeyPath: result.filePaths[0],
keyInputMode: 'file',
})
}
}}
onClick={() => setFormData({ ...formData, authType: type.value })}
className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
{t('dialog.pickKeyFile')}
<Icon className="h-3.5 w-3.5" />
{type.label}
</button>
</div>
</div>
) : (
<div>
<label htmlFor="session-private-key-text" className="ui-label">
{t('dialog.privateKeyText')}
</label>
<textarea
id="session-private-key-text"
value={formData.privateKeyText}
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
rows={6}
spellCheck={false}
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
/>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
</div>
)}
)
})}
</div>
)}
{((isSsh && formData.authType === 'password') || formData.protocol === 'vnc') && (
<div>
<label htmlFor="session-passphrase" className="ui-label">
{t('dialog.passphrase')}
<label htmlFor="session-password" className="ui-label">
{t('dialog.password')}
</label>
<input
id="session-passphrase"
id="session-password"
type="password"
value={formData.passphrase}
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
placeholder={t('dialog.passphrasePlaceholder')}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
</div>
)}
)}
{(isSsh || formData.protocol === 'vnc') && groupField}
</section>
{isSsh && formData.authType === 'key' && (
<div className="space-y-2.5">
<div>
<div className="ui-label">{t('dialog.keySource')}</div>
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
{(
[
['file', t('dialog.keyFile')],
['text', t('dialog.keyText')],
] as const
).map(([value, label]) => {
const active = formData.keyInputMode === value
return (
<button
key={value}
type="button"
onClick={() => setFormData({ ...formData, keyInputMode: value })}
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
active
? 'bg-dark-700 text-content-primary shadow-sm'
: 'text-txt-muted hover:text-content-secondary'
}`}
>
{label}
</button>
)
})}
</div>
</div>
{formData.keyInputMode === 'file' ? (
<div>
<label htmlFor="session-private-key" className="ui-label">
{t('dialog.privateKeyPath')}
</label>
<div className="flex gap-1.5">
<input
id="session-private-key"
type="text"
value={formData.privateKeyPath}
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
placeholder={DEFAULT_KEY_PATH}
className="ui-input flex-1"
/>
<button
type="button"
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600"
onClick={async () => {
const result = await window.opsxApi?.showOpenDialog({
title: t('dialog.pickKeyDialogTitle'),
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }],
})
if (!result?.canceled && result?.filePaths?.[0]) {
setFormData({
...formData,
privateKeyPath: result.filePaths[0],
keyInputMode: 'file',
})
}
}}
>
{t('dialog.pickKeyFile')}
</button>
</div>
</div>
) : (
<div>
<label htmlFor="session-private-key-text" className="ui-label">
{t('dialog.privateKeyText')}
</label>
<textarea
id="session-private-key-text"
value={formData.privateKeyText}
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
rows={6}
spellCheck={false}
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
/>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
</div>
)}
<div>
<label htmlFor="session-passphrase" className="ui-label">
{t('dialog.passphrase')}
</label>
<input
id="session-passphrase"
type="password"
value={formData.passphrase}
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
placeholder={t('dialog.passphrasePlaceholder')}
className="ui-input"
/>
</div>
</div>
)}
{groupField}
</section>
)}
{/* 高级 */}
<div className="border-dark-700/50 overflow-hidden rounded-xl border">
@@ -794,7 +812,7 @@ export default function SessionDialog({
>
<span className="text-xs font-semibold text-content-secondary">{t('dialog.advanced')}</span>
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
{showAdvanced ? t('dialog.collapse') : t('dialog.advancedCollapsed')}
{showAdvanced ? t('dialog.collapse') : isSsh ? t('dialog.advancedCollapsed') : t('dialog.tags')}
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
</span>
</button>
@@ -817,146 +835,150 @@ export default function SessionDialog({
</div>
</div>
<div>
<label className="ui-label">{t('dialog.startupCommand')}</label>
<input
type="text"
value={formData.startupCommand}
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
placeholder={t('dialog.startupCommandPlaceholder')}
className="ui-input-sm font-mono"
/>
</div>
{isSsh && (
<>
<div>
<label className="ui-label">{t('dialog.startupCommand')}</label>
<input
type="text"
value={formData.startupCommand}
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
placeholder={t('dialog.startupCommandPlaceholder')}
className="ui-input-sm font-mono"
/>
</div>
<div>
<label className="ui-label">{t('dialog.proxy')}</label>
<div className="mb-2 flex gap-1">
{(
[
{ value: 'none', label: t('dialog.proxyNone') },
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
] as const
).map((t) => (
<button
key={t.value}
type="button"
onClick={() => {
if (t.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
return
}
<div>
<label className="ui-label">{t('dialog.proxy')}</label>
<div className="mb-2 flex gap-1">
{(
[
{ value: 'none', label: t('dialog.proxyNone') },
{ value: 'http', label: 'HTTP' },
{ value: 'socks5', label: 'SOCKS5' },
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
if (option.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
return
}
setFormData({
...formData,
proxy: {
...formData.proxy,
type: option.value,
port:
formData.proxy.port ||
(option.value === 'http' ? 8080 : option.value === 'socks5' ? 1080 : 0),
},
jumpSessionIds: option.value === 'none' ? formData.jumpSessionIds : [],
})
}}
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${
formData.proxy.type === option.value
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
: 'border-dark-600 text-txt-muted hover:border-dark-500'
}`}
>
{option.label}
</button>
))}
</div>
{formData.proxy.type !== 'none' && (
<div className="space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
<input
type="text"
value={formData.proxy.host}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, host: e.target.value },
})
}
placeholder={t('dialog.proxyHostPlaceholder')}
className="ui-input-sm col-span-2"
/>
<input
type="number"
value={formData.proxy.port || ''}
onChange={(e) =>
setFormData({
...formData,
proxy: {
...formData.proxy,
port: parseInt(e.target.value) || 0,
},
})
}
placeholder={t('dialog.proxyPortPlaceholder')}
className="ui-input-sm"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<input
type="text"
value={formData.proxy.username}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, username: e.target.value },
})
}
placeholder={t('dialog.proxyUserPlaceholder')}
autoComplete="off"
className="ui-input-sm"
/>
<input
type="password"
value={formData.proxy.password}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, password: e.target.value },
})
}
placeholder={t('dialog.proxyPasswordPlaceholder')}
autoComplete="new-password"
className="ui-input-sm"
/>
</div>
</div>
)}
</div>
<div>
<label htmlFor="session-jump-host" className="ui-label">
{t('dialog.jumpHost')}
</label>
<select
id="session-jump-host"
value={formData.jumpSessionIds[0] || ''}
onChange={(event) =>
setFormData({
...formData,
proxy: {
...formData.proxy,
type: t.value,
port:
formData.proxy.port ||
(t.value === 'http' ? 8080 : t.value === 'socks5' ? 1080 : 0),
},
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
jumpSessionIds: event.target.value ? [event.target.value] : [],
proxy: event.target.value
? { type: 'none', host: '', port: 0, username: '', password: '' }
: formData.proxy,
})
}}
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${
formData.proxy.type === t.value
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
: 'border-dark-600 text-txt-muted hover:border-dark-500'
}`}
}
className="ui-input-sm"
>
{t.label}
</button>
))}
</div>
{formData.proxy.type !== 'none' && (
<div className="space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
<input
type="text"
value={formData.proxy.host}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, host: e.target.value },
})
}
placeholder={t('dialog.proxyHostPlaceholder')}
className="ui-input-sm col-span-2"
/>
<input
type="number"
value={formData.proxy.port || ''}
onChange={(e) =>
setFormData({
...formData,
proxy: {
...formData.proxy,
port: parseInt(e.target.value) || 0,
},
})
}
placeholder={t('dialog.proxyPortPlaceholder')}
className="ui-input-sm"
/>
</div>
<div className="grid grid-cols-2 gap-1.5">
<input
type="text"
value={formData.proxy.username}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, username: e.target.value },
})
}
placeholder={t('dialog.proxyUserPlaceholder')}
autoComplete="off"
className="ui-input-sm"
/>
<input
type="password"
value={formData.proxy.password}
onChange={(e) =>
setFormData({
...formData,
proxy: { ...formData.proxy, password: e.target.value },
})
}
placeholder={t('dialog.proxyPasswordPlaceholder')}
autoComplete="new-password"
className="ui-input-sm"
/>
</div>
<option value="">{t('dialog.jumpNoneAlt')}</option>
{jumpCandidates.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.username}@{session.host})
</option>
))}
</select>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
</div>
)}
</div>
<div>
<label htmlFor="session-jump-host" className="ui-label">
{t('dialog.jumpHost')}
</label>
<select
id="session-jump-host"
value={formData.jumpSessionIds[0] || ''}
onChange={(event) =>
setFormData({
...formData,
jumpSessionIds: event.target.value ? [event.target.value] : [],
proxy: event.target.value
? { type: 'none', host: '', port: 0, username: '', password: '' }
: formData.proxy,
})
}
className="ui-input-sm"
>
<option value="">{t('dialog.jumpNoneAlt')}</option>
{jumpCandidates.map((session) => (
<option key={session.id} value={session.id}>
{session.name} ({session.username}@{session.host})
</option>
))}
</select>
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
</div>
</>
)}
</div>
)}
</div>
@@ -985,7 +1007,7 @@ export default function SessionDialog({
</div>
</form>
</aside>
</div>
</AppOverlay>
{PromptDialogComponent}
</>
)
+97 -84
View File
@@ -200,12 +200,12 @@ export default function AiProviderSettings() {
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<select
aria-label={t('ai.presets')}
value={presetId}
onChange={(event) => setPresetId(event.target.value)}
className="ui-input-sm min-w-52 flex-1"
className="ui-select-pill"
>
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
@@ -228,11 +228,11 @@ export default function AiProviderSettings() {
</Button>
</div>
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
<aside className="space-y-0.5">
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
<div className="grid min-h-[22rem] grid-cols-[12rem_minmax(0,1fr)] gap-5">
<aside className="border-dark-700/45 bg-dark-800/50 h-fit overflow-hidden rounded-2xl border py-2">
<div className="px-3 pb-1.5 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
{store.providers.length === 0 ? (
<p className="px-2 py-6 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
<p className="px-3 py-5 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
) : (
store.providers.map((provider) => {
const model = provider.models.find((item) => item.id === provider.defaultModelId)
@@ -241,7 +241,7 @@ export default function AiProviderSettings() {
key={provider.id}
type="button"
onClick={() => setSelectedId(provider.id)}
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
className={`w-full px-3 py-2 text-left transition-colors ${
selectedId === provider.id
? 'bg-accent-blue/12 text-accent-blue'
: 'hover:bg-dark-700/50 text-content-secondary'
@@ -260,34 +260,40 @@ export default function AiProviderSettings() {
)}
</aside>
<main className="min-w-0">
<main className="min-w-0 max-w-lg">
{!draft ? (
<div className="flex h-full items-center justify-center text-sm text-txt-faint">{t('ai.emptyHint')}</div>
<div className="border-dark-600/80 flex h-40 items-center justify-center rounded-2xl border border-dashed text-sm text-txt-faint">
{t('ai.emptyHint')}
</div>
) : (
<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"
/>
</Field>
<Field label={t('ai.model')}>
{manualModel || draft.models.length === 0 ? (
<div className="space-y-3">
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<FormRow label={t('ai.name')}>
<CompactField>
<input
aria-label={t('ai.model')}
value={currentModel?.model || ''}
onChange={(event) => updateManualModel(event.target.value)}
placeholder="deepseek-v4-pro"
className="ui-input-sm font-mono"
value={draft.name}
onChange={(event) => updateDraft({ name: event.target.value })}
className="ui-input-sm"
/>
</CompactField>
</FormRow>
<FormRow label={t('ai.model')}>
{manualModel || draft.models.length === 0 ? (
<CompactField>
<input
aria-label={t('ai.model')}
value={currentModel?.model || ''}
onChange={(event) => updateManualModel(event.target.value)}
placeholder="deepseek-v4-pro"
className="ui-input-sm font-mono"
/>
</CompactField>
) : (
<select
aria-label={t('ai.model')}
value={draft.defaultModelId}
onChange={(event) => chooseModel(event.target.value)}
className="ui-input-sm"
className="ui-select-pill"
>
{draft.models.map((model) => (
<option key={model.id} value={model.id}>
@@ -297,100 +303,103 @@ export default function AiProviderSettings() {
<option value="__manual">{t('ai.otherModels')}</option>
</select>
)}
</Field>
</FormRow>
{manualModel && draft.models.length > 0 && (
<div className="flex justify-end px-4 py-2">
<button
type="button"
className="text-xs text-accent-blue hover:underline"
onClick={() => setManualModel(false)}
>
{t('ai.backToList')}
</button>
</div>
)}
<FormRow label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : t('ai.apiKey')}>
<CompactField wide>
<input
type="password"
autoComplete="off"
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-...'
}
className="ui-input-sm"
/>
</CompactField>
</FormRow>
{(needsEndpoint || advancedOpen) && (
<FormRow label={t('ai.endpoint')}>
<CompactField wide>
<input
aria-label={t('ai.endpoint')}
value={currentModel?.baseUrl || draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
disabled={!!currentModel?.baseUrl && !needsEndpoint}
/>
</CompactField>
</FormRow>
)}
</div>
{manualModel && draft.models.length > 0 && (
<button
type="button"
className="text-xs text-accent-blue hover:underline"
onClick={() => setManualModel(false)}
>
{t('ai.backToList')}
</button>
)}
<Field label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : 'API Key'}>
<input
type="password"
autoComplete="off"
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-...'
}
className="ui-input-sm"
/>
</Field>
{(needsEndpoint || advancedOpen) && (
<Field label="Endpoint">
<input
aria-label="Endpoint"
value={currentModel?.baseUrl || draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
disabled={!!currentModel?.baseUrl && !needsEndpoint}
/>
</Field>
)}
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
className="flex items-center gap-1 text-xs text-txt-faint hover:text-txt-muted"
className="flex items-center gap-1 px-0.5 text-xs text-txt-faint hover:text-txt-muted"
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
{t('ai.advanced')}
</button>
{advancedOpen && (
<div className="grid grid-cols-2 gap-3">
<Field label={t('ai.protocol')}>
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<FormRow label={t('ai.protocol')}>
<select
value={draft.protocol}
onChange={(event) =>
updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })
}
className="ui-input-sm"
className="ui-select-pill"
>
<option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic</option>
</select>
</Field>
<Field label={t('ai.auth')}>
</FormRow>
<FormRow label={t('ai.auth')}>
<select
value={draft.authStyle}
onChange={(event) =>
updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })
}
className="ui-input-sm"
className="ui-select-pill"
>
<option value="bearer">Bearer</option>
<option value="x-api-key">x-api-key</option>
<option value="both">Bearer + x-api-key</option>
<option value="none">{t('ai.authNone')}</option>
</select>
</Field>
<label className="col-span-2 flex items-center gap-2 text-xs text-txt-muted">
</FormRow>
<FormRow label={t('ai.allowHttp')}>
<input
type="checkbox"
checked={draft.allowInsecureHttp}
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
className="h-3.5 w-3.5 accent-blue-500"
/>
{t('ai.allowHttp')}
</label>
</FormRow>
</div>
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
<div className="flex flex-wrap items-center gap-2 pt-0.5">
<Button
type="button"
variant="secondary"
@@ -450,11 +459,15 @@ export default function AiProviderSettings() {
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
{children}
<label className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 px-4 py-3">
<span className="min-w-[6.5rem] text-sm text-content-primary">{label}</span>
<div className="ml-auto flex-shrink-0">{children}</div>
</label>
)
}
function CompactField({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
return <div className={wide ? 'w-[17.5rem] max-w-full' : 'w-[12.5rem] max-w-full'}>{children}</div>
}
+178 -73
View File
@@ -18,12 +18,21 @@ import {
Check,
Sparkles,
UserRound,
ExternalLink,
} from 'lucide-react'
import AiProviderSettings from './AiProviderSettings'
import AccountSettings from './AccountSettings'
import { themes, type TerminalThemeId } from '../../themes/terminal'
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
import {
buildBugReportUrl,
formatRuntimeSystem,
GITHUB_DISCUSSIONS_URL,
GITHUB_LICENSE_URL,
GITHUB_RELEASES_URL,
WECHAT_ID,
} from '../../utils/bugReport'
// ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
@@ -148,6 +157,17 @@ export default function SettingsView() {
}
}, [])
const openBugReport = useCallback(() => {
const url = buildBugReportUrl({
appVersion: runtimeInfo?.appVersion,
os: runtimeInfo?.os,
arch: runtimeInfo?.arch,
logPath: runtimeInfo?.logPath,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
})
openHttpUrl(url)
}, [runtimeInfo])
// 当前 tab 元信息
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
@@ -171,7 +191,7 @@ export default function SettingsView() {
const diagnosticText = runtimeInfo
? [
`OpsXShell v${runtimeInfo.appVersion}`,
`${runtimeInfo.os} ${runtimeInfo.arch}`,
formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch),
`Data: ${runtimeInfo.dataPath}`,
`Log: ${runtimeInfo.logPath}`,
].join('\n')
@@ -249,7 +269,7 @@ export default function SettingsView() {
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
settings.sidebarCollapsed ? 'translate-x-5' : 'translate-x-0'
}`}
/>
@@ -334,7 +354,7 @@ export default function SettingsView() {
onClick={() => setSetting('terminalTheme', key as TerminalThemeId)}
className={`rounded-xl border p-2.5 text-left text-xs transition-colors ${
settings.terminalTheme === key
? 'bg-accent-blue/10 ring-accent-blue/30 border-accent-blue ring-1'
? 'border-accent-blue bg-accent-blue/10 ring-1 ring-accent-blue/30'
: 'border-dark-600/60 hover:border-dark-500'
}`}
>
@@ -389,7 +409,9 @@ export default function SettingsView() {
const defs = getKeybindingsByCategory(category)
return (
<SettingCard key={category}>
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{t(`shortcuts.category.${category}`)}</h3>
<h3 className="mb-3 text-sm font-semibold text-content-secondary">
{t(`shortcuts.category.${category}`)}
</h3>
<div className="space-y-1">
{defs.map((def) => {
const currentKeys = allKeys[def.id]
@@ -400,13 +422,13 @@ export default function SettingsView() {
<div
key={def.id}
className={`flex items-center justify-between rounded-lg px-3 py-2 transition-colors ${
isRecordingThis ? 'bg-accent-blue/10 border-accent-blue/30 border' : 'hover:bg-dark-800/60'
isRecordingThis ? 'border border-accent-blue/30 bg-accent-blue/10' : 'hover:bg-dark-800/60'
}`}
>
<div className="min-w-0 flex-1">
<span className="text-sm text-content-primary">{t(def.labelKey)}</span>
{isCustom && (
<span className="bg-accent-blue/10 ml-2 rounded px-1.5 py-0.5 text-[10px] text-accent-blue">
<span className="ml-2 rounded bg-accent-blue/10 px-1.5 py-0.5 text-[10px] text-accent-blue">
{t('shortcuts.custom')}
</span>
)}
@@ -415,7 +437,7 @@ export default function SettingsView() {
<div className="flex items-center gap-2">
{isRecordingThis ? (
<div className="flex items-center gap-2">
<kbd className="bg-accent-blue/20 border-accent-blue/40 inline-flex animate-pulse items-center rounded border px-3 py-1 font-mono text-xs text-accent-blue">
<kbd className="inline-flex animate-pulse items-center rounded border border-accent-blue/40 bg-accent-blue/20 px-3 py-1 font-mono text-xs text-accent-blue">
{recordedKeys || t('shortcuts.pressKeys')}
</kbd>
<button
@@ -442,7 +464,7 @@ export default function SettingsView() {
<kbd
className={`inline-flex items-center rounded px-2.5 py-1 font-mono text-xs transition-colors ${
isCustom
? 'bg-accent-blue/10 border-accent-blue/30 border text-accent-blue'
? 'border border-accent-blue/30 bg-accent-blue/10 text-accent-blue'
: 'border border-dark-600 bg-dark-700 text-content-secondary'
} group-hover:border-accent-blue/50`}
>
@@ -472,15 +494,13 @@ export default function SettingsView() {
{/* 录入确认弹层 */}
{recordingId && recordedKeys && (
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-dark-600 bg-dark-800 px-5 py-3 shadow-2xl">
<span className="text-sm text-content-secondary">
{t('shortcuts.setTo', { keys: recordedKeys })}
</span>
<span className="text-sm text-content-secondary">{t('shortcuts.setTo', { keys: recordedKeys })}</span>
<button
onClick={async () => {
await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
stopRecording()
}}
className="hover:bg-accent-blue/80 rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors"
className="rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors hover:bg-accent-blue/80"
>
{t('common:action.confirm')}
</button>
@@ -525,6 +545,7 @@ export default function SettingsView() {
checked={settings.autoReconnect !== false}
onChange={(v) => setSetting('autoReconnect', v)}
/>
<p className="px-3 py-2 text-[11px] leading-relaxed text-txt-faint">{t('ssh.sessionPolicyHint')}</p>
<SettingRow label={t('ssh.timeout')} description={t('ssh.timeoutDesc')}>
<input
type="number"
@@ -588,12 +609,12 @@ export default function SettingsView() {
<div className="space-y-6">
<SettingGroup title={t('security.credentials')}>
<SettingRow label={t('security.sessionPassword')} description={t('security.sessionPasswordDesc')}>
<span className="border-accent-green/25 bg-accent-green/10 rounded-md border px-2 py-1 text-xs font-medium text-accent-green">
<span className="rounded-md border border-accent-green/25 bg-accent-green/10 px-2 py-1 text-xs font-medium text-accent-green">
{t('security.enabled')}
</span>
</SettingRow>
<SettingRow label={t('security.masterKey')} description={t('security.masterKeyDesc')}>
<code className="text-xs text-txt-muted break-all">
<code className="break-all text-xs text-txt-muted">
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
</code>
</SettingRow>
@@ -605,71 +626,101 @@ export default function SettingsView() {
// ──────── 关于 ─────────
case 'about':
return (
<div className="max-w-2xl space-y-5">
<div className="max-w-xl space-y-6">
<SettingCard>
<div className="flex items-start gap-4 py-2">
<div className="border-dark-600/50 flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl border bg-dark-700">
<Terminal className="h-8 w-8 text-content-primary" />
</div>
<div className="flex items-center gap-4 py-1">
<img
src="/app-icon.png"
alt=""
width={64}
height={64}
className="h-16 w-16 flex-shrink-0 rounded-[18px] shadow-sm"
/>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-content-primary">OpsXShell</h3>
<h3 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h3>
<p className="mt-0.5 text-sm text-txt-muted">{t('about.tagline')}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue">
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-txt-faint">
<span className="font-mono text-content-secondary">
{t('about.appVersion', { version: appVersion })}
</span>
<button
type="button"
onClick={() => copyText('version', diagnosticText)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
title={t('about.copyVersion')}
>
{copiedField === 'version' ? (
<Check className="h-3.5 w-3.5 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
</button>
{runtimeInfo ? (
<>
<span aria-hidden>·</span>
<span>{formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch)}</span>
</>
) : null}
</div>
</div>
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>{t('about.runtime')}</SettingSectionTitle>
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
<div className="flex items-center justify-between gap-4 py-2">
<span className="text-txt-muted">{t('about.system')}</span>
<code className="text-right text-xs text-content-secondary">
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : t('common:status.reading')}
</code>
<SettingGroup title={t('about.resources')}>
<button
type="button"
onClick={() => void copyText('wechat', WECHAT_ID)}
aria-label={t('about.copyWechat')}
className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{t('about.wechat')}</div>
<div className="mt-0.5 font-mono text-xs leading-relaxed text-txt-muted">{WECHAT_ID}</div>
</div>
</div>
</SettingCard>
{copiedField === 'wechat' ? (
<Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
)}
</button>
<AboutLink label={t('about.reportBug')} description={t('about.reportBugDesc')} onClick={openBugReport} />
<AboutLink
label={t('about.github')}
description={t('about.githubDesc')}
onClick={() => openHttpUrl(GITHUB_DISCUSSIONS_URL)}
/>
<AboutLink
label={t('about.releases')}
description={t('about.releasesDesc')}
onClick={() => openHttpUrl(GITHUB_RELEASES_URL)}
/>
<AboutLink
label={t('about.licenseLink')}
description={t('about.licenseLinkDesc')}
onClick={() => openHttpUrl(GITHUB_LICENSE_URL)}
/>
</SettingGroup>
<SettingCard>
<SettingSectionTitle>{t('about.localData')}</SettingSectionTitle>
<div className="mt-3 space-y-3 text-xs">
<div>
<div className="mb-1 text-txt-muted">{t('about.sessionsSettings')}</div>
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? t('common:status.reading')}</code>
</div>
<div>
<div className="mb-1 text-txt-muted">{t('about.masterKeyPath')}</div>
<code className="block break-all text-content-secondary">
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
</code>
</div>
<div>
<div className="mb-1 text-txt-muted">{t('about.logPath')}</div>
<code className="block break-all text-content-secondary">
{runtimeInfo?.logPath ?? t('common:status.reading')}
</code>
</div>
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
</div>
</SettingCard>
<SettingGroup title={t('about.diagnostics')}>
<SettingRow label={t('about.logPath')}>
<CopyValueButton
copied={copiedField === 'log'}
value={runtimeInfo?.logPath}
label={t('about.copyPath')}
onCopy={() => runtimeInfo?.logPath && void copyText('log', runtimeInfo.logPath)}
/>
</SettingRow>
<SettingRow label={t('about.sessionsSettings')}>
<CopyValueButton
copied={copiedField === 'data'}
value={runtimeInfo?.dataPath}
label={t('about.copyPath')}
onCopy={() => runtimeInfo?.dataPath && void copyText('data', runtimeInfo.dataPath)}
/>
</SettingRow>
<SettingRow label={t('about.copyDiagnostics')} description={t('about.copyVersion')}>
<button
type="button"
onClick={() => copyText('version', diagnosticText)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
>
{copiedField === 'version' ? (
<Check className="h-3.5 w-3.5 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
</button>
</SettingRow>
</SettingGroup>
<p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p>
</div>
@@ -833,6 +884,65 @@ function KnownHostsSettingsCard() {
)
}
function openHttpUrl(url: string) {
const open = window.opsxApi?.openExternal
if (open) {
void open(url).catch(() => {
window.open(url, '_blank', 'noopener,noreferrer')
})
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
function AboutLink({ label, description, onClick }: { label: string; description: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary">{label}</div>
<div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>
</div>
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
</button>
)
}
function CopyValueButton({
value,
copied,
label,
onCopy,
}: {
value?: string
copied: boolean
label: string
onCopy: () => void
}) {
if (!value) {
return <span className="text-xs text-txt-faint">—</span>
}
return (
<button
type="button"
onClick={onCopy}
title={value}
aria-label={label}
className="inline-flex max-w-[16rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary"
>
<code className="truncate">{value}</code>
{copied ? (
<Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5 flex-shrink-0" />
)}
</button>
)
}
/** 分组卡片:多行设置 */
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
return (
@@ -850,7 +960,7 @@ function SettingCard({ children, highlight = false }: { children: React.ReactNod
return (
<div
className={`rounded-2xl p-4 transition-colors ${
highlight ? 'bg-accent-blue/5 border-accent-blue/25 border' : 'bg-dark-800/50 border-dark-700/45 border'
highlight ? 'border border-accent-blue/25 bg-accent-blue/5' : 'bg-dark-800/50 border-dark-700/45 border'
}`}
>
{children}
@@ -858,11 +968,6 @@ function SettingCard({ children, highlight = false }: { children: React.ReactNod
)
}
/** 区域小标题 */
function SettingSectionTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <div className={`mb-1 text-sm font-semibold text-content-secondary ${className}`}>{children}</div>
}
/** 左文右控行 */
function SettingRow({
label,
+468 -402
View File
@@ -1,16 +1,12 @@
/**
* SFTPView - SFTP 文件传输主组件 (简化版)
*
*
* 使用子组件拆分后的简化版本
*/
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
FolderOpen, Upload, Download, Trash2,
Edit3, FileText, ShieldCheck,
Unplug, FileCode,
} from 'lucide-react'
import { FolderOpen, Upload, Download, Trash2, Edit3, FileText, ShieldCheck, Unplug, FileCode } from 'lucide-react'
import { useSFTP } from '../../hooks'
import { useToast } from '../../hooks/useToast'
import { getConnectionStore } from '../../stores/connection'
@@ -32,11 +28,8 @@ import type { SftpDragEntry } from './dragDrop'
import { dndLog, shortPath } from './dragDrop'
import { isEditableRemoteTextFile } from '../../utils/fileLanguage'
import { isWithinRemoteTextEditLimit } from '../../utils/remoteTextLimits'
import {
conflictNamesFromListing,
formatOverwriteMessage,
transferDestinationPath,
} from './transferConflicts'
import { subscribeTabReconnect } from '../../utils/tabReconnect'
import { conflictNamesFromListing, formatOverwriteMessage, transferDestinationPath } from './transferConflicts'
interface SFTPViewProps {
tabId: string
@@ -89,7 +82,7 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
connectionId,
localLoading,
remoteLoading,
} = useSFTP({ tabId })
} = useSFTP({ tabId, sessionId })
const { toast } = useToast()
const { prompt, PromptDialogComponent } = usePromptDialog()
@@ -147,6 +140,17 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
}
}, [sessionId])
useEffect(
() =>
subscribeTabReconnect(tabId, () => {
if (!sessionId) return
clearError()
autoConnectAttempted.current = true
void connectRef.current(sessionId)
}),
[tabId, sessionId, clearError]
)
// Keep pool idle-suspend from arming while this SFTP tab is in front.
useEffect(() => {
if (!isActive || !connectionId || !isConnected) return
@@ -154,285 +158,330 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
}, [isActive, connectionId, isConnected, tabId])
// Sort handler
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortOrder('asc')
}
}, [sortField, sortOrder])
const handleSort = useCallback(
(field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortOrder('asc')
}
},
[sortField, sortOrder]
)
// Local selection
const toggleLocalSelect = useCallback((name: string) => {
setLocalSelected((prev: Set<string>) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}, [setLocalSelected])
const toggleLocalSelect = useCallback(
(name: string) => {
setLocalSelected((prev: Set<string>) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
},
[setLocalSelected]
)
// Remote selection
const toggleRemoteSelect = useCallback((name: string) => {
setRemoteSelected((prev: Set<string>) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}, [setRemoteSelected])
const reportTransfer = useCallback((action: 'upload' | 'download', result: { completed: number; failed: number; errors: string[] }) => {
const actionLabel = action === 'upload' ? t('toast.uploadAction') : t('toast.downloadAction')
if (result.failed > 0) {
const detail = result.errors[0] || actionLabel
toast.error(t('toast.uploadPartial', {
action: actionLabel,
completed: result.completed,
failed: result.failed,
detail,
}))
} else if (result.completed > 0) {
toast.success(t('toast.uploadSuccess', { action: actionLabel, completed: result.completed }))
}
}, [t, toast])
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
const dest = targetPath ?? remotePath
let pending = sources
const names = sources.map((source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path)
let conflicts =
dest === remotePath
? conflictNamesFromListing(names, remoteFiles)
: []
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
const hits: string[] = []
for (const name of names) {
const remoteTarget = transferDestinationPath('upload', dest, name)
try {
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
}
}
conflicts = hits
}
if (conflicts.length > 0) {
const overwrite = await confirm({
title: t('dialog.overwriteTitle'),
message: formatOverwriteMessage(conflicts, 'upload', t),
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'),
danger: true,
const toggleRemoteSelect = useCallback(
(name: string) => {
setRemoteSelected((prev: Set<string>) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
if (!overwrite) {
const skip = new Set(conflicts)
pending = sources.filter((source) => {
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
return !skip.has(name)
})
if (pending.length === 0) {
toast.info(t('toast.allSkippedExisting'))
return
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
}
}
setIsTransferPanelOpen(true)
const result = await uploadFiles(pending, dest)
reportTransfer('upload', result)
}, [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles])
},
[setRemoteSelected]
)
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
const dest = targetPath ?? localPath
const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name))
let pending = selected
const names = selected.map((file) => file.name)
let conflicts =
dest === localPath ? conflictNamesFromListing(names, localFiles) : []
if (dest !== localPath && window.opsxApi?.localGetStats) {
const hits: string[] = []
for (const name of names) {
const localTarget = transferDestinationPath('download', dest, name)
try {
const result = (await window.opsxApi.localGetStats(localTarget)) as {
success?: boolean
stats?: unknown
const reportTransfer = useCallback(
(action: 'upload' | 'download', result: { completed: number; failed: number; errors: string[] }) => {
const actionLabel = action === 'upload' ? t('toast.uploadAction') : t('toast.downloadAction')
if (result.failed > 0) {
const detail = result.errors[0] || actionLabel
toast.error(
t('toast.uploadPartial', {
action: actionLabel,
completed: result.completed,
failed: result.failed,
detail,
})
)
} else if (result.completed > 0) {
toast.success(t('toast.uploadSuccess', { action: actionLabel, completed: result.completed }))
}
},
[t, toast]
)
const performUpload = useCallback(
async (
sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>,
targetPath?: string
) => {
const dest = targetPath ?? remotePath
let pending = sources
const names = sources.map(
(source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
)
let conflicts = dest === remotePath ? conflictNamesFromListing(names, remoteFiles) : []
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
const hits: string[] = []
for (const name of names) {
const remoteTarget = transferDestinationPath('upload', dest, name)
try {
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
}
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
}
conflicts = hits
}
if (conflicts.length > 0) {
const overwrite = await confirm({
title: t('dialog.overwriteTitle'),
message: formatOverwriteMessage(conflicts, 'upload', t),
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'),
danger: true,
})
if (!overwrite) {
const skip = new Set(conflicts)
pending = sources.filter((source) => {
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
return !skip.has(name)
})
if (pending.length === 0) {
toast.info(t('toast.allSkippedExisting'))
return
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
}
}
conflicts = hits
}
if (conflicts.length > 0) {
const overwrite = await confirm({
title: t('dialog.overwriteTitle'),
message: formatOverwriteMessage(conflicts, 'download', t),
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'),
danger: true,
})
if (!overwrite) {
const skip = new Set(conflicts)
pending = selected.filter((file) => !skip.has(file.name))
if (pending.length === 0) {
toast.info(t('toast.allSkippedExisting'))
return
setIsTransferPanelOpen(true)
const result = await uploadFiles(pending, dest)
reportTransfer('upload', result)
},
[confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles]
)
const performDownload = useCallback(
async (files?: FileItem[], targetPath?: string) => {
const dest = targetPath ?? localPath
const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name))
let pending = selected
const names = selected.map((file) => file.name)
let conflicts = dest === localPath ? conflictNamesFromListing(names, localFiles) : []
if (dest !== localPath && window.opsxApi?.localGetStats) {
const hits: string[] = []
for (const name of names) {
const localTarget = transferDestinationPath('download', dest, name)
try {
const result = (await window.opsxApi.localGetStats(localTarget)) as {
success?: boolean
stats?: unknown
}
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
}
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
conflicts = hits
}
}
setIsTransferPanelOpen(true)
const result = await downloadFiles(pending, dest)
reportTransfer('download', result)
}, [
confirm,
downloadFiles,
localFiles,
localPath,
remoteFiles,
remoteSelected,
reportTransfer,
t,
toast,
])
if (conflicts.length > 0) {
const overwrite = await confirm({
title: t('dialog.overwriteTitle'),
message: formatOverwriteMessage(conflicts, 'download', t),
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'),
danger: true,
})
if (!overwrite) {
const skip = new Set(conflicts)
pending = selected.filter((file) => !skip.has(file.name))
if (pending.length === 0) {
toast.info(t('toast.allSkippedExisting'))
return
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
}
}
setIsTransferPanelOpen(true)
const result = await downloadFiles(pending, dest)
reportTransfer('download', result)
},
[confirm, downloadFiles, localFiles, localPath, remoteFiles, remoteSelected, reportTransfer, t, toast]
)
// ===== Context menu handlers =====
// Rename handler
const handleRename = useCallback(async (file: FileItem) => {
const newName = await prompt({
title: t('dialog.renameTitle'),
message: t('dialog.renameMessage'),
placeholder: t('dialog.renamePlaceholder'),
defaultValue: file.name,
})
if (newName && newName !== file.name) {
const ok = await renameRemote(file, newName)
if (ok) {
toast.success(t('toast.renamed', { name: newName }))
} else {
toast.error(t('toast.renameFailed'))
const handleRename = useCallback(
async (file: FileItem) => {
const newName = await prompt({
title: t('dialog.renameTitle'),
message: t('dialog.renameMessage'),
placeholder: t('dialog.renamePlaceholder'),
defaultValue: file.name,
})
if (newName && newName !== file.name) {
const ok = await renameRemote(file, newName)
if (ok) {
toast.success(t('toast.renamed', { name: newName }))
} else {
toast.error(t('toast.renameFailed'))
}
}
}
}, [prompt, renameRemote, t, toast])
},
[prompt, renameRemote, t, toast]
)
// Properties handler
const handleShowProperties = useCallback((file: FileItem) => {
setPropertiesFile(file)
}, [])
const handleDeleteRemote = useCallback(async (files: FileItem[]) => {
if (files.length === 0) return
const hasDir = files.some((file) => file.type === 'directory')
const names = files.slice(0, 3).map((file) => file.name).join(', ')
const ok = await confirm({
title: t('dialog.deleteTitle'),
message:
t('dialog.deleteMessage', { count: files.length, names }) +
(hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''),
danger: true,
confirmText: t('contextMenu.delete'),
})
if (!ok) return
const success = await deleteRemote(files)
if (!success) toast.error(t('toast.deleteFailed'))
}, [confirm, deleteRemote, t, toast])
const handleDeleteRemote = useCallback(
async (files: FileItem[]) => {
if (files.length === 0) return
const hasDir = files.some((file) => file.type === 'directory')
const names = files
.slice(0, 3)
.map((file) => file.name)
.join(', ')
const ok = await confirm({
title: t('dialog.deleteTitle'),
message:
t('dialog.deleteMessage', { count: files.length, names }) +
(hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''),
danger: true,
confirmText: t('contextMenu.delete'),
})
if (!ok) return
const success = await deleteRemote(files)
if (!success) toast.error(t('toast.deleteFailed'))
},
[confirm, deleteRemote, t, toast]
)
// Permissions (chmod) handler — fetch stat first, then show dialog
const handleChmod = useCallback(async (file: FileItem) => {
const stats = await getRemoteStat(file.path)
const mode = stats?.mode ?? 0o644
setChmodFile(file)
setChmodInitialMode(mode & 0o7777)
}, [getRemoteStat])
const handleChmod = useCallback(
async (file: FileItem) => {
const stats = await getRemoteStat(file.path)
const mode = stats?.mode ?? 0o644
setChmodFile(file)
setChmodInitialMode(mode & 0o7777)
},
[getRemoteStat]
)
// Chmod confirm callback
const handleChmodConfirm = useCallback(async (filePath: string, mode: number) => {
const ok = await chmodRemote(filePath, mode)
if (ok) {
toast.success(t('toast.chmodUpdated', { mode: mode.toString(8) }))
return true
} else {
toast.error(t('toast.chmodFailed'))
return false
}
}, [chmodRemote, toast, t])
const handleChmodConfirm = useCallback(
async (filePath: string, mode: number) => {
const ok = await chmodRemote(filePath, mode)
if (ok) {
toast.success(t('toast.chmodUpdated', { mode: mode.toString(8) }))
return true
} else {
toast.error(t('toast.chmodFailed'))
return false
}
},
[chmodRemote, toast, t]
)
// Local context menu
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
e.preventDefault()
const items: MenuItem[] = [
{
icon: <FolderOpen className="w-4 h-4" />,
label: t('contextMenu.open'),
action: () => file.type === 'directory' && navigateLocal(file.path),
},
...(file.type !== 'directory'
? [{
icon: <Upload className="w-4 h-4" />,
label: t('contextMenu.upload'),
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
} as MenuItem]
: []),
{
icon: <FileText className="w-4 h-4" />,
label: t('contextMenu.properties'),
separator: true,
action: () => handleShowProperties(file),
},
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
}, [navigateLocal, handleShowProperties, performUpload, t])
const showLocalContextMenu = useCallback(
(e: React.MouseEvent, file: FileItem) => {
e.preventDefault()
const items: MenuItem[] = [
{
icon: <FolderOpen className="h-4 w-4" />,
label: t('contextMenu.open'),
action: () => file.type === 'directory' && navigateLocal(file.path),
},
...(file.type !== 'directory'
? [
{
icon: <Upload className="h-4 w-4" />,
label: t('contextMenu.upload'),
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
} as MenuItem,
]
: []),
{
icon: <FileText className="h-4 w-4" />,
label: t('contextMenu.properties'),
separator: true,
action: () => handleShowProperties(file),
},
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
},
[navigateLocal, handleShowProperties, performUpload, t]
)
// Remote context menu
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
e.preventDefault()
const items: MenuItem[] = [
{
icon: <FolderOpen className="w-4 h-4" />,
label: t('contextMenu.open'),
action: () => file.type === 'directory' && navigateRemote(file.path),
},
{
icon: <Download className="w-4 h-4" />,
label: t('contextMenu.download'),
action: () => void performDownload([file]),
},
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
? [
{
icon: <FileCode className="w-4 h-4" />,
label: t('contextMenu.edit'),
action: () => {
if (!isWithinRemoteTextEditLimit(file.size)) {
toast.warning(t('errors:sftp.editTooLarge'))
return
}
setEditFile(file)
},
} as MenuItem,
]
: []),
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) },
{
icon: <FileText className="w-4 h-4" />,
label: t('contextMenu.properties'),
action: () => handleShowProperties(file),
},
{
icon: <ShieldCheck className="w-4 h-4" />,
label: t('contextMenu.permissions'),
action: () => handleChmod(file),
},
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => void handleDeleteRemote([file]) },
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
}, [navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t])
const showRemoteContextMenu = useCallback(
(e: React.MouseEvent, file: FileItem) => {
e.preventDefault()
const items: MenuItem[] = [
{
icon: <FolderOpen className="h-4 w-4" />,
label: t('contextMenu.open'),
action: () => file.type === 'directory' && navigateRemote(file.path),
},
{
icon: <Download className="h-4 w-4" />,
label: t('contextMenu.download'),
action: () => void performDownload([file]),
},
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
? [
{
icon: <FileCode className="h-4 w-4" />,
label: t('contextMenu.edit'),
action: () => {
if (!isWithinRemoteTextEditLimit(file.size)) {
toast.warning(t('errors:sftp.editTooLarge'))
return
}
setEditFile(file)
},
} as MenuItem,
]
: []),
{
icon: <Edit3 className="h-4 w-4" />,
label: t('contextMenu.rename'),
separator: true,
action: () => handleRename(file),
},
{
icon: <FileText className="h-4 w-4" />,
label: t('contextMenu.properties'),
action: () => handleShowProperties(file),
},
{
icon: <ShieldCheck className="h-4 w-4" />,
label: t('contextMenu.permissions'),
action: () => handleChmod(file),
},
{
icon: <Trash2 className="h-4 w-4" />,
label: t('contextMenu.delete'),
separator: true,
danger: true,
action: () => void handleDeleteRemote([file]),
},
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
},
[navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t]
)
// Navigate up
const navigateLocalUp = useCallback(() => {
@@ -465,10 +514,13 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
const focusLocalPane = useCallback(() => setActivePane('local'), [])
const focusRemotePane = useCallback(() => setActivePane('remote'), [])
const navigateRemoteClear = useCallback((p: string) => {
clearError()
navigateRemote(p)
}, [clearError, navigateRemote])
const navigateRemoteClear = useCallback(
(p: string) => {
clearError()
navigateRemote(p)
},
[clearError, navigateRemote]
)
const navigateRemoteBackClear = useCallback(() => {
clearError()
@@ -486,29 +538,37 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
}, [clearError, navigateRemoteUp])
// Handle file open
const handleLocalOpen = useCallback((file: FileItem) => {
if (file.type === 'directory') {
navigateLocal(file.path)
}
}, [navigateLocal])
const handleLocalOpen = useCallback(
(file: FileItem) => {
if (file.type === 'directory') {
navigateLocal(file.path)
}
},
[navigateLocal]
)
const handleRemoteOpen = useCallback((file: FileItem) => {
if (file.type === 'directory') {
navigateRemote(file.path)
}
}, [navigateRemote])
const handleRemoteOpen = useCallback(
(file: FileItem) => {
if (file.type === 'directory') {
navigateRemote(file.path)
}
},
[navigateRemote]
)
// Upload selected local rows first; otherwise use the native picker so Rust
// receives real absolute paths instead of browser-only File objects.
const handleUpload = useCallback(async () => {
const selectedFiles = localFiles.filter((file) => localSelected.has(file.name))
if (selectedFiles.length > 0) {
await performUpload(selectedFiles.map((file) => ({
path: file.path,
name: file.name,
size: file.size,
isDirectory: file.type === 'directory',
})))
await performUpload(
selectedFiles.map((file) => ({
path: file.path,
name: file.name,
size: file.size,
isDirectory: file.type === 'directory',
}))
)
return
}
@@ -533,61 +593,67 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
}, [performDownload])
// Handle drag-and-drop upload on remote panel
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
if (!isConnected) return
const sources = Array.from(files).flatMap((file) => {
const path = (file as File & { path?: string }).path
return path ? [{ path, name: file.name, size: file.size }] : []
})
dndLog('dispatch-os-upload', {
target: shortPath(targetPath),
fileCount: files.length,
withPath: sources.length,
})
if (sources.length === 0) {
toast.warning(t('toast.noDragPath'))
return
}
void performUpload(sources, targetPath)
}, [isConnected, performUpload, toast, t])
const handleDropFiles = useCallback(
(files: FileList, targetPath: string) => {
if (!isConnected) return
const sources = Array.from(files).flatMap((file) => {
const path = (file as File & { path?: string }).path
return path ? [{ path, name: file.name, size: file.size }] : []
})
dndLog('dispatch-os-upload', {
target: shortPath(targetPath),
fileCount: files.length,
withPath: sources.length,
})
if (sources.length === 0) {
toast.warning(t('toast.noDragPath'))
return
}
void performUpload(sources, targetPath)
},
[isConnected, performUpload, toast, t]
)
const handleDropEntry = useCallback((entries: SftpDragEntry[], targetPath: string) => {
if (!isConnected || entries.length === 0) return
const localEntries = entries.filter((entry) => entry.source === 'local')
const remoteEntries = entries.filter((entry) => entry.source === 'remote')
dndLog('dispatch', {
target: shortPath(targetPath),
upload: localEntries.length,
download: remoteEntries.length,
uploadNames: localEntries.slice(0, 8).map((e) => e.name),
downloadNames: remoteEntries.slice(0, 8).map((e) => e.name),
via: 'pointer',
})
if (localEntries.length > 0) {
void performUpload(
localEntries.map((entry) => ({
path: entry.path,
name: entry.name,
size: entry.size,
isDirectory: entry.type === 'directory',
})),
targetPath
)
}
if (remoteEntries.length > 0) {
void performDownload(
remoteEntries.map((entry) => ({
name: entry.name,
path: entry.path,
type: entry.type,
size: entry.size,
modified: 0,
permissions: '',
})),
targetPath
)
}
}, [isConnected, performDownload, performUpload])
const handleDropEntry = useCallback(
(entries: SftpDragEntry[], targetPath: string) => {
if (!isConnected || entries.length === 0) return
const localEntries = entries.filter((entry) => entry.source === 'local')
const remoteEntries = entries.filter((entry) => entry.source === 'remote')
dndLog('dispatch', {
target: shortPath(targetPath),
upload: localEntries.length,
download: remoteEntries.length,
uploadNames: localEntries.slice(0, 8).map((e) => e.name),
downloadNames: remoteEntries.slice(0, 8).map((e) => e.name),
via: 'pointer',
})
if (localEntries.length > 0) {
void performUpload(
localEntries.map((entry) => ({
path: entry.path,
name: entry.name,
size: entry.size,
isDirectory: entry.type === 'directory',
})),
targetPath
)
}
if (remoteEntries.length > 0) {
void performDownload(
remoteEntries.map((entry) => ({
name: entry.name,
path: entry.path,
type: entry.type,
size: entry.size,
modified: 0,
permissions: '',
})),
targetPath
)
}
},
[isConnected, performDownload, performUpload]
)
const handleDropFailed = useCallback(() => {
dndLog('toast-drop-failed')
@@ -653,7 +719,7 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
{/* File Panels */}
{!isConnected ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<EmptyState
icon={Unplug}
title={t('panel.notConnectedTitle')}
@@ -665,74 +731,74 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
/>
</div>
) : (
<div className="flex-1 flex gap-2 p-4 min-h-0">
{/* Local Panel */}
<FileListPanel
title={t('panel.local')}
isLocal={true}
path={localPath}
homePath={localHistory[0] || localPath}
files={sortedLocalFiles}
selected={localSelected}
history={localHistory}
historyIndex={localHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateLocal}
onNavigateBack={navigateLocalBack}
onNavigateForward={navigateLocalForward}
onNavigateUp={navigateLocalUp}
onSelect={toggleLocalSelect}
onOpen={handleLocalOpen}
onContextMenu={showLocalContextMenu}
onSort={handleSort}
onDropEntry={handleDropEntry}
onDropFailed={handleDropFailed}
onRefresh={refreshLocal}
onNewFolder={handleNewLocalFolder}
onFocusPane={focusLocalPane}
refreshing={localLoading}
loading={localLoading && localFiles.length === 0}
/>
<div className="flex min-h-0 flex-1 gap-2 p-4">
{/* Local Panel */}
<FileListPanel
title={t('panel.local')}
isLocal={true}
path={localPath}
homePath={localHistory[0] || localPath}
files={sortedLocalFiles}
selected={localSelected}
history={localHistory}
historyIndex={localHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateLocal}
onNavigateBack={navigateLocalBack}
onNavigateForward={navigateLocalForward}
onNavigateUp={navigateLocalUp}
onSelect={toggleLocalSelect}
onOpen={handleLocalOpen}
onContextMenu={showLocalContextMenu}
onSort={handleSort}
onDropEntry={handleDropEntry}
onDropFailed={handleDropFailed}
onRefresh={refreshLocal}
onNewFolder={handleNewLocalFolder}
onFocusPane={focusLocalPane}
refreshing={localLoading}
loading={localLoading && localFiles.length === 0}
/>
<TransferRail
canUpload={isConnected}
canDownload={isConnected && remoteSelected.size > 0}
onUpload={railUpload}
onDownload={railDownload}
/>
<TransferRail
canUpload={isConnected}
canDownload={isConnected && remoteSelected.size > 0}
onUpload={railUpload}
onDownload={railDownload}
/>
{/* Remote Panel */}
<FileListPanel
title={t('panel.remote')}
isLocal={false}
path={remotePath}
homePath={remoteHomePath}
files={sortedRemoteFiles}
selected={remoteSelected}
history={remoteHistory}
historyIndex={remoteHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateRemoteClear}
onNavigateBack={navigateRemoteBackClear}
onNavigateForward={navigateRemoteForwardClear}
onNavigateUp={navigateRemoteUpClear}
onSelect={toggleRemoteSelect}
onOpen={handleRemoteOpen}
onContextMenu={showRemoteContextMenu}
onSort={handleSort}
onDropFiles={isConnected ? handleDropFiles : undefined}
onDropEntry={handleDropEntry}
onDropFailed={handleDropFailed}
onRefresh={isConnected ? refreshRemote : undefined}
onNewFolder={handleNewRemoteFolder}
onFocusPane={focusRemotePane}
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
refreshing={remoteLoading}
listError={remoteListError}
/>
</div>
{/* Remote Panel */}
<FileListPanel
title={t('panel.remote')}
isLocal={false}
path={remotePath}
homePath={remoteHomePath}
files={sortedRemoteFiles}
selected={remoteSelected}
history={remoteHistory}
historyIndex={remoteHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateRemoteClear}
onNavigateBack={navigateRemoteBackClear}
onNavigateForward={navigateRemoteForwardClear}
onNavigateUp={navigateRemoteUpClear}
onSelect={toggleRemoteSelect}
onOpen={handleRemoteOpen}
onContextMenu={showRemoteContextMenu}
onSort={handleSort}
onDropFiles={isConnected ? handleDropFiles : undefined}
onDropEntry={handleDropEntry}
onDropFailed={handleDropFailed}
onRefresh={isConnected ? refreshRemote : undefined}
onNewFolder={handleNewRemoteFolder}
onFocusPane={focusRemotePane}
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
refreshing={remoteLoading}
listError={remoteListError}
/>
</div>
)}
{/* Transfer Panel */}
+19 -7
View File
@@ -1,7 +1,9 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle, Clock3 } from 'lucide-react'
import { translateErrorMessage } from '../../i18n/format'
import type { ConnectionState } from '../../stores/connection'
import type { DisconnectKind } from '../../utils/disconnectReason'
export interface ShellClosedOverlayProps {
reason: string
@@ -33,7 +35,7 @@ export function ShellClosedOverlay({
<div className="mb-1.5 text-sm font-medium text-slate-100">
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
</div>
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div>
<div className="mb-2 text-xs leading-relaxed text-slate-400">{translateErrorMessage(reason)}</div>
<div className="mb-4 text-[11px] text-accent-green">
{t(isLocal ? 'overlay.localShellClosedHint' : 'overlay.shellClosedHint')}
</div>
@@ -57,6 +59,7 @@ export interface TerminalOverlayProps {
showSuspendedOverlay: boolean
needsReconnect: boolean
reconnectAttempt?: number
disconnectKind?: DisconnectKind
isLocal?: boolean
onReconnect: () => void
onResume: () => void
@@ -68,6 +71,7 @@ function TerminalOverlay({
showSuspendedOverlay,
needsReconnect,
reconnectAttempt = 0,
disconnectKind = 'unexpected',
isLocal = false,
onReconnect,
onResume,
@@ -78,18 +82,26 @@ function TerminalOverlay({
return (
<>
{showDisconnectBanner && (
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-6 text-center">
<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(isLocal ? 'overlay.localDisconnected' : 'overlay.disconnected')}
{isLocal
? t('overlay.localDisconnected')
: disconnectKind === 'keepalive'
? t('overlay.disconnectedKeepalive')
: t('overlay.disconnected')}
</div>
<div className="mb-6 text-sm leading-relaxed text-slate-400">
{statusMessage?.trim()
? statusMessage
: t(isLocal ? 'overlay.localDisconnectedHint' : 'overlay.disconnectedHint')}
: isLocal
? t('overlay.localDisconnectedHint')
: disconnectKind === 'keepalive'
? t('overlay.disconnectedKeepaliveHint')
: t('overlay.disconnectedHint')}
</div>
<button
type="button"
@@ -104,7 +116,7 @@ function TerminalOverlay({
)}
{showSuspendedOverlay && (
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center">
<div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center">
<div className="max-w-sm px-6 text-center">
<div className="mb-2 text-base font-medium text-feedback-warning">{t('overlay.suspended')}</div>
<div className="mb-5 text-sm text-slate-400">{t('overlay.suspendedHint')}</div>
@@ -129,7 +141,7 @@ function TerminalOverlay({
)}
{connectionState === 'error' && (
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center">
<div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center">
<div className="max-w-md px-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/25 bg-red-500/15">
<AlertTriangle className="h-6 w-6 text-red-400" />
@@ -151,7 +163,7 @@ function TerminalOverlay({
)}
{(connectionState === 'connecting' || connectionState === 'reconnecting') && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-terminal-bg)_45%,transparent)]">
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-terminal-bg)_45%,transparent)]">
<div className="px-4 text-center">
<div className="inline-flex animate-pulse items-center gap-2 text-sm font-medium text-accent-blue">
<TerminalIcon className="h-4 w-4" />
+2
View File
@@ -56,6 +56,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
statusMessage,
showSuspendedOverlay,
reconnectAttempt,
disconnectKind,
connectionIdRef,
shellIdRef,
hasConnectedRef,
@@ -334,6 +335,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
}
reconnectAttempt={reconnectAttempt}
disconnectKind={disconnectKind}
isLocal={!!currentTab?.isLocal}
onReconnect={handleReconnect}
onResume={resumeConnection}
+62 -30
View File
@@ -46,7 +46,12 @@ import { createLogger } from '../../utils/logger'
import type { TerminalViewProps } from './types'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
import {
panesAfterConnectionStateChange,
resolveTerminalInputTargets,
shouldBroadcastTerminalInput,
shouldCollapseTerminalSplitForOverlay,
} from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
@@ -274,6 +279,8 @@ function PaneTerminal({
void shellResize(sid, term.cols, term.rows)
}
}
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => onResize()) : null
resizeObserver?.observe(container)
window.addEventListener('resize', onResize)
// Fit before advertising size so the primary shell is not born at 80×24.
@@ -315,6 +322,7 @@ function PaneTerminal({
return () => {
disposed = true
setXtermReady(false)
resizeObserver?.disconnect()
window.removeEventListener('resize', onResize)
container.removeEventListener('contextmenu', handleContextMenu)
unregisterShellHandler(paneId)
@@ -341,9 +349,13 @@ function PaneTerminal({
safeTerminalFit(fitAddonRef.current, term)
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
// Resize when shell id attaches
// Resize when shell id attaches; drop the stale id when the session is gone.
useEffect(() => {
if (!shellId || !xtermRef.current) return
if (!shellId) {
shellIdRef.current = null
return
}
if (!xtermRef.current) return
shellIdRef.current = shellId
safeTerminalFit(fitAddonRef.current, xtermRef.current)
void shellResize(shellId, xtermRef.current.cols, xtermRef.current.rows)
@@ -470,6 +482,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const canEnableBroadcast = effectiveBroadcastCount >= 2
const requirePro = useEntitlementStore((state) => state.requirePro)
const [aiPanelOpen, setAiPanelOpen] = useState(false)
const [shellIoReady, setShellIoReady] = useState(false)
const isLocalTab = !!currentTab?.isLocal
const [contextMenu, setContextMenu] = useState<{
paneId: string
@@ -517,6 +530,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
statusMessage,
showSuspendedOverlay,
reconnectAttempt,
disconnectKind,
connectionIdRef,
shellIdRef,
hasConnectedRef,
@@ -544,17 +558,14 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}, [tabId, direction, panes.length, ratio, updateTab])
// Sync primary shell from connection hook → first pane.
// Also adopt the pending id while connecting so bash/sh PS1 is not dropped.
// Drop every pane's dead shell when the pooled session is gone so Cmd+D
// splits recreate after auto-reconnect instead of looking frozen.
useEffect(() => {
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))
})
const primaryShellId =
connectionState === 'connected' || connectionState === 'connecting'
? shellIdRef.current || currentTab?.connectionInstance?.shellId || null
: null
setPanes((prev) => panesAfterConnectionStateChange(prev, connectionState, primaryShellId))
}, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef])
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
@@ -596,8 +607,10 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (broadcastInput && effectiveBroadcastCount < 2) setBroadcastInput(false)
}, [broadcastInput, effectiveBroadcastCount, setBroadcastInput])
// Fan-out shell data to panes (SSH + local)
// Fan-out shell data to panes (SSH + local). Do not connect until listen() resolves —
// otherwise MOTD from bootstrap shows up and the prompt (later events) never arrives.
useEffect(() => {
let cancelled = false
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
for (const h of shellHandlersRef.current.values()) {
h(shellId, data)
@@ -608,11 +621,19 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (conn) conn.stats.bytesTransferred += shellPayloadByteLength(data)
}
}
window.opsxApi?.onShellData(handler)
window.opsxApi?.onLocalData?.(handler)
const api = window.opsxApi
const pending = [
api?.onShellData ? Promise.resolve(api.onShellData(handler)) : Promise.resolve(),
api?.onLocalData ? Promise.resolve(api.onLocalData(handler)) : Promise.resolve(),
]
void Promise.all(pending).then(() => {
if (!cancelled) setShellIoReady(true)
})
return () => {
window.opsxApi?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void)
window.opsxApi?.removeListener('local:data', handler as (...args: unknown[]) => void)
cancelled = true
setShellIoReady(false)
api?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void)
api?.removeListener('local:data', handler as (...args: unknown[]) => void)
}
}, [connectionIdRef])
@@ -712,6 +733,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const armInitialConnect = useCallback(() => {
if (connectArmedRef.current || hasConnectedRef.current) return
if (!shellIoReady) return
const current = getTabById(tabId)
if (!current) return
if (!(isActive || current.connectOnMount)) return
@@ -728,7 +750,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
requestAnimationFrame(() => {
void connect()
})
}, [connect, getTabById, isActive, restoreSnapshot, snapshotStore, tabId, updateTab, hasConnectedRef])
}, [connect, getTabById, isActive, restoreSnapshot, shellIoReady, snapshotStore, tabId, updateTab, hasConnectedRef])
const registerShellHandler = useCallback((paneId: string, handler: (shellId: string, data: unknown) => void) => {
shellHandlersRef.current.set(paneId, handler)
@@ -777,7 +799,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
useEffect(() => {
if (!isActive || hasConnectedRef.current || connectArmedRef.current) return
if (primaryXtermRef.current) armInitialConnect()
}, [isActive, armInitialConnect, hasConnectedRef])
}, [isActive, armInitialConnect, hasConnectedRef, shellIoReady])
const onShellReady = useCallback(
(paneId: string, shellId: string) => {
@@ -1073,12 +1095,21 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const multi = panes.length > 1
const isLocal = !!currentTab?.isLocal
const needsReconnect =
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
const layoutSplit =
multi &&
!shouldCollapseTerminalSplitForOverlay({
connectionState,
needsReconnect,
showSuspendedOverlay,
})
const paneStyle = (index: number, paneId: string): CSSProperties => {
if (maximizedPaneId === paneId) {
return { flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0 }
}
if (!multi) return { flex: 1 }
if (!layoutSplit) return { flex: 1 }
if (panes.length === 2) {
const share = index === 0 ? ratio : 1 - ratio
return direction === 'horizontal'
@@ -1090,7 +1121,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
return (
<div className="flex h-full flex-col bg-dark-900">
<div className="app-workspace">
<div className="border-dark-700/35 bg-dark-800/40 flex items-center gap-0.5 border-b px-2 py-1">
<button
type="button"
@@ -1230,8 +1261,8 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
</div>
</div>
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden p-1">
<div className="app-workspace-body">
<div className="terminal-canvas">
{showSearch && (
<TerminalSearchBar
searchQuery={searchQuery}
@@ -1246,13 +1277,15 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
)}
<div
className={`h-full min-h-0 w-full ${
multi ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex'
layoutSplit ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex'
}`}
>
{panes.map((pane, index) => (
<div
key={pane.id}
className={`relative min-h-0 min-w-0 ${maximizedPaneId && maximizedPaneId !== pane.id ? 'hidden' : ''}`}
className={`relative min-h-0 min-w-0 ${
maximizedPaneId && maximizedPaneId !== pane.id ? 'hidden' : ''
} ${!layoutSplit && index > 0 ? 'hidden' : ''}`}
style={paneStyle(index, pane.id)}
>
<PaneTerminal
@@ -1291,7 +1324,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
onReopen={() => void reopenPaneShell(pane.id)}
/>
)}
{multi && panes.length === 2 && index === 0 && (
{layoutSplit && panes.length === 2 && index === 0 && (
<div
onMouseDown={onDividerMouseDown}
className={
@@ -1309,10 +1342,9 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
connectionState={connectionState}
statusMessage={statusMessage}
showSuspendedOverlay={showSuspendedOverlay}
needsReconnect={
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
}
needsReconnect={needsReconnect}
reconnectAttempt={reconnectAttempt}
disconnectKind={disconnectKind}
isLocal={isLocal}
onReconnect={() => {
const count = Math.min(MAX_PANES, Math.max(1, panes.length))