feat: harden local shell, SFTP, and desktop session reliability

Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-16 16:08:11 +08:00
1 parent 188f3556ed
commit 08696896f9
223 files changed
+13493 -3423

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ interface AgentMarkdownProps {
}
/** 简单检测是否含 Markdown 结构(标题/表格/引用/列表/粗体) */
// eslint-disable-next-line react-refresh/only-export-components -- shared helper colocated with markdown renderer
export function looksLikeMarkdown(text: string): boolean {
const t = text.trim()
if (!t) return false
+1 -1
View File
@@ -222,7 +222,7 @@ export function CommandPalette({
}
return items
}, [sessions, snippets, addTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession, t])
}, [sessions, snippets, addTab, activeTabId, tabs, onNewSession, onQuickConnect, onOpenSettings, onEditSession, t])
// ── 过滤结果 ──
const filteredItems = useMemo(() => {
+53 -7
View File
@@ -2,7 +2,7 @@
* Professional modal shell — shared by dialogs across OpsXShell
*/
import { type ReactNode, useEffect, useId } from 'react'
import { type ReactNode, useEffect, useId, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from 'lucide-react'
import Button from './Button'
@@ -62,21 +62,65 @@ export default function Modal({
const resolvedConfirm = confirmText ?? t('action.confirm')
const resolvedCancel = cancelText ?? t('action.cancel')
const titleId = useId()
const panelRef = useRef<HTMLDivElement>(null)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
if (!isOpen) return
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
restoreFocusRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null
const focusables = () =>
Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
) ?? []
).filter((el) => !el.hasAttribute('disabled') && el.tabIndex !== -1)
requestAnimationFrame(() => {
const root = panelRef.current
if (!root) return
if (root.contains(document.activeElement)) return
const target =
root.querySelector<HTMLElement>('[data-autofocus]') ??
root.querySelector<HTMLElement>('input:not([disabled]), textarea, select') ??
focusables()[0]
target?.focus()
})
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopImmediatePropagation()
onCloseRef.current()
return
}
if (e.key !== 'Tab' || !panelRef.current) return
const items = focusables()
if (items.length === 0) return
const first = items[0]
const last = items[items.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
// Lock body scroll while open
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleEsc)
window.addEventListener('keydown', handleKey, true)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', handleEsc)
window.removeEventListener('keydown', handleKey, true)
restoreFocusRef.current?.focus()
}
}, [isOpen, onClose])
}, [isOpen])
if (!isOpen) return null
@@ -93,6 +137,7 @@ export default function Modal({
/>
<div
ref={panelRef}
className={`
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
flex flex-col
@@ -144,6 +189,7 @@ export default function Modal({
size="sm"
onClick={onConfirm}
loading={loading}
data-autofocus={isDanger ? true : undefined}
>
{resolvedConfirm}
</Button>
+1
View File
@@ -119,6 +119,7 @@ export function usePromptDialog() {
<p className="text-content-secondary mb-3">{state.message}</p>
<input
ref={inputRef}
data-autofocus
type={state.inputType ?? 'text'}
autoComplete={state.inputType === 'password' ? 'new-password' : 'off'}
value={state.value}
+17 -17
View File
@@ -18,31 +18,31 @@ const iconMap: Record<ToastType, typeof CheckCircle> = {
info: Info,
}
/** 类型 → 颜色样式映射 */
/** 类型 → 颜色样式映射(theme tokens,浅色/深色都可读) */
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
success: {
bg: 'bg-green-900/40',
border: 'border-green-700/60',
icon: 'text-green-400',
text: 'text-green-200',
bg: 'bg-feedback-success-bg',
border: 'border-feedback-success/35',
icon: 'text-feedback-success',
text: 'text-content-primary',
},
error: {
bg: 'bg-red-900/40',
border: 'border-red-700/60',
icon: 'text-red-400',
text: 'text-red-200',
bg: 'bg-feedback-error-bg',
border: 'border-feedback-error/35',
icon: 'text-feedback-error',
text: 'text-content-primary',
},
warning: {
bg: 'bg-surface-basember-900/40',
border: 'border-amber-700/60',
icon: 'text-amber-400',
text: 'text-amber-200',
bg: 'bg-feedback-warning-bg',
border: 'border-feedback-warning/35',
icon: 'text-feedback-warning',
text: 'text-content-primary',
},
info: {
bg: 'bg-blue-900/40',
border: 'border-blue-700/60',
icon: 'text-blue-400',
text: 'text-blue-200',
bg: 'bg-feedback-info-bg',
border: 'border-feedback-info/35',
icon: 'text-feedback-info',
text: 'text-content-primary',
},
}
+8 -8
View File
@@ -11,17 +11,17 @@ interface ToastContainerProps {
}
const iconMap: Record<ToastType, React.ReactNode> = {
info: <Info className="w-4 h-4 text-blue-400" />,
success: <CheckCircle className="w-4 h-4 text-green-400" />,
error: <XCircle className="w-4 h-4 text-red-400" />,
warning: <AlertTriangle className="w-4 h-4 text-yellow-400" />,
info: <Info className="w-4 h-4 text-feedback-info" />,
success: <CheckCircle className="w-4 h-4 text-feedback-success" />,
error: <XCircle className="w-4 h-4 text-feedback-error" />,
warning: <AlertTriangle className="w-4 h-4 text-feedback-warning" />,
}
const bgMap: Record<ToastType, string> = {
info: 'bg-blue-900/80 border-blue-700',
success: 'bg-green-900/80 border-green-700',
error: 'bg-red-900/80 border-red-700',
warning: 'bg-yellow-900/80 border-yellow-700',
info: 'bg-feedback-info-bg border-feedback-info/35',
success: 'bg-feedback-success-bg border-feedback-success/35',
error: 'bg-feedback-error-bg border-feedback-error/35',
warning: 'bg-feedback-warning-bg border-feedback-warning/35',
}
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
@@ -100,15 +100,15 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
<span
className={`text-xs px-1.5 py-0.5 rounded ${
conn.state === 'connected'
? 'bg-green-400/10 text-green-400'
? 'bg-feedback-success/10 text-feedback-success'
: conn.state === 'connecting' || conn.state === 'reconnecting'
? 'bg-yellow-400/10 text-yellow-400'
? 'bg-feedback-warning/10 text-feedback-warning'
: conn.state === 'background'
? 'bg-green-400/10 text-green-400 opacity-70'
? 'bg-feedback-success/10 text-feedback-success opacity-70'
: conn.state === 'suspended'
? 'bg-surface-active/10 text-txt-muted'
: conn.state === 'error'
? 'bg-red-400/10 text-red-400'
? 'bg-feedback-error/10 text-feedback-error'
: 'bg-surface-active/10 text-txt-muted'
}`}
>
@@ -200,9 +200,9 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
const { t } = useTranslation('common')
const items = [
{ label: t('connectionPanel.stat.total'), value: total, color: 'text-content-secondary' },
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-green-400' },
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-yellow-400' },
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-red-400' },
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-feedback-success' },
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-feedback-warning' },
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-feedback-error' },
]
return (
+277 -55
View File
@@ -11,10 +11,22 @@ import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
import type { ForwardRule, ForwardRuleType } from './forwardRules'
import {
DEFAULT_LOCAL_BIND_HOST,
DEFAULT_REMOTE_BIND_HOST,
formatHostPort,
isAllInterfacesBind,
parseHostPort,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
type ForwardRule,
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
@@ -25,6 +37,8 @@ export interface ForwardRuleDraft {
type: ForwardRuleType
name: string
localPort: number
localBindHost: string
remoteBindHost: string
remoteHost: string
remotePort: number
}
@@ -43,6 +57,8 @@ interface ForwardRuleDrawerProps {
rules?: ForwardRule[]
runtimes?: Map<string, ForwardRuleRuntime>
busy?: boolean
/** Current SSH session label, e.g. root@opsbase.cn:55555 — shown for local forward target. */
viaSession?: string
onChange: (next: Partial<ForwardRule>) => void
onClose: () => void
onSave: () => void
@@ -55,36 +71,74 @@ const EMPTY_DRAFT: ForwardRuleDraft = {
type: 'local',
name: '',
localPort: 0,
remoteHost: '',
localBindHost: DEFAULT_LOCAL_BIND_HOST,
remoteBindHost: DEFAULT_REMOTE_BIND_HOST,
remoteHost: '127.0.0.1',
remotePort: 0,
}
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function emptyForwardDraft(): ForwardRuleDraft {
return { ...EMPTY_DRAFT }
}
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function emptyDynamicDraft(name = 'SOCKS5'): ForwardRuleDraft {
return {
type: 'dynamic',
name,
localPort: DEFAULT_DYNAMIC_PORT,
localBindHost: '',
remoteBindHost: '',
remoteHost: '',
remotePort: 0,
}
}
function ruleSummary(rule: ForwardRule, t: (key: string, opts?: Record<string, unknown>) => string): string {
function ruleSummary(
rule: ForwardRule,
t: (key: string, opts?: Record<string, unknown>) => string,
boundPort?: number
): string {
if (rule.type === 'dynamic') {
return t('portForward.socksProxy', { port: resolveDynamicPort(rule.localPort) })
return t('portForward.socksProxy', { port: resolveDynamicPort(boundPort ?? rule.localPort) })
}
if (rule.type === 'remote') {
const bind = resolveRemoteBindHost(rule.remoteBindHost)
const remote = boundPort ?? rule.remotePort
const host = resolveForwardTargetHost(rule.remoteHost)
// Default remote bind is loopback — list as port only (matches ssh -R port:…).
if (bind === DEFAULT_REMOTE_BIND_HOST || bind === 'localhost') {
return t('portForward.remoteToLocalPort', {
remote,
host,
local: rule.localPort,
})
}
return t('portForward.remoteToLocal', {
remote: rule.remotePort,
bind,
remote,
host,
local: rule.localPort,
})
}
const host = rule.remoteHost || 'localhost'
return `127.0.0.1:${rule.localPort} → ${host}:${rule.remotePort}`
const bind = resolveLocalBindHost(rule.localBindHost)
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
const local = boundPort ?? rule.localPort
// Default bind is localhost — list as port only (matches ssh -L 8080:…).
if (bind === DEFAULT_LOCAL_BIND_HOST || bind === 'localhost') {
return t('portForward.localToRemotePort', {
local,
host,
remote: rule.remotePort,
})
}
return t('portForward.localToRemote', {
bind,
local,
host,
remote: rule.remotePort,
})
}
export default function ForwardRuleDrawer({
@@ -95,6 +149,7 @@ export default function ForwardRuleDrawer({
rules,
runtimes,
busy = false,
viaSession,
onChange,
onClose,
onSave,
@@ -152,17 +207,33 @@ export default function ForwardRuleDrawer({
type: 'dynamic',
name: nextName,
localPort: DEFAULT_DYNAMIC_PORT,
localBindHost: '',
remoteBindHost: '',
remoteHost: '',
remotePort: 0,
})
return
}
const nextLocalPort = draft.type === 'dynamic' ? 8080 : draft.localPort || 8080
const nextRemotePort =
draft.type === 'dynamic'
? next === 'remote'
? 18080
: 80
: draft.remotePort || (next === 'remote' ? 18080 : 80)
onChange({
...draft,
type: next,
name: nextName,
localPort: draft.type === 'dynamic' ? 0 : draft.localPort,
localPort: nextLocalPort,
localBindHost:
next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
remoteBindHost:
next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
remoteHost: resolveForwardTargetHost(draft.remoteHost) || '127.0.0.1',
remotePort: nextRemotePort,
})
}
@@ -292,7 +363,8 @@ export default function ForwardRuleDrawer({
) : (
<div className="space-y-1.5">
{localRemoteRules.map((rule) => {
const running = Boolean(runtimes?.get(rule.id)?.running)
const runtime = runtimes?.get(rule.id)
const running = Boolean(runtime?.running)
return (
<div
key={rule.id}
@@ -339,7 +411,7 @@ export default function ForwardRuleDrawer({
)}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-txt-muted">
{ruleSummary(rule, t)}
{ruleSummary(rule, t, runtime?.boundPort)}
</p>
</div>
<div className="flex shrink-0 gap-0.5">
@@ -456,51 +528,201 @@ export default function ForwardRuleDrawer({
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ui-label">
{type === 'remote' ? t('portForward.localTargetPort') : t('portForward.localListenPort')}
</label>
<input
type="number"
min={1}
max={65535}
value={draft.localPort || ''}
onChange={(e) => onChange({ ...draft, localPort: Number(e.target.value) })}
placeholder={t('portForward.portPlaceholder')}
className="ui-input font-mono"
inputMode="numeric"
/>
</div>
<div>
<label className="ui-label">
{type === 'remote' ? t('portForward.remoteListenPort') : t('portForward.remoteTargetPort')}
</label>
<input
type="number"
min={1}
max={65535}
value={draft.remotePort || ''}
onChange={(e) => onChange({ ...draft, remotePort: Number(e.target.value) })}
placeholder={t('portForward.portPlaceholder')}
className="ui-input font-mono"
inputMode="numeric"
/>
</div>
</div>
{type === 'local' && (
<div>
<label className="ui-label">{t('portForward.remoteHost')}</label>
<input
type="text"
value={draft.remoteHost || ''}
onChange={(e) => onChange({ ...draft, remoteHost: e.target.value })}
placeholder={t('portForward.remoteHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
</div>
{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">
{t('portForward.viaSession', { target: viaSession })}
</p>
) : null}
<div>
<label className="ui-label">{t('portForward.localListenPort')}</label>
<input
type="number"
min={1}
max={65535}
value={draft.localPort || ''}
onChange={(e) =>
onChange({
...draft,
localPort: Number(e.target.value),
localBindHost: resolveLocalBindHost(draft.localBindHost),
})
}
placeholder="8080"
className="ui-input font-mono"
inputMode="numeric"
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localListenPortHint')}
</p>
</div>
<div>
<label className="ui-label">{t('portForward.targetAddress')}</label>
<input
type="text"
value={formatHostPort(
resolveForwardTargetHost(draft.remoteHost),
draft.remotePort
)}
onChange={(e) => {
const raw = e.target.value.trim()
const parsed = parseHostPort(raw)
if (parsed) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(parsed.host),
remotePort: parsed.port,
})
return
}
const colon = raw.lastIndexOf(':')
if (colon > 0) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
remotePort: Number(raw.slice(colon + 1)) || 0,
})
} else {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
})
}
}}
placeholder={t('portForward.targetAddressPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{viaSession
? t('portForward.targetAddressHintVia', { target: viaSession })
: t('portForward.targetAddressHint')}
</p>
</div>
<details className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2">
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
{t('portForward.localBindAdvanced')}
</summary>
<div className="mt-2">
<label className="ui-label">{t('portForward.localBindHost')}</label>
<input
type="text"
value={resolveLocalBindHost(draft.localBindHost)}
onChange={(e) =>
onChange({
...draft,
localBindHost: resolveLocalBindHost(e.target.value),
})
}
placeholder={t('portForward.localBindHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localBindHostHint')}
</p>
</div>
</details>
</>
) : (
<>
{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">
{t('portForward.viaSessionRemote', { target: viaSession })}
</p>
) : null}
<div>
<label className="ui-label">{t('portForward.remoteListenPort')}</label>
<input
type="number"
min={1}
max={65535}
value={draft.remotePort || ''}
onChange={(e) =>
onChange({
...draft,
remotePort: Number(e.target.value),
remoteBindHost: resolveRemoteBindHost(draft.remoteBindHost),
})
}
placeholder="18080"
className="ui-input font-mono"
inputMode="numeric"
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.remoteListenPortHint')}
</p>
</div>
<div>
<label className="ui-label">{t('portForward.localTargetAddress')}</label>
<input
type="text"
value={formatHostPort(
resolveForwardTargetHost(draft.remoteHost),
draft.localPort
)}
onChange={(e) => {
const raw = e.target.value.trim()
const parsed = parseHostPort(raw)
if (parsed) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(parsed.host),
localPort: parsed.port,
})
return
}
const colon = raw.lastIndexOf(':')
if (colon > 0) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
localPort: Number(raw.slice(colon + 1)) || 0,
})
} else {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
})
}
}}
placeholder={t('portForward.localTargetAddressPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localTargetAddressHint')}
</p>
</div>
<details
className="rounded-lg border border-dark-700/40 bg-dark-900/20 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">
{t('portForward.remoteBindAdvanced')}
</summary>
<div className="mt-2">
<label className="ui-label">{t('portForward.remoteBindHost')}</label>
<input
type="text"
value={resolveRemoteBindHost(draft.remoteBindHost)}
onChange={(e) =>
onChange({
...draft,
remoteBindHost: resolveRemoteBindHost(e.target.value),
})
}
placeholder={t('portForward.remoteBindHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.remoteBindHostHint')}
</p>
</div>
</details>
</>
)}
</section>
)}
+109 -17
View File
@@ -4,12 +4,13 @@
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
@@ -21,9 +22,19 @@ import {
duplicateForwardRule,
parseForwardRules,
portForwardStorageKey,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
runtimeMap,
type ForwardRule,
} from './forwardRules'
import {
forgetForwardIntent,
forwardIntentKey,
rememberForwardIntent,
restoreForwardIntent,
tunnelCoversRule,
} from './forwardIntent'
interface PortForwardViewProps {
tabId: string
@@ -41,6 +52,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
const connectionId = currentTab?.connectionInstance?.connectionId
const connection = connectionId ? pool.getConnection(connectionId) : null
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId, connectionId)
const intentKey = forwardIntentKey(currentTab?.sessionId, tabId)
const [rules, setRules] = useState<ForwardRule[]>([])
const [tunnels, setTunnels] = useState<SSHForwardStatus[]>([])
@@ -115,6 +127,8 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
...emptyForwardDraft(),
type: 'local',
name: t('common:portForward.defaultLocalName'),
localBindHost: '127.0.0.1',
remoteHost: '127.0.0.1',
})
setShowAddModal(true)
}
@@ -127,11 +141,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
}
if (newRule.type !== 'dynamic' && !validPort(newRule.remotePort)) {
setDrawerError(t('common:portForward.errorTargetPort'))
setDrawerError(
newRule.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorRemoteListenAddress')
)
return
}
if (newRule.type === 'local' && !newRule.remoteHost?.trim()) {
setDrawerError(t('common:portForward.errorRemoteHost'))
if (newRule.type !== 'dynamic' && !newRule.remoteHost?.trim()) {
setDrawerError(
newRule.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorLocalTargetAddress')
)
return
}
if (
@@ -148,7 +170,12 @@ 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!,
remoteHost: newRule.remoteHost?.trim(),
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,
}
@@ -172,17 +199,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
result = await window.opsxApi?.sshForwardLocal(
connectionId,
rule.localPort,
rule.remoteHost || '',
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi?.sshForwardRemote(
connectionId,
rule.remotePort || 0,
'127.0.0.1',
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi?.sshForwardDynamic(
@@ -193,18 +222,43 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
}
if (result?.success) {
rememberForwardIntent(intentKey, rule)
await refreshTunnels()
return true
}
setError(result?.error || t('common:portForward.errorStartFailed'))
setError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
)
} catch (err: unknown) {
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStartFailed'))
const raw = err instanceof Error ? err.message : String(err)
setError(raw ? translateErrorMessage(raw) : t('common:portForward.errorStartFailed'))
} finally {
setIsLoading(false)
}
return false
}
const startForwardRef = useRef(startForward)
startForwardRef.current = startForward
const previousConnectionRef = useRef<string | undefined>(undefined)
useEffect(() => {
if (!connectionId) return
const previous = previousConnectionRef.current
previousConnectionRef.current = connectionId
if (!previous || previous === connectionId) return
void (async () => {
const listed = await window.opsxApi?.sshForwardList(connectionId)
const tunnels = listed?.success ? listed.tunnels || [] : []
await restoreForwardIntent(
intentKey,
(rule) => tunnelCoversRule(tunnels, rule),
(rule) => startForwardRef.current(rule)
)
})()
}, [connectionId, intentKey])
const quickStartSocks = async () => {
const existing = rules.find((rule) => rule.type === 'dynamic')
if (existing) {
@@ -230,11 +284,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
setIsLoading(true)
setError(null)
try {
const result = await window.opsxApi?.sshForwardStop(rule.id)
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'))
return false
}
forgetForwardIntent(intentKey, rule.id)
await refreshTunnels()
return true
} catch (err: unknown) {
@@ -416,14 +478,44 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<span>{t('common:portForward.socksProxy', { port: boundPort })}</span>
) : rule.type === 'local' ? (
<span>
127.0.0.1:{boundPort} → {rule.remoteHost}:{rule.remotePort}
{(() => {
const bind = resolveLocalBindHost(rule.localBindHost)
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
if (bind === '127.0.0.1' || bind === 'localhost') {
return t('common:portForward.localToRemotePort', {
local: boundPort,
host,
remote: rule.remotePort,
})
}
return t('common:portForward.localToRemote', {
bind,
local: boundPort,
host,
remote: rule.remotePort,
})
})()}
</span>
) : (
<span>
{t('common:portForward.remoteToLocal', {
remote: runtime?.boundPort || rule.remotePort,
local: rule.localPort,
})}
{(() => {
const bind = resolveRemoteBindHost(rule.remoteBindHost)
const remote = runtime?.boundPort || rule.remotePort
const host = resolveForwardTargetHost(rule.remoteHost)
if (bind === '127.0.0.1' || bind === 'localhost') {
return t('common:portForward.remoteToLocalPort', {
remote,
host,
local: rule.localPort,
})
}
return t('common:portForward.remoteToLocal', {
bind,
remote,
host,
local: rule.localPort,
})
})()}
</span>
)}
</div>
@@ -0,0 +1,80 @@
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import type { ForwardRule } from './forwardRules'
/**
* Process-local "this rule should stay up" intent.
* Never persisted — app restart must not resurrect listeners.
* Survives SSH reconnect so the same session can restore forwards.
*/
const remembered = new Map<string, Map<string, ForwardRule>>()
const resumeLocks = new Map<string, Promise<number>>()
export function forwardIntentKey(sessionId: string | undefined, tabId: string): string {
return sessionId ? `session:${sessionId}` : `tab:${tabId}`
}
export function rememberForwardIntent(key: string, rule: ForwardRule): void {
let rules = remembered.get(key)
if (!rules) {
rules = new Map()
remembered.set(key, rules)
}
rules.set(rule.id, { ...rule })
}
export function forgetForwardIntent(key: string, ruleId: string): void {
remembered.get(key)?.delete(ruleId)
}
export function listForwardIntent(key: string): ForwardRule[] {
return [...(remembered.get(key)?.values() ?? [])]
}
export function tunnelCoversRule(tunnels: SSHForwardStatus[], rule: ForwardRule): boolean {
return tunnels.some((item) => {
if (!item.running || item.kind !== rule.type) return false
if (item.forwardId === rule.id) return true
// One dynamic SOCKS per SSH host.
if (rule.type === 'dynamic') return true
// boundPort is the listen port: local → localPort, remote → remotePort.
if (rule.type === 'remote') {
return item.boundPort === rule.remotePort
}
return item.boundPort === rule.localPort
})
}
/** Drop all remembered start intents for a tab/session (tab close). */
export function clearForwardIntent(key: string): void {
remembered.delete(key)
resumeLocks.delete(key)
}
/** Start remembered rules that are not running. Concurrent calls share one pass. */
export function restoreForwardIntent(
key: string,
isRunning: (rule: ForwardRule) => boolean,
start: (rule: ForwardRule) => Promise<boolean>
): Promise<number> {
const existing = resumeLocks.get(key)
if (existing) return existing
const work = (async () => {
let restored = 0
for (const rule of listForwardIntent(key)) {
if (isRunning(rule)) continue
if (await start(rule)) restored += 1
}
return restored
})().finally(() => {
resumeLocks.delete(key)
})
resumeLocks.set(key, work)
return work
}
export function resetForwardIntentForTests(): void {
remembered.clear()
resumeLocks.clear()
}
+88 -1
View File
@@ -3,15 +3,96 @@ import i18n from '../../i18n'
export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
/** Default local listen bind for `-L` (this machine only). */
export const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'
/** Default remote listen bind for `-R` (only the SSH server itself). */
export const DEFAULT_REMOTE_BIND_HOST = '127.0.0.1'
export interface ForwardRule {
id: string
type: ForwardRuleType
name: string
localPort: number
/** Local listen address for local forwards (127.0.0.1 / LAN IP / 0.0.0.0). */
localBindHost?: string
/** Remote listen address for remote forwards (127.0.0.1 or 0.0.0.0; needs GatewayPorts for all-interfaces). */
remoteBindHost?: string
/** Destination host in OpenSSH `-L` / `-R` sense (not a listen bind).
* - local: host as seen from the SSH server
* - remote: host as seen from this machine (service to expose; localhost or LAN IP)
*/
remoteHost?: string
remotePort?: number
}
export function resolveLocalBindHost(host: unknown): string {
const value = typeof host === 'string' ? host.trim() : ''
return value || DEFAULT_LOCAL_BIND_HOST
}
/** Normalize remote `-R` listen bind. Default loopback; `0.0.0.0` opens all remote interfaces. */
export function resolveRemoteBindHost(host: unknown): string {
const value = typeof host === 'string' ? host.trim() : ''
if (!value || value.toLowerCase() === 'localhost') return DEFAULT_REMOTE_BIND_HOST
return value
}
export function isAllInterfacesBind(host: string): boolean {
const value = host.trim().toLowerCase()
return value === '0.0.0.0' || value === '*' || value === '::' || value === '[::]'
}
/** Format `host:port` for listen/target address fields. */
export function formatHostPort(host: string, port: number | undefined): string {
const h = host.trim() || DEFAULT_LOCAL_BIND_HOST
if (!port || port < 1) return `${h}:`
return `${h}:${port}`
}
/** Parse `host:port` or `[ipv6]:port`. Returns null if invalid. */
export function parseHostPort(value: string): { host: string; port: number } | null {
const trimmed = value.trim()
if (!trimmed) return null
let host: string
let portText: string
if (trimmed.startsWith('[')) {
const end = trimmed.indexOf(']')
if (end < 1 || trimmed[end + 1] !== ':') return null
host = trimmed.slice(1, end)
portText = trimmed.slice(end + 2)
} else {
const lastColon = trimmed.lastIndexOf(':')
if (lastColon <= 0) return null
host = trimmed.slice(0, lastColon)
portText = trimmed.slice(lastColon + 1)
}
const port = Number(portText)
if (!host.trim() || !Number.isInteger(port) || port < 1 || port > 65535) return null
return { host: host.trim(), port }
}
/**
* Target host is where the SSH server connects (not a listen bind).
* `0.0.0.0` / `::` are not valid connect destinations — treat as remote loopback.
*/
export function resolveForwardTargetHost(host: unknown): string {
const value = typeof host === 'string' ? host.trim() : ''
if (!value || isListenOnlyAddress(value)) return '127.0.0.1'
return value
}
/** True for addresses that only make sense as listen binds, not TCP connect targets. */
export function isListenOnlyAddress(host: string): boolean {
const value = host.trim().toLowerCase()
return value === '0.0.0.0' || value === '::' || value === '[::]'
}
/** @deprecated Use resolveForwardTargetHost; kept for call-site clarity. */
export function isInvalidForwardTargetHost(host: string): boolean {
return isListenOnlyAddress(host)
}
export function portForwardStorageKey(
sessionId: string | undefined,
tabId: string,
@@ -54,8 +135,14 @@ export function parseForwardRules(value: unknown): ForwardRule[] {
type: type as ForwardRuleType,
localPort,
}
if (type === 'local') {
parsed.localBindHost = resolveLocalBindHost(rule.localBindHost)
}
if (type === 'remote') {
parsed.remoteBindHost = resolveRemoteBindHost(rule.remoteBindHost)
}
if (typeof rule.remoteHost === 'string' && rule.remoteHost.trim()) {
parsed.remoteHost = rule.remoteHost.trim()
parsed.remoteHost = resolveForwardTargetHost(rule.remoteHost)
}
const remotePort = Number(rule.remotePort)
if (Number.isInteger(remotePort) && remotePort >= 1 && remotePort <= 65535) {
+18 -24
View File
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Monitor, Cast } from 'lucide-react'
import Modal from '../common/Modal'
import DesktopResolutionPicker from './DesktopResolutionPicker'
import type { DesktopProtocol, RdpConfig, VncConfig } from '../../../shared/types/desktop.types'
import { clampDesktopResolution, getDisplayResolution } from '../../utils/desktopResolution'
interface DesktopConnectDialogProps {
protocol: DesktopProtocol
@@ -32,6 +34,9 @@ export default function DesktopConnectDialog({
if (!isOpen) return
setPort(isRdp ? 3389 : 5900)
setError(null)
const display = getDisplayResolution()
setWidth(display.width)
setHeight(display.height)
}, [isOpen, isRdp])
const title = isRdp ? t('connect.rdpTitle') : t('connect.vncTitle')
@@ -46,6 +51,7 @@ export default function DesktopConnectDialog({
setError(t('connect.errorUsernameRequired'))
return
}
const resolution = clampDesktopResolution(width, height)
if (isRdp) {
onConnect('rdp', {
host: host.trim(),
@@ -53,8 +59,8 @@ export default function DesktopConnectDialog({
username: username.trim(),
password,
domain: domain.trim() || undefined,
width,
height,
width: resolution.width,
height: resolution.height,
security: 'nla',
})
} else {
@@ -62,8 +68,8 @@ export default function DesktopConnectDialog({
host: host.trim(),
port,
password: password || undefined,
width,
height,
width: resolution.width,
height: resolution.height,
})
}
onClose()
@@ -145,26 +151,14 @@ export default function DesktopConnectDialog({
autoComplete="off"
/>
</div>
<div>
<label htmlFor="desktop-width" className="ui-label">{t('connect.width')}</label>
<input
id="desktop-width"
type="number"
value={width}
onChange={(event) => setWidth(Number(event.target.value) || 1920)}
className="ui-input font-mono"
/>
</div>
<div>
<label htmlFor="desktop-height" className="ui-label">{t('connect.height')}</label>
<input
id="desktop-height"
type="number"
value={height}
onChange={(event) => setHeight(Number(event.target.value) || 1080)}
className="ui-input font-mono"
/>
</div>
<DesktopResolutionPicker
width={width}
height={height}
onChange={(next) => {
setWidth(next.width)
setHeight(next.height)
}}
/>
</section>
<p className="text-[11px] text-txt-faint leading-relaxed">
{isRdp ? t('connect.rdpFootnote') : t('connect.vncFootnote')}
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
DESKTOP_RESOLUTION_PRESETS,
clampDesktopResolution,
formatResolution,
matchPresetId,
type DesktopResolution,
} from '../../utils/desktopResolution'
interface DesktopResolutionPickerProps {
width: number
height: number
onChange: (next: DesktopResolution) => void
ns?: 'desktop' | 'sessions'
resolutionLabelKey?: string
widthLabelKey?: string
heightLabelKey?: string
customKey?: string
}
export default function DesktopResolutionPicker({
width,
height,
onChange,
ns = 'desktop',
resolutionLabelKey = ns === 'desktop' ? 'connect.resolution' : 'dialog.resolution',
widthLabelKey = ns === 'desktop' ? 'connect.width' : 'dialog.width',
heightLabelKey = ns === 'desktop' ? 'connect.height' : 'dialog.height',
customKey = ns === 'desktop' ? 'connect.resolutionCustom' : 'dialog.resolutionCustom',
}: DesktopResolutionPickerProps) {
const { t } = useTranslation(ns)
const current = clampDesktopResolution(width, height)
const matched = matchPresetId(current)
const [forceCustom, setForceCustom] = useState(false)
const selected = forceCustom || matched === 'custom' ? 'custom' : matched
const showCustom = selected === 'custom'
return (
<div className="space-y-2 sm:col-span-3">
<div>
<label htmlFor={`${ns}-desktop-resolution`} className="ui-label">
{t(resolutionLabelKey)}
</label>
<select
id={`${ns}-desktop-resolution`}
className="ui-input font-mono"
value={selected}
onChange={(event) => {
const value = event.target.value
if (value === 'custom') {
setForceCustom(true)
onChange(current)
return
}
const preset = DESKTOP_RESOLUTION_PRESETS.find((item) => item.id === value)
if (!preset) return
setForceCustom(false)
onChange({ width: preset.width, height: preset.height })
}}
>
{DESKTOP_RESOLUTION_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{formatResolution(preset)}
</option>
))}
<option value="custom">{t(customKey)}</option>
</select>
</div>
{showCustom && (
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor={`${ns}-desktop-width`} className="ui-label">
{t(widthLabelKey)}
</label>
<input
id={`${ns}-desktop-width`}
type="number"
min={200}
max={8192}
step={2}
value={width}
onChange={(event) => {
setForceCustom(true)
onChange(clampDesktopResolution(Number(event.target.value) || current.width, height))
}}
className="ui-input font-mono"
/>
</div>
<div>
<label htmlFor={`${ns}-desktop-height`} className="ui-label">
{t(heightLabelKey)}
</label>
<input
id={`${ns}-desktop-height`}
type="number"
min={200}
max={8192}
value={height}
onChange={(event) => {
setForceCustom(true)
onChange(clampDesktopResolution(width, Number(event.target.value) || current.height))
}}
className="ui-input font-mono"
/>
</div>
</div>
)}
</div>
)
}
+291 -94
View File
@@ -1,16 +1,18 @@
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import {
Cast,
ClipboardPaste,
FolderInput,
FolderOpen,
Maximize2,
Minimize2,
Monitor,
RefreshCw,
Unplug,
} from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
import type {
DesktopFrameEvent,
DesktopPointerButton,
@@ -28,6 +30,7 @@ import {
rdpDisconnect,
rdpInputKey,
rdpInputPointer,
rdpResize,
rdpUploadFile,
} from '../../platform/rdp'
import {
@@ -41,6 +44,7 @@ import {
vncInputPointer,
} from '../../platform/vnc'
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
import { clampDesktopResolution } from '../../utils/desktopResolution'
interface DesktopSessionViewProps {
tabId: string
@@ -70,6 +74,7 @@ function decodeRgbaBase64(base64: string): Uint8ClampedArray<ArrayBuffer> {
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
const { t } = useTranslation('desktop')
const canvasRef = useRef<HTMLCanvasElement>(null)
const paneRef = useRef<HTMLDivElement>(null)
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
const nativeSessionIdRef = useRef<string | null>(null)
const isActiveRef = useRef(isActive)
@@ -77,7 +82,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const buttonsRef = useRef({ left: false, middle: false, right: false })
const pointerRafRef = useRef<number | null>(null)
const paintRafRef = useRef<number | null>(null)
const hideBarTimerRef = useRef<number | null>(null)
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
const lastRemoteResizeRef = useRef<{ width: number; height: number } | null>(null)
const remoteResizeTimerRef = useRef<number | null>(null)
const connectedRef = useRef(false)
const pendingPointerRef = useRef<{
x: number
y: number
@@ -87,10 +96,17 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
const sessions = useSessionStore((state) => state.sessions)
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
const fullscreenActive = useDesktopFullscreenStore(
(state) => state.active && state.tabId === tabId
)
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
const [statusMessage, setStatusMessage] = useState<string>(() => t('status.connecting'))
const [error, setError] = useState<string | null>(null)
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
const [chromeVisible, setChromeVisible] = useState(true)
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
isActiveRef.current = isActive
@@ -203,6 +219,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
updateTabStatus(tabId, 'connecting')
setStatusMessage(t('status.connecting'))
setError(null)
connectedRef.current = false
lastRemoteResizeRef.current = null
void (async () => {
try {
@@ -210,13 +228,20 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
unlistenStatus = await onRdpStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
if (!nativeSessionIdRef.current) 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
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'))
} else if (payload.status === 'disconnected') {
connectedRef.current = false
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
} else if (payload.message) {
@@ -288,6 +313,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
return () => {
disposed = true
connectedRef.current = false
if (remoteResizeTimerRef.current != null) {
window.clearTimeout(remoteResizeTimerRef.current)
remoteResizeTimerRef.current = null
}
if (paintRafRef.current != null) {
window.cancelAnimationFrame(paintRafRef.current)
paintRafRef.current = null
@@ -311,6 +341,123 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
canvasRef.current?.focus()
}, [isActive])
// Fit canvas to pane; for RDP also request remote dynamic resolution (mstsc / FreeRDP style).
useEffect(() => {
const pane = paneRef.current
const canvas = canvasRef.current
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 requestRemoteResize = () => {
if (protocol !== 'rdp' || !connectedRef.current || !isActive) return
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
const next = clampDesktopResolution(pane.clientWidth, pane.clientHeight)
const last = lastRemoteResizeRef.current
if (last && last.width === next.width && last.height === next.height) return
// Skip tiny panes (drawer/animation) that would thrash the session.
if (next.width < 640 || next.height < 480) return
lastRemoteResizeRef.current = next
void rdpResize(sessionId, next.width, next.height).catch(() => {
// Allow retry on next size change if Display Control was not ready.
lastRemoteResizeRef.current = last
})
}
const onPaneResize = () => {
updateFit()
if (remoteResizeTimerRef.current != null) {
window.clearTimeout(remoteResizeTimerRef.current)
}
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, 280)
}
updateFit()
// After fullscreen / maximize layout settles, request matching remote size once.
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, fullscreenActive ? 120 : 280)
const observer = new ResizeObserver(onPaneResize)
observer.observe(pane)
return () => {
observer.disconnect()
if (remoteResizeTimerRef.current != null) {
window.clearTimeout(remoteResizeTimerRef.current)
remoteResizeTimerRef.current = null
}
}
}, [fbSize.height, fbSize.width, fullscreenActive, isActive, protocol])
// Leave OS fullscreen when this tab is hidden or unmounted.
useEffect(() => {
if (!isActive && fullscreenActive) {
void exitFullscreen()
}
}, [exitFullscreen, fullscreenActive, isActive])
useEffect(() => {
return () => {
if (hideBarTimerRef.current != null) {
window.clearTimeout(hideBarTimerRef.current)
}
// Only exit when this session view is truly torn down (tab closed), not on HMR-only paths.
const state = useDesktopFullscreenStore.getState()
if (state.active && state.tabId === tabId) {
const stillOpen = useTabStore.getState().tabs.some((item) => item.id === tabId)
if (!stillOpen) void state.exit()
}
}
}, [tabId])
useEffect(() => {
if (!fullscreenActive) {
setChromeVisible(true)
if (hideBarTimerRef.current != null) {
window.clearTimeout(hideBarTimerRef.current)
hideBarTimerRef.current = null
}
return
}
// If the user leaves OS fullscreen via system UI, drop session chrome-hide.
const timer = window.setInterval(() => {
void (async () => {
try {
const state = useDesktopFullscreenStore.getState()
if (!state.active || state.tabId !== tabId) return
if (Date.now() - state.enteredAt < 1600) return
const osFs = await window.opsxApi?.isFullscreen?.()
if (osFs === false) {
useDesktopFullscreenStore.setState({ active: false, tabId: null, enteredAt: 0 })
}
} catch {
// ignore
}
})()
}, 1000)
return () => window.clearInterval(timer)
}, [fullscreenActive, tabId])
const scheduleHideChrome = useCallback(() => {
if (!fullscreenActive) return
if (hideBarTimerRef.current != null) window.clearTimeout(hideBarTimerRef.current)
hideBarTimerRef.current = window.setTimeout(() => {
setChromeVisible(false)
hideBarTimerRef.current = null
}, 1800)
}, [fullscreenActive])
const revealChrome = useCallback(() => {
setChromeVisible(true)
scheduleHideChrome()
}, [scheduleHideChrome])
const flushPointer = () => {
pointerRafRef.current = null
const sessionId = nativeSessionIdRef.current
@@ -383,6 +530,21 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
// Local fullscreen controls — never forward to the remote desktop.
if (event.key === 'F11') {
event.preventDefault()
event.stopPropagation()
if (pressed) void toggleFullscreen(tabId)
return
}
if (fullscreenActive && event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (pressed) void exitFullscreen()
return
}
event.preventDefault()
const isPasteChord =
@@ -434,20 +596,6 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
}
}
const disconnectSession = async () => {
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
try {
if (protocol === 'rdp') await rdpDisconnect(sessionId)
else await vncDisconnect(sessionId)
nativeSessionIdRef.current = null
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
}
}
const Icon = protocol === 'rdp' ? Monitor : Cast
const hostLabel =
resolvedConfig && 'host' in resolvedConfig
@@ -458,91 +606,140 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const toolBtn =
'inline-flex h-7 w-7 items-center justify-center rounded-md text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-40'
return (
<div className="h-full flex flex-col bg-dark-900">
<div className="flex items-center gap-2 border-b border-dark-700/50 bg-dark-800/80 px-2.5 py-1.5 text-xs">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
{hostLabel}
</span>
<span className="tabular-nums text-txt-faint">
{fbSize.width}×{fbSize.height}
</span>
{tab?.connectionStatus === 'connecting' ? (
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
) : (
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
}`}
/>
const toolbar = (
<div
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
fullscreenActive
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
: 'border-b border-dark-700/50 bg-dark-800/80'
}`}
onMouseEnter={() => {
if (fullscreenActive) {
setChromeVisible(true)
if (hideBarTimerRef.current != null) {
window.clearTimeout(hideBarTimerRef.current)
hideBarTimerRef.current = null
}
}
}}
onMouseLeave={() => {
if (fullscreenActive) scheduleHideChrome()
}}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
{hostLabel}
</span>
<span className="tabular-nums text-txt-faint">
{fbSize.width}×{fbSize.height}
</span>
{tab?.connectionStatus === 'connecting' ? (
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
) : (
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
}`}
/>
)}
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
{fullscreenActive && (
<span className="hidden shrink-0 text-[10px] text-txt-faint sm:inline">{t('toolbar.exitFullscreenHint')}</span>
)}
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => void pasteToRemote()}
title={t('toolbar.pasteClipboard')}
aria-label={t('toolbar.pasteAria')}
>
<ClipboardPaste className="h-3.5 w-3.5" />
</button>
{protocol === 'rdp' && (
<>
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => fileInputRef.current?.click()}
title={t('toolbar.shareDrive')}
aria-label={t('toolbar.shareDriveAria')}
>
<FolderInput className="h-3.5 w-3.5" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(event) => {
void onShareFileSelected(event.target.files)
event.target.value = ''
}}
/>
<button
type="button"
className={toolBtn}
onClick={() => void openSharedFolder()}
title={t('toolbar.openShareDir')}
aria-label={t('toolbar.openShareDirAria')}
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</>
)}
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => void pasteToRemote()}
title={t('toolbar.pasteClipboard')}
aria-label={t('toolbar.pasteAria')}
>
<ClipboardPaste className="h-3.5 w-3.5" />
</button>
{protocol === 'rdp' && (
<>
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => fileInputRef.current?.click()}
title={t('toolbar.shareDrive')}
aria-label={t('toolbar.shareDriveAria')}
>
<FolderInput className="h-3.5 w-3.5" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(event) => {
void onShareFileSelected(event.target.files)
event.target.value = ''
}}
/>
<button
type="button"
className={toolBtn}
onClick={() => void openSharedFolder()}
title={t('toolbar.openShareDir')}
aria-label={t('toolbar.openShareDirAria')}
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</>
)}
<button
type="button"
className={`${toolBtn} text-red-400 hover:bg-red-500/15 hover:text-red-300`}
disabled={!connected}
onClick={() => void disconnectSession()}
title={t('toolbar.disconnect')}
aria-label={t('toolbar.disconnectAria')}
>
<Unplug className="h-3.5 w-3.5" />
</button>
</div>
<button
type="button"
className={toolBtn}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void (fullscreenActive ? exitFullscreen() : enterFullscreen(tabId))
}}
title={fullscreenActive ? t('toolbar.exitFullscreen') : t('toolbar.fullscreen')}
aria-label={fullscreenActive ? t('toolbar.exitFullscreenAria') : t('toolbar.fullscreenAria')}
>
{fullscreenActive ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
</div>
</div>
)
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-black p-2">
return (
<div
className={`relative flex h-full flex-col bg-dark-900 ${fullscreenActive ? 'bg-black' : ''}`}
onMouseMove={(event) => {
if (!fullscreenActive) return
if (event.clientY <= 56) revealChrome()
}}
>
{fullscreenActive ? (
<div
className={`pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 transition-opacity duration-200 ${
chromeVisible ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="pointer-events-auto w-full max-w-5xl">{toolbar}</div>
</div>
) : (
toolbar
)}
<div
ref={paneRef}
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${
fullscreenActive ? 'p-0' : 'p-2'
}`}
>
<canvas
ref={canvasRef}
tabIndex={0}
className="max-h-full max-w-full cursor-default outline-none shadow-lg shadow-black/40"
className="cursor-default outline-none shadow-lg shadow-black/40"
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => mapPointer(event)}
onMouseDown={(event) => mapPointer(event, true)}
+18 -7
View File
@@ -75,16 +75,24 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
.finally(() => setSessionsLoading(false))
}, [setSessions])
// Click outside to close context menu
// Click outside to close context menu (only while open)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!contextMenu) return
const handlePointerDown = (e: PointerEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setContextMenu(null)
}
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [contextMenu])
// Resize handler
const handleResizeStart = useCallback((e: React.MouseEvent) => {
@@ -423,7 +431,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
title={t('sidebar.newSession')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="h-3.5 w-3.5 text-accent-green" />
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
{t('sidebar.newSessionShort')}
</button>
</div>
@@ -517,6 +525,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
onPointerDown={(e) => e.stopPropagation()}
>
{(contextMenu.kind === 'blank'
? [
@@ -536,7 +545,9 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
]
: [
{ label: t('contextMenu.session.open'), action: 'connect' },
{ label: t('common:tab.kind.sftp'), action: 'sftp' },
...(isSshSession(contextMenu.session)
? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }]
: []),
{ label: t('contextMenu.session.edit'), action: 'edit' },
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
+132 -54
View File
@@ -7,7 +7,7 @@ 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 } from '../../i18n/format'
import { formatLocaleTime, translateErrorMessage } from '../../i18n/format'
import { useToast } from '../../hooks/useToast'
import ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
@@ -17,8 +17,18 @@ import ForwardRuleDrawer, {
import {
parseForwardRules,
portForwardStorageKey,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
type ForwardRule,
} from '../connection/forwardRules'
import {
forgetForwardIntent,
forwardIntentKey,
rememberForwardIntent,
restoreForwardIntent,
tunnelCoversRule,
} from '../connection/forwardIntent'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
// ============== Status Display Config ==============
@@ -34,37 +44,37 @@ const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey
connected: {
icon: <Wifi className="w-3 h-3" />,
labelKey: 'status.connected',
colorClass: 'text-green-400',
colorClass: 'text-feedback-success',
animateClass: '',
},
connecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
labelKey: 'status.connecting',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse',
},
background: {
icon: <Wifi className="w-3 h-3" />,
labelKey: 'status.background',
colorClass: 'text-green-400',
colorClass: 'text-feedback-success',
animateClass: '',
},
suspended: {
icon: <WifiOff className="w-3 h-3" />,
labelKey: 'status.suspended',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: '',
},
reconnecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
labelKey: 'status.reconnecting',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse',
},
error: {
icon: <AlertCircle className="w-3 h-3" />,
labelKey: 'status.error',
colorClass: 'text-red-400',
colorClass: 'text-feedback-error',
animateClass: '',
},
disconnected: {
@@ -127,7 +137,7 @@ function formatCpuPercent(pct: number): string {
function pctClass(pct?: number): string {
if (pct == null) return 'text-txt-faint'
if (pct >= WARN_PCT) return 'text-amber-400'
if (pct >= WARN_PCT) return 'text-feedback-warning'
return 'text-txt-muted'
}
@@ -382,7 +392,6 @@ export default function StatusBar() {
connectionState: undefined,
hostLabel: undefined,
stats: undefined,
tabKind: activeTab?.type as string | undefined,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -399,7 +408,6 @@ export default function StatusBar() {
: activeTab.title
: activeTab.title,
stats: undefined,
tabKind: activeTab.type,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -411,7 +419,6 @@ export default function StatusBar() {
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
hostLabel: t('terminal:toolbar.labelLocal'),
stats: undefined,
tabKind: activeTab.type,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -431,7 +438,6 @@ export default function StatusBar() {
connectionState: conn.state,
hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`,
stats: conn.stats,
tabKind: activeTab.type,
connectionId: instance.connectionId,
isRemoteSsh: true,
sessionOsPretty,
@@ -444,7 +450,6 @@ export default function StatusBar() {
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
hostLabel: `${session.username}@${session.host}:${session.port || 22}`,
stats: undefined,
tabKind: activeTab.type,
connectionId: instance?.connectionId,
isRemoteSsh: Boolean(instance?.connectionId),
sessionOsPretty,
@@ -461,7 +466,6 @@ export default function StatusBar() {
connectionState: state,
hostLabel: label,
stats: undefined,
tabKind: activeTab.type,
connectionId: instance?.connectionId,
isRemoteSsh: Boolean(instance?.connectionId),
sessionOsPretty,
@@ -481,6 +485,11 @@ export default function StatusBar() {
return portForwardStorageKey(activeTab?.sessionId, activeTabId, connectionId)
}, [activeTab?.sessionId, activeTabId, connectionId])
const intentKey = useMemo(
() => (activeTabId ? forwardIntentKey(activeTab?.sessionId, activeTabId) : null),
[activeTab?.sessionId, activeTabId]
)
const forwardRuntimes = useMemo(() => {
const map = new Map<string, { running: boolean; boundPort?: number }>()
for (const tunnel of forwardTunnels) {
@@ -489,10 +498,13 @@ export default function StatusBar() {
boundPort: tunnel.boundPort,
})
}
const dynamic = forwardRules.find((rule) => rule.type === 'dynamic')
const dynamicTunnel = forwardTunnels.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
if (dynamic && dynamicTunnel && !map.get(dynamic.id)?.running) {
map.set(dynamic.id, { running: true, boundPort: dynamicTunnel.boundPort })
// Rule ids can diverge from runtime ids (reconnect / legacy). Match by kind+listen port.
for (const rule of forwardRules) {
if (map.get(rule.id)?.running) continue
const tunnel = forwardTunnels.find((item) => tunnelCoversRule([item], rule))
if (tunnel) {
map.set(rule.id, { running: true, boundPort: tunnel.boundPort })
}
}
return map
}, [forwardRules, forwardTunnels])
@@ -585,6 +597,11 @@ export default function StatusBar() {
return () => window.clearInterval(timer)
}, [socksAvailable, loadForwardRules, refreshForwardState])
const previousForwardConnectionRef = useRef<string | undefined>(undefined)
useEffect(() => {
previousForwardConnectionRef.current = undefined
}, [intentKey])
const openForwardDrawer = useCallback(() => {
if (!connectionId || !activeTabId) return
if (!requirePro('portForward')) return
@@ -594,6 +611,10 @@ export default function StatusBar() {
...emptyForwardDraft(),
type: 'local',
name: t('common:portForward.defaultLocalName'),
localBindHost: '127.0.0.1',
localPort: 8080,
remoteHost: '127.0.0.1',
remotePort: 80,
})
setForwardDrawerError(null)
setForwardDrawerOpen(true)
@@ -617,17 +638,19 @@ export default function StatusBar() {
result = await window.opsxApi.sshForwardLocal(
connectionId,
rule.localPort,
rule.remoteHost || '',
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi.sshForwardRemote(
connectionId,
rule.remotePort || 0,
'127.0.0.1',
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi.sshForwardDynamic(
@@ -637,48 +660,73 @@ export default function StatusBar() {
)
}
if (!result?.success) {
setForwardDrawerError(result?.error || t('common:portForward.errorStartFailed'))
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
)
return false
}
if (intentKey) rememberForwardIntent(intentKey, rule)
await refreshForwardState()
return true
},
[connectionId, refreshForwardState, t]
[connectionId, intentKey, refreshForwardState, t]
)
useEffect(() => {
if (!socksAvailable || !connectionId || !intentKey) return
const previous = previousForwardConnectionRef.current
previousForwardConnectionRef.current = connectionId
if (!previous || previous === connectionId) return
void (async () => {
const listed = await window.opsxApi?.sshForwardList?.(connectionId)
const tunnels = listed?.success ? listed.tunnels || [] : []
const restored = await restoreForwardIntent(
intentKey,
(rule) => tunnelCoversRule(tunnels, rule),
(rule) => startForwardRule(rule)
)
if (restored > 0) {
toast.success(t('common:statusBar.forwardResumed', { count: restored }))
}
})()
}, [socksAvailable, connectionId, intentKey, startForwardRule, t, toast])
const stopForwardRule = useCallback(
async (rule: ForwardRule): Promise<boolean> => {
if (!window.opsxApi?.sshForwardStop) return false
const result = await window.opsxApi.sshForwardStop(rule.id)
if (!result?.success) {
// Dynamic may have been started with a different id historically.
if (rule.type === 'dynamic' && connectionId) {
const listed = await window.opsxApi.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
if (match) {
const stopMatch = await window.opsxApi.sshForwardStop(match.forwardId)
if (!stopMatch?.success) {
setForwardDrawerError(
stopMatch?.error || result?.error || t('common:portForward.errorStopFailed')
)
return false
}
await refreshForwardState()
return true
}
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)
}
setForwardDrawerError(result?.error || t('common:portForward.errorStopFailed'))
}
if (!result?.success) {
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStopFailed')
)
return false
}
if (intentKey) forgetForwardIntent(intentKey, rule.id)
await refreshForwardState()
return true
},
[connectionId, refreshForwardState, t]
[connectionId, intentKey, refreshForwardState, t]
)
const handleStartRule = useCallback(
async (rule: ForwardRule) => {
if (!connectionId || forwardSaving) return
if (forwardRuntimes.get(rule.id)?.running) {
toast.success(t('common:statusBar.forwardStarted'))
return
}
setForwardSaving(true)
setForwardDrawerError(null)
try {
@@ -701,16 +749,18 @@ export default function StatusBar() {
}
}
const ok = await startForwardRule(target)
if (ok && target.type === 'dynamic') {
if (ok) {
toast.success(
t('common:statusBar.forwardStartedDynamic', {
port: resolveDynamicPort(target.localPort),
})
target.type === 'dynamic'
? t('common:statusBar.forwardStartedDynamic', {
port: resolveDynamicPort(target.localPort),
})
: t('common:statusBar.forwardStarted')
)
}
} catch (error) {
setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) ||
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
t('common:portForward.errorStartFailed')
)
} finally {
@@ -720,6 +770,7 @@ export default function StatusBar() {
[
connectionId,
forwardRules,
forwardRuntimes,
forwardSaving,
persistForwardRules,
startForwardRule,
@@ -800,11 +851,19 @@ export default function StatusBar() {
return
}
if (!validPort(forwardDraft.remotePort)) {
setForwardDrawerError(t('common:portForward.errorTargetPort'))
setForwardDrawerError(
forwardDraft.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorRemoteListenAddress')
)
return
}
if (forwardDraft.type === 'local' && !forwardDraft.remoteHost?.trim()) {
setForwardDrawerError(t('common:portForward.errorRemoteHost'))
if (!forwardDraft.remoteHost?.trim()) {
setForwardDrawerError(
forwardDraft.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorLocalTargetAddress')
)
return
}
@@ -813,7 +872,15 @@ export default function StatusBar() {
type: forwardDraft.type || 'local',
name: forwardDraft.name.trim(),
localPort: forwardDraft.localPort,
remoteHost: forwardDraft.remoteHost?.trim(),
localBindHost:
forwardDraft.type === 'local'
? resolveLocalBindHost(forwardDraft.localBindHost)
: undefined,
remoteBindHost:
forwardDraft.type === 'remote'
? resolveRemoteBindHost(forwardDraft.remoteBindHost)
: undefined,
remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost),
remotePort: forwardDraft.remotePort,
}
@@ -836,7 +903,7 @@ export default function StatusBar() {
toast.success(t('common:statusBar.forwardStarted'))
} catch (error) {
setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) ||
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
t('common:portForward.errorStartFailed')
)
} finally {
@@ -955,7 +1022,9 @@ export default function StatusBar() {
}`}
>
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
<span className="tabular-nums">{t('common:statusBar.socksLabel')}</span>
<span className="tabular-nums">
{t('common:statusBar.socksLabel')} :{dynamicListenPort}
</span>
</button>
)}
@@ -972,6 +1041,7 @@ export default function StatusBar() {
rules={forwardRules}
runtimes={forwardRuntimes}
busy={forwardSaving}
viaSession={hostLabel}
onChange={(next) => {
setForwardDraft({
type: next.type || 'local',
@@ -980,6 +1050,14 @@ export default function StatusBar() {
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,
})
+20 -2
View File
@@ -121,7 +121,11 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
>
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
<div className="flex min-w-0 flex-1 items-stretch overflow-x-auto">
<div
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
role="tablist"
aria-label={t('tab.listAria')}
>
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
@@ -147,7 +151,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
<button
type="button"
onClick={onOpenLauncher}
className="bg-accent-blue/90 shadow-accent-blue/20 mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md text-white shadow-sm transition-colors hover:bg-accent-blue"
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-dark-600 text-white transition-colors hover:bg-accent-blue"
title={t('launcher.title')}
aria-label={t('launcher.title')}
>
@@ -175,6 +179,8 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
sessionId: currentTab.sessionId,
connectionConfig: currentTab.connectionConfig,
serialConfig: currentTab.serialConfig,
rdpConfig: currentTab.rdpConfig,
vncConfig: currentTab.vncConfig,
isLocal: currentTab.isLocal,
localShellKind: currentTab.localShellKind,
localShellPath: currentTab.localShellPath,
@@ -182,6 +188,18 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
})
}
}}
onOpenSftp={
currentTab?.type === 'terminal' && currentTab.sessionId && !currentTab.isLocal
? () => {
const name = currentTab.title.replace(/^SFTP:\s*/i, '')
addTab({
title: `SFTP: ${name}`,
type: 'sftp',
sessionId: currentTab.sessionId,
})
}
: undefined
}
/>
</div>
)
+5 -1
View File
@@ -117,8 +117,12 @@ function TabContent({
return (
<div
key={tab.id}
aria-hidden={!isActive}
{...(!isActive ? { inert: '' } : {})}
className={`absolute inset-0 transition-opacity duration-150 ${
isActive ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
isActive
? 'visible opacity-100 z-10'
: 'invisible opacity-0 z-0 pointer-events-none'
}`}
>
{content}
@@ -77,9 +77,9 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
)
const badgeClass = (kind: string) => {
if (kind === 'zsh') return 'bg-teal-700 text-teal-100'
if (kind === 'bash') return 'bg-dark-600 text-emerald-300'
if (kind === 'fish') return 'bg-violet-700 text-violet-100'
if (kind === 'zsh') return 'bg-teal-700/90 text-teal-50'
if (kind === 'bash') return 'bg-accent-green/15 text-accent-green'
if (kind === 'fish') return 'bg-accent-purple/15 text-accent-purple'
return 'bg-dark-600 text-content-secondary'
}
@@ -108,6 +108,8 @@ export function SessionTree({
<button
type="button"
data-group-row
aria-expanded={isExpanded}
aria-controls={`session-group-${group}`}
onClick={() => onToggleGroup(group)}
onContextMenu={(e) => {
e.preventDefault()
@@ -126,10 +128,12 @@ export function SessionTree({
</button>
<div
id={`session-group-${group}`}
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
aria-hidden={!isExpanded}
>
<div className="overflow-hidden">
<div className="overflow-hidden" {...(!isExpanded ? { inert: '' } : {})}>
{groupSessions.length === 0 ? (
<div className="px-9 py-2 text-[11px] text-txt-faint">{t('tree.emptyTitle')}</div>
) : (
@@ -142,7 +146,7 @@ export function SessionTree({
key={session.id}
data-session-row
role="button"
tabIndex={0}
tabIndex={isExpanded ? 0 : -1}
onClick={() => {
if (multiSelect) onToggleSelection?.(session)
else onSelectSession(session)
@@ -22,6 +22,7 @@ interface TabContextMenuProps {
onCloseLeft: () => void
onCloseAll: () => void
onClone?: () => void
onOpenSftp?: () => void
}
export function TabContextMenu({
@@ -34,19 +35,30 @@ export function TabContextMenu({
onCloseLeft,
onCloseAll,
onClone,
onOpenSftp,
}: TabContextMenuProps) {
const { t } = useTranslation('common')
const menuRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!isOpen) return
const handlePointerDown = (e: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
onCloseRef.current()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCloseRef.current()
}
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [isOpen])
if (!isOpen) return null
@@ -61,16 +73,21 @@ export function TabContextMenu({
if (onClone) {
items.unshift({ label: t('tab.menu.clone'), action: onClone })
}
if (onOpenSftp) {
items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp })
}
return (
<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]"
onPointerDown={(e) => e.stopPropagation()}
>
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={() => {
item.action()
onClose()
+27 -4
View File
@@ -16,7 +16,7 @@ interface TabItemProps {
isDragging: boolean
dragOverIndex: number | null
onActivate: () => void
onClose: (e: React.MouseEvent) => void
onClose: (e: { stopPropagation(): void }) => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent, index: number) => void
onDragOver: (e: React.DragEvent, index: number) => void
@@ -38,7 +38,7 @@ export function TabItem({
onDrop,
onDragEnd,
}: TabItemProps) {
const { t } = useTranslation('terminal')
const { t } = useTranslation(['terminal', 'common'])
const displayTitle = resolveTabTitle(tab)
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
const session = useSessionStore((state) =>
@@ -49,7 +49,7 @@ export function TabItem({
if (isLocalTerm) {
return (
<Terminal
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-emerald-400' : 'text-emerald-500/80'}`}
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-accent-green' : 'text-accent-green/70'}`}
aria-label={t('toolbar.labelLocal')}
/>
)
@@ -99,8 +99,28 @@ export function TabItem({
return (
<div
data-tab-item
role="tab"
aria-selected={isActive}
tabIndex={0}
draggable
onClick={onActivate}
onAuxClick={(e) => {
if (e.button === 1) {
e.preventDefault()
e.stopPropagation()
onClose(e)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onActivate()
}
if (e.key === 'Delete') {
e.preventDefault()
onClose(e)
}
}}
onContextMenu={onContextMenu}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
@@ -132,9 +152,12 @@ export function TabItem({
</span>
<button
type="button"
onClick={onClose}
aria-label={t('common:tab.closeAria', { title: displayTitle })}
className={`
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 group-hover:opacity-100
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600
group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100
${isActive ? 'opacity-100' : ''}
`}
>
+225 -54
View File
@@ -1,5 +1,5 @@
/**
* UpgradeView — in-app Pro comparison + one-time checkout / license activate
* UpgradeView — Free / Yearly / Perpetual plan picker + license activate
*/
import { useCallback, useEffect, useState } from 'react'
@@ -12,11 +12,11 @@ import {
Shield,
} from 'lucide-react'
import {
licenseServerBaseUrl,
proFeatureLabel,
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
import { checkout as licenseCheckout } from '../../platform/licenseApi'
import { createLogger } from '../../utils/logger'
const logger = createLogger('UpgradeView')
@@ -29,15 +29,49 @@ interface UpgradeViewProps {
const FREE_ITEM_KEYS = ['ssh', 'sftp', 'local', 'crud', 'import'] as const
const PRO_ITEM_KEYS = ['forward', 'proxy', 'serial', 'ai', 'batch', 'importExport', 'desktop'] as const
type SelectablePlan = 'free' | 'yearly' | 'perpetual'
function looksLikeValidLicenseKey(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
}
function activatedLabel(
t: (key: string) => string,
planType: string | null
): string {
switch (planType) {
case 'yearly':
return t('pro.activatedYearly')
case 'trial':
return t('pro.activatedTrial')
case 'perpetual':
case 'lifetime':
return t('pro.activatedPerpetual')
default:
return t('pro.activated')
}
}
export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
const { t } = useTranslation('common')
const plan = useEntitlementStore((s) => s.plan)
const deviceId = useEntitlementStore((s) => s.deviceId)
const email = useEntitlementStore((s) => s.email)
const expiresAt = useEntitlementStore((s) => s.expiresAt)
const maxDevices = useEntitlementStore((s) => s.maxDevices)
const planType = useEntitlementStore((s) => s.planType)
const activate = useEntitlementStore((s) => s.activate)
const setEmail = useEntitlementStore((s) => s.setEmail)
const clear = useEntitlementStore((s) => s.clear)
const hydrate = useEntitlementStore((s) => s.hydrate)
const [emailInput, setEmailInput] = useState(email)
const [selectedPlan, setSelectedPlan] = useState<SelectablePlan>(
plan === 'pro' ? (planType === 'yearly' ? 'yearly' : 'perpetual') : 'free'
)
const [checkoutLoading, setCheckoutLoading] = useState(false)
const [checkoutError, setCheckoutError] = useState<string | null>(null)
const [licenseInput, setLicenseInput] = useState('')
@@ -49,25 +83,44 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
void hydrate()
}, [hydrate])
useEffect(() => {
setEmailInput(email)
}, [email])
useEffect(() => {
if (plan === 'pro') {
setSelectedPlan(planType === 'yearly' ? 'yearly' : 'perpetual')
} else {
setSelectedPlan('free')
}
}, [plan, planType])
const highlight = feature ? proFeatureLabel(feature) : null
const startCheckout = useCallback(async () => {
const startCheckout = useCallback(async (planChoice: 'yearly' | 'perpetual') => {
setSelectedPlan(planChoice)
setCheckoutLoading(true)
setCheckoutError(null)
const trimmedEmail = emailInput.trim()
if (!trimmedEmail || !trimmedEmail.includes('@')) {
setCheckoutError(t('pro.emailRequired'))
setCheckoutLoading(false)
return
}
try {
await setEmail(trimmedEmail)
const id =
deviceId ||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
const res = await fetch(`${licenseServerBaseUrl()}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: id }),
const result = await licenseCheckout({
customerEmail: trimmedEmail,
deviceId: id,
planType: planChoice,
})
const data = (await res.json()) as { checkoutUrl?: string; error?: string }
if (!res.ok || !data.checkoutUrl) {
throw new Error(data.error || t('pro.checkoutUnavailable', { status: res.status }))
if (!result.checkoutUrl) {
throw new Error(t('pro.checkoutUnavailable', { status: 'no-url' }))
}
await window.opsxApi.openExternal(data.checkoutUrl)
await window.opsxApi.openExternal(result.checkoutUrl)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn('checkout failed', message)
@@ -75,19 +128,36 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
} finally {
setCheckoutLoading(false)
}
}, [deviceId, t])
}, [deviceId, emailInput, setEmail, t])
const handleActivate = useCallback(async () => {
setActivateError(null)
setActivateOk(false)
const result = await activate(licenseInput)
if (!looksLikeValidLicenseKey(licenseInput)) {
setActivateError(t('pro.invalidKeyFormat'))
return
}
const trimmedEmail = emailInput.trim()
if (trimmedEmail) await setEmail(trimmedEmail)
const result = await activate(licenseInput, trimmedEmail || undefined)
if (result.success) {
setActivateOk(true)
setLicenseInput('')
} else {
setActivateError(result.error || t('pro.activateFailed'))
}
}, [activate, licenseInput, t])
}, [activate, emailInput, licenseInput, setEmail, t])
const cardClass = (id: SelectablePlan, accent = false) => {
const selected = selectedPlan === id
return `flex flex-col rounded-xl border px-4 py-4 text-left transition-colors ${
selected
? accent
? 'border-accent-blue bg-accent-blue/10'
: 'border-dark-500 bg-dark-800/60'
: 'border-dark-700/60 bg-dark-800/40 hover:border-dark-500'
}`
}
return (
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
@@ -104,72 +174,173 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
{comingSoon && highlight
? t('pro.subtitleComingSoon', { feature: highlight })
: highlight
? t('pro.subtitleUnlock', { feature: highlight })
? t('pro.subtitleUnlock')
: t('pro.subtitleDefault')}
</p>
{plan === 'pro' && (
{highlight && !comingSoon ? (
<p className="mt-1 text-xs text-txt-faint">{t('pro.subtitleUnlockDetail', { feature: highlight })}</p>
) : null}
{(plan === 'pro' || email) && (
<p className="mt-2 text-xs text-accent-green">
{t('pro.activated')}
{expiresAt
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('pro.lifetime')}
{plan === 'pro' ? activatedLabel(t, planType) : t('pro.accountEmail', { email })}
{plan === 'pro' && email ? ` · ${email}` : null}
{plan === 'pro'
? expiresAt
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('pro.lifetime')
: null}
{plan === 'pro' && maxDevices
? ` · ${t('pro.devices', { count: maxDevices })}`
: null}
</p>
)}
</div>
</header>
<div className="mb-8 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-dark-700/60 bg-dark-800/40 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-txt-faint">{t('pro.freeTier')}</div>
<ul className="space-y-2">
<p className="mb-3 text-center text-sm text-txt-muted">{t('pro.choosePlan')}</p>
{plan !== 'pro' && (
<div className="mb-4 space-y-2">
<label className="ui-label" htmlFor="license-email">
{t('pro.emailLabel')}
</label>
<input
id="license-email"
type="email"
autoComplete="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
placeholder="you@example.com"
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-content-primary outline-none focus:border-accent-blue"
/>
{checkoutError && (
<p className="text-xs text-red-400">{checkoutError}</p>
)}
</div>
)}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
{/* Free */}
<div className={cardClass('free')}>
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedPlan('free')}
>
<div className="text-xs font-medium uppercase tracking-wide text-txt-faint">
{t('pro.planFree')}
</div>
<div className="mt-1 text-xl font-semibold text-content-primary">{t('pro.planFreePrice')}</div>
<div className="mt-1 text-xs text-txt-muted">{t('pro.planFreeDesc')}</div>
</button>
<ul className="mt-3 flex-1 space-y-1.5">
{FREE_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-2 text-sm text-content-secondary">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-muted" />
<li key={key} className="flex items-start gap-1.5 text-xs text-content-secondary">
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-txt-muted" />
{t(`pro.freeItems.${key}`)}
</li>
))}
</ul>
<div className="mt-4 rounded-lg border border-dark-600 px-3 py-2 text-center text-xs text-txt-muted">
{plan !== 'pro' ? t('pro.planFreeCta') : t('pro.planFree')}
</div>
</div>
<div className="rounded-xl border border-accent-blue/35 bg-accent-blue/5 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">{t('pro.proTier')}</div>
<ul className="space-y-2">
{/* Yearly */}
<div className={cardClass('yearly', true)}>
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedPlan('yearly')}
>
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
{t('pro.planYearly')}
</div>
<div className="mt-1 text-xl font-semibold text-content-primary">¥99</div>
<div className="mt-1 text-xs text-txt-muted">{t('pro.planYearlyDesc')}</div>
</button>
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
<ul className="mt-2 flex-1 space-y-1.5">
{PRO_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-2 text-sm text-content-primary">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<li key={key} className="flex items-start gap-1.5 text-xs text-content-primary">
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-accent-blue" />
{t(`pro.proItems.${key}`)}
</li>
))}
</ul>
{plan !== 'pro' && (
<button
type="button"
disabled={checkoutLoading}
onClick={() => void startCheckout('yearly')}
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-60"
>
{checkoutLoading && selectedPlan === 'yearly' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ExternalLink className="h-3.5 w-3.5" />
)}
{t('pro.goUpgradeYearly')}
</button>
)}
{plan === 'pro' && planType === 'yearly' && (
<div className="mt-4 rounded-lg border border-accent-blue/40 px-3 py-2 text-center text-xs text-accent-blue">
{t('pro.activatedYearly')}
</div>
)}
</div>
{/* Perpetual */}
<div className={cardClass('perpetual', true)}>
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedPlan('perpetual')}
>
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
{t('pro.planPerpetual')}
</div>
<div className="mt-1 text-xl font-semibold text-content-primary">¥199</div>
<div className="mt-1 text-xs text-txt-muted">{t('pro.planPerpetualDesc')}</div>
</button>
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
<ul className="mt-2 flex-1 space-y-1.5">
{PRO_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-1.5 text-xs text-content-primary">
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-accent-blue" />
{t(`pro.proItems.${key}`)}
</li>
))}
</ul>
{plan !== 'pro' && (
<button
type="button"
disabled={checkoutLoading}
onClick={() => void startCheckout('perpetual')}
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-60"
>
{checkoutLoading && selectedPlan === 'perpetual' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ExternalLink className="h-3.5 w-3.5" />
)}
{t('pro.goUpgradePerpetual')}
</button>
)}
{plan === 'pro' && (planType === 'perpetual' || planType === 'lifetime' || (!planType && !expiresAt)) && (
<div className="mt-4 rounded-lg border border-accent-blue/40 px-3 py-2 text-center text-xs text-accent-blue">
{t('pro.activatedPerpetual')}
</div>
)}
</div>
</div>
{plan !== 'pro' && (
<>
<p className="mb-3 text-center text-sm text-txt-muted">
{t('pro.oneTimeFee')}
</p>
<button
type="button"
disabled={checkoutLoading}
onClick={() => void startCheckout()}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-blue px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-60"
>
{checkoutLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ExternalLink className="h-4 w-4" />
)}
{t('pro.goUpgrade')}
</button>
{checkoutError && (
<p className="mt-2 text-xs text-red-400">{checkoutError}</p>
)}
<button
type="button"
onClick={() => setShowActivate((v) => !v)}
className="mt-3 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
className="mt-1 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
>
{t('pro.haveLicense')}
</button>
@@ -185,7 +356,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
onChange={(e) => setLicenseInput(e.target.value)}
rows={3}
spellCheck={false}
placeholder="opsx1...."
placeholder="LIC-… / LICOFF-…"
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
/>
<button
+9 -3
View File
@@ -19,7 +19,7 @@ export default function UpgradeWall() {
if (!wall.open) return null
const feature = wall.feature as ProFeature | undefined
const label = feature ? proFeatureLabel(feature) : t('pro.feature')
const label = feature ? proFeatureLabel(feature) : null
const comingSoon = wall.comingSoon
return (
@@ -48,11 +48,17 @@ export default function UpgradeWall() {
<Crown className="h-5 w-5" />
</div>
<h2 id="upgrade-wall-title" className="text-base font-semibold text-content-primary">
{comingSoon ? `${label}${t('pro.wallComingSoonSuffix')}` : t('pro.wallUnlock', { feature: label })}
{comingSoon ? t('pro.wallUnlockComingSoon') : t('pro.wallUnlock')}
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
{wall.message ||
(comingSoon ? t('pro.wallMessageComingSoon') : t('pro.wallMessageDefault'))}
(comingSoon
? label
? t('pro.wallMessageComingSoonFeature', { feature: label })
: t('pro.wallMessageComingSoon')
: label
? t('pro.wallMessageFeature', { feature: label })
: t('pro.wallMessageDefault'))}
</p>
</div>
+9 -6
View File
@@ -7,6 +7,7 @@ import { Cable, RefreshCw } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { safeTerminalDispose, safeTerminalFit } from '../../utils/safeTerminalFit'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
@@ -72,7 +73,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
terminal.open(container)
terminalRef.current = terminal
fitRef.current = fit
fit.fit()
safeTerminalFit(fit, terminal)
const pipeline = new TerminalPipeline({
input: async (data) => {
@@ -109,7 +110,9 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
}
container.addEventListener('keydown', handleKeyDown)
const handleResize = () => fit.fit()
const handleResize = () => {
safeTerminalFit(fit, terminal)
}
window.addEventListener('resize', handleResize)
updateTabStatus(tabId, 'connecting')
terminal.writeln(`\x1b[36m${t('serial:terminal.opening', { path: config.path, baud: config.baudRate })}\x1b[0m`)
@@ -167,15 +170,15 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
const sessionId = sessionIdRef.current
sessionIdRef.current = null
if (sessionId) void closeSerial(sessionId)
terminal.dispose()
terminalRef.current = null
fitRef.current = null
terminalRef.current = null
safeTerminalDispose(terminal)
}
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus, t])
useEffect(() => {
if (isActive) {
fitRef.current?.fit()
safeTerminalFit(fitRef.current, terminalRef.current)
terminalRef.current?.focus()
}
}, [isActive])
@@ -189,7 +192,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
{tab?.connectionStatus === 'connecting' && <RefreshCw className="w-3 h-3 animate-spin text-yellow-400" />}
{error && <span className="ml-auto text-red-400 truncate">{error}</span>}
</div>
<div ref={containerRef} className="flex-1 min-h-0 p-1" />
<div ref={containerRef} className="terminal-surface min-h-0 flex-1 p-1" />
</div>
)
}
@@ -121,7 +121,7 @@ export default function BatchCommandDialog({
} finally {
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
}
}, [timeout, updateResult])
}, [timeout, updateResult, t])
const execute = useCallback(async () => {
const commandText = command.trim()
+1 -1
View File
@@ -126,7 +126,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
}
}
},
[tabId, timeout, updateResult]
[tabId, timeout, updateResult, t]
)
const execute = useCallback(async () => {
+53 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Cable, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
import { Cable, Cast, Monitor, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
import Modal from '../common/Modal'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
@@ -9,7 +9,11 @@ import {
type AvailableLocalShell,
} from '../../platform/localTerminal'
import { fallbackLocalShells } from '../../utils/platformShells'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
import {
buildOpenTabPayload,
defaultPortForProtocol,
sessionProtocol,
} from '../../utils/sessionProtocol'
import { useEntitlementStore } from '../../stores/entitlementStore'
interface QuickLauncherProps {
@@ -18,6 +22,8 @@ interface QuickLauncherProps {
onNewSession: () => void
onQuickConnect: () => void
onSerialConnect: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
}
export default function QuickLauncher({
@@ -26,6 +32,8 @@ export default function QuickLauncher({
onNewSession,
onQuickConnect,
onSerialConnect,
onRdpConnect,
onVncConnect,
}: QuickLauncherProps) {
const { t } = useTranslation(['common', 'sessions'])
const [query, setQuery] = useState('')
@@ -86,7 +94,7 @@ export default function QuickLauncher({
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
<LogIn className="w-4 h-4 text-accent-blue" />
<span>
@@ -95,7 +103,7 @@ export default function QuickLauncher({
</span>
</button>
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
<Plus className="w-4 h-4 text-accent-green" />
<Plus className="w-4 h-4 shrink-0 text-accent-green" strokeWidth={2.5} />
<span>
<strong>{t('common:launcher.cards.saveSession.title')}</strong>
<small>{t('common:launcher.cards.saveSession.desc')}</small>
@@ -108,6 +116,42 @@ export default function QuickLauncher({
<small>{t('common:launcher.cards.serial.desc')}</small>
</span>
</button>
{onRdpConnect && (
<button
type="button"
onClick={() =>
run(() => {
if (!requirePro('rdp')) return
onRdpConnect()
})
}
className="launcher-card"
>
<Monitor className="w-4 h-4 text-accent-blue" />
<span>
<strong>{t('common:launcher.cards.rdp.title')}</strong>
<small>{t('common:launcher.cards.rdp.desc')}</small>
</span>
</button>
)}
{onVncConnect && (
<button
type="button"
onClick={() =>
run(() => {
if (!requirePro('vnc')) return
onVncConnect()
})
}
className="launcher-card"
>
<Cast className="w-4 h-4 text-accent-blue" />
<span>
<strong>{t('common:launcher.cards.vnc.title')}</strong>
<small>{t('common:launcher.cards.vnc.desc')}</small>
</span>
</button>
)}
</div>
<section>
@@ -131,7 +175,7 @@ export default function QuickLauncher({
}))}
className="launcher-row"
>
<Terminal className="w-4 h-4 text-emerald-400" />
<Terminal className="w-4 h-4 text-accent-green" />
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
{shell.isDefault && <span className="launcher-badge">{t('common:launcher.systemDefault')}</span>}
</button>
@@ -161,7 +205,10 @@ export default function QuickLauncher({
<Server className="w-4 h-4 text-accent-blue" />
<span className="flex-1 min-w-0 text-left">
<strong className="truncate">{session.name}</strong>
<small className="truncate">{session.username}@{session.host}:{session.port || 22}</small>
<small className="truncate">
{session.username}@{session.host}:
{session.port || defaultPortForProtocol(sessionProtocol(session))}
</small>
</span>
<span className="launcher-badge">{session.group || 'default'}</span>
</button>
+78 -68
View File
@@ -20,6 +20,8 @@ import {
} from '../../utils/sessionGroups'
import { useSettingsStore } from '../../stores/settingsStore'
import { usePromptDialog } from '../common/PromptDialog'
import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
import { getDisplayResolution } from '../../utils/desktopResolution'
const logger = createLogger('SessionDialog')
@@ -152,14 +154,17 @@ export default function SessionDialog({
: [],
domain: editSession.domain || '',
security: editSession.security || 'nla',
width: editSession.width || 1920,
height: editSession.height || 1080,
width: editSession.width || getDisplayResolution().width,
height: editSession.height || getDisplayResolution().height,
})
setShowAdvanced(hasAdvanced)
} else {
const display = getDisplayResolution()
setFormData({
...emptyForm,
group: normalizeGroupId(defaultGroup),
width: display.width,
height: display.height,
})
setTarget('')
setShowAdvanced(false)
@@ -185,12 +190,15 @@ export default function SessionDialog({
prev.port === defaultPortForProtocol(prev.protocol)
? defaultPortForProtocol(protocol)
: prev.port
const display = getDisplayResolution()
const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh'
return {
...prev,
protocol,
username: nextUser,
port: nextPort,
authType: 'password',
...(applyDisplaySize ? { width: display.width, height: display.height } : {}),
}
})
// Keep host-only target when switching protocol.
@@ -394,6 +402,50 @@ export default function SessionDialog({
{ value: 'key', label: t('dialog.authKey'), icon: Key },
]
const groupField = (
<div>
<label htmlFor="session-group" className="ui-label">
<Folder className="inline w-3 h-3 mr-0.5" />
{t('dialog.group')}
</label>
<select
id="session-group"
value={normalizeGroupId(formData.group)}
onChange={(e) => {
const value = e.target.value
if (value !== '__new__') {
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
return
}
void (async () => {
const raw = await prompt({
title: t('groups.new.title'),
message: t('groups.new.message'),
placeholder: t('groups.new.placeholder'),
confirmText: t('groups.new.confirm'),
})
if (raw == null) return
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
})()
}}
className="ui-input"
>
{knownGroups.map((group) => (
<option key={group} value={group}>
{groupDisplayName(group)}
</option>
))}
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
<option value={normalizeGroupId(formData.group)}>
{groupDisplayName(formData.group)}
</option>
)}
<option value="__new__">{t('groups.newOption')}</option>
</select>
<p className="mt-1 text-[11px] text-txt-faint">{t('groups.hint')}</p>
</div>
)
return (
<>
<div className="fixed inset-0 z-[100] flex justify-end">
@@ -549,73 +601,30 @@ export default function SessionDialog({
/>
</div>
)}
{formData.protocol !== 'ssh' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ui-label" htmlFor="session-width">{t('dialog.width')}</label>
<input
id="session-width"
type="number"
value={formData.width}
onChange={(e) => setFormData({ ...formData, width: Number(e.target.value) || 1920 })}
className="ui-input font-mono"
/>
</div>
<div>
<label className="ui-label" htmlFor="session-height">{t('dialog.height')}</label>
<input
id="session-height"
type="number"
value={formData.height}
onChange={(e) => setFormData({ ...formData, height: Number(e.target.value) || 1080 })}
className="ui-input font-mono"
/>
</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-group" className="ui-label">
<Folder className="inline w-3 h-3 mr-0.5" />
{t('dialog.group')}
</label>
<select
id="session-group"
value={normalizeGroupId(formData.group)}
onChange={(e) => {
const value = e.target.value
if (value !== '__new__') {
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
return
}
void (async () => {
const raw = await prompt({
title: t('groups.new.title'),
message: t('groups.new.message'),
placeholder: t('groups.new.placeholder'),
confirmText: t('groups.new.confirm'),
})
if (raw == null) return
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
})()
}}
className="ui-input"
>
{knownGroups.map((group) => (
<option key={group} value={group}>
{groupDisplayName(group)}
</option>
))}
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
<option value={normalizeGroupId(formData.group)}>
{groupDisplayName(formData.group)}
</option>
)}
<option value="__new__">{t('groups.newOption')}</option>
</select>
<p className="mt-1 text-[11px] text-txt-faint">
{t('groups.hint')}
</p>
</div>
{formData.protocol !== 'ssh' && (
<DesktopResolutionPicker
ns="sessions"
width={formData.width}
height={formData.height}
onChange={(next) => setFormData((prev) => ({ ...prev, width: next.width, height: next.height }))}
/>
)}
{/* RDP:分组仍在连接区;SSH / VNC 放到密码下方 */}
{formData.protocol === 'rdp' && groupField}
</section>
{/* 凭据 */}
@@ -647,7 +656,7 @@ export default function SessionDialog({
</div>
)}
{(formData.authType === 'password' || !isSsh) && (
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
<div>
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
<input
@@ -774,6 +783,7 @@ export default function SessionDialog({
</div>
)}
{(isSsh || formData.protocol === 'vnc') && groupField}
</section>
{/* 高级 */}
+7 -2
View File
@@ -10,7 +10,11 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { shellDisplayFromPath } from '../../utils/shellName'
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
import {
buildOpenTabPayload,
defaultPortForProtocol,
sessionProtocol,
} from '../../utils/sessionProtocol'
interface WelcomeViewProps {
onNewSession?: () => void
@@ -273,7 +277,8 @@ export default function WelcomeView({
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content-primary">{session.name}</span>
<span className="block truncate font-mono text-[11px] text-txt-faint">
{session.username}@{session.host}:{session.port || 22}
{session.username}@{session.host}:
{session.port || defaultPortForProtocol(sessionProtocol(session))}
</span>
</span>
{session.group ? (
+348
View File
@@ -0,0 +1,348 @@
/**
* AccountSettings — license email, plan status, activate / devices / clear
* (standalone settings nav tab, not under General)
*/
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import { useEntitlementStore } from '../../stores/entitlementStore'
import {
deactivateLicenseDevice,
listLicenseDevices,
type LicenseDevice,
} from '../../platform/licenseApi'
function looksLikeValidLicenseKey(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
}
function planLabel(
t: (key: string) => string,
plan: 'free' | 'pro',
planType: string | null
): string {
if (plan !== 'pro') return t('account.planFree')
switch (planType) {
case 'yearly':
return t('account.planYearly')
case 'trial':
return t('account.planTrial')
case 'perpetual':
case 'lifetime':
return t('account.planPerpetual')
default:
return t('account.planPerpetual')
}
}
export default function AccountSettings() {
const { t } = useTranslation('settings')
const { t: tc } = useTranslation('common')
const plan = useEntitlementStore((s) => s.plan)
const email = useEntitlementStore((s) => s.email)
const deviceId = useEntitlementStore((s) => s.deviceId)
const licenseKey = useEntitlementStore((s) => s.licenseKey)
const expiresAt = useEntitlementStore((s) => s.expiresAt)
const maxDevices = useEntitlementStore((s) => s.maxDevices)
const planType = useEntitlementStore((s) => s.planType)
const hydrate = useEntitlementStore((s) => s.hydrate)
const setEmail = useEntitlementStore((s) => s.setEmail)
const activate = useEntitlementStore((s) => s.activate)
const clear = useEntitlementStore((s) => s.clear)
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
const [emailInput, setEmailInput] = useState(email)
const [licenseInput, setLicenseInput] = useState('')
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [devices, setDevices] = useState<LicenseDevice[]>([])
const [deviceUsed, setDeviceUsed] = useState(0)
const [deviceMax, setDeviceMax] = useState(0)
const [devicesLoading, setDevicesLoading] = useState(false)
const [removingId, setRemovingId] = useState<string | null>(null)
useEffect(() => {
void hydrate()
}, [hydrate])
useEffect(() => {
setEmailInput(email)
}, [email])
const refreshDevices = useCallback(async () => {
if (plan !== 'pro' || !licenseKey) {
setDevices([])
setDeviceUsed(0)
setDeviceMax(0)
return
}
setDevicesLoading(true)
try {
const result = await listLicenseDevices(licenseKey)
setDevices(
result.devices.map((d) => ({
...d,
isCurrent: Boolean(deviceId && d.deviceId === deviceId),
}))
)
setDeviceUsed(result.used)
setDeviceMax(result.maxDevices || maxDevices || 0)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setDevicesLoading(false)
}
}, [deviceId, licenseKey, maxDevices, plan])
useEffect(() => {
void refreshDevices()
}, [refreshDevices])
const saveEmail = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
try {
await setEmail(emailInput.trim())
setMessage(t('account.saved'))
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}, [emailInput, setEmail, t])
const handleActivate = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
if (!looksLikeValidLicenseKey(licenseInput)) {
setError(tc('pro.invalidKeyFormat'))
setBusy(false)
return
}
if (emailInput.trim()) await setEmail(emailInput.trim())
const result = await activate(licenseInput, emailInput.trim() || undefined)
if (result.success) {
setMessage(t('account.activateOk'))
setLicenseInput('')
await refreshDevices()
} else {
setError(result.error || tc('pro.activateFailed'))
}
setBusy(false)
}, [activate, emailInput, licenseInput, refreshDevices, setEmail, t, tc])
const handleDeactivate = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
await clear()
setDevices([])
setMessage(t('account.saved'))
setBusy(false)
}, [clear, t])
const handleRemoveDevice = useCallback(
async (target: LicenseDevice) => {
if (!licenseKey || target.isCurrent) return
setRemovingId(target.deviceId)
setError(null)
setMessage(null)
try {
await deactivateLicenseDevice(licenseKey, target.deviceId)
setMessage(t('account.deviceRemoved'))
await refreshDevices()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setRemovingId(null)
}
},
[licenseKey, refreshDevices, t]
)
const atSeatLimit = deviceMax > 0 && deviceUsed >= deviceMax
return (
<div className="space-y-6">
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.title')}</h3>
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<Row
label={t('account.plan')}
description={
plan === 'pro'
? expiresAt
? t('account.expires', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('account.lifetime')
: undefined
}
>
<span className="text-sm text-content-primary">
{planLabel(t, plan, planType)}
{maxDevices ? ` · ${t('account.devices', { count: maxDevices })}` : ''}
</span>
</Row>
<Row label={t('account.email')} description={t('account.emailDesc')}>
<div className="flex min-w-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<input
type="email"
autoComplete="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
placeholder="you@example.com"
className="w-56 rounded-lg border border-dark-600 bg-dark-900 px-3 py-1.5 text-sm text-content-primary outline-none focus:border-accent-blue"
/>
<button
type="button"
disabled={busy}
onClick={() => void saveEmail()}
className="rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-content-primary hover:bg-dark-600 disabled:opacity-40"
>
{t('account.saveEmail')}
</button>
</div>
</Row>
<Row label={t('account.deviceId')}>
<code className="max-w-[14rem] truncate text-xs text-txt-muted" title={deviceId || '—'}>
{deviceId || '—'}
</code>
</Row>
</div>
</div>
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.activate')}</h3>
<div className="border-dark-700/45 bg-dark-800/50 space-y-3 overflow-hidden rounded-2xl border p-4">
<textarea
value={licenseInput}
onChange={(e) => setLicenseInput(e.target.value)}
rows={3}
spellCheck={false}
placeholder={t('account.pasteLicense')}
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy || !licenseInput.trim()}
onClick={() => void handleActivate()}
className="rounded-lg bg-accent-blue px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-40"
>
{t('account.activate')}
</button>
<button
type="button"
disabled={busy}
onClick={() => openUpgradeTab()}
className="rounded-lg border border-dark-600 px-3 py-1.5 text-xs text-content-secondary hover:bg-dark-700"
>
{t('account.upgrade')}
</button>
{plan === 'pro' && (
<button
type="button"
disabled={busy}
onClick={() => void handleDeactivate()}
className="rounded-lg border border-red-500/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
>
{t('account.deactivate')}
</button>
)}
</div>
{message && <p className="text-xs text-accent-green">{message}</p>}
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
</div>
{plan === 'pro' && licenseKey ? (
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">
{t('account.deviceList')}
{deviceMax > 0 ? ` · ${deviceUsed}/${deviceMax}` : ''}
</h3>
<div className="border-dark-700/45 bg-dark-800/50 overflow-hidden rounded-2xl border">
{atSeatLimit ? (
<p className="border-b border-dark-700/35 px-4 py-2.5 text-xs text-feedback-warning">
{t('account.seatFull')}
</p>
) : null}
{devicesLoading ? (
<div className="flex items-center gap-2 px-4 py-6 text-xs text-txt-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('account.loadingDevices')}
</div>
) : devices.length === 0 ? (
<p className="px-4 py-6 text-xs text-txt-muted">{t('account.noDevices')}</p>
) : (
<ul className="divide-dark-700/35 divide-y">
{devices.map((d) => (
<li
key={d.deviceId || String(d.id)}
className="flex flex-wrap items-center justify-between gap-2 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-content-primary">
{d.deviceName || 'OpsXShell'}
{d.isCurrent ? (
<span className="ml-2 text-xs text-accent-blue">
{t('account.thisDevice')}
</span>
) : null}
</div>
<code className="mt-0.5 block truncate text-[11px] text-txt-muted">
{d.deviceId}
</code>
</div>
{!d.isCurrent ? (
<button
type="button"
disabled={removingId === d.deviceId}
onClick={() => void handleRemoveDevice(d)}
className="rounded-lg border border-red-500/35 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
{removingId === d.deviceId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
t('account.removeDevice')
)}
</button>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
) : null}
</div>
)
}
function Row({
label,
description,
children,
}: {
label: string
description?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
<div className="min-w-[11rem] flex-1 basis-[14rem]">
<div className="text-sm font-medium text-content-primary">{label}</div>
{description ? <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div> : null}
</div>
<div className="ml-auto flex-shrink-0">{children}</div>
</div>
)
}
@@ -202,7 +202,7 @@ export default function AiProviderSettings() {
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
{presetsByFamily(familyId).map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
</option>
))}
</optgroup>
+66 -61
View File
@@ -5,7 +5,6 @@ import { useKeybindingStore } from '../../stores/keybindingStore'
import type { AppLanguage } from '../../i18n/detectLocale'
import {
Monitor,
Palette,
Terminal,
Keyboard,
Shield,
@@ -18,13 +17,16 @@ import {
Copy,
Check,
Sparkles,
UserRound,
} 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'
// ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
interface TabMeta {
id: SettingsTab
@@ -41,11 +43,12 @@ interface RuntimeInfo {
sshBackend: string
dataPath: string
credentialPath: string
logPath: string
}
const TAB_LIST: TabMeta[] = [
{ id: 'general', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor },
{ id: 'appearance', labelKey: 'nav.appearance', descriptionKey: 'nav.appearanceDesc', icon: Palette },
{ id: 'account', labelKey: 'nav.account', descriptionKey: 'nav.accountDesc', icon: UserRound },
{ id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal },
{ id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
@@ -148,25 +151,13 @@ export default function SettingsView() {
// 当前 tab 元信息
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
// ── 搜索过滤 ──────────────────────────────────────────────
// ── 搜索过滤(跨语种:Language / 语言 / 言語 … 均可命中)────────
const filtered = useMemo((): SettingsTab[] | null => {
if (!searchQuery.trim()) return null
const q = searchQuery.toLowerCase()
const keywords: Record<SettingsTab, string[]> = {
general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'],
appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'],
terminal: [t('nav.terminal'), t('terminal.fontFamily'), 'font', 'terminal', 'nord', 'tokyo', 'dracula', 'one-dark', 'default'],
ai: ['ai', t('nav.ai'), 'provider', 'deepseek', 'glm', 'minimax', 'custom'],
shortcuts: [t('nav.shortcuts'), 'keyboard', 'shortcut', 'hotkey'],
ssh: ['ssh', t('ssh.connection'), t('ssh.pool'), 'timeout', 'reconnect'],
security: [t('nav.security'), t('security.credentials'), 'encrypt', 'known hosts'],
about: [t('nav.about'), 'version', 'about', 'license'],
}
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
.map(([id]) => id)
return matchIds.length > 0 ? matchIds : []
}, [searchQuery, t])
return filterSettingsTabsByQuery(
TAB_LIST.map((tab) => tab.id as SettingsSearchTab),
searchQuery
)
}, [searchQuery])
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
useEffect(() => {
@@ -182,6 +173,7 @@ export default function SettingsView() {
`OpsXShell v${runtimeInfo.appVersion}`,
`${runtimeInfo.os} ${runtimeInfo.arch}`,
`Data: ${runtimeInfo.dataPath}`,
`Log: ${runtimeInfo.logPath}`,
].join('\n')
: `OpsXShell v${appVersion}`
@@ -197,10 +189,39 @@ export default function SettingsView() {
// ── 渲染内容区 ────────────────────────────────────────────
const renderContent = () => {
switch (activeTab) {
// ──────── 常规 ─────────
// ──────── 通用(主题 / 语言 / 工作区)─────────
case 'general':
return (
<div className="space-y-6">
<SettingGroup title={t('appearance.themeGroup')}>
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
<select
value={settings.theme}
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
className="ui-select-pill"
>
<option value="dark">{t('appearance.dark')}</option>
<option value="light">{t('appearance.light')}</option>
<option value="system">{t('appearance.system')}</option>
</select>
</SettingRow>
<SettingRow label={t('language.label')} description={t('language.description')}>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
className="ui-select-pill"
>
<option value="zh-CN">{t('language.zhCN')}</option>
<option value="zh-TW">{t('language.zhTW')}</option>
<option value="en">{t('language.en')}</option>
<option value="ja">{t('language.ja')}</option>
<option value="ko">{t('language.ko')}</option>
<option value="vi">{t('language.vi')}</option>
<option value="ru">{t('language.ru')}</option>
</select>
</SettingRow>
</SettingGroup>
<SettingGroup title={t('general.layout')}>
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
<div className="flex items-center gap-2">
@@ -238,40 +259,8 @@ export default function SettingsView() {
</div>
)
// ──────── 外观 ─────────
case 'appearance':
return (
<div className="space-y-6">
<SettingGroup title={t('appearance.themeGroup')}>
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
<select
value={settings.theme}
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
className="ui-select-pill"
>
<option value="dark">{t('appearance.dark')}</option>
<option value="light">{t('appearance.light')}</option>
<option value="system">{t('appearance.system')}</option>
</select>
</SettingRow>
<SettingRow label={t('language.label')} description={t('language.description')}>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
className="ui-select-pill"
>
<option value="zh-CN">{t('language.zhCN')}</option>
<option value="zh-TW">{t('language.zhTW')}</option>
<option value="en">{t('language.en')}</option>
<option value="ja">{t('language.ja')}</option>
<option value="ko">{t('language.ko')}</option>
<option value="vi">{t('language.vi')}</option>
<option value="ru">{t('language.ru')}</option>
</select>
</SettingRow>
</SettingGroup>
</div>
)
case 'account':
return <AccountSettings />
// ──────── 终端 ─────────
case 'terminal':
@@ -520,6 +509,16 @@ export default function SettingsView() {
checked={settings.keepAlive}
onChange={(v) => setSetting('keepAlive', v)}
/>
<SettingRow label={t('ssh.keepAliveInterval')} description={t('ssh.keepAliveIntervalDesc')}>
<input
type="number"
min={5}
max={120}
value={settings.keepAliveIntervalSec}
onChange={(e) => setSetting('keepAliveIntervalSec', parseInt(e.target.value) || 15)}
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
<SettingToggle
label={t('ssh.autoReconnect')}
description={t('ssh.autoReconnectDesc')}
@@ -572,10 +571,10 @@ export default function SettingsView() {
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
<input
type="number"
min={1}
max={120}
min={5}
max={240}
value={Math.round(settings.idleTimeoutMs / 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
@@ -662,6 +661,12 @@ export default function SettingsView() {
{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>
@@ -711,9 +716,9 @@ export default function SettingsView() {
key={tab.id}
data-testid={`settings-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
>
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium leading-5">{t(tab.labelKey)}</div>
<div className={`truncate text-[11px] leading-4 ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
+3 -1
View File
@@ -25,13 +25,15 @@ export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: Breadc
onClick={onHome}
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
title={t('nav.home')}
aria-label={t('nav.home')}
>
<Home className="h-4 w-4 text-txt-muted" />
</button>
<button
type="button"
onClick={() => onNavigate(root || '/')}
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-dark-200"
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-secondary"
title={rootLabel}
>
{rootLabel}
</button>
+28 -7
View File
@@ -13,9 +13,13 @@ interface ConnectionPanelProps {
isConnected: boolean
isConnecting: boolean
error: string | null
/** Last remote directory to resume after reconnect (shown as hint). */
resumePath?: string | null
sessions: Session[]
showSelector: boolean
onConnect: (sessionId: string) => void
/** Prefer same-session reconnect when the tab already knows sessionId. */
onReconnect?: () => void
onShowSelector: () => void
onCloseSelector: () => void
}
@@ -24,13 +28,16 @@ export function ConnectionPanel({
isConnected,
isConnecting,
error,
resumePath,
sessions,
showSelector,
onConnect,
onReconnect,
onShowSelector,
onCloseSelector,
}: ConnectionPanelProps) {
const { t } = useTranslation('sftp')
const canResumePath = Boolean(resumePath)
if (isConnected) {
return null
@@ -40,22 +47,36 @@ export function ConnectionPanel({
return (
<div className="flex items-center gap-2 border-b border-accent-blue/20 bg-accent-blue/10 px-4 py-2">
<Loader2 className="h-4 w-4 animate-spin text-accent-blue" />
<span className="text-sm text-accent-blue">{t('connection.connecting')}</span>
<span className="text-sm text-accent-blue">
{canResumePath
? t('connection.reconnectingResume', { path: resumePath })
: t('connection.connecting')}
</span>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-between border-b border-red-500/20 bg-red-500/10 px-4 py-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">{error}</span>
<div className="flex items-center justify-between gap-3 border-b border-red-500/20 bg-red-500/10 px-4 py-2">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2 min-w-0">
<AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0" />
<span className="text-sm text-red-400 truncate">{error}</span>
</div>
{canResumePath && (
<span className="truncate pl-6 text-[11px] text-red-300/80" title={resumePath ?? undefined}>
{t('connection.resumeHint', { path: resumePath })}
</span>
)}
</div>
<button
type="button"
onClick={onShowSelector}
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600"
onClick={() => {
if (onReconnect) onReconnect()
else onShowSelector()
}}
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600 flex-shrink-0"
>
{t('connection.reconnect')}
</button>
+6 -4
View File
@@ -5,6 +5,7 @@
import { Monitor, ChevronRight, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Session } from '../../stores/connection'
import { isSshSession } from '../../utils/sessionProtocol'
interface ConnectionSelectorProps {
sessions: Session[]
@@ -14,6 +15,7 @@ interface ConnectionSelectorProps {
export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSelectorProps) {
const { t } = useTranslation('sftp')
const sshSessions = sessions.filter((session) => isSshSession(session))
return (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
@@ -25,7 +27,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
</button>
</div>
<div className="p-4 max-h-80 overflow-y-auto">
{sessions.length === 0 ? (
{sshSessions.length === 0 ? (
<div className="text-center py-8 text-txt-muted">
<Monitor className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{t('connection.noSessions')}</p>
@@ -33,7 +35,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
</div>
) : (
<div className="space-y-2">
{sessions.map((session) => (
{sshSessions.map((session) => (
<button
key={session.id}
onClick={() => onSelect(session.id)}
@@ -41,12 +43,12 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
>
<Monitor className="w-8 h-8 text-cyan-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-slate-200 font-medium truncate group-hover:text-content-primary">{session.name}</div>
<div className="text-content-primary font-medium truncate group-hover:text-content-primary">{session.name}</div>
<div className="text-sm text-txt-muted truncate">
{session.username}@{session.host}:{session.port}
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-600 group-hover:text-txt-muted transition-colors" />
<ChevronRight className="w-5 h-5 text-txt-faint group-hover:text-txt-muted transition-colors" />
</button>
))}
</div>
+59 -8
View File
@@ -1,8 +1,19 @@
/**
* ContextMenu - 右键菜单组件
*
* Rendered via portal to document.body so parent overflow/transform cannot clip it.
* Position is fitted to the viewport (flips up/left when near edges).
*/
import { useEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CONTEXT_MENU_VIEWPORT_MARGIN, fitContextMenuToViewport } from './contextMenuPosition'
interface MenuPosition {
left: number
top: number
}
export interface MenuItem {
icon: React.ReactNode
@@ -21,9 +32,40 @@ interface ContextMenuProps {
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<MenuPosition>({ left: x, top: y })
const [positioned, setPositioned] = useState(false)
useLayoutEffect(() => {
const fitToViewport = () => {
const menu = menuRef.current
if (!menu) return
// Prefer scrollHeight/Width so we measure full content even if maxHeight
// temporarily constrains the box during the first paint.
const width = Math.max(menu.offsetWidth, menu.scrollWidth)
const height = Math.max(menu.offsetHeight, menu.scrollHeight)
setPosition(fitContextMenuToViewport(
x,
y,
width,
height,
window.innerWidth,
window.innerHeight,
))
setPositioned(true)
}
fitToViewport()
// Second pass after fonts/layout settle (still before paint when possible).
const raf = window.requestAnimationFrame(fitToViewport)
window.addEventListener('resize', fitToViewport)
return () => {
window.cancelAnimationFrame(raf)
window.removeEventListener('resize', fitToViewport)
}
}, [items, x, y])
useEffect(() => {
const handleClick = (e: MouseEvent) => {
const handlePointerDown = (e: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
@@ -31,19 +73,26 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [onClose])
return (
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-dark-700 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[180px]"
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
className="fixed z-[200] min-w-[180px] overflow-y-auto rounded-lg border border-dark-700 bg-dark-700 py-1 shadow-xl"
style={{
left: position.left,
top: position.top,
maxWidth: `calc(100vw - ${CONTEXT_MENU_VIEWPORT_MARGIN * 2}px)`,
maxHeight: `calc(100vh - ${CONTEXT_MENU_VIEWPORT_MARGIN * 2}px)`,
visibility: positioned ? 'visible' : 'hidden',
}}
onPointerDown={(e) => e.stopPropagation()}
>
{items.map((item, index) => (
<div key={index}>
@@ -51,6 +100,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
<div className="my-1 border-t border-dark-600/30" />
)}
<button
type="button"
onClick={() => {
item.action()
onClose()
@@ -64,6 +114,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
</button>
</div>
))}
</div>
</div>,
document.body
)
}
+415 -131
View File
@@ -1,29 +1,29 @@
/**
* FileListPanel - 文件列表面板
*
* 本地面板或远程面板的文件列表组件
* 远程面板支持拖拽上传
* 支持当前文件夹内文件名搜索
*
* 本地面板或远程面板的文件列表组件。
* 栏间传输:自定义指针拖动(不依赖 WKWebView HTML5 drop)。
* 系统文件拖入远程:仍走 HTML5 drop。
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen,
Search, X, Eye, EyeOff,
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, Download, FolderOpen,
Search, X, Eye, EyeOff, RefreshCw, FolderPlus,
} from 'lucide-react'
import { Breadcrumb } from './Breadcrumb'
import { SortHeader } from './SortHeader'
import { FileRow } from './FileRow'
import { ContextMenu, type MenuItem } from './ContextMenu'
import { FileListSkeleton } from '../common/Skeleton'
import EmptyState from '../common/EmptyState'
import { filterFilesByName, filterHiddenFiles, isHiddenFileName } from './utils'
import { parentPath } from '../../utils/localPath'
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
import {
hasSftpDragEntry,
readSftpDragEntry,
writeSftpDragEntry,
clearSftpDragEntry,
dndLog,
shortPath,
type SftpDragEntry,
} from './dragDrop'
@@ -31,6 +31,8 @@ interface FileListPanelProps {
title: string
isLocal: boolean
path: string
/** 主目录(Home 按钮目标);远程应传会话起始家目录,不是 / */
homePath?: string
files: FileItem[]
selected: Set<string>
history: string[]
@@ -45,20 +47,74 @@ interface FileListPanelProps {
onOpen: (file: FileItem) => void
onContextMenu: (e: React.MouseEvent, file: FileItem) => void
onSort: (field: SortField) => void
contextMenu: { x: number; y: number; items: MenuItem[] } | null
onCloseContextMenu: () => void
/** 操作系统文件拖入远程面板。 */
onDropFiles?: (files: FileList, targetPath: string) => void
/** OpsXShell 本地/远程栏之间的内部拖拽。 */
onDropEntry?: (entry: SftpDragEntry, targetPath: string) => void
/** 是否正在加载 */
/** 本地/远程栏之间的内部指针拖放(支持多选批量)。 */
onDropEntry?: (entries: SftpDragEntry[], targetPath: string) => void
/** 系统文件 drop 接受了但读不到路径时回调(用于 toast)。 */
onDropFailed?: () => void
onRefresh?: () => void
onNewFolder?: () => void
onFocusPane?: () => void
/** 是否正在加载(空列表时显示骨架) */
loading?: boolean
/** 刷新中:图标旋转 + 状态栏提示,不拆掉现有列表 */
refreshing?: boolean
listError?: string | null
}
export function FileListPanel({
function isAtFilesystemRoot(path: string, isLocal: boolean): boolean {
const parent = parentPath(path, isLocal)
return parent === path || path === '/' || /^[a-zA-Z]:\\?$/.test(path)
}
const POINTER_DRAG_EVENT = 'opsxshell:sftp-pointer-drag'
const POINTER_DRAG_THRESHOLD = 6
interface PointerDragDetail {
source: SftpDragEntry['source'] | null
target: SftpDragEntry['source'] | null
targetPath: string | null
}
interface PointerDragState {
pointerId: number
startX: number
startY: number
entries: SftpDragEntry[]
active: boolean
}
function hasOsFiles(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types || []).includes('Files')
}
function pointerDropTarget(clientX: number, clientY: number, source: SftpDragEntry['source']) {
const hit = document.elementFromPoint?.(clientX, clientY)
const pane = hit?.closest<HTMLElement>('[data-sftp-pane]')
if (!pane || pane.dataset.sftpPane === source) return null
const directory = hit?.closest<HTMLElement>('[data-sftp-directory-path]')
const targetPath = directory && pane.contains(directory)
? directory.dataset.sftpDirectoryPath
: pane.dataset.sftpPath
if (!targetPath) return null
return {
pane: pane.dataset.sftpPane as SftpDragEntry['source'],
path: targetPath,
}
}
function announcePointerDrag(detail: PointerDragDetail) {
window.dispatchEvent(new CustomEvent<PointerDragDetail>(POINTER_DRAG_EVENT, { detail }))
}
export const FileListPanel = memo(function FileListPanel({
title,
isLocal,
path,
homePath,
files,
selected,
history,
@@ -73,22 +129,26 @@ export function FileListPanel({
onOpen,
onContextMenu,
onSort,
contextMenu,
onCloseContextMenu,
onDropFiles,
onDropEntry,
onDropFailed,
onRefresh,
onNewFolder,
onFocusPane,
loading = false,
refreshing = false,
listError = null,
}: FileListPanelProps) {
const { t } = useTranslation('sftp')
const canGoBack = historyIndex > 0
const canGoForward = historyIndex < history.length - 1
const canGoUp = !isAtFilesystemRoot(path, isLocal)
const resolvedHome = homePath || (isLocal ? history[0] || path : '/')
// Current-folder filename filter + hidden files toggle
const [nameFilter, setNameFilter] = useState('')
const [showHidden, setShowHidden] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
// Clear name filter when directory changes (keep showHidden preference)
useEffect(() => {
setNameFilter('')
}, [path])
@@ -109,26 +169,61 @@ export function FileListPanel({
)
const isFiltering = nameFilter.trim().length > 0
// Drag state for remote panel upload
const parentEntry = useMemo((): FileItem | null => {
if (!canGoUp || isFiltering) return null
return {
name: '..',
path: parentPath(path, isLocal),
type: 'directory',
size: 0,
modified: 0,
permissions: 'drwxr-xr-x',
}
}, [canGoUp, isFiltering, isLocal, path])
const [isDragOver, setIsDragOver] = useState(false)
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null)
const [dragSource, setDragSource] = useState<SftpDragEntry['source'] | null>(null)
const dragCounterRef = useRef(0)
/** Nested dragenter/leave — OS file HTML5 drop only. */
const dragDepthRef = useRef(0)
const pointerDragRef = useRef<PointerDragState | null>(null)
const suppressNextClickRef = useRef(false)
const [pointerPreview, setPointerPreview] = useState<{
x: number
y: number
entries: SftpDragEntry[]
canDrop: boolean
} | null>(null)
const acceptsDrag = useCallback((dataTransfer: DataTransfer) => {
const internal = readSftpDragEntry(dataTransfer)
if (internal) return internal.source !== (isLocal ? 'local' : 'remote') && !!onDropEntry
if (hasSftpDragEntry(dataTransfer)) return !!onDropEntry
return !isLocal && !!onDropFiles && Array.from(dataTransfer.types || []).includes('Files')
}, [isLocal, onDropEntry, onDropFiles])
const acceptsOsFileDrag = useCallback((dataTransfer: DataTransfer) => {
if (isLocal || !onDropFiles) return false
return hasOsFiles(dataTransfer)
}, [isLocal, onDropFiles])
const resetDragState = useCallback(() => {
dragCounterRef.current = 0
const resetOsDragState = useCallback(() => {
dragDepthRef.current = 0
setIsDragOver(false)
setDropTargetPath(null)
setDragSource(null)
}, [])
// Cross-pane highlight for pointer-based internal dragging.
useEffect(() => {
const onPointerDrag = (event: Event) => {
const detail = (event as CustomEvent<PointerDragDetail>).detail
const pane = isLocal ? 'local' : 'remote'
const active = detail.target === pane
setIsDragOver(active)
setDragSource(active ? detail.source : null)
setDropTargetPath(active ? detail.targetPath : null)
}
window.addEventListener(POINTER_DRAG_EVENT, onPointerDrag)
return () => {
window.removeEventListener(POINTER_DRAG_EVENT, onPointerDrag)
document.body.classList.remove('sftp-pointer-dragging')
}
}, [isLocal])
const clearFilter = useCallback(() => {
setNameFilter('')
searchInputRef.current?.focus()
@@ -146,7 +241,6 @@ export function FileListPanel({
}
}, [nameFilter])
// Panel-level shortcut: focus search (Ctrl/Cmd+F)
const handlePanelKeyDown = useCallback((e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') {
e.preventDefault()
@@ -156,81 +250,213 @@ export function FileListPanel({
}
}, [])
const consumeOsDrop = useCallback((dataTransfer: DataTransfer, targetPath: string) => {
resetOsDragState()
const droppedFiles = dataTransfer.files
if (!isLocal && droppedFiles && droppedFiles.length > 0) {
dndLog('drop-os-files', {
pane: 'remote',
target: shortPath(targetPath),
count: droppedFiles.length,
})
onDropFiles?.(droppedFiles, targetPath)
return
}
dndLog('drop-os-failed', {
pane: isLocal ? 'local' : 'remote',
target: shortPath(targetPath),
types: Array.from(dataTransfer.types || []),
})
onDropFailed?.()
}, [isLocal, onDropFiles, onDropFailed, resetOsDragState])
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
setDragSource(readSftpDragEntry(e.dataTransfer)?.source || null)
dragCounterRef.current++
if (dragCounterRef.current === 1) {
setIsDragOver(true)
dragDepthRef.current += 1
setDragSource(null)
setIsDragOver(true)
if (dragDepthRef.current === 1) {
dndLog('os-dragenter', { pane: 'remote', accept: true })
}
}, [acceptsDrag])
}, [acceptsOsFileDrag])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer) && dragDepthRef.current === 0) return
e.preventDefault()
e.stopPropagation()
dragCounterRef.current--
if (dragCounterRef.current === 0) {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setIsDragOver(false)
setDropTargetPath(null)
}
}, [acceptsDrag])
}, [acceptsOsFileDrag])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [acceptsDrag])
if (!isDragOver) setIsDragOver(true)
}, [acceptsOsFileDrag, isDragOver])
const handleDrop = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
const entry = readSftpDragEntry(e.dataTransfer)
resetDragState()
consumeOsDrop(e.dataTransfer, path)
}, [acceptsOsFileDrag, consumeOsDrop, path])
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
onDropEntry?.(entry, path)
return
}
const droppedFiles = e.dataTransfer.files
if (droppedFiles && droppedFiles.length > 0) {
onDropFiles?.(droppedFiles, path)
}
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, path, resetDragState])
const dropOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
if (!acceptsDrag(event.dataTransfer)) return
const dropOsOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
const entry = readSftpDragEntry(event.dataTransfer)
resetDragState()
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
onDropEntry?.(entry, directory.path)
return
dndLog('drop-os-on-dir', { dir: shortPath(directory.path) })
consumeOsDrop(event.dataTransfer, directory.path)
}, [acceptsOsFileDrag, consumeOsDrop])
const dragEntriesFor = useCallback((file: FileItem): SftpDragEntry[] => {
const source: SftpDragEntry['source'] = isLocal ? 'local' : 'remote'
const selectedNames = selected.has(file.name) && selected.size > 1
? selected
: new Set([file.name])
const entries = files
.filter((item) => selectedNames.has(item.name))
.map((item) => ({
source,
name: item.name,
path: item.path,
type: item.type,
size: item.size,
}))
return entries.length > 0 ? entries : [{
source,
name: file.name,
path: file.path,
type: file.type,
size: file.size,
}]
}, [files, isLocal, selected])
const resetPointerDrag = useCallback(() => {
pointerDragRef.current = null
setPointerPreview(null)
announcePointerDrag({ source: null, target: null, targetPath: null })
clearSftpDragEntry()
document.body.classList.remove('sftp-pointer-dragging')
}, [])
const startPointerDrag = useCallback((file: FileItem, event: React.PointerEvent) => {
if (event.button !== 0 || !onDropEntry) return
// Stop the browser from starting a text selection before the drag threshold.
event.preventDefault()
window.getSelection()?.removeAllRanges()
const entries = dragEntriesFor(file)
pointerDragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
entries,
active: false,
}
if (!isLocal && event.dataTransfer.files?.length) {
onDropFiles?.(event.dataTransfer.files, directory.path)
event.currentTarget.setPointerCapture?.(event.pointerId)
}, [dragEntriesFor, onDropEntry])
const movePointerDrag = useCallback((event: React.PointerEvent) => {
const state = pointerDragRef.current
if (!state || state.pointerId !== event.pointerId) return
if (!state.active) {
const distance = Math.hypot(event.clientX - state.startX, event.clientY - state.startY)
if (distance < POINTER_DRAG_THRESHOLD) return
state.active = true
suppressNextClickRef.current = true
document.body.classList.add('sftp-pointer-dragging')
window.getSelection()?.removeAllRanges()
dndLog('pointer-drag-start', {
source: state.entries[0]?.source,
count: state.entries.length,
names: state.entries.slice(0, 8).map((entry) => entry.name),
})
}
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, resetDragState])
event.preventDefault()
window.getSelection()?.removeAllRanges()
const source = state.entries[0]?.source
if (!source) return
const target = pointerDropTarget(event.clientX, event.clientY, source)
setPointerPreview({
x: event.clientX,
y: event.clientY,
entries: state.entries,
canDrop: Boolean(target),
})
announcePointerDrag({
source,
target: target?.pane || null,
targetPath: target?.path || null,
})
}, [])
const finishPointerDrag = useCallback((event: React.PointerEvent) => {
const state = pointerDragRef.current
if (!state || state.pointerId !== event.pointerId) return
event.currentTarget.releasePointerCapture?.(event.pointerId)
if (state.active) {
event.preventDefault()
const source = state.entries[0]?.source
const target = source ? pointerDropTarget(event.clientX, event.clientY, source) : null
if (target) {
dndLog('pointer-drop', {
source,
target: target.pane,
targetPath: shortPath(target.path),
count: state.entries.length,
})
onDropEntry?.(state.entries, target.path)
} else {
dndLog('pointer-drop-cancelled', { source })
}
setTimeout(() => {
suppressNextClickRef.current = false
}, 0)
}
resetPointerDrag()
}, [onDropEntry, resetPointerDrag])
const cancelPointerDrag = useCallback((event: React.PointerEvent) => {
const state = pointerDragRef.current
if (!state || state.pointerId !== event.pointerId) return
event.currentTarget.releasePointerCapture?.(event.pointerId)
suppressNextClickRef.current = false
resetPointerDrag()
}, [resetPointerDrag])
const statusText = (() => {
if (refreshing) return t('status.refreshing')
if (loading) return t('status.loading')
// listError is shown in the banner above; keep the footer for selection/count.
if (selected.size > 0) return t('status.selected', { count: selected.size })
if (isFiltering) return t('status.filtered', { shown: filteredFiles.length, total: visibleFiles.length })
if (!showHidden && hiddenCount > 0) return t('status.hiddenCount', { total: visibleFiles.length, hidden: hiddenCount })
return t('status.total', { count: visibleFiles.length })
})()
const PreviewIcon = pointerPreview?.entries[0]?.source === 'remote' ? Download : Upload
return (
<div
aria-label={t('panel.ariaLabel', { title })}
className={`flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
data-sftp-pane={isLocal ? 'local' : 'remote'}
data-sftp-path={path}
className={`sftp-file-panel flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700/40'
}`}
onMouseDown={() => onFocusPane?.()}
onFocusCapture={() => onFocusPane?.()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
@@ -238,13 +464,36 @@ export function FileListPanel({
onKeyDown={handlePanelKeyDown}
tabIndex={-1}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
<div className="flex items-center gap-2 min-w-0">
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue flex-shrink-0" /> : <Folder className="w-4 h-4 text-accent-green flex-shrink-0" />}
<span className="text-sm font-medium truncate">{title}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={refreshing || loading}
className="p-1.5 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary disabled:opacity-50 disabled:pointer-events-none"
title={t('toolbar.refresh')}
aria-label={t('toolbar.refreshAria')}
aria-busy={refreshing || loading}
>
<RefreshCw className={`w-4 h-4 ${(refreshing || loading) ? 'animate-spin' : ''}`} />
</button>
)}
{onNewFolder && (
<button
type="button"
onClick={onNewFolder}
className="p-1.5 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary"
title={t('toolbar.newFolder')}
aria-label={t('toolbar.newFolderAria')}
>
<FolderPlus className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={() => setShowHidden((v) => !v)}
@@ -277,22 +526,22 @@ export function FileListPanel({
</button>
<button
onClick={onNavigateUp}
className="p-1.5 hover:bg-dark-600 rounded"
title={t('nav.up')}
disabled={!canGoUp}
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
title={canGoUp ? t('nav.up') : t('nav.upAtRoot')}
>
<ChevronDown className="w-4 h-4 rotate-180" />
</button>
</div>
</div>
{/* Breadcrumb + search */}
<div className="px-3 py-2 border-b border-dark-700/20 flex items-center gap-2 min-w-0">
<div className="flex-1 min-w-0">
<Breadcrumb
path={path}
isLocal={isLocal}
onNavigate={onNavigate}
onHome={() => onNavigate(isLocal ? (history[0] || path) : '/')}
onHome={() => onNavigate(resolvedHome)}
/>
</div>
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
@@ -321,27 +570,40 @@ export function FileListPanel({
</div>
</div>
{/* Column Headers — 与 FileRow 共用 px-4 对齐 */}
{listError && (
<div className="px-3 py-1.5 border-b border-red-500/20 bg-red-500/10 text-xs text-red-400 flex items-center justify-between gap-2">
<span className="truncate">{listError}</span>
{canGoUp && (
<button
type="button"
onClick={onNavigateUp}
className="flex-shrink-0 rounded bg-dark-700 px-2 py-0.5 text-content-secondary hover:bg-dark-600"
>
{t('empty.goUp')}
</button>
)}
</div>
)}
<div className="px-4 border-b border-dark-700/20 bg-dark-800/80">
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto relative">
{loading ? (
<FileListSkeleton rows={6} />
) : files.length === 0 ? (
) : files.length === 0 && !parentEntry ? (
<EmptyState
icon={FolderOpen}
title={t('empty.dirTitle')}
description={isLocal ? t('empty.dirDescLocal') : t('empty.dirDescRemote')}
action={
path !== '/'
canGoUp
? { label: t('empty.goUp'), onClick: onNavigateUp }
: undefined
}
/>
) : visibleFiles.length === 0 ? (
) : visibleFiles.length === 0 && !parentEntry ? (
<EmptyState
icon={EyeOff}
title={t('empty.hiddenOnlyTitle')}
@@ -351,7 +613,7 @@ export function FileListPanel({
onClick: () => setShowHidden(true),
}}
/>
) : filteredFiles.length === 0 ? (
) : filteredFiles.length === 0 && !parentEntry ? (
<EmptyState
icon={Search}
title={t('empty.noMatchTitle')}
@@ -359,54 +621,65 @@ export function FileListPanel({
action={{ label: t('nav.clearSearch'), onClick: clearFilter }}
/>
) : (
filteredFiles.map((file) => (
<FileRow
key={file.name}
file={file}
isSelected={selected.has(file.name)}
onSelect={() => onSelect(file.name)}
onOpen={() => onOpen(file)}
onContextMenu={(e) => onContextMenu(e, file)}
onDragStart={(event) => {
writeSftpDragEntry(event.dataTransfer, {
source: isLocal ? 'local' : 'remote',
name: file.name,
path: file.path,
type: file.type,
size: file.size,
})
}}
onDragEnd={resetDragState}
onDragEnter={file.type === 'directory' ? (event) => {
if (!acceptsDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
setDragSource(readSftpDragEntry(event.dataTransfer)?.source || null)
setIsDragOver(true)
setDropTargetPath(file.path)
} : undefined}
onDragLeave={file.type === 'directory' ? (event) => {
event.preventDefault()
event.stopPropagation()
setDropTargetPath((current) => current === file.path ? null : current)
} : undefined}
onDragOver={file.type === 'directory' ? (event) => {
if (!acceptsDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
} : undefined}
onDrop={file.type === 'directory' ? (event) => dropOnDirectory(event, file) : undefined}
isDropTarget={dropTargetPath === file.path}
/>
))
<>
{parentEntry && (
<FileRow
key="__parent__"
file={parentEntry}
isSelected={false}
onSelect={() => {}}
onOpen={() => onNavigate(parentEntry.path)}
onContextMenu={(e) => {
e.preventDefault()
}}
/>
)}
{filteredFiles.map((file) => (
<FileRow
key={file.name}
file={file}
isSelected={selected.has(file.name)}
onSelect={() => {
if (suppressNextClickRef.current) return
onSelect(file.name)
}}
onOpen={() => onOpen(file)}
onContextMenu={(e) => onContextMenu(e, file)}
onPointerDown={(event) => startPointerDrag(file, event)}
onPointerMove={movePointerDrag}
onPointerUp={finishPointerDrag}
onPointerCancel={cancelPointerDrag}
onDragEnter={file.type === 'directory' ? (event) => {
if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
setDragSource(null)
setIsDragOver(true)
setDropTargetPath(file.path)
} : undefined}
onDragLeave={file.type === 'directory' ? (event) => {
event.preventDefault()
setDropTargetPath((current) => current === file.path ? null : current)
} : undefined}
onDragOver={file.type === 'directory' ? (event) => {
if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
} : undefined}
onDrop={file.type === 'directory' ? (event) => dropOsOnDirectory(event, file) : undefined}
isDropTarget={dropTargetPath === file.path}
/>
))}
</>
)}
{/* Drag overlay - pointer-events-none so it doesn't interfere with drop events */}
{isDragOver && (
<div className="absolute inset-0 bg-accent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
<div className="flex flex-col items-center gap-2 text-accent-blue">
<Upload className="w-10 h-10" />
{isLocal || dragSource === 'remote' ? (
<Download className="w-10 h-10" />
) : (
<Upload className="w-10 h-10" />
)}
<span className="text-sm font-medium">
{isLocal || dragSource === 'remote'
? t('transfer.dropDownload', { path: dropTargetPath || path })
@@ -415,20 +688,31 @@ export function FileListPanel({
</div>
</div>
)}
{pointerPreview && (
<div
className={`pointer-events-none fixed z-[100] flex max-w-64 items-center gap-2 rounded-md border px-3 py-2 text-xs shadow-xl ${
pointerPreview.canDrop
? 'border-accent-blue bg-dark-700 text-content-primary'
: 'border-dark-600 bg-dark-800 text-txt-muted'
}`}
style={{ left: pointerPreview.x + 14, top: pointerPreview.y + 14 }}
>
<PreviewIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{pointerPreview.entries[0]?.name}</span>
{pointerPreview.entries.length > 1 && (
<span className="flex-shrink-0">+{pointerPreview.entries.length - 1}</span>
)}
</div>
)}
</div>
{/* Status Bar */}
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-txt-muted flex items-center justify-between gap-2">
<span>{statusText}</span>
<span className="truncate">{statusText}</span>
{isFiltering && (
<span className="text-txt-faint truncate">{t('status.filterHint', { query: nameFilter.trim() })}</span>
)}
</div>
{/* Context Menu */}
{contextMenu && (
<ContextMenu {...contextMenu} onClose={onCloseContextMenu} />
)}
</div>
)
}
})
+24 -7
View File
@@ -13,6 +13,10 @@ interface FileRowProps {
onSelect: () => void
onOpen: () => void
onContextMenu: (e: React.MouseEvent) => void
onPointerDown?: (e: React.PointerEvent) => void
onPointerMove?: (e: React.PointerEvent) => void
onPointerUp?: (e: React.PointerEvent) => void
onPointerCancel?: (e: React.PointerEvent) => void
onDragStart?: (e: React.DragEvent) => void
onDragEnd?: (e: React.DragEvent) => void
onDragEnter?: (e: React.DragEvent) => void
@@ -28,6 +32,10 @@ export function FileRow({
onSelect,
onOpen,
onContextMenu,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onDragStart,
onDragEnd,
onDragEnter,
@@ -42,22 +50,29 @@ export function FileRow({
onClick={onSelect}
onDoubleClick={file.type === 'directory' || file.type === 'symlink' ? onOpen : undefined}
onContextMenu={onContextMenu}
draggable={!!onDragStart}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
data-sftp-entry-path={file.path}
data-sftp-directory-path={file.type === 'directory' ? file.path : undefined}
draggable={false}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
className={`h-10 flex select-none items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
isDropTarget
? 'bg-accent-blue/25 ring-1 ring-inset ring-accent-blue'
: isSelected
? 'bg-accent-blue/20 hover:bg-accent-blue/25'
: 'hover:bg-dark-700/40'
}`}
style={{ WebkitUserSelect: 'none', userSelect: 'none', touchAction: 'none' }}
>
<div className="flex-1 flex items-center gap-2 min-w-0">
<div className="sftp-col-name flex-1 flex items-center gap-2 min-w-0">
{file.type === 'directory' ? (
<Folder className="w-4 h-4 text-accent-blue flex-shrink-0" />
) : file.type === 'symlink' ? (
@@ -66,13 +81,15 @@ export function FileRow({
<File className="w-4 h-4 text-txt-muted flex-shrink-0" />
)}
<span className="text-sm text-content-primary truncate group-hover:text-content-primary">{file.name}</span>
{file.type === 'symlink' && <span className="text-xs text-yellow-500">→</span>}
{file.type === 'symlink' && <span className="text-xs text-yellow-500 flex-shrink-0">→</span>}
</div>
<div className="w-32 text-right text-xs text-txt-muted">{formatDate(file.modified)}</div>
<div className="w-24 text-right text-xs text-txt-muted">
<div className="sftp-col-modified w-28 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
{formatDate(file.modified)}
</div>
<div className="sftp-col-size w-16 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
{file.type === 'directory' ? '--' : formatSize(file.size)}
</div>
<div className="w-24 text-right text-xs text-txt-muted font-mono">
<div className="sftp-col-type w-16 flex-shrink-0 text-right text-xs text-txt-muted font-mono truncate">
{file.type === 'directory'
? t('fileType.folder')
: file.type === 'symlink'
+196 -94
View File
@@ -1,15 +1,25 @@
/**
* Remote text file editor (SFTP) with Monaco
* Remote text file editor (SFTP) with Monaco.
*
* Renders as an in-pane overlay (not viewport-fixed) so the app title bar /
* tab bar stay free for window dragging.
*/
import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'
import { useTranslation } from 'react-i18next'
import { X, Save, FileCode } from 'lucide-react'
import { X, Save, FileCode, RotateCcw } from 'lucide-react'
import type { editor } from 'monaco-editor'
import { guessLanguageFromPath } from '../../utils/fileLanguage'
import { translateErrorMessage } from '../../i18n/format'
import { readRemoteTextFile, remotePath as toRemotePath, writeRemoteTextFile } from '../../platform/sftp'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { useTheme } from '../../hooks/useTheme'
const MonacoEditor = lazy(() => import('@monaco-editor/react'))
/** Above this, turn off expensive Monaco features (minimap, folding, etc.). */
const LIGHT_EDITOR_CHARS = 80_000
export interface RemoteTextEditorProps {
isOpen: boolean
sftpId: string
@@ -28,6 +38,8 @@ export default function RemoteTextEditor({
onSaved,
}: RemoteTextEditorProps) {
const { t } = useTranslation(['sftp', 'common'])
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const { effective } = useTheme()
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [loading, setLoading] = useState(false)
@@ -36,6 +48,36 @@ export default function RemoteTextEditor({
const dirty = content !== original
const language = useMemo(() => guessLanguageFromPath(remotePath || fileName), [remotePath, fileName])
const lightMode = content.length > LIGHT_EDITOR_CHARS
const editorOptions = useMemo((): editor.IStandaloneEditorConstructionOptions => {
return {
fontSize: 13,
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
minimap: { enabled: !lightMode && content.length > 20_000, scale: 0.75 },
wordWrap: 'on',
automaticLayout: true,
scrollBeyondLastLine: false,
tabSize: 2,
renderWhitespace: lightMode ? 'none' : 'selection',
smoothScrolling: !lightMode,
padding: { top: 12, bottom: 12 },
folding: !lightMode,
links: !lightMode,
matchBrackets: lightMode ? 'never' : 'always',
occurrencesHighlight: lightMode ? 'off' : 'singleFile',
renderLineHighlight: lightMode ? 'none' : 'line',
quickSuggestions: !lightMode,
suggestOnTriggerCharacters: !lightMode,
parameterHints: { enabled: !lightMode },
hover: { enabled: !lightMode },
codeLens: false,
lightbulb: {
enabled: (lightMode ? 'off' : 'onCode') as editor.ShowLightbulbIconMode,
},
unicodeHighlight: { ambiguousCharacters: false },
}
}, [content.length, lightMode])
const load = useCallback(async () => {
if (!isOpen || !sftpId || !remotePath) return
@@ -49,33 +91,17 @@ export default function RemoteTextEditor({
setContent(result.content)
setOriginal(result.content)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
} finally {
setLoading(false)
}
}, [isOpen, sftpId, remotePath])
}, [isOpen, sftpId, remotePath, t])
useEffect(() => {
void load()
}, [load])
// Cmd/Ctrl+S
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
if (dirty && !saving && !loading) {
void handleSave()
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, dirty, saving, loading, content])
const handleSave = async () => {
const handleSave = useCallback(async () => {
setSaving(true)
setError(null)
try {
@@ -90,93 +116,169 @@ export default function RemoteTextEditor({
setOriginal(content)
onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
}, [content, onSaved, remotePath, sftpId, t])
const handleClose = () => {
if (dirty && !confirm(t('editor.unsavedClose'))) return
const handleClose = useCallback(async () => {
if (dirty) {
const ok = await confirm({
title: t('editor.unsavedTitle'),
message: t('editor.unsavedClose'),
danger: true,
confirmText: t('editor.discardAndClose'),
})
if (!ok) return
}
onClose()
}
}, [dirty, confirm, onClose, t])
const handleReload = useCallback(async () => {
if (dirty) {
const ok = await confirm({
title: t('editor.reloadTitle'),
message: t('editor.unsavedReload'),
danger: true,
confirmText: t('editor.discardAndReload'),
})
if (!ok) return
}
void load()
}, [dirty, confirm, load, t])
// Cmd/Ctrl+S save · Escape close
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
e.stopPropagation()
if (dirty && !saving && !loading) void handleSave()
return
}
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
void handleClose()
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [isOpen, dirty, saving, loading, handleSave, handleClose])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-dark-600 bg-dark-800 shadow-2xl">
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<FileCode className="h-4 w-4 flex-shrink-0 text-accent-blue" />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-content-primary">
{fileName}
{dirty && <span className="ml-1 text-yellow-400">•</span>}
<span className="ml-2 text-xs font-normal text-txt-faint">{language}</span>
</div>
<div className="truncate font-mono text-xs text-txt-faint">{remotePath}</div>
<div
className="absolute inset-0 z-30 flex flex-col bg-dark-900"
role="dialog"
aria-modal="true"
aria-label={t('editor.title', { name: fileName })}
>
<header className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/60 bg-dark-800 px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
<FileCode className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 truncate text-sm font-medium text-content-primary">
<span className="truncate">{fileName}</span>
{dirty && (
<span className="flex-shrink-0 rounded bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-normal text-yellow-400">
{t('editor.dirtyBadge')}
</span>
)}
<span className="flex-shrink-0 rounded bg-dark-700 px-1.5 py-0.5 text-[10px] font-normal uppercase tracking-wide text-txt-faint">
{language}
</span>
</div>
<div className="truncate font-mono text-[11px] text-txt-faint" title={remotePath}>
{remotePath}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={!dirty || saving || loading}
onClick={() => void handleSave()}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-blue px-3 py-1.5 text-sm text-white hover:bg-blue-500 disabled:opacity-40"
>
<Save className="h-3.5 w-3.5" />
{saving ? t('editor.saving') : t('editor.save')}
</button>
<button type="button" onClick={handleClose} className="rounded-lg p-2 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => void handleReload()}
disabled={loading || saving}
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-40"
title={t('editor.reload')}
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('editor.reload')}</span>
</button>
<button
type="button"
disabled={!dirty || saving || loading}
onClick={() => void handleSave()}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors disabled:opacity-40 ${
dirty
? 'bg-accent-blue text-white hover:bg-blue-500'
: 'bg-dark-700 text-txt-muted'
}`}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('editor.saving') : t('editor.save')}
</button>
<button
type="button"
onClick={() => void handleClose()}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
title={t('common:action.close')}
aria-label={t('common:action.close')}
>
<X className="h-4 w-4" />
</button>
</div>
</header>
{error && (
<div className="flex-shrink-0 border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">
{error}
</div>
)}
<div className="min-h-0 flex-1 bg-[#1e1e1e]">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
{t('common:status.loading')}
</div>
</div>
{error && (
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</div>
) : error && !content ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-txt-muted">
{t('editor.loadBlocked')}
</div>
) : (
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
{t('editor.loadingEditor')}
</div>
}
>
<MonacoEditor
height="100%"
language={language}
theme={effective === 'light' ? 'vs' : 'vs-dark'}
value={content}
onChange={(v) => setContent(v ?? '')}
options={editorOptions}
/>
</Suspense>
)}
<div className="min-h-0 flex-1">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('common:status.loading')}</div>
) : (
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('editor.loadingEditor')}</div>
}
>
<MonacoEditor
height="100%"
language={language}
theme="vs-dark"
value={content}
onChange={(v) => setContent(v ?? '')}
options={{
fontSize: 13,
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
minimap: { enabled: true, scale: 0.75 },
wordWrap: 'on',
automaticLayout: true,
scrollBeyondLastLine: false,
tabSize: 2,
renderWhitespace: 'selection',
smoothScrolling: true,
}}
/>
</Suspense>
)}
</div>
<div className="flex justify-between border-t border-dark-700 px-4 py-2 text-xs text-txt-faint">
<span>{t('editor.footer')}</span>
<span>
{t('editor.charCount', { count: content.length.toLocaleString() })}
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
</span>
</div>
</div>
<footer className="flex flex-shrink-0 items-center justify-between gap-3 border-t border-dark-700/60 bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
<span>{t('editor.footer')}</span>
<span>
{t('editor.charCount', { count: content.length.toLocaleString() })}
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
</span>
</footer>
{ConfirmDialogComponent}
</div>
)
}
+346 -81
View File
@@ -4,30 +4,39 @@
* 使用子组件拆分后的简化版本
*/
import { useState, useCallback, useEffect, useRef } from 'react'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
FolderOpen, Upload, Download, Trash2,
Edit3, Copy, FileText, ShieldCheck,
Edit3, FileText, ShieldCheck,
Unplug, FileCode,
} from 'lucide-react'
import { useSFTP } from '../../hooks'
import { useToast } from '../../hooks/useToast'
import { getConnectionStore } from '../../stores/connection'
import { FileListPanel } from './FileListPanel'
import { TransferPanel } from './TransferPanel'
import { Toolbar } from './Toolbar'
import { ConnectionPanel } from './ConnectionPanel'
import { TransferRail } from './TransferRail'
import { ContextMenu, type MenuItem } from './ContextMenu'
import { ToastContainer } from '../common/ToastContainer'
import EmptyState from '../common/EmptyState'
import { sortFiles } from './utils'
import { usePromptDialog } from '../common/PromptDialog'
import { useConfirmDialog } from '../common/ConfirmDialog'
import FilePropertiesDialog from './FilePropertiesDialog'
import ChmodDialog from './ChmodDialog'
import RemoteTextEditor from './RemoteTextEditor'
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
import { parentPath } from '../../utils/localPath'
import { parentPath, joinLocalPath } from '../../utils/localPath'
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'
interface SFTPViewProps {
tabId: string
@@ -35,8 +44,8 @@ interface SFTPViewProps {
sessionId?: string
}
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
const { t } = useTranslation('sftp')
export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPViewProps) {
const { t } = useTranslation(['sftp', 'errors'])
const {
sessions,
localPath,
@@ -59,23 +68,32 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
navigateRemoteBack,
navigateRemoteForward,
loadRemoteDirectory,
remoteHomePath,
isConnected,
isConnecting,
connect,
transferQueue,
uploadFiles,
downloadFiles,
cancelTransfer,
removeTransfer,
retryTransfer,
deleteRemote,
renameRemote,
mkdirRemote,
getRemoteStat,
chmodRemote,
error,
clearError,
sftpId,
connectionId,
localLoading,
remoteLoading,
} = useSFTP({ tabId })
const { toasts, toast, removeToast } = useToast()
const { toast } = useToast()
const { prompt, PromptDialogComponent } = usePromptDialog()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
// UI State
const [sortField, setSortField] = useState<SortField>('name')
@@ -83,6 +101,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(true)
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
const hadConnectedRef = useRef(false)
useEffect(() => {
if (isConnected) hadConnectedRef.current = true
}, [isConnected])
// Properties dialog state
const [propertiesFile, setPropertiesFile] = useState<FileItem | null>(null)
@@ -93,6 +115,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
// Remote text editor
const [editFile, setEditFile] = useState<FileItem | null>(null)
/** 工具栏「新建文件夹」作用于当前焦点面板 */
const [, setActivePane] = useState<'local' | 'remote'>('remote')
/** 仅远程列表错误展示在远程面板(连接错误仍走 ConnectionPanel) */
const remoteListError = isConnected ? error : null
const lastListErrorToast = useRef<string | null>(null)
// Surface list failures as a short toast (status strip alone is easy to miss).
useEffect(() => {
if (!remoteListError) {
lastListErrorToast.current = null
return
}
if (lastListErrorToast.current === remoteListError) return
lastListErrorToast.current = remoteListError
toast.warning(remoteListError, 4000)
}, [remoteListError, toast])
// Auto-connect when sessionId is provided and not connected
const autoConnectAttempted = useRef(false)
@@ -109,6 +147,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}
}, [sessionId])
// Keep pool idle-suspend from arming while this SFTP tab is in front.
useEffect(() => {
if (!isActive || !connectionId || !isConnected) return
getConnectionStore().setActiveTab(connectionId, tabId)
}, [isActive, connectionId, isConnected, tabId])
// Sort handler
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
@@ -155,14 +199,107 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}, [t, toast])
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
const result = await uploadFiles(sources, targetPath)
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,
})
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)
}, [reportTransfer, uploadFiles])
}, [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles])
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
const result = await downloadFiles(files, targetPath)
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
}
}
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
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
}
}
setIsTransferPanelOpen(true)
const result = await downloadFiles(pending, dest)
reportTransfer('download', result)
}, [downloadFiles, reportTransfer])
}, [
confirm,
downloadFiles,
localFiles,
localPath,
remoteFiles,
remoteSelected,
reportTransfer,
t,
toast,
])
// ===== Context menu handlers =====
@@ -184,21 +321,28 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}
}, [prompt, renameRemote, t, toast])
// Copy path handler
const handleCopyPath = useCallback(async (filePath: string) => {
try {
await navigator.clipboard.writeText(filePath)
toast.success(t('toast.pathCopied'))
} catch {
toast.error(t('toast.pathCopyFailed'))
}
}, [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])
// Permissions (chmod) handler — fetch stat first, then show dialog
const handleChmod = useCallback(async (file: FileItem) => {
const stats = await getRemoteStat(file.path)
@@ -217,7 +361,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
toast.error(t('toast.chmodFailed'))
return false
}
}, [chmodRemote, toast])
}, [chmodRemote, toast, t])
// Local context menu
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
@@ -235,15 +379,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
} as MenuItem]
: []),
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), separator: true, action: () => handleCopyPath(file.path) },
{
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, handleCopyPath, handleShowProperties, performUpload])
}, [navigateLocal, handleShowProperties, performUpload, t])
// Remote context menu
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
@@ -259,17 +403,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
label: t('contextMenu.download'),
action: () => void performDownload([file]),
},
...(file.type === 'file'
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
? [
{
icon: <FileCode className="w-4 h-4" />,
label: t('contextMenu.edit'),
action: () => setEditFile(file),
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: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), action: () => handleCopyPath(file.path) },
{
icon: <FileText className="w-4 h-4" />,
label: t('contextMenu.properties'),
@@ -280,10 +429,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
label: t('contextMenu.permissions'),
action: () => handleChmod(file),
},
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => deleteRemote([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, deleteRemote, handleRename, handleCopyPath, handleShowProperties, handleChmod])
}, [navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t])
// Navigate up
const navigateLocalUp = useCallback(() => {
@@ -294,6 +443,48 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
navigateRemote(parentPath(remotePath, false))
}, [remotePath, navigateRemote])
const sortedLocalFiles = useMemo(
() => sortFiles(localFiles, sortField, sortOrder),
[localFiles, sortField, sortOrder]
)
const sortedRemoteFiles = useMemo(
() => sortFiles(remoteFiles, sortField, sortOrder),
[remoteFiles, sortField, sortOrder]
)
const refreshLocal = useCallback(() => {
void loadLocalDirectory(localPath)
}, [loadLocalDirectory, localPath])
const refreshRemote = useCallback(() => {
if (!isConnected) return
clearError()
void loadRemoteDirectory(remotePath)
}, [clearError, isConnected, loadRemoteDirectory, remotePath])
const focusLocalPane = useCallback(() => setActivePane('local'), [])
const focusRemotePane = useCallback(() => setActivePane('remote'), [])
const navigateRemoteClear = useCallback((p: string) => {
clearError()
navigateRemote(p)
}, [clearError, navigateRemote])
const navigateRemoteBackClear = useCallback(() => {
clearError()
navigateRemoteBack()
}, [clearError, navigateRemoteBack])
const navigateRemoteForwardClear = useCallback(() => {
clearError()
navigateRemoteForward()
}, [clearError, navigateRemoteForward])
const navigateRemoteUpClear = useCallback(() => {
clearError()
navigateRemoteUp()
}, [clearError, navigateRemoteUp])
// Handle file open
const handleLocalOpen = useCallback((file: FileItem) => {
if (file.type === 'directory') {
@@ -333,6 +524,14 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}
}, [localFiles, localPath, localSelected, performUpload, t])
const railUpload = useCallback(() => {
void handleUpload()
}, [handleUpload])
const railDownload = useCallback(() => {
void performDownload()
}, [performDownload])
// Handle drag-and-drop upload on remote panel
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
if (!isConnected) return
@@ -340,6 +539,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
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
@@ -347,83 +551,128 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
void performUpload(sources, targetPath)
}, [isConnected, performUpload, toast, t])
const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => {
if (!isConnected) return
if (entry.source === 'local') {
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(
[{ path: entry.path, name: entry.name, size: entry.size, isDirectory: entry.type === 'directory' }],
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
)
} else {
void performDownload([{
name: entry.name,
path: entry.path,
type: entry.type,
size: entry.size,
modified: 0,
permissions: '',
}], targetPath)
}
}, [isConnected, performDownload, performUpload])
// Handle new folder
const handleNewFolder = useCallback(async () => {
const handleDropFailed = useCallback(() => {
dndLog('toast-drop-failed')
toast.warning(t('toast.dropFailed'))
}, [t, toast])
const handleNewLocalFolder = useCallback(async () => {
const name = await prompt({
title: t('dialog.newFolderTitle'),
message: t('dialog.newFolderMessage'),
placeholder: t('dialog.newFolderPlaceholder'),
})
if (name && window.opsxApi?.localMkdir) {
window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => {
loadLocalDirectory(localPath)
})
if (!name || !window.opsxApi?.localMkdir) return
const fullPath = joinLocalPath(localPath, name.trim())
try {
await window.opsxApi.localMkdir(fullPath)
await loadLocalDirectory(localPath)
toast.success(t('toast.folderCreated', { name: name.trim() }))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('errors.mkdirFailed'))
}
}, [localPath, loadLocalDirectory, prompt, t])
}, [localPath, loadLocalDirectory, prompt, t, toast])
const handleNewRemoteFolder = useCallback(async () => {
const name = await prompt({
title: t('dialog.newFolderTitle'),
message: t('dialog.newFolderMessage'),
placeholder: t('dialog.newFolderPlaceholder'),
})
if (!name) return
const ok = await mkdirRemote(name.trim(), remotePath)
if (ok) {
toast.success(t('toast.folderCreated', { name: name.trim() }))
} else {
toast.error(t('errors.mkdirFailed'))
}
}, [mkdirRemote, prompt, remotePath, t, toast])
return (
<div className="h-full flex flex-col bg-dark-900">
<div className="relative flex h-full flex-col bg-dark-900">
{/* Connection Status */}
<ConnectionPanel
isConnected={isConnected}
isConnecting={isConnecting}
error={error}
resumePath={!isConnected && hadConnectedRef.current ? remotePath : null}
sessions={sessions}
showSelector={showConnectionSelector}
onConnect={(sessionId) => sessionId && connect(sessionId)}
onConnect={(id) => id && connect(id)}
onReconnect={() => {
clearError()
autoConnectAttempted.current = false
if (sessionId) {
autoConnectAttempted.current = true
void connect(sessionId)
return
}
setShowConnectionSelector(true)
}}
onShowSelector={() => setShowConnectionSelector(true)}
onCloseSelector={() => setShowConnectionSelector(false)}
/>
{/* Toolbar */}
<Toolbar
isConnected={isConnected}
onUpload={handleUpload}
canDownload={remoteFiles.some((file) => remoteSelected.has(file.name))}
onDownload={() => void performDownload()}
onRefresh={() => {
loadLocalDirectory(localPath)
if (isConnected) loadRemoteDirectory(remotePath)
}}
onNewFolder={handleNewFolder}
/>
{/* File Panels */}
{!isConnected ? (
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={Unplug}
title={t('panel.notConnectedTitle')}
description={t('panel.notConnectedDesc')}
description={
hadConnectedRef.current && remotePath
? t('panel.reconnectResumeDesc', { path: remotePath })
: t('panel.notConnectedDesc')
}
/>
</div>
) : (
<div className="flex-1 flex gap-4 p-4 min-h-0">
<div className="flex-1 flex gap-2 p-4 min-h-0">
{/* Local Panel */}
<FileListPanel
title={t('panel.local')}
isLocal={true}
path={localPath}
files={sortFiles(localFiles, sortField, sortOrder)}
homePath={localHistory[0] || localPath}
files={sortedLocalFiles}
selected={localSelected}
history={localHistory}
historyIndex={localHistoryIndex}
@@ -437,9 +686,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
onOpen={handleLocalOpen}
onContextMenu={showLocalContextMenu}
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
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}
/>
{/* Remote Panel */}
@@ -447,25 +707,30 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
title={t('panel.remote')}
isLocal={false}
path={remotePath}
files={sortFiles(remoteFiles, sortField, sortOrder)}
homePath={remoteHomePath}
files={sortedRemoteFiles}
selected={remoteSelected}
history={remoteHistory}
historyIndex={remoteHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateRemote}
onNavigateBack={navigateRemoteBack}
onNavigateForward={navigateRemoteForward}
onNavigateUp={navigateRemoteUp}
onNavigate={navigateRemoteClear}
onNavigateBack={navigateRemoteBackClear}
onNavigateForward={navigateRemoteForwardClear}
onNavigateUp={navigateRemoteUpClear}
onSelect={toggleRemoteSelect}
onOpen={handleRemoteOpen}
onContextMenu={showRemoteContextMenu}
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
onDropFiles={isConnected ? handleDropFiles : undefined}
onDropEntry={handleDropEntry}
loading={isConnecting}
onDropFailed={handleDropFailed}
onRefresh={isConnected ? refreshRemote : undefined}
onNewFolder={handleNewRemoteFolder}
onFocusPane={focusRemotePane}
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
refreshing={remoteLoading}
listError={remoteListError}
/>
</div>
)}
@@ -475,7 +740,9 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
tasks={transferQueue}
isOpen={isTransferPanelOpen}
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
onCancel={cancelTransfer}
onRemove={removeTransfer}
onRetry={retryTransfer}
/>
{/* Global Context Menu */}
@@ -488,6 +755,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
/>
)}
{PromptDialogComponent}
{ConfirmDialogComponent}
{/* File Properties Dialog */}
{propertiesFile && (
@@ -527,9 +795,6 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
onSaved={() => toast.success(t('editor.saved'))}
/>
)}
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
)
}
+4 -4
View File
@@ -13,10 +13,10 @@ interface SortHeaderProps {
}
const FIELDS: { key: SortField; className: string }[] = [
{ key: 'name', className: 'flex-1 min-w-0 justify-start' },
{ key: 'modified', className: 'w-32 justify-end' },
{ key: 'size', className: 'w-24 justify-end' },
{ key: 'type', className: 'w-24 justify-end' },
{ key: 'name', className: 'sftp-col-name flex-1 min-w-0 justify-start' },
{ key: 'modified', className: 'sftp-col-modified w-28 flex-shrink-0 justify-end' },
{ key: 'size', className: 'sftp-col-size w-16 flex-shrink-0 justify-end' },
{ key: 'type', className: 'sftp-col-type w-16 flex-shrink-0 justify-end' },
]
export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
-74
View File
@@ -1,74 +0,0 @@
/**
* Toolbar - SFTP 工具栏(图标优先,文案放 title/aria-label)
*/
import { useTranslation } from 'react-i18next'
import { Upload, Download, RefreshCw, FolderPlus } from 'lucide-react'
interface ToolbarProps {
isConnected: boolean
canDownload: boolean
onUpload: () => void
onDownload: () => void
onRefresh: () => void
onNewFolder: () => void
}
const iconBtn =
'inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:opacity-40'
export function Toolbar({
isConnected,
canDownload,
onUpload,
onDownload,
onRefresh,
onNewFolder,
}: ToolbarProps) {
const { t } = useTranslation('sftp')
return (
<div className="flex items-center gap-1 border-b border-dark-700/30 bg-dark-800 px-3 py-1.5">
<button
type="button"
onClick={onUpload}
disabled={!isConnected}
title={t('toolbar.upload')}
aria-label={t('toolbar.uploadAria')}
className={`${iconBtn} bg-accent-blue text-white hover:bg-blue-600 disabled:bg-dark-700 disabled:text-txt-muted`}
>
<Upload className="h-4 w-4" />
</button>
<button
type="button"
onClick={onDownload}
disabled={!isConnected || !canDownload}
title={canDownload ? t('toolbar.downloadHint') : t('toolbar.downloadNeedSelection')}
aria-label={t('toolbar.downloadAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<Download className="h-4 w-4" />
</button>
<button
type="button"
onClick={onRefresh}
disabled={!isConnected}
title={t('toolbar.refresh')}
aria-label={t('toolbar.refreshAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<RefreshCw className="h-4 w-4" />
</button>
<button
type="button"
onClick={onNewFolder}
disabled={!isConnected}
title={t('toolbar.newFolder')}
aria-label={t('toolbar.newFolderAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<FolderPlus className="h-4 w-4" />
</button>
</div>
)
}
+35 -10
View File
@@ -2,7 +2,7 @@
* TransferItem - 传输任务项组件
*/
import { Download, Upload, Clock, CheckCircle, XCircle, X } from 'lucide-react'
import { Download, Upload, Clock, CheckCircle, XCircle, X, RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TransferTask } from '../../../shared/types/sftp.types'
import { formatSpeed } from './utils'
@@ -11,21 +11,27 @@ interface TransferItemProps {
task: TransferTask
onCancel?: () => void
onRemove: () => void
onRetry?: () => void
}
export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
export function TransferItem({ task, onCancel, onRemove, onRetry }: TransferItemProps) {
const { t } = useTranslation('sftp')
const isActive = task.status === 'transferring' || task.status === 'pending'
const canCancel = isActive && Boolean(onCancel)
const canRetry =
Boolean(onRetry) &&
(task.status === 'failed' || task.status === 'canceled') &&
Boolean(task.destinationDir)
return (
<div className="flex items-center gap-3 px-3 py-2 bg-dark-700/50 rounded-lg group">
<div className={`flex-shrink-0 ${task.type === 'download' ? 'text-accent-blue' : 'text-green-400'}`}>
<div className={`flex-shrink-0 ${task.type === 'download' ? 'text-accent-blue' : 'text-accent-green'}`}>
{task.type === 'download' ? <Download className="w-4 h-4" /> : <Upload className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-content-primary truncate">{task.fileName}</div>
<div className="flex items-center gap-2 mt-0.5">
{isActive && (
{task.status === 'transferring' && (
<>
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden max-w-[120px]">
<div className="h-full bg-accent-blue transition-all duration-300" style={{ width: `${task.progress}%` }} />
@@ -41,15 +47,15 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
</span>
)}
{task.status === 'completed' && (
<span className="text-xs text-green-400 flex items-center gap-1">
<span className="text-xs text-feedback-success flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
{t('transfer.statusDone')}
</span>
)}
{task.status === 'failed' && (
<span className="text-xs text-red-400 flex items-center gap-1" title={task.error}>
<XCircle className="w-3 h-3" />
{t('transfer.statusFailed')}
<span className="text-xs text-feedback-error flex items-center gap-1 truncate" title={task.error}>
<XCircle className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{task.error || t('transfer.statusFailed')}</span>
</span>
)}
{task.status === 'canceled' && (
@@ -60,21 +66,40 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
)}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{task.status === 'transferring' && onCancel && (
<div
className={`flex-shrink-0 flex items-center gap-1 ${
canCancel || canRetry ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
} transition-opacity`}
>
{canRetry && (
<button
type="button"
onClick={onRetry}
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-accent-blue"
title={t('transfer.retry')}
aria-label={t('transfer.retry')}
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
{canCancel && (
<button
type="button"
onClick={onCancel}
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-red-400"
title={t('transfer.cancel')}
aria-label={t('transfer.cancel')}
>
<X className="w-3.5 h-3.5" />
</button>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'canceled') && (
<button
type="button"
onClick={onRemove}
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-content-primary"
title={t('transfer.remove')}
aria-label={t('transfer.remove')}
>
<X className="w-3.5 h-3.5" />
</button>
+41 -8
View File
@@ -1,9 +1,10 @@
/**
* TransferPanel - 传输队列面板
*
* 显示文件上传/下载的传输队列
*
* 显示文件上传/下载的传输队列;进行中任务置顶。
*/
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { TransferItem } from './TransferItem'
@@ -15,16 +16,47 @@ interface TransferPanelProps {
onToggle: () => void
onCancel?: (id: string) => void
onRemove: (id: string) => void
onRetry?: (id: string) => void
}
export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: TransferPanelProps) {
/** transferring → pending → failed/canceled → completed(组内保持原相对顺序) */
// eslint-disable-next-line react-refresh/only-export-components -- pure helper used by tests and panel
export function sortTransferTasks(tasks: TransferTask[]): TransferTask[] {
const rank = (status: TransferTask['status']): number => {
switch (status) {
case 'transferring':
return 0
case 'pending':
return 1
case 'failed':
case 'canceled':
return 2
case 'completed':
return 3
default:
return 4
}
}
return tasks
.map((task, index) => ({ task, index }))
.sort((a, b) => {
const byStatus = rank(a.task.status) - rank(b.task.status)
return byStatus !== 0 ? byStatus : a.index - b.index
})
.map(({ task }) => task)
}
export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove, onRetry }: TransferPanelProps) {
const { t } = useTranslation('sftp')
const activeCount = tasks.filter(t => t.status === 'pending' || t.status === 'transferring').length
const completedCount = tasks.filter(t => t.status === 'completed').length
const orderedTasks = useMemo(() => sortTransferTasks(tasks), [tasks])
const activeCount = tasks.filter((task) => task.status === 'pending' || task.status === 'transferring').length
const completedCount = tasks.filter((task) => task.status === 'completed').length
return (
<div className="bg-dark-800 border-t border-dark-700/30">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center justify-between px-4 py-2 hover:bg-dark-700 transition-colors"
>
@@ -36,7 +68,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
</span>
)}
{completedCount > 0 && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-full">
<span className="px-2 py-0.5 bg-feedback-success/15 text-feedback-success text-xs rounded-full">
{t('transfer.completed', { count: completedCount })}
</span>
)}
@@ -47,14 +79,15 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
</div>
</button>
{isOpen && tasks.length > 0 && (
{isOpen && orderedTasks.length > 0 && (
<div className="max-h-48 overflow-y-auto border-t border-dark-700/30">
{tasks.map((task) => (
{orderedTasks.map((task) => (
<TransferItem
key={task.id}
task={task}
onCancel={onCancel ? () => onCancel(task.id) : undefined}
onRemove={() => onRemove(task.id)}
onRetry={onRetry ? () => onRetry(task.id) : undefined}
/>
))}
</div>
+51
View File
@@ -0,0 +1,51 @@
/**
* Dual-pane transfer rail (WinSCP / Total Commander style).
* Explicit upload/download buttons; cross-pane drag uses pointer drag in FileListPanel.
* OS file drops onto the remote pane still use HTML5 drop.
*/
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface TransferRailProps {
canUpload: boolean
canDownload: boolean
onUpload: () => void
onDownload: () => void
}
const btn =
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-dark-600/50 bg-dark-700 text-content-secondary transition-colors hover:bg-dark-600 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-35'
export function TransferRail({ canUpload, canDownload, onUpload, onDownload }: TransferRailProps) {
const { t } = useTranslation('sftp')
return (
<div
className="flex flex-col items-center justify-center gap-2 self-stretch px-1"
role="group"
aria-label={t('transfer.railAria')}
>
<button
type="button"
className={btn}
disabled={!canUpload}
title={t('transfer.railUpload')}
aria-label={t('transfer.railUploadAria')}
onClick={onUpload}
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={btn}
disabled={!canDownload}
title={canDownload ? t('transfer.railDownload') : t('transfer.railDownloadNeedSelection')}
aria-label={t('transfer.railDownloadAria')}
onClick={onDownload}
>
<ArrowLeft className="h-4 w-4" />
</button>
</div>
)
}
@@ -0,0 +1,41 @@
export const CONTEXT_MENU_VIEWPORT_MARGIN = 8
interface MenuPosition {
left: number
top: number
}
/**
* Place a context menu inside the viewport.
* Prefer aligning the top-left to the click; if it would overflow the bottom/right,
* shift (flip) so the full menu stays visible.
*/
export function fitContextMenuToViewport(
x: number,
y: number,
menuWidth: number,
menuHeight: number,
viewportWidth: number,
viewportHeight: number,
): MenuPosition {
const margin = CONTEXT_MENU_VIEWPORT_MARGIN
const maxLeft = Math.max(margin, viewportWidth - menuWidth - margin)
const maxTop = Math.max(margin, viewportHeight - menuHeight - margin)
let left = x
let top = y
// Horizontal: flip left of cursor when not enough room on the right.
if (left + menuWidth + margin > viewportWidth) {
left = x - menuWidth
}
left = Math.min(Math.max(left, margin), maxLeft)
// Vertical: flip above cursor when not enough room below.
if (top + menuHeight + margin > viewportHeight) {
top = y - menuHeight
}
top = Math.min(Math.max(top, margin), maxTop)
return { left, top }
}
+236 -10
View File
@@ -1,32 +1,69 @@
import type { FileItem } from '../../../shared/types/sftp.types'
/** Legacy custom MIME — kept for older in-flight drags / tests. */
export const SFTP_ENTRY_MIME = 'application/x-opsxshell-sftp-entry'
/** text/plain prefix — WebKit/Tauri often strips unknown MIME until drop (or rejects them). */
export const SFTP_ENTRY_TEXT_PREFIX = 'opsxshell-sftp:'
export interface SftpDragEntry extends Pick<FileItem, 'name' | 'path' | 'type' | 'size'> {
source: 'local' | 'remote'
}
export function writeSftpDragEntry(dataTransfer: DataTransfer, entry: SftpDragEntry): void {
dataTransfer.effectAllowed = 'copy'
dataTransfer.setData(SFTP_ENTRY_MIME, JSON.stringify(entry))
dataTransfer.setData('text/plain', entry.path)
/**
* Module mirror of the in-flight drag payload.
* Critical on macOS Tauri (WKWebView): dragend may fire before drop, and
* dataTransfer.getData is empty during dragover for custom types.
* Same idea as Netcatty's in-memory `draggedFiles` (identity lives in React/module
* state; text/plain is only a browser formality).
*/
let activeDragEntries: SftpDragEntry[] = []
let clearTimer: ReturnType<typeof setTimeout> | null = null
let dragGeneration = 0
/** DEV-only DnD breadcrumb — no passwords; paths truncated to last 2 segments. */
export function dndLog(event: string, detail?: Record<string, unknown>): void {
if (!import.meta.env.DEV) return
if (detail === undefined) {
console.log('[sftp-dnd]', event)
return
}
console.log('[sftp-dnd]', event, detail)
}
export function hasSftpDragEntry(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types || []).includes(SFTP_ENTRY_MIME)
export function shortPath(path: string): string {
const parts = path.replace(/\\/g, '/').split('/').filter(Boolean)
if (parts.length <= 2) return path || '/'
return `…/${parts.slice(-2).join('/')}`
}
export function readSftpDragEntry(dataTransfer: DataTransfer): SftpDragEntry | null {
function summarizeEntries(entries: SftpDragEntry[]) {
return {
count: entries.length,
source: entries[0]?.source,
names: entries.slice(0, 8).map((e) => e.name),
types: entries.slice(0, 8).map((e) => e.type),
}
}
function cancelScheduledClear() {
if (clearTimer !== null) {
clearTimeout(clearTimer)
clearTimer = null
}
}
function parseEntry(raw: string): SftpDragEntry | null {
try {
const raw = dataTransfer.getData(SFTP_ENTRY_MIME)
if (!raw) return null
const value = JSON.parse(raw) as Partial<SftpDragEntry>
if (
(value.source !== 'local' && value.source !== 'remote') ||
typeof value.path !== 'string' ||
typeof value.name !== 'string' ||
!['file', 'directory', 'symlink'].includes(value.type || '')
) return null
) {
return null
}
return {
source: value.source,
path: value.path,
@@ -38,3 +75,192 @@ export function readSftpDragEntry(dataTransfer: DataTransfer): SftpDragEntry | n
return null
}
}
export function writeSftpDragEntry(dataTransfer: DataTransfer, entry: SftpDragEntry): void {
writeSftpDragEntries(dataTransfer, [entry])
}
/**
* Seed the in-memory payload before the browser's `dragstart` event.
* WKWebView can hide DataTransfer data while a drag is being negotiated, so the
* opposite pane needs an identity it can consult during the first dragover.
*/
export function primeSftpDragEntries(entries: SftpDragEntry[]): void {
if (entries.length === 0) return
cancelScheduledClear()
dragGeneration += 1
activeDragEntries = entries
dndLog('drag-prime', { gen: dragGeneration, ...summarizeEntries(entries) })
}
/** Prefer this when dragging a multi-selection (Netcatty-style cross-pane batch). */
export function writeSftpDragEntries(dataTransfer: DataTransfer, entries: SftpDragEntry[]): void {
if (entries.length === 0) return
primeSftpDragEntries(entries)
dataTransfer.effectAllowed = 'copy'
// Prefer text/plain — reliable across WKWebView dragover/drop.
const encoded = `${SFTP_ENTRY_TEXT_PREFIX}${JSON.stringify(entries.length === 1 ? entries[0] : entries)}`
let plainOk = false
let mimeOk = false
try {
dataTransfer.setData('text/plain', encoded)
plainOk = true
} catch {
// Some hosts throw on setData; mirror is enough for in-app DnD.
}
try {
dataTransfer.setData(SFTP_ENTRY_MIME, JSON.stringify(entries.length === 1 ? entries[0] : entries))
mimeOk = true
} catch {
// Custom MIME is optional (WebKit may ignore / hide it).
}
dndLog('drag-start', {
gen: dragGeneration,
...summarizeEntries(entries),
plainOk,
mimeOk,
})
}
export function clearSftpDragEntry(): void {
if (activeDragEntries.length > 0) {
dndLog('clear', { gen: dragGeneration, had: activeDragEntries.length })
}
cancelScheduledClear()
activeDragEntries = []
}
/**
* Clear after dragend. Use a long delay so a late `drop` (WKWebView order quirk)
* can still read the mirror. Successful drops should call clearSftpDragEntry() themselves.
*/
export function scheduleClearSftpDragEntry(delayMs = 400): void {
cancelScheduledClear()
const gen = dragGeneration
const pending = activeDragEntries.length
if (pending > 0) {
dndLog('schedule-clear', { gen, delayMs, pending })
}
clearTimer = setTimeout(() => {
// Ignore stale timers from an older drag if a new one started.
if (gen === dragGeneration) {
if (activeDragEntries.length > 0) {
dndLog('delayed-clear', { gen, cleared: activeDragEntries.length })
}
activeDragEntries = []
}
clearTimer = null
}, delayMs)
}
export function peekSftpDragEntry(): SftpDragEntry | null {
return activeDragEntries[0] || null
}
export function peekSftpDragEntries(): SftpDragEntry[] {
return activeDragEntries.slice()
}
export function hasSftpDragEntry(dataTransfer: DataTransfer): boolean {
// Prefer the module mirror — custom MIME / text types are often hidden during WebKit dragover.
if (activeDragEntries.length > 0) return true
const types = Array.from(dataTransfer.types || [])
if (types.includes(SFTP_ENTRY_MIME)) return true
return false
}
function parsePayload(raw: string): SftpDragEntry[] {
try {
const value = JSON.parse(raw) as unknown
if (Array.isArray(value)) {
return value.map((item) => parseEntry(JSON.stringify(item))).filter((item): item is SftpDragEntry => Boolean(item))
}
const single = parseEntry(raw)
return single ? [single] : []
} catch {
return []
}
}
export function readSftpDragEntry(dataTransfer: DataTransfer): SftpDragEntry | null {
return readSftpDragEntries(dataTransfer)[0] || null
}
export function readSftpDragEntries(dataTransfer: DataTransfer): SftpDragEntry[] {
if (activeDragEntries.length > 0) return activeDragEntries.slice()
try {
const plain = dataTransfer.getData('text/plain') || dataTransfer.getData('Text')
if (plain?.trim().startsWith(SFTP_ENTRY_TEXT_PREFIX)) {
const parsed = parsePayload(plain.trim().slice(SFTP_ENTRY_TEXT_PREFIX.length))
if (parsed.length > 0) return parsed
}
} catch {
// getData empty/throws during dragover
}
try {
const raw = dataTransfer.getData(SFTP_ENTRY_MIME)
if (raw) {
const parsed = parsePayload(raw)
if (parsed.length > 0) return parsed
}
} catch {
// ignore
}
return []
}
/** Where the drop payload came from — for diagnostics at drop time only. */
export function resolveSftpDragEntries(dataTransfer: DataTransfer): {
entries: SftpDragEntry[]
via: 'mirror' | 'text/plain' | 'mime' | 'empty'
} {
if (activeDragEntries.length > 0) {
return { entries: activeDragEntries.slice(), via: 'mirror' }
}
try {
const plain = dataTransfer.getData('text/plain') || dataTransfer.getData('Text')
if (plain?.trim().startsWith(SFTP_ENTRY_TEXT_PREFIX)) {
const parsed = parsePayload(plain.trim().slice(SFTP_ENTRY_TEXT_PREFIX.length))
if (parsed.length > 0) return { entries: parsed, via: 'text/plain' }
}
} catch {
// ignore
}
try {
const raw = dataTransfer.getData(SFTP_ENTRY_MIME)
if (raw) {
const parsed = parsePayload(raw)
if (parsed.length > 0) return { entries: parsed, via: 'mime' }
}
} catch {
// ignore
}
return { entries: [], via: 'empty' }
}
/** True when an in-app SFTP drag (or OS Files) should allow drop on a pane. */
export function isCrossPaneSftpDrag(
dataTransfer: DataTransfer,
paneIsLocal: boolean
): boolean {
const entry = peekSftpDragEntry() || readSftpDragEntry(dataTransfer)
if (entry) {
return entry.source !== (paneIsLocal ? 'local' : 'remote')
}
// OS file drop only onto remote
if (!paneIsLocal && Array.from(dataTransfer.types || []).includes('Files')) {
return true
}
// WebKit may expose only text/plain (and keep it unreadable) until drop.
// Accept it provisionally so the drop can report a useful error rather than
// disappearing without feedback when no mirror is available.
const types = Array.from(dataTransfer.types || [])
return types.includes(SFTP_ENTRY_MIME) || types.includes('text/plain') || types.includes('Text')
}
+1 -1
View File
@@ -10,4 +10,4 @@ export { TransferItem } from './TransferItem'
export { ConnectionSelector } from './ConnectionSelector'
export { FileListPanel } from './FileListPanel'
export { TransferPanel } from './TransferPanel'
export { Toolbar } from './Toolbar'
export { TransferRail } from './TransferRail'
+44
View File
@@ -0,0 +1,44 @@
import { joinLocalPath } from '../../utils/localPath'
export type ConflictSide = 'upload' | 'download'
/** Destination path for a single transfer name. */
export function transferDestinationPath(
side: ConflictSide,
destinationDir: string,
fileName: string
): string {
if (side === 'upload') {
return destinationDir === '/' ? `/${fileName}` : `${destinationDir.replace(/\/+$/, '')}/${fileName}`
}
return joinLocalPath(destinationDir, fileName)
}
/** Names that already exist in the current directory listing. */
export function conflictNamesFromListing(
names: string[],
listing: Array<{ name: string }>
): string[] {
const existing = new Set(listing.map((entry) => entry.name))
return names.filter((name) => existing.has(name))
}
export function formatOverwriteMessage(
names: string[],
side: ConflictSide,
t: (key: string, opts?: Record<string, unknown>) => string
): string {
if (names.length === 1) {
return t(
side === 'upload' ? 'dialog.overwriteOneRemote' : 'dialog.overwriteOneLocal',
{ name: names[0] }
)
}
const preview = names.slice(0, 5).map((name) => `• ${name}`).join('\n')
const more =
names.length > 5 ? `\n${t('dialog.overwriteMore', { count: names.length - 5 })}` : ''
return `${t(
side === 'upload' ? 'dialog.overwriteManyRemote' : 'dialog.overwriteManyLocal',
{ count: names.length }
)}\n${preview}${more}`
}
@@ -1,9 +1,10 @@
import { useEffect, type ComponentType } from 'react'
import { useEffect, useRef, type ComponentType } from 'react'
import { useTranslation } from 'react-i18next'
import {
Clipboard,
ClipboardPaste,
Eraser,
FolderOpen,
Maximize2,
Minimize2,
MousePointer2,
@@ -38,6 +39,7 @@ interface TerminalContextMenuProps {
onSplitDown: () => void
onToggleMaximize: () => void
onClosePane: () => void
onOpenSftp?: () => void
}
export default function TerminalContextMenu({
@@ -57,29 +59,43 @@ export default function TerminalContextMenu({
onSplitDown,
onToggleMaximize,
onClosePane,
onOpenSftp,
}: TerminalContextMenuProps) {
const { t } = useTranslation('terminal')
const menuRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
const dismiss = () => onClose()
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
window.addEventListener('mousedown', dismiss)
const dismissOutside = (event: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onCloseRef.current()
}
}
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCloseRef.current()
}
const dismiss = () => onCloseRef.current()
document.addEventListener('pointerdown', dismissOutside, true)
window.addEventListener('blur', dismiss)
window.addEventListener('resize', dismiss)
window.addEventListener('keydown', escape)
return () => {
window.removeEventListener('mousedown', dismiss)
document.removeEventListener('pointerdown', dismissOutside, true)
window.removeEventListener('blur', dismiss)
window.removeEventListener('resize', dismiss)
window.removeEventListener('keydown', escape)
}
}, [onClose])
}, [])
const actions: MenuAction[] = [
{ label: t('contextMenu.copy'), icon: Clipboard, onSelect: onCopy, disabled: !hasSelection },
{ label: t('contextMenu.paste'), icon: ClipboardPaste, onSelect: onPaste },
{ label: t('contextMenu.selectAll'), icon: MousePointer2, onSelect: onSelectAll },
{ label: t('contextMenu.clear'), icon: Eraser, onSelect: onClear },
...(onOpenSftp
? [{ label: t('contextMenu.openSftp'), icon: FolderOpen, onSelect: onOpenSftp, dividerBefore: true } satisfies MenuAction]
: []),
{ label: t('contextMenu.splitRight'), icon: SplitSquareHorizontal, onSelect: onSplitRight, disabled: !canSplit, dividerBefore: true },
{ label: t('contextMenu.splitDown'), icon: SeparatorHorizontal, onSelect: onSplitDown, disabled: !canSplit },
{
@@ -96,10 +112,11 @@ export default function TerminalContextMenu({
return (
<div
ref={menuRef}
role="menu"
aria-label={t('contextMenu.aria')}
style={{ left, top }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
className="fixed z-[140] w-52 overflow-hidden rounded-lg border border-dark-600/90 bg-dark-800 py-1.5 shadow-2xl shadow-black/45"
>
{actions.map((action) => (
@@ -110,8 +127,8 @@ export default function TerminalContextMenu({
role="menuitem"
disabled={action.disabled}
onClick={() => {
onClose()
action.onSelect()
onClose()
}}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-35 ${
action.danger
+16 -16
View File
@@ -14,7 +14,7 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
const { t } = useTranslation('terminal')
return (
<div className="bg-dark-900/92 absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="terminal-overlay-scrim absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-5 text-center">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border">
{idleTimeout ? (
@@ -23,11 +23,11 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
<TerminalIcon className="h-6 w-6 text-accent-blue" />
)}
</div>
<div className="mb-1.5 text-sm font-medium text-content-primary">
<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-txt-muted">{reason}</div>
<div className="mb-4 text-[11px] text-emerald-400">{t('overlay.shellClosedHint')}</div>
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div>
<div className="mb-4 text-[11px] text-accent-green">{t('overlay.shellClosedHint')}</div>
<button
type="button"
disabled={restarting}
@@ -67,13 +67,13 @@ function TerminalOverlay({
return (
<>
{showDisconnectBanner && (
<div className="bg-dark-900/92 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-6 text-center">
<div className="bg-accent-blue/15 border-accent-blue/25 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<WifiOff className="h-7 w-7 text-accent-blue" />
</div>
<div className="mb-1.5 text-base font-medium text-content-primary">{t('overlay.disconnected')}</div>
<div className="mb-6 text-sm leading-relaxed text-txt-muted">
<div className="mb-1.5 text-base font-medium text-slate-100">{t('overlay.disconnected')}</div>
<div className="mb-6 text-sm leading-relaxed text-slate-400">
{statusMessage?.trim() ? statusMessage : t('overlay.disconnectedHint')}
</div>
<button
@@ -89,10 +89,10 @@ function TerminalOverlay({
)}
{showSuspendedOverlay && (
<div className="bg-dark-900/90 absolute inset-0 z-10 flex items-center justify-center">
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-sm px-6 text-center">
<div className="mb-2 text-base font-medium text-yellow-400">{t('overlay.suspended')}</div>
<div className="mb-5 text-sm text-txt-muted">{t('overlay.suspendedHint')}</div>
<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>
<div className="flex items-center justify-center gap-2">
<button
type="button"
@@ -104,7 +104,7 @@ function TerminalOverlay({
<button
type="button"
onClick={onReconnect}
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-content-primary transition-colors hover:bg-dark-600"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm text-slate-100 transition-colors hover:bg-slate-600"
>
{t('overlay.reconnect')}
</button>
@@ -114,13 +114,13 @@ function TerminalOverlay({
)}
{connectionState === 'error' && (
<div className="bg-dark-900/88 absolute inset-0 z-10 flex items-center justify-center">
<div className="terminal-overlay-scrim absolute inset-0 z-10 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" />
</div>
<div className="mb-1.5 font-medium text-red-300">{t('overlay.connectFailed')}</div>
<div className="mb-5 text-sm leading-relaxed text-txt-muted">
<div className="mb-5 text-sm leading-relaxed text-slate-400">
{statusMessage || t('overlay.connectFailedHint')}
</div>
<button
@@ -136,7 +136,7 @@ function TerminalOverlay({
)}
{(connectionState === 'connecting' || connectionState === 'reconnecting') && (
<div className="bg-dark-900/45 pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<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="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" />
@@ -149,7 +149,7 @@ function TerminalOverlay({
: t('overlay.connecting')}
</div>
{statusMessage && connectionState === 'reconnecting' && (
<div className="mt-2 max-w-sm text-xs text-txt-muted">{statusMessage}</div>
<div className="mt-2 max-w-sm text-xs text-slate-400">{statusMessage}</div>
)}
</div>
</div>
+14 -2
View File
@@ -26,7 +26,10 @@ function TerminalSearchBar({
const { t } = useTranslation('terminal')
return (
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
<div
role="search"
className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm"
>
<Search size={14} className="text-txt-muted shrink-0" />
<input
ref={searchInputRef}
@@ -47,28 +50,37 @@ function TerminalSearchBar({
className="flex-1 bg-dark-900 text-content-primary text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-ph"
/>
{searchResultCount !== undefined && (
<span className="text-xs text-txt-muted whitespace-nowrap min-w-[60px] text-center">
<span
className="text-xs text-txt-muted whitespace-nowrap min-w-[60px] text-center"
aria-live="polite"
>
{searchResultIndex ?? 0}/{searchResultCount}
</span>
)}
<button
type="button"
onClick={onSearchPrevious}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title={t('search.previous')}
aria-label={t('search.previous')}
>
<ChevronUp size={16} />
</button>
<button
type="button"
onClick={onSearchNext}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title={t('search.next')}
aria-label={t('search.next')}
>
<ChevronDown size={16} />
</button>
<button
type="button"
onClick={onClose}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title={t('search.close')}
aria-label={t('search.close')}
>
<X size={16} />
</button>
+10 -5
View File
@@ -6,6 +6,7 @@ import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { safeTerminalDispose, safeTerminalFit, safeTerminalWrite } from '../../utils/safeTerminalFit'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { getConnectionStore } from '../../stores/connection'
@@ -100,7 +101,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
}
const handleResize = () => {
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, xtermRef.current)
if (shellIdRef.current && xtermRef.current) {
const { cols, rows } = xtermRef.current
window.opsxApi?.sshShellResize(shellIdRef.current, cols, rows)
@@ -132,7 +133,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
term.loadAddon(webLinksAddon)
term.open(terminalRef.current)
fitAddon.fit()
safeTerminalFit(fitAddon, term)
xtermRef.current = term
fitAddonRef.current = fitAddon
@@ -148,7 +149,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
}
},
output: {
write: (data) => term.write(data),
write: (data) => safeTerminalWrite(term, data),
transform: (data) => {
const currentSettings = settingsRef.current
return currentSettings.keywordHighlightEnabled && currentSettings.keywordHighlightRules?.length
@@ -220,7 +221,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
term.options.fontFamily = buildTerminalFontFamily(settings.fontFamily)
term.options.scrollback = settings.scrollback || 10000
Object.assign(term.options, XTERM_RENDER_OPTIONS)
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, term)
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
// 初始连接
@@ -277,6 +278,10 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
zmodemRef.current = null
pipelineRef.current?.dispose()
pipelineRef.current = null
fitAddonRef.current = null
const term = xtermRef.current
xtermRef.current = null
safeTerminalDispose(term)
if (registeredShellHandlerRef.current) {
window.opsxApi?.removeListener(
'ssh:shell-data',
@@ -303,7 +308,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
return (
<div className="flex h-full flex-col bg-dark-900">
<div className="relative flex-1 overflow-hidden p-2">
<div ref={terminalRef} className="h-full w-full rounded bg-dark-900" />
<div ref={terminalRef} className="terminal-surface h-full w-full rounded" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
+85 -15
View File
@@ -22,6 +22,7 @@ import {
Sparkles,
} from 'lucide-react'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { safeTerminalClear, safeTerminalDispose, safeTerminalFit, safeTerminalWrite } from '../../utils/safeTerminalFit'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { getConnectionStore } from '../../stores/connection'
@@ -38,7 +39,9 @@ import { shellPayloadByteLength } from '../../utils/shellData'
import { shellWrite, shellResize, shellCreate, shellKill, flushSshShellBootstrap } from '../../utils/shellIO'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
import TerminalOverlay, { ShellClosedOverlay } from './TerminalOverlay'
import TerminalSearchBar from './TerminalSearchBar'
import { createLogger } from '../../utils/logger'
import type { TerminalViewProps } from './types'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
@@ -70,6 +73,7 @@ interface PaneRuntime {
capture: () => TerminalAiContext
getSize: () => { cols: number; rows: number }
getTerminal: () => Terminal | null
getSearchAddon: () => SearchAddon | null
copySelection: () => void
pasteClipboard: () => void
selectAll: () => void
@@ -132,6 +136,7 @@ function PaneTerminal({
const containerRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const searchAddonRef = useRef<SearchAddon | null>(null)
const shellIdRef = useRef(shellId)
const connectionIdRef = useRef(connectionId)
const onInputRef = useRef(onInput)
@@ -172,9 +177,10 @@ function PaneTerminal({
term.loadAddon(fitAddon)
term.loadAddon(searchAddon)
term.loadAddon(webLinks)
searchAddonRef.current = searchAddon
const container = containerRef.current
term.open(container)
fitAddon.fit()
safeTerminalFit(fitAddon, term)
const pasteClipboard = async () => {
try {
@@ -205,6 +211,9 @@ function PaneTerminal({
void pasteClipboard()
return false
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
return false
}
return true
})
@@ -223,13 +232,14 @@ function PaneTerminal({
fitAddonRef.current = fitAddon
setXtermReady(true)
let disposed = false
const highlightStream = new StreamingKeywordHighlight()
const pipeline = new TerminalPipeline({
input: (data) => {
if (shellIdRef.current) onInputRef.current(paneId, data)
},
output: {
write: (text) => term.write(text),
write: (text) => safeTerminalWrite(term, text),
transform: (text) => {
const currentSettings = settingsRef.current
if (!currentSettings.keywordHighlightEnabled || !currentSettings.keywordHighlightRules?.length) {
@@ -245,6 +255,7 @@ function PaneTerminal({
? null
: new ZmodemSftpBridge({
writeToTerminal: (data) => {
if (disposed) return
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
},
sendToPeer: (data) => {
@@ -256,12 +267,14 @@ function PaneTerminal({
onStatus: setZmodemStatus,
})
term.onData((data) => {
if (disposed) return
zmodem?.noteInput(data)
pipeline.input(data)
})
const onResize = () => {
fitAddon.fit()
if (disposed) return
if (!safeTerminalFit(fitAddon, term)) return
const sid = shellIdRef.current
if (sid && term) {
void shellResize(sid, term.cols, term.rows)
@@ -270,10 +283,10 @@ function PaneTerminal({
window.addEventListener('resize', onResize)
// Fit before advertising size so the primary shell is not born at 80×24.
fitAddon.fit()
safeTerminalFit(fitAddon, term)
registerShellHandler(paneId, (sid, data) => {
if (sid !== shellIdRef.current || !xtermRef.current) return
if (disposed || sid !== shellIdRef.current || !xtermRef.current) return
if (zmodem) {
const bytes =
data instanceof Uint8Array
@@ -290,13 +303,15 @@ function PaneTerminal({
capture: () => captureTerminal(term),
getSize: () => ({ cols: term.cols, rows: term.rows }),
getTerminal: () => xtermRef.current,
getSearchAddon: () => searchAddonRef.current,
copySelection,
pasteClipboard: () => { void pasteClipboard() },
selectAll: () => term.selectAll(),
clear: () => term.clear(),
clear: () => safeTerminalClear(term),
})
return () => {
disposed = true
setXtermReady(false)
window.removeEventListener('resize', onResize)
container.removeEventListener('contextmenu', handleContextMenu)
@@ -304,8 +319,10 @@ function PaneTerminal({
unregisterRuntime(paneId)
zmodem?.dispose()
pipeline.dispose()
term.dispose()
fitAddonRef.current = null
searchAddonRef.current = null
xtermRef.current = null
safeTerminalDispose(term)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -319,14 +336,14 @@ function PaneTerminal({
term.options.fontFamily = buildTerminalFontFamily(settings.fontFamily)
term.options.scrollback = settings.scrollback || 10000
Object.assign(term.options, XTERM_RENDER_OPTIONS)
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, term)
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
// Resize when shell id attaches
useEffect(() => {
if (!shellId || !xtermRef.current) return
shellIdRef.current = shellId
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, xtermRef.current)
void shellResize(shellId, xtermRef.current.cols, xtermRef.current.rows)
}, [shellId])
@@ -381,11 +398,11 @@ function PaneTerminal({
creatingRef.current = false
}
})()
}, [autoCreateShell, connectionId, shellId, paneId, onShellReady, isLocal, tabId, xtermReady])
}, [autoCreateShell, connectionId, shellId, paneId, onShellReady, isLocal, tabId, xtermReady, t])
useEffect(() => {
if (isActive && isFocused) {
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, xtermRef.current)
xtermRef.current?.focus()
}
}, [isActive, isFocused])
@@ -403,7 +420,7 @@ function PaneTerminal({
return (
<div className={`relative h-full w-full ${isFocused ? 'ring-accent-blue/40 ring-1' : ''}`} onMouseDown={onFocus}>
<div ref={containerRef} className="h-full w-full" />
<div ref={containerRef} className="terminal-surface h-full w-full" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
{zmodemBanner}
@@ -416,7 +433,7 @@ function PaneTerminal({
export default function TerminalWorkspace({ tabId, isActive = true }: TerminalViewProps) {
const { t } = useTranslation('terminal')
const { loadSettings } = useSettingsStore()
const { getTabById, updateTab, updateTabConnectionInstance, closeTab, tabs } = useTabStore()
const { getTabById, updateTab, updateTabConnectionInstance, closeTab, tabs, addTab } = useTabStore()
const snapshotStore = useTerminalSnapshotStore()
const currentTab = getTabById(tabId)
const savedLayout = currentTab?.splitLayout
@@ -472,6 +489,31 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const shellHandlersRef = useRef<Map<string, (shellId: string, data: unknown) => void>>(new Map())
const paneRuntimesRef = useRef<Map<string, PaneRuntime>>(new Map())
const focusedXtermRef = useRef<Terminal | null>(null)
const focusedSearchAddonRef = useRef<SearchAddon | null>(null)
useEffect(() => {
const runtime = paneRuntimesRef.current.get(focusedPaneId)
focusedXtermRef.current = runtime?.getTerminal() ?? null
focusedSearchAddonRef.current = runtime?.getSearchAddon() ?? null
}, [focusedPaneId, panes])
const {
showSearch,
searchQuery,
searchResultCount,
searchResultIndex,
searchInputRef,
handleSearchChange,
handleSearchNext,
handleSearchPrevious,
handleCloseSearch,
} = useTerminalSearch({
xtermRef: focusedXtermRef,
searchAddonRef: focusedSearchAddonRef,
isActive,
targetKey: focusedPaneId,
})
const {
connectionState,
@@ -531,7 +573,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
: [])
: []
registerBroadcastWorkspace({ tabId, targets })
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId, t])
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
@@ -685,7 +727,11 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
primaryXtermRef.current = runtime.getTerminal()
armInitialConnect()
}
}, [armInitialConnect])
if (paneId === focusedPaneId) {
focusedXtermRef.current = runtime.getTerminal()
focusedSearchAddonRef.current = runtime.getSearchAddon()
}
}, [armInitialConnect, focusedPaneId])
const unregisterRuntime = useCallback((paneId: string) => {
paneRuntimesRef.current.delete(paneId)
if (panesRef.current[0]?.id === paneId) {
@@ -1155,6 +1201,18 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
<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">
{showSearch && (
<TerminalSearchBar
searchQuery={searchQuery}
searchResultCount={searchResultCount}
searchResultIndex={searchResultIndex}
searchInputRef={searchInputRef}
onSearchChange={handleSearchChange}
onSearchNext={handleSearchNext}
onSearchPrevious={handleSearchPrevious}
onClose={handleCloseSearch}
/>
)}
<div
className={`h-full min-h-0 w-full ${
multi ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex'
@@ -1259,6 +1317,18 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
onSplitDown={() => setSplitDirection('vertical')}
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
onOpenSftp={
!isLocal && currentTab?.sessionId
? () => {
const sessionId = currentTab.sessionId!
addTab({
title: `SFTP: ${currentTab.title}`,
type: 'sftp',
sessionId,
})
}
: undefined
}
/>
)}
</div>