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:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' : ''}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabId, timeout, updateResult]
|
||||
[tabId, timeout, updateResult, t]
|
||||
)
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 高级 */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}`}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user