chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进
This commit is contained in:
1 parent
f71eef0eba
commit
ae6093c9b3
163 files changed
+4911
-2494
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user