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

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

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

No files matched your search

+14 -14
View File
@@ -16,31 +16,31 @@ export const AI_PROVIDER_FAMILY_META: Record<
{ label: string; description: string }
> = {
openai_compatible: {
label: 'OpenAI兼容',
description: '按量',
label: 'OpenAI compatible',
description: 'payg',
},
standalone: {
label: '────────',
label: 'Native APIs',
description: '',
},
plan: {
label: 'Plan计划',
description: '套餐',
label: 'Subscription plans',
description: 'plan',
},
local: {
label: '私有化本地',
description: '本地',
label: 'Self-hosted',
description: 'local',
},
custom: {
label: 'Custom自定义',
description: '自定义',
label: 'Custom',
description: 'custom',
},
}
export const AI_BILLING_LABEL: Record<AiBillingMode, string> = {
payg: '按量',
plan: 'Plan',
local: '本地',
payg: 'payg',
plan: 'plan',
local: 'local',
}
const model = (
@@ -292,8 +292,8 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
// ——— Custom ———
{
id: 'custom',
name: 'Custom自定义',
description: '自定义',
name: 'Custom',
description: 'Custom endpoint',
family: 'custom',
presetId: 'custom',
protocol: 'openai-chat',
+53 -23
View File
@@ -33,7 +33,7 @@ import {
isEncryptedSessionBackup,
} from '../utils/sessionBackupCrypto'
import { usePromptDialog } from '../components/common/PromptDialog'
import { licenseServerBaseUrl } from '../stores/entitlementStore'
import { claimPurchase } from '../platform/licenseApi'
const ConnectionPanel = lazy(() => import('../components/connection/ConnectionPanel'))
const SessionDialog = lazy(() => import('../components/sessions/SessionDialog'))
@@ -87,6 +87,9 @@ function App() {
useEffect(() => {
if (!settingsLoaded) return
void import('../i18n').then(({ setAppLanguage }) => setAppLanguage(settings.language))
void window.opsxApi?.applyNativeMenuLocale?.(settings.language).catch(() => {
// Native menu sync is best-effort (browser / tests have no menu bar).
})
}, [settingsLoaded, settings.language])
useEffect(() => {
@@ -97,6 +100,7 @@ function App() {
idleTimeoutMs: settings.idleTimeoutMs,
suspendTimeoutMs: settings.suspendTimeoutMs,
maxReconnectAttempts: settings.maxReconnectAttempts,
keepaliveIntervalMs: Math.max(5, settings.keepAliveIntervalSec || 15) * 1000,
})
}, [settings, settingsLoaded])
@@ -470,35 +474,53 @@ function App() {
void (async () => {
try {
const parsed = new URL(url)
const tokenFromQuery = parsed.searchParams.get('token')
if (tokenFromQuery) {
const result = await useEntitlementStore.getState().activate(tokenFromQuery)
if (result.success) {
toast.success(t('common:pro.licenseActivated'))
useEntitlementStore.getState().openUpgradeTab()
return
}
}
const q = parsed.searchParams
const licenseKey =
q.get('licenseKey') || q.get('license_key') || q.get('token') || ''
const orderNo = q.get('orderNo') || q.get('order_no') || ''
const emailFromLink =
q.get('email') || q.get('customer_email') || q.get('customerEmail') || ''
const store = useEntitlementStore.getState()
const deviceId =
useEntitlementStore.getState().deviceId ||
store.deviceId ||
q.get('deviceId') ||
q.get('device_id') ||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
if (!deviceId) return
const res = await fetch(`${licenseServerBaseUrl()}/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
})
const data = (await res.json()) as { token?: string; error?: string }
if (data.token) {
const result = await useEntitlementStore.getState().activate(data.token)
if (emailFromLink) {
await store.setEmail(emailFromLink)
}
const email = (emailFromLink || store.email).trim()
const tryActivate = async (key: string) => {
const result = await store.activate(key, email || undefined)
if (result.success) {
toast.success(t('common:pro.licenseActivated'))
useEntitlementStore.getState().openUpgradeTab()
return
store.openUpgradeTab()
return true
}
return false
}
if (licenseKey) {
if (await tryActivate(licenseKey)) return
} else if (orderNo || email) {
toast.info(t('common:pro.claimPending'))
try {
const claimed = await claimPurchase({
customerEmail: email || undefined,
orderNo: orderNo || undefined,
deviceId,
})
if (await tryActivate(claimed.licenseKey)) return
} catch (error) {
logger.warn('claim purchase pending', error)
}
}
toast.info(t('common:pro.paymentPending'))
useEntitlementStore.getState().openUpgradeTab()
store.openUpgradeTab()
} catch (error) {
logger.warn('处理 Pro 深链接失败', error)
useEntitlementStore.getState().openUpgradeTab()
@@ -601,6 +623,14 @@ function App() {
if (!requirePro('serial')) return
setIsSerialConnectOpen(true)
}}
onRdpConnect={() => {
if (!requirePro('rdp')) return
setDesktopConnectProtocol('rdp')
}}
onVncConnect={() => {
if (!requirePro('vnc')) return
setDesktopConnectProtocol('vnc')
}}
/>
</Suspense>
+52 -31
View File
@@ -5,6 +5,8 @@
* - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放
* - 不叠加 CSS app-region 或 React 双击处理,避免一次双击切换两次
* - 标签栏空白区也由 Tauri 原生拖拽区处理
*
* RDP/VNC 全屏时只隐藏 chrome,保持 TabContent 挂载,避免会话 remount 立刻退出全屏。
*/
import type { ReactNode } from 'react'
@@ -15,7 +17,9 @@ import TabBar from '../components/layout/TabBar'
import TabContent from '../components/layout/TabContent'
import StatusBar from '../components/layout/StatusBar'
import type { Session } from '../stores'
import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore'
import { getDesktopPlatform } from '../platform/runtime'
import { startWindowDragging } from '../platform/tauri'
export interface AppShellProps {
activeTabId: string | null
@@ -42,7 +46,11 @@ function TitleBar() {
return (
<header
data-tauri-drag-region
className="bg-dark-900/95 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
onMouseDown={(event) => {
if (event.button !== 0) return
void startWindowDragging()
}}
className="relative z-20 bg-dark-900/95 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
>
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
<div data-tauri-drag-region className="flex h-full min-w-0 flex-1 items-center justify-center px-2" />
@@ -114,13 +122,18 @@ export default function AppShell({
}
const desktopSidebarHidden = !isMobile && sidebarCollapsed
const desktopFullscreen = useDesktopFullscreenStore((state) => state.active)
return (
<div className="flex h-screen flex-col overflow-hidden bg-dark-900 text-content-primary">
<TitleBar />
<div
className={`flex h-screen flex-col overflow-hidden text-content-primary ${
desktopFullscreen ? 'bg-black' : 'bg-dark-900'
}`}
>
{!desktopFullscreen && <TitleBar />}
<div className="flex min-h-0 flex-1 overflow-hidden">
{isMobile && mobileSidebarOpen && (
{!desktopFullscreen && isMobile && mobileSidebarOpen && (
<div
className="titlebar-no-drag fixed inset-0 z-40 bg-black/50 transition-opacity duration-300"
onClick={onCloseMobileSidebar}
@@ -128,32 +141,38 @@ export default function AppShell({
/>
)}
<aside
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
isMobile
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: desktopSidebarHidden
? 'w-0 overflow-hidden border-0'
: ''
} `}
aria-hidden={desktopSidebarHidden}
>
<Sidebar
onNewSession={handleSidebarNewSession}
onQuickConnect={handleSidebarQuickConnect}
onEditSession={handleSidebarEditSession}
onCollapse={isMobile ? undefined : onToggleSidebar}
/>
</aside>
{!desktopFullscreen && (
<aside
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
isMobile
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: desktopSidebarHidden
? 'w-0 overflow-hidden border-0'
: ''
} `}
aria-hidden={desktopSidebarHidden}
>
<Sidebar
onNewSession={handleSidebarNewSession}
onQuickConnect={handleSidebarQuickConnect}
onEditSession={handleSidebarEditSession}
onCollapse={isMobile ? undefined : onToggleSidebar}
/>
</aside>
)}
<main className="flex min-w-0 flex-1 flex-col bg-dark-800">
<div className="flex items-stretch">
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
<div className="min-w-0 flex-1">
<TabBar onOpenLauncher={onOpenLauncher} />
<main
className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}
>
{!desktopFullscreen && (
<div className="flex items-stretch">
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
{desktopSidebarHidden && <ExpandSidebarButton onClick={onToggleSidebar} />}
<div className="min-w-0 flex-1">
<TabBar onOpenLauncher={onOpenLauncher} />
</div>
</div>
</div>
)}
<div className="titlebar-no-drag min-h-0 flex-1 overflow-hidden">
<TabContent
@@ -167,9 +186,11 @@ export default function AppShell({
</main>
</div>
<div className="titlebar-no-drag">
<StatusBar />
</div>
{!desktopFullscreen && (
<div className="titlebar-no-drag">
<StatusBar />
</div>
)}
<div className="titlebar-no-drag">{children}</div>
</div>
+1
View File
@@ -7,6 +7,7 @@ interface AgentMarkdownProps {
}
/** 简单检测是否含 Markdown 结构(标题/表格/引用/列表/粗体) */
// eslint-disable-next-line react-refresh/only-export-components -- shared helper colocated with markdown renderer
export function looksLikeMarkdown(text: string): boolean {
const t = text.trim()
if (!t) return false
+1 -1
View File
@@ -222,7 +222,7 @@ export function CommandPalette({
}
return items
}, [sessions, snippets, addTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession, t])
}, [sessions, snippets, addTab, activeTabId, tabs, onNewSession, onQuickConnect, onOpenSettings, onEditSession, t])
// ── 过滤结果 ──
const filteredItems = useMemo(() => {
+53 -7
View File
@@ -2,7 +2,7 @@
* Professional modal shell — shared by dialogs across OpsXShell
*/
import { type ReactNode, useEffect, useId } from 'react'
import { type ReactNode, useEffect, useId, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from 'lucide-react'
import Button from './Button'
@@ -62,21 +62,65 @@ export default function Modal({
const resolvedConfirm = confirmText ?? t('action.confirm')
const resolvedCancel = cancelText ?? t('action.cancel')
const titleId = useId()
const panelRef = useRef<HTMLDivElement>(null)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
if (!isOpen) return
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
restoreFocusRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null
const focusables = () =>
Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
) ?? []
).filter((el) => !el.hasAttribute('disabled') && el.tabIndex !== -1)
requestAnimationFrame(() => {
const root = panelRef.current
if (!root) return
if (root.contains(document.activeElement)) return
const target =
root.querySelector<HTMLElement>('[data-autofocus]') ??
root.querySelector<HTMLElement>('input:not([disabled]), textarea, select') ??
focusables()[0]
target?.focus()
})
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopImmediatePropagation()
onCloseRef.current()
return
}
if (e.key !== 'Tab' || !panelRef.current) return
const items = focusables()
if (items.length === 0) return
const first = items[0]
const last = items[items.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
// Lock body scroll while open
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleEsc)
window.addEventListener('keydown', handleKey, true)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', handleEsc)
window.removeEventListener('keydown', handleKey, true)
restoreFocusRef.current?.focus()
}
}, [isOpen, onClose])
}, [isOpen])
if (!isOpen) return null
@@ -93,6 +137,7 @@ export default function Modal({
/>
<div
ref={panelRef}
className={`
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
flex flex-col
@@ -144,6 +189,7 @@ export default function Modal({
size="sm"
onClick={onConfirm}
loading={loading}
data-autofocus={isDanger ? true : undefined}
>
{resolvedConfirm}
</Button>
+1
View File
@@ -119,6 +119,7 @@ export function usePromptDialog() {
<p className="text-content-secondary mb-3">{state.message}</p>
<input
ref={inputRef}
data-autofocus
type={state.inputType ?? 'text'}
autoComplete={state.inputType === 'password' ? 'new-password' : 'off'}
value={state.value}
+17 -17
View File
@@ -18,31 +18,31 @@ const iconMap: Record<ToastType, typeof CheckCircle> = {
info: Info,
}
/** 类型 → 颜色样式映射 */
/** 类型 → 颜色样式映射(theme tokens,浅色/深色都可读) */
const colorMap: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
success: {
bg: 'bg-green-900/40',
border: 'border-green-700/60',
icon: 'text-green-400',
text: 'text-green-200',
bg: 'bg-feedback-success-bg',
border: 'border-feedback-success/35',
icon: 'text-feedback-success',
text: 'text-content-primary',
},
error: {
bg: 'bg-red-900/40',
border: 'border-red-700/60',
icon: 'text-red-400',
text: 'text-red-200',
bg: 'bg-feedback-error-bg',
border: 'border-feedback-error/35',
icon: 'text-feedback-error',
text: 'text-content-primary',
},
warning: {
bg: 'bg-surface-basember-900/40',
border: 'border-amber-700/60',
icon: 'text-amber-400',
text: 'text-amber-200',
bg: 'bg-feedback-warning-bg',
border: 'border-feedback-warning/35',
icon: 'text-feedback-warning',
text: 'text-content-primary',
},
info: {
bg: 'bg-blue-900/40',
border: 'border-blue-700/60',
icon: 'text-blue-400',
text: 'text-blue-200',
bg: 'bg-feedback-info-bg',
border: 'border-feedback-info/35',
icon: 'text-feedback-info',
text: 'text-content-primary',
},
}
+8 -8
View File
@@ -11,17 +11,17 @@ interface ToastContainerProps {
}
const iconMap: Record<ToastType, React.ReactNode> = {
info: <Info className="w-4 h-4 text-blue-400" />,
success: <CheckCircle className="w-4 h-4 text-green-400" />,
error: <XCircle className="w-4 h-4 text-red-400" />,
warning: <AlertTriangle className="w-4 h-4 text-yellow-400" />,
info: <Info className="w-4 h-4 text-feedback-info" />,
success: <CheckCircle className="w-4 h-4 text-feedback-success" />,
error: <XCircle className="w-4 h-4 text-feedback-error" />,
warning: <AlertTriangle className="w-4 h-4 text-feedback-warning" />,
}
const bgMap: Record<ToastType, string> = {
info: 'bg-blue-900/80 border-blue-700',
success: 'bg-green-900/80 border-green-700',
error: 'bg-red-900/80 border-red-700',
warning: 'bg-yellow-900/80 border-yellow-700',
info: 'bg-feedback-info-bg border-feedback-info/35',
success: 'bg-feedback-success-bg border-feedback-success/35',
error: 'bg-feedback-error-bg border-feedback-error/35',
warning: 'bg-feedback-warning-bg border-feedback-warning/35',
}
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
@@ -100,15 +100,15 @@ function ConnectionCard({ conn, onReconnect, onDisconnect, reconnectingId }: Con
<span
className={`text-xs px-1.5 py-0.5 rounded ${
conn.state === 'connected'
? 'bg-green-400/10 text-green-400'
? 'bg-feedback-success/10 text-feedback-success'
: conn.state === 'connecting' || conn.state === 'reconnecting'
? 'bg-yellow-400/10 text-yellow-400'
? 'bg-feedback-warning/10 text-feedback-warning'
: conn.state === 'background'
? 'bg-green-400/10 text-green-400 opacity-70'
? 'bg-feedback-success/10 text-feedback-success opacity-70'
: conn.state === 'suspended'
? 'bg-surface-active/10 text-txt-muted'
: conn.state === 'error'
? 'bg-red-400/10 text-red-400'
? 'bg-feedback-error/10 text-feedback-error'
: 'bg-surface-active/10 text-txt-muted'
}`}
>
@@ -200,9 +200,9 @@ function StatsOverview({ total, connected, background, error }: StatsBarProps) {
const { t } = useTranslation('common')
const items = [
{ label: t('connectionPanel.stat.total'), value: total, color: 'text-content-secondary' },
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-green-400' },
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-yellow-400' },
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-red-400' },
{ label: t('connectionPanel.stat.active'), value: connected, color: 'text-feedback-success' },
{ label: t('connectionPanel.stat.background'), value: background, color: 'text-feedback-warning' },
{ label: t('connectionPanel.stat.error'), value: error, color: 'text-feedback-error' },
]
return (
+277 -55
View File
@@ -11,10 +11,22 @@ import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
import type { ForwardRule, ForwardRuleType } from './forwardRules'
import {
DEFAULT_LOCAL_BIND_HOST,
DEFAULT_REMOTE_BIND_HOST,
formatHostPort,
isAllInterfacesBind,
parseHostPort,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
type ForwardRule,
type ForwardRuleType,
} from './forwardRules'
export const DEFAULT_DYNAMIC_PORT = 1080
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function resolveDynamicPort(port: unknown): number {
const value = Number(port)
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
@@ -25,6 +37,8 @@ export interface ForwardRuleDraft {
type: ForwardRuleType
name: string
localPort: number
localBindHost: string
remoteBindHost: string
remoteHost: string
remotePort: number
}
@@ -43,6 +57,8 @@ interface ForwardRuleDrawerProps {
rules?: ForwardRule[]
runtimes?: Map<string, ForwardRuleRuntime>
busy?: boolean
/** Current SSH session label, e.g. root@opsbase.cn:55555 — shown for local forward target. */
viaSession?: string
onChange: (next: Partial<ForwardRule>) => void
onClose: () => void
onSave: () => void
@@ -55,36 +71,74 @@ const EMPTY_DRAFT: ForwardRuleDraft = {
type: 'local',
name: '',
localPort: 0,
remoteHost: '',
localBindHost: DEFAULT_LOCAL_BIND_HOST,
remoteBindHost: DEFAULT_REMOTE_BIND_HOST,
remoteHost: '127.0.0.1',
remotePort: 0,
}
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function emptyForwardDraft(): ForwardRuleDraft {
return { ...EMPTY_DRAFT }
}
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
export function emptyDynamicDraft(name = 'SOCKS5'): ForwardRuleDraft {
return {
type: 'dynamic',
name,
localPort: DEFAULT_DYNAMIC_PORT,
localBindHost: '',
remoteBindHost: '',
remoteHost: '',
remotePort: 0,
}
}
function ruleSummary(rule: ForwardRule, t: (key: string, opts?: Record<string, unknown>) => string): string {
function ruleSummary(
rule: ForwardRule,
t: (key: string, opts?: Record<string, unknown>) => string,
boundPort?: number
): string {
if (rule.type === 'dynamic') {
return t('portForward.socksProxy', { port: resolveDynamicPort(rule.localPort) })
return t('portForward.socksProxy', { port: resolveDynamicPort(boundPort ?? rule.localPort) })
}
if (rule.type === 'remote') {
const bind = resolveRemoteBindHost(rule.remoteBindHost)
const remote = boundPort ?? rule.remotePort
const host = resolveForwardTargetHost(rule.remoteHost)
// Default remote bind is loopback — list as port only (matches ssh -R port:…).
if (bind === DEFAULT_REMOTE_BIND_HOST || bind === 'localhost') {
return t('portForward.remoteToLocalPort', {
remote,
host,
local: rule.localPort,
})
}
return t('portForward.remoteToLocal', {
remote: rule.remotePort,
bind,
remote,
host,
local: rule.localPort,
})
}
const host = rule.remoteHost || 'localhost'
return `127.0.0.1:${rule.localPort} → ${host}:${rule.remotePort}`
const bind = resolveLocalBindHost(rule.localBindHost)
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
const local = boundPort ?? rule.localPort
// Default bind is localhost — list as port only (matches ssh -L 8080:…).
if (bind === DEFAULT_LOCAL_BIND_HOST || bind === 'localhost') {
return t('portForward.localToRemotePort', {
local,
host,
remote: rule.remotePort,
})
}
return t('portForward.localToRemote', {
bind,
local,
host,
remote: rule.remotePort,
})
}
export default function ForwardRuleDrawer({
@@ -95,6 +149,7 @@ export default function ForwardRuleDrawer({
rules,
runtimes,
busy = false,
viaSession,
onChange,
onClose,
onSave,
@@ -152,17 +207,33 @@ export default function ForwardRuleDrawer({
type: 'dynamic',
name: nextName,
localPort: DEFAULT_DYNAMIC_PORT,
localBindHost: '',
remoteBindHost: '',
remoteHost: '',
remotePort: 0,
})
return
}
const nextLocalPort = draft.type === 'dynamic' ? 8080 : draft.localPort || 8080
const nextRemotePort =
draft.type === 'dynamic'
? next === 'remote'
? 18080
: 80
: draft.remotePort || (next === 'remote' ? 18080 : 80)
onChange({
...draft,
type: next,
name: nextName,
localPort: draft.type === 'dynamic' ? 0 : draft.localPort,
localPort: nextLocalPort,
localBindHost:
next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
remoteBindHost:
next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
remoteHost: resolveForwardTargetHost(draft.remoteHost) || '127.0.0.1',
remotePort: nextRemotePort,
})
}
@@ -292,7 +363,8 @@ export default function ForwardRuleDrawer({
) : (
<div className="space-y-1.5">
{localRemoteRules.map((rule) => {
const running = Boolean(runtimes?.get(rule.id)?.running)
const runtime = runtimes?.get(rule.id)
const running = Boolean(runtime?.running)
return (
<div
key={rule.id}
@@ -339,7 +411,7 @@ export default function ForwardRuleDrawer({
)}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-txt-muted">
{ruleSummary(rule, t)}
{ruleSummary(rule, t, runtime?.boundPort)}
</p>
</div>
<div className="flex shrink-0 gap-0.5">
@@ -456,51 +528,201 @@ export default function ForwardRuleDrawer({
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ui-label">
{type === 'remote' ? t('portForward.localTargetPort') : t('portForward.localListenPort')}
</label>
<input
type="number"
min={1}
max={65535}
value={draft.localPort || ''}
onChange={(e) => onChange({ ...draft, localPort: Number(e.target.value) })}
placeholder={t('portForward.portPlaceholder')}
className="ui-input font-mono"
inputMode="numeric"
/>
</div>
<div>
<label className="ui-label">
{type === 'remote' ? t('portForward.remoteListenPort') : t('portForward.remoteTargetPort')}
</label>
<input
type="number"
min={1}
max={65535}
value={draft.remotePort || ''}
onChange={(e) => onChange({ ...draft, remotePort: Number(e.target.value) })}
placeholder={t('portForward.portPlaceholder')}
className="ui-input font-mono"
inputMode="numeric"
/>
</div>
</div>
{type === 'local' && (
<div>
<label className="ui-label">{t('portForward.remoteHost')}</label>
<input
type="text"
value={draft.remoteHost || ''}
onChange={(e) => onChange({ ...draft, remoteHost: e.target.value })}
placeholder={t('portForward.remoteHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
</div>
{type === 'local' ? (
<>
{viaSession ? (
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
{t('portForward.viaSession', { target: viaSession })}
</p>
) : null}
<div>
<label className="ui-label">{t('portForward.localListenPort')}</label>
<input
type="number"
min={1}
max={65535}
value={draft.localPort || ''}
onChange={(e) =>
onChange({
...draft,
localPort: Number(e.target.value),
localBindHost: resolveLocalBindHost(draft.localBindHost),
})
}
placeholder="8080"
className="ui-input font-mono"
inputMode="numeric"
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localListenPortHint')}
</p>
</div>
<div>
<label className="ui-label">{t('portForward.targetAddress')}</label>
<input
type="text"
value={formatHostPort(
resolveForwardTargetHost(draft.remoteHost),
draft.remotePort
)}
onChange={(e) => {
const raw = e.target.value.trim()
const parsed = parseHostPort(raw)
if (parsed) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(parsed.host),
remotePort: parsed.port,
})
return
}
const colon = raw.lastIndexOf(':')
if (colon > 0) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
remotePort: Number(raw.slice(colon + 1)) || 0,
})
} else {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
})
}
}}
placeholder={t('portForward.targetAddressPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{viaSession
? t('portForward.targetAddressHintVia', { target: viaSession })
: t('portForward.targetAddressHint')}
</p>
</div>
<details className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2">
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
{t('portForward.localBindAdvanced')}
</summary>
<div className="mt-2">
<label className="ui-label">{t('portForward.localBindHost')}</label>
<input
type="text"
value={resolveLocalBindHost(draft.localBindHost)}
onChange={(e) =>
onChange({
...draft,
localBindHost: resolveLocalBindHost(e.target.value),
})
}
placeholder={t('portForward.localBindHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localBindHostHint')}
</p>
</div>
</details>
</>
) : (
<>
{viaSession ? (
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
{t('portForward.viaSessionRemote', { target: viaSession })}
</p>
) : null}
<div>
<label className="ui-label">{t('portForward.remoteListenPort')}</label>
<input
type="number"
min={1}
max={65535}
value={draft.remotePort || ''}
onChange={(e) =>
onChange({
...draft,
remotePort: Number(e.target.value),
remoteBindHost: resolveRemoteBindHost(draft.remoteBindHost),
})
}
placeholder="18080"
className="ui-input font-mono"
inputMode="numeric"
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.remoteListenPortHint')}
</p>
</div>
<div>
<label className="ui-label">{t('portForward.localTargetAddress')}</label>
<input
type="text"
value={formatHostPort(
resolveForwardTargetHost(draft.remoteHost),
draft.localPort
)}
onChange={(e) => {
const raw = e.target.value.trim()
const parsed = parseHostPort(raw)
if (parsed) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(parsed.host),
localPort: parsed.port,
})
return
}
const colon = raw.lastIndexOf(':')
if (colon > 0) {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw.slice(0, colon)),
localPort: Number(raw.slice(colon + 1)) || 0,
})
} else {
onChange({
...draft,
remoteHost: resolveForwardTargetHost(raw || '127.0.0.1'),
})
}
}}
placeholder={t('portForward.localTargetAddressPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.localTargetAddressHint')}
</p>
</div>
<details
className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2"
open={isAllInterfacesBind(resolveRemoteBindHost(draft.remoteBindHost))}
>
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
{t('portForward.remoteBindAdvanced')}
</summary>
<div className="mt-2">
<label className="ui-label">{t('portForward.remoteBindHost')}</label>
<input
type="text"
value={resolveRemoteBindHost(draft.remoteBindHost)}
onChange={(e) =>
onChange({
...draft,
remoteBindHost: resolveRemoteBindHost(e.target.value),
})
}
placeholder={t('portForward.remoteBindHostPlaceholder')}
className="ui-input font-mono"
spellCheck={false}
/>
<p className="mt-1 text-[10px] leading-relaxed text-txt-faint">
{t('portForward.remoteBindHostHint')}
</p>
</div>
</details>
</>
)}
</section>
)}
+109 -17
View File
@@ -4,12 +4,13 @@
* 支持本地端口转发、远程端口转发和动态 SOCKS5 代理
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTabStore } from '../../stores/tabStore'
import { useConnectionPool } from '../../stores/connection'
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { translateErrorMessage } from '../../i18n/format'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
@@ -21,9 +22,19 @@ import {
duplicateForwardRule,
parseForwardRules,
portForwardStorageKey,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
runtimeMap,
type ForwardRule,
} from './forwardRules'
import {
forgetForwardIntent,
forwardIntentKey,
rememberForwardIntent,
restoreForwardIntent,
tunnelCoversRule,
} from './forwardIntent'
interface PortForwardViewProps {
tabId: string
@@ -41,6 +52,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
const connectionId = currentTab?.connectionInstance?.connectionId
const connection = connectionId ? pool.getConnection(connectionId) : null
const storageKey = portForwardStorageKey(currentTab?.sessionId, tabId, connectionId)
const intentKey = forwardIntentKey(currentTab?.sessionId, tabId)
const [rules, setRules] = useState<ForwardRule[]>([])
const [tunnels, setTunnels] = useState<SSHForwardStatus[]>([])
@@ -115,6 +127,8 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
...emptyForwardDraft(),
type: 'local',
name: t('common:portForward.defaultLocalName'),
localBindHost: '127.0.0.1',
remoteHost: '127.0.0.1',
})
setShowAddModal(true)
}
@@ -127,11 +141,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
}
if (newRule.type !== 'dynamic' && !validPort(newRule.remotePort)) {
setDrawerError(t('common:portForward.errorTargetPort'))
setDrawerError(
newRule.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorRemoteListenAddress')
)
return
}
if (newRule.type === 'local' && !newRule.remoteHost?.trim()) {
setDrawerError(t('common:portForward.errorRemoteHost'))
if (newRule.type !== 'dynamic' && !newRule.remoteHost?.trim()) {
setDrawerError(
newRule.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorLocalTargetAddress')
)
return
}
if (
@@ -148,7 +170,12 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
type: newRule.type as 'local' | 'remote' | 'dynamic',
name: newRule.name.trim(),
localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!,
remoteHost: newRule.remoteHost?.trim(),
localBindHost:
newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined,
remoteBindHost:
newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
remoteHost:
newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
remotePort: newRule.remotePort,
}
@@ -172,17 +199,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
result = await window.opsxApi?.sshForwardLocal(
connectionId,
rule.localPort,
rule.remoteHost || '',
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi?.sshForwardRemote(
connectionId,
rule.remotePort || 0,
'127.0.0.1',
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi?.sshForwardDynamic(
@@ -193,18 +222,43 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
}
if (result?.success) {
rememberForwardIntent(intentKey, rule)
await refreshTunnels()
return true
}
setError(result?.error || t('common:portForward.errorStartFailed'))
setError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
)
} catch (err: unknown) {
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStartFailed'))
const raw = err instanceof Error ? err.message : String(err)
setError(raw ? translateErrorMessage(raw) : t('common:portForward.errorStartFailed'))
} finally {
setIsLoading(false)
}
return false
}
const startForwardRef = useRef(startForward)
startForwardRef.current = startForward
const previousConnectionRef = useRef<string | undefined>(undefined)
useEffect(() => {
if (!connectionId) return
const previous = previousConnectionRef.current
previousConnectionRef.current = connectionId
if (!previous || previous === connectionId) return
void (async () => {
const listed = await window.opsxApi?.sshForwardList(connectionId)
const tunnels = listed?.success ? listed.tunnels || [] : []
await restoreForwardIntent(
intentKey,
(rule) => tunnelCoversRule(tunnels, rule),
(rule) => startForwardRef.current(rule)
)
})()
}, [connectionId, intentKey])
const quickStartSocks = async () => {
const existing = rules.find((rule) => rule.type === 'dynamic')
if (existing) {
@@ -230,11 +284,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
setIsLoading(true)
setError(null)
try {
const result = await window.opsxApi?.sshForwardStop(rule.id)
let result = await window.opsxApi?.sshForwardStop(rule.id)
if (!result?.success && connectionId) {
const listed = await window.opsxApi?.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
if (match) {
result = await window.opsxApi?.sshForwardStop(match.forwardId)
}
}
if (!result?.success) {
setError(result?.error || t('common:portForward.errorStopFailed'))
return false
}
forgetForwardIntent(intentKey, rule.id)
await refreshTunnels()
return true
} catch (err: unknown) {
@@ -416,14 +478,44 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
<span>{t('common:portForward.socksProxy', { port: boundPort })}</span>
) : rule.type === 'local' ? (
<span>
127.0.0.1:{boundPort} → {rule.remoteHost}:{rule.remotePort}
{(() => {
const bind = resolveLocalBindHost(rule.localBindHost)
const host = (rule.remoteHost || '').trim() || '127.0.0.1'
if (bind === '127.0.0.1' || bind === 'localhost') {
return t('common:portForward.localToRemotePort', {
local: boundPort,
host,
remote: rule.remotePort,
})
}
return t('common:portForward.localToRemote', {
bind,
local: boundPort,
host,
remote: rule.remotePort,
})
})()}
</span>
) : (
<span>
{t('common:portForward.remoteToLocal', {
remote: runtime?.boundPort || rule.remotePort,
local: rule.localPort,
})}
{(() => {
const bind = resolveRemoteBindHost(rule.remoteBindHost)
const remote = runtime?.boundPort || rule.remotePort
const host = resolveForwardTargetHost(rule.remoteHost)
if (bind === '127.0.0.1' || bind === 'localhost') {
return t('common:portForward.remoteToLocalPort', {
remote,
host,
local: rule.localPort,
})
}
return t('common:portForward.remoteToLocal', {
bind,
remote,
host,
local: rule.localPort,
})
})()}
</span>
)}
</div>
@@ -0,0 +1,80 @@
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
import type { ForwardRule } from './forwardRules'
/**
* Process-local "this rule should stay up" intent.
* Never persisted — app restart must not resurrect listeners.
* Survives SSH reconnect so the same session can restore forwards.
*/
const remembered = new Map<string, Map<string, ForwardRule>>()
const resumeLocks = new Map<string, Promise<number>>()
export function forwardIntentKey(sessionId: string | undefined, tabId: string): string {
return sessionId ? `session:${sessionId}` : `tab:${tabId}`
}
export function rememberForwardIntent(key: string, rule: ForwardRule): void {
let rules = remembered.get(key)
if (!rules) {
rules = new Map()
remembered.set(key, rules)
}
rules.set(rule.id, { ...rule })
}
export function forgetForwardIntent(key: string, ruleId: string): void {
remembered.get(key)?.delete(ruleId)
}
export function listForwardIntent(key: string): ForwardRule[] {
return [...(remembered.get(key)?.values() ?? [])]
}
export function tunnelCoversRule(tunnels: SSHForwardStatus[], rule: ForwardRule): boolean {
return tunnels.some((item) => {
if (!item.running || item.kind !== rule.type) return false
if (item.forwardId === rule.id) return true
// One dynamic SOCKS per SSH host.
if (rule.type === 'dynamic') return true
// boundPort is the listen port: local → localPort, remote → remotePort.
if (rule.type === 'remote') {
return item.boundPort === rule.remotePort
}
return item.boundPort === rule.localPort
})
}
/** Drop all remembered start intents for a tab/session (tab close). */
export function clearForwardIntent(key: string): void {
remembered.delete(key)
resumeLocks.delete(key)
}
/** Start remembered rules that are not running. Concurrent calls share one pass. */
export function restoreForwardIntent(
key: string,
isRunning: (rule: ForwardRule) => boolean,
start: (rule: ForwardRule) => Promise<boolean>
): Promise<number> {
const existing = resumeLocks.get(key)
if (existing) return existing
const work = (async () => {
let restored = 0
for (const rule of listForwardIntent(key)) {
if (isRunning(rule)) continue
if (await start(rule)) restored += 1
}
return restored
})().finally(() => {
resumeLocks.delete(key)
})
resumeLocks.set(key, work)
return work
}
export function resetForwardIntentForTests(): void {
remembered.clear()
resumeLocks.clear()
}
+88 -1
View File
@@ -3,15 +3,96 @@ import i18n from '../../i18n'
export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
/** Default local listen bind for `-L` (this machine only). */
export const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'
/** Default remote listen bind for `-R` (only the SSH server itself). */
export const DEFAULT_REMOTE_BIND_HOST = '127.0.0.1'
export interface ForwardRule {
id: string
type: ForwardRuleType
name: string
localPort: number
/** Local listen address for local forwards (127.0.0.1 / LAN IP / 0.0.0.0). */
localBindHost?: string
/** Remote listen address for remote forwards (127.0.0.1 or 0.0.0.0; needs GatewayPorts for all-interfaces). */
remoteBindHost?: string
/** Destination host in OpenSSH `-L` / `-R` sense (not a listen bind).
* - local: host as seen from the SSH server
* - remote: host as seen from this machine (service to expose; localhost or LAN IP)
*/
remoteHost?: string
remotePort?: number
}
export function resolveLocalBindHost(host: unknown): string {
const value = typeof host === 'string' ? host.trim() : ''
return value || DEFAULT_LOCAL_BIND_HOST
}
/** Normalize remote `-R` listen bind. Default loopback; `0.0.0.0` opens all remote interfaces. */
export function resolveRemoteBindHost(host: unknown): string {
const value = typeof host === 'string' ? host.trim() : ''
if (!value || value.toLowerCase() === 'localhost') return DEFAULT_REMOTE_BIND_HOST
return value
}
export function isAllInterfacesBind(host: string): boolean {
const value = host.trim().toLowerCase()
return value === '0.0.0.0' || value === '*' || value === '::' || value === '[::]'
}
/** Format `host:port` for listen/target address fields. */
export function formatHostPort(host: string, port: number | undefined): string {
const h = host.trim() || DEFAULT_LOCAL_BIND_HOST
if (!port || port < 1) return `${h}:`
return `${h}:${port}`
}
/** Parse `host:port` or `[ipv6]:port`. Returns null if invalid. */
export function parseHostPort(value: string): { host: string; port: number } | null {
const trimmed = value.trim()
if (!trimmed) return null
let host: string
let portText: string
if (trimmed.startsWith('[')) {
const end = trimmed.indexOf(']')
if (end < 1 || trimmed[end + 1] !== ':') return null
host = trimmed.slice(1, end)
portText = trimmed.slice(end + 2)
} else {
const lastColon = trimmed.lastIndexOf(':')
if (lastColon <= 0) return null
host = trimmed.slice(0, lastColon)
portText = trimmed.slice(lastColon + 1)
}
const port = Number(portText)
if (!host.trim() || !Number.isInteger(port) || port < 1 || port > 65535) return null
return { host: host.trim(), port }
}
/**
* Target host is where the SSH server connects (not a listen bind).
* `0.0.0.0` / `::` are not valid connect destinations — treat as remote loopback.
*/
export function resolveForwardTargetHost(host: unknown): string {
const value = typeof host === 'string' ? host.trim() : ''
if (!value || isListenOnlyAddress(value)) return '127.0.0.1'
return value
}
/** True for addresses that only make sense as listen binds, not TCP connect targets. */
export function isListenOnlyAddress(host: string): boolean {
const value = host.trim().toLowerCase()
return value === '0.0.0.0' || value === '::' || value === '[::]'
}
/** @deprecated Use resolveForwardTargetHost; kept for call-site clarity. */
export function isInvalidForwardTargetHost(host: string): boolean {
return isListenOnlyAddress(host)
}
export function portForwardStorageKey(
sessionId: string | undefined,
tabId: string,
@@ -54,8 +135,14 @@ export function parseForwardRules(value: unknown): ForwardRule[] {
type: type as ForwardRuleType,
localPort,
}
if (type === 'local') {
parsed.localBindHost = resolveLocalBindHost(rule.localBindHost)
}
if (type === 'remote') {
parsed.remoteBindHost = resolveRemoteBindHost(rule.remoteBindHost)
}
if (typeof rule.remoteHost === 'string' && rule.remoteHost.trim()) {
parsed.remoteHost = rule.remoteHost.trim()
parsed.remoteHost = resolveForwardTargetHost(rule.remoteHost)
}
const remotePort = Number(rule.remotePort)
if (Number.isInteger(remotePort) && remotePort >= 1 && remotePort <= 65535) {
+18 -24
View File
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Monitor, Cast } from 'lucide-react'
import Modal from '../common/Modal'
import DesktopResolutionPicker from './DesktopResolutionPicker'
import type { DesktopProtocol, RdpConfig, VncConfig } from '../../../shared/types/desktop.types'
import { clampDesktopResolution, getDisplayResolution } from '../../utils/desktopResolution'
interface DesktopConnectDialogProps {
protocol: DesktopProtocol
@@ -32,6 +34,9 @@ export default function DesktopConnectDialog({
if (!isOpen) return
setPort(isRdp ? 3389 : 5900)
setError(null)
const display = getDisplayResolution()
setWidth(display.width)
setHeight(display.height)
}, [isOpen, isRdp])
const title = isRdp ? t('connect.rdpTitle') : t('connect.vncTitle')
@@ -46,6 +51,7 @@ export default function DesktopConnectDialog({
setError(t('connect.errorUsernameRequired'))
return
}
const resolution = clampDesktopResolution(width, height)
if (isRdp) {
onConnect('rdp', {
host: host.trim(),
@@ -53,8 +59,8 @@ export default function DesktopConnectDialog({
username: username.trim(),
password,
domain: domain.trim() || undefined,
width,
height,
width: resolution.width,
height: resolution.height,
security: 'nla',
})
} else {
@@ -62,8 +68,8 @@ export default function DesktopConnectDialog({
host: host.trim(),
port,
password: password || undefined,
width,
height,
width: resolution.width,
height: resolution.height,
})
}
onClose()
@@ -145,26 +151,14 @@ export default function DesktopConnectDialog({
autoComplete="off"
/>
</div>
<div>
<label htmlFor="desktop-width" className="ui-label">{t('connect.width')}</label>
<input
id="desktop-width"
type="number"
value={width}
onChange={(event) => setWidth(Number(event.target.value) || 1920)}
className="ui-input font-mono"
/>
</div>
<div>
<label htmlFor="desktop-height" className="ui-label">{t('connect.height')}</label>
<input
id="desktop-height"
type="number"
value={height}
onChange={(event) => setHeight(Number(event.target.value) || 1080)}
className="ui-input font-mono"
/>
</div>
<DesktopResolutionPicker
width={width}
height={height}
onChange={(next) => {
setWidth(next.width)
setHeight(next.height)
}}
/>
</section>
<p className="text-[11px] text-txt-faint leading-relaxed">
{isRdp ? t('connect.rdpFootnote') : t('connect.vncFootnote')}
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
DESKTOP_RESOLUTION_PRESETS,
clampDesktopResolution,
formatResolution,
matchPresetId,
type DesktopResolution,
} from '../../utils/desktopResolution'
interface DesktopResolutionPickerProps {
width: number
height: number
onChange: (next: DesktopResolution) => void
ns?: 'desktop' | 'sessions'
resolutionLabelKey?: string
widthLabelKey?: string
heightLabelKey?: string
customKey?: string
}
export default function DesktopResolutionPicker({
width,
height,
onChange,
ns = 'desktop',
resolutionLabelKey = ns === 'desktop' ? 'connect.resolution' : 'dialog.resolution',
widthLabelKey = ns === 'desktop' ? 'connect.width' : 'dialog.width',
heightLabelKey = ns === 'desktop' ? 'connect.height' : 'dialog.height',
customKey = ns === 'desktop' ? 'connect.resolutionCustom' : 'dialog.resolutionCustom',
}: DesktopResolutionPickerProps) {
const { t } = useTranslation(ns)
const current = clampDesktopResolution(width, height)
const matched = matchPresetId(current)
const [forceCustom, setForceCustom] = useState(false)
const selected = forceCustom || matched === 'custom' ? 'custom' : matched
const showCustom = selected === 'custom'
return (
<div className="space-y-2 sm:col-span-3">
<div>
<label htmlFor={`${ns}-desktop-resolution`} className="ui-label">
{t(resolutionLabelKey)}
</label>
<select
id={`${ns}-desktop-resolution`}
className="ui-input font-mono"
value={selected}
onChange={(event) => {
const value = event.target.value
if (value === 'custom') {
setForceCustom(true)
onChange(current)
return
}
const preset = DESKTOP_RESOLUTION_PRESETS.find((item) => item.id === value)
if (!preset) return
setForceCustom(false)
onChange({ width: preset.width, height: preset.height })
}}
>
{DESKTOP_RESOLUTION_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{formatResolution(preset)}
</option>
))}
<option value="custom">{t(customKey)}</option>
</select>
</div>
{showCustom && (
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor={`${ns}-desktop-width`} className="ui-label">
{t(widthLabelKey)}
</label>
<input
id={`${ns}-desktop-width`}
type="number"
min={200}
max={8192}
step={2}
value={width}
onChange={(event) => {
setForceCustom(true)
onChange(clampDesktopResolution(Number(event.target.value) || current.width, height))
}}
className="ui-input font-mono"
/>
</div>
<div>
<label htmlFor={`${ns}-desktop-height`} className="ui-label">
{t(heightLabelKey)}
</label>
<input
id={`${ns}-desktop-height`}
type="number"
min={200}
max={8192}
value={height}
onChange={(event) => {
setForceCustom(true)
onChange(clampDesktopResolution(width, Number(event.target.value) || current.height))
}}
className="ui-input font-mono"
/>
</div>
</div>
)}
</div>
)
}
+291 -94
View File
@@ -1,16 +1,18 @@
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import {
Cast,
ClipboardPaste,
FolderInput,
FolderOpen,
Maximize2,
Minimize2,
Monitor,
RefreshCw,
Unplug,
} from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
import type {
DesktopFrameEvent,
DesktopPointerButton,
@@ -28,6 +30,7 @@ import {
rdpDisconnect,
rdpInputKey,
rdpInputPointer,
rdpResize,
rdpUploadFile,
} from '../../platform/rdp'
import {
@@ -41,6 +44,7 @@ import {
vncInputPointer,
} from '../../platform/vnc'
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
import { clampDesktopResolution } from '../../utils/desktopResolution'
interface DesktopSessionViewProps {
tabId: string
@@ -70,6 +74,7 @@ function decodeRgbaBase64(base64: string): Uint8ClampedArray<ArrayBuffer> {
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
const { t } = useTranslation('desktop')
const canvasRef = useRef<HTMLCanvasElement>(null)
const paneRef = useRef<HTMLDivElement>(null)
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
const nativeSessionIdRef = useRef<string | null>(null)
const isActiveRef = useRef(isActive)
@@ -77,7 +82,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const buttonsRef = useRef({ left: false, middle: false, right: false })
const pointerRafRef = useRef<number | null>(null)
const paintRafRef = useRef<number | null>(null)
const hideBarTimerRef = useRef<number | null>(null)
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
const lastRemoteResizeRef = useRef<{ width: number; height: number } | null>(null)
const remoteResizeTimerRef = useRef<number | null>(null)
const connectedRef = useRef(false)
const pendingPointerRef = useRef<{
x: number
y: number
@@ -87,10 +96,17 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
const sessions = useSessionStore((state) => state.sessions)
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
const fullscreenActive = useDesktopFullscreenStore(
(state) => state.active && state.tabId === tabId
)
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
const [statusMessage, setStatusMessage] = useState<string>(() => t('status.connecting'))
const [error, setError] = useState<string | null>(null)
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
const [chromeVisible, setChromeVisible] = useState(true)
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
isActiveRef.current = isActive
@@ -203,6 +219,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
updateTabStatus(tabId, 'connecting')
setStatusMessage(t('status.connecting'))
setError(null)
connectedRef.current = false
lastRemoteResizeRef.current = null
void (async () => {
try {
@@ -210,13 +228,20 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
unlistenStatus = await onRdpStatus((payload) => {
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
if (payload.width && payload.height) {
setFbSize({ width: payload.width, height: payload.height })
lastRemoteResizeRef.current = { width: payload.width, height: payload.height }
}
if (payload.status === 'connected') {
connectedRef.current = true
updateTabStatus(tabId, 'connected')
setStatusMessage(payload.message || t('status.connected'))
} else if (payload.status === 'error') {
connectedRef.current = false
updateTabStatus(tabId, 'error')
setError(payload.message || t('status.connectFailed'))
} else if (payload.status === 'disconnected') {
connectedRef.current = false
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
} else if (payload.message) {
@@ -288,6 +313,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
return () => {
disposed = true
connectedRef.current = false
if (remoteResizeTimerRef.current != null) {
window.clearTimeout(remoteResizeTimerRef.current)
remoteResizeTimerRef.current = null
}
if (paintRafRef.current != null) {
window.cancelAnimationFrame(paintRafRef.current)
paintRafRef.current = null
@@ -311,6 +341,123 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
canvasRef.current?.focus()
}, [isActive])
// Fit canvas to pane; for RDP also request remote dynamic resolution (mstsc / FreeRDP style).
useEffect(() => {
const pane = paneRef.current
const canvas = canvasRef.current
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
const updateFit = () => {
const availW = Math.max(1, pane.clientWidth)
const availH = Math.max(1, pane.clientHeight)
const scale = Math.min(availW / fbSize.width, availH / fbSize.height)
const displayW = Math.max(1, Math.floor(fbSize.width * scale))
const displayH = Math.max(1, Math.floor(fbSize.height * scale))
canvas.style.width = `${displayW}px`
canvas.style.height = `${displayH}px`
}
const requestRemoteResize = () => {
if (protocol !== 'rdp' || !connectedRef.current || !isActive) return
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
const next = clampDesktopResolution(pane.clientWidth, pane.clientHeight)
const last = lastRemoteResizeRef.current
if (last && last.width === next.width && last.height === next.height) return
// Skip tiny panes (drawer/animation) that would thrash the session.
if (next.width < 640 || next.height < 480) return
lastRemoteResizeRef.current = next
void rdpResize(sessionId, next.width, next.height).catch(() => {
// Allow retry on next size change if Display Control was not ready.
lastRemoteResizeRef.current = last
})
}
const onPaneResize = () => {
updateFit()
if (remoteResizeTimerRef.current != null) {
window.clearTimeout(remoteResizeTimerRef.current)
}
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, 280)
}
updateFit()
// After fullscreen / maximize layout settles, request matching remote size once.
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, fullscreenActive ? 120 : 280)
const observer = new ResizeObserver(onPaneResize)
observer.observe(pane)
return () => {
observer.disconnect()
if (remoteResizeTimerRef.current != null) {
window.clearTimeout(remoteResizeTimerRef.current)
remoteResizeTimerRef.current = null
}
}
}, [fbSize.height, fbSize.width, fullscreenActive, isActive, protocol])
// Leave OS fullscreen when this tab is hidden or unmounted.
useEffect(() => {
if (!isActive && fullscreenActive) {
void exitFullscreen()
}
}, [exitFullscreen, fullscreenActive, isActive])
useEffect(() => {
return () => {
if (hideBarTimerRef.current != null) {
window.clearTimeout(hideBarTimerRef.current)
}
// Only exit when this session view is truly torn down (tab closed), not on HMR-only paths.
const state = useDesktopFullscreenStore.getState()
if (state.active && state.tabId === tabId) {
const stillOpen = useTabStore.getState().tabs.some((item) => item.id === tabId)
if (!stillOpen) void state.exit()
}
}
}, [tabId])
useEffect(() => {
if (!fullscreenActive) {
setChromeVisible(true)
if (hideBarTimerRef.current != null) {
window.clearTimeout(hideBarTimerRef.current)
hideBarTimerRef.current = null
}
return
}
// If the user leaves OS fullscreen via system UI, drop session chrome-hide.
const timer = window.setInterval(() => {
void (async () => {
try {
const state = useDesktopFullscreenStore.getState()
if (!state.active || state.tabId !== tabId) return
if (Date.now() - state.enteredAt < 1600) return
const osFs = await window.opsxApi?.isFullscreen?.()
if (osFs === false) {
useDesktopFullscreenStore.setState({ active: false, tabId: null, enteredAt: 0 })
}
} catch {
// ignore
}
})()
}, 1000)
return () => window.clearInterval(timer)
}, [fullscreenActive, tabId])
const scheduleHideChrome = useCallback(() => {
if (!fullscreenActive) return
if (hideBarTimerRef.current != null) window.clearTimeout(hideBarTimerRef.current)
hideBarTimerRef.current = window.setTimeout(() => {
setChromeVisible(false)
hideBarTimerRef.current = null
}, 1800)
}, [fullscreenActive])
const revealChrome = useCallback(() => {
setChromeVisible(true)
scheduleHideChrome()
}, [scheduleHideChrome])
const flushPointer = () => {
pointerRafRef.current = null
const sessionId = nativeSessionIdRef.current
@@ -383,6 +530,21 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
// Local fullscreen controls — never forward to the remote desktop.
if (event.key === 'F11') {
event.preventDefault()
event.stopPropagation()
if (pressed) void toggleFullscreen(tabId)
return
}
if (fullscreenActive && event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (pressed) void exitFullscreen()
return
}
event.preventDefault()
const isPasteChord =
@@ -434,20 +596,6 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
}
}
const disconnectSession = async () => {
const sessionId = nativeSessionIdRef.current
if (!sessionId) return
try {
if (protocol === 'rdp') await rdpDisconnect(sessionId)
else await vncDisconnect(sessionId)
nativeSessionIdRef.current = null
updateTabStatus(tabId, 'disconnected')
setStatusMessage(t('status.disconnected'))
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
}
}
const Icon = protocol === 'rdp' ? Monitor : Cast
const hostLabel =
resolvedConfig && 'host' in resolvedConfig
@@ -458,91 +606,140 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
const toolBtn =
'inline-flex h-7 w-7 items-center justify-center rounded-md text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-40'
return (
<div className="h-full flex flex-col bg-dark-900">
<div className="flex items-center gap-2 border-b border-dark-700/50 bg-dark-800/80 px-2.5 py-1.5 text-xs">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
{hostLabel}
</span>
<span className="tabular-nums text-txt-faint">
{fbSize.width}×{fbSize.height}
</span>
{tab?.connectionStatus === 'connecting' ? (
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
) : (
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
}`}
/>
const toolbar = (
<div
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
fullscreenActive
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
: 'border-b border-dark-700/50 bg-dark-800/80'
}`}
onMouseEnter={() => {
if (fullscreenActive) {
setChromeVisible(true)
if (hideBarTimerRef.current != null) {
window.clearTimeout(hideBarTimerRef.current)
hideBarTimerRef.current = null
}
}
}}
onMouseLeave={() => {
if (fullscreenActive) scheduleHideChrome()
}}
>
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
{hostLabel}
</span>
<span className="tabular-nums text-txt-faint">
{fbSize.width}×{fbSize.height}
</span>
{tab?.connectionStatus === 'connecting' ? (
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
) : (
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
}`}
/>
)}
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
{fullscreenActive && (
<span className="hidden shrink-0 text-[10px] text-txt-faint sm:inline">{t('toolbar.exitFullscreenHint')}</span>
)}
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => void pasteToRemote()}
title={t('toolbar.pasteClipboard')}
aria-label={t('toolbar.pasteAria')}
>
<ClipboardPaste className="h-3.5 w-3.5" />
</button>
{protocol === 'rdp' && (
<>
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => fileInputRef.current?.click()}
title={t('toolbar.shareDrive')}
aria-label={t('toolbar.shareDriveAria')}
>
<FolderInput className="h-3.5 w-3.5" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(event) => {
void onShareFileSelected(event.target.files)
event.target.value = ''
}}
/>
<button
type="button"
className={toolBtn}
onClick={() => void openSharedFolder()}
title={t('toolbar.openShareDir')}
aria-label={t('toolbar.openShareDirAria')}
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</>
)}
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => void pasteToRemote()}
title={t('toolbar.pasteClipboard')}
aria-label={t('toolbar.pasteAria')}
>
<ClipboardPaste className="h-3.5 w-3.5" />
</button>
{protocol === 'rdp' && (
<>
<button
type="button"
className={toolBtn}
disabled={!connected}
onClick={() => fileInputRef.current?.click()}
title={t('toolbar.shareDrive')}
aria-label={t('toolbar.shareDriveAria')}
>
<FolderInput className="h-3.5 w-3.5" />
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(event) => {
void onShareFileSelected(event.target.files)
event.target.value = ''
}}
/>
<button
type="button"
className={toolBtn}
onClick={() => void openSharedFolder()}
title={t('toolbar.openShareDir')}
aria-label={t('toolbar.openShareDirAria')}
>
<FolderOpen className="h-3.5 w-3.5" />
</button>
</>
)}
<button
type="button"
className={`${toolBtn} text-red-400 hover:bg-red-500/15 hover:text-red-300`}
disabled={!connected}
onClick={() => void disconnectSession()}
title={t('toolbar.disconnect')}
aria-label={t('toolbar.disconnectAria')}
>
<Unplug className="h-3.5 w-3.5" />
</button>
</div>
<button
type="button"
className={toolBtn}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void (fullscreenActive ? exitFullscreen() : enterFullscreen(tabId))
}}
title={fullscreenActive ? t('toolbar.exitFullscreen') : t('toolbar.fullscreen')}
aria-label={fullscreenActive ? t('toolbar.exitFullscreenAria') : t('toolbar.fullscreenAria')}
>
{fullscreenActive ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
</div>
</div>
)
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-black p-2">
return (
<div
className={`relative flex h-full flex-col bg-dark-900 ${fullscreenActive ? 'bg-black' : ''}`}
onMouseMove={(event) => {
if (!fullscreenActive) return
if (event.clientY <= 56) revealChrome()
}}
>
{fullscreenActive ? (
<div
className={`pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 transition-opacity duration-200 ${
chromeVisible ? 'opacity-100' : 'opacity-0'
}`}
>
<div className="pointer-events-auto w-full max-w-5xl">{toolbar}</div>
</div>
) : (
toolbar
)}
<div
ref={paneRef}
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${
fullscreenActive ? 'p-0' : 'p-2'
}`}
>
<canvas
ref={canvasRef}
tabIndex={0}
className="max-h-full max-w-full cursor-default outline-none shadow-lg shadow-black/40"
className="cursor-default outline-none shadow-lg shadow-black/40"
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => mapPointer(event)}
onMouseDown={(event) => mapPointer(event, true)}
+18 -7
View File
@@ -75,16 +75,24 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
.finally(() => setSessionsLoading(false))
}, [setSessions])
// Click outside to close context menu
// Click outside to close context menu (only while open)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!contextMenu) return
const handlePointerDown = (e: PointerEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setContextMenu(null)
}
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [contextMenu])
// Resize handler
const handleResizeStart = useCallback((e: React.MouseEvent) => {
@@ -423,7 +431,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
title={t('sidebar.newSession')}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-dark-600/60 bg-dark-700 px-2 py-2 text-xs font-medium text-content-primary transition-colors hover:border-accent-green/40 hover:bg-dark-600"
>
<Plus className="h-3.5 w-3.5 text-accent-green" />
<Plus className="h-3.5 w-3.5 shrink-0 text-accent-green" strokeWidth={2.5} />
{t('sidebar.newSessionShort')}
</button>
</div>
@@ -517,6 +525,7 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[148px]"
onPointerDown={(e) => e.stopPropagation()}
>
{(contextMenu.kind === 'blank'
? [
@@ -536,7 +545,9 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
]
: [
{ label: t('contextMenu.session.open'), action: 'connect' },
{ label: t('common:tab.kind.sftp'), action: 'sftp' },
...(isSshSession(contextMenu.session)
? [{ label: t('common:tab.kind.sftp'), action: 'sftp' }]
: []),
{ label: t('contextMenu.session.edit'), action: 'edit' },
{ label: t('contextMenu.session.duplicate'), action: 'duplicate' },
{ label: t('contextMenu.session.delete'), action: 'delete', danger: true },
+132 -54
View File
@@ -7,7 +7,7 @@ import type { ConnectionState } from '../../stores/connection'
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
import { useSessionStore, useEntitlementStore } from '../../stores'
import type { HostMetrics } from '../../../shared/types/host-metrics.types'
import { formatLocaleTime } from '../../i18n/format'
import { formatLocaleTime, translateErrorMessage } from '../../i18n/format'
import { useToast } from '../../hooks/useToast'
import ForwardRuleDrawer, {
DEFAULT_DYNAMIC_PORT,
@@ -17,8 +17,18 @@ import ForwardRuleDrawer, {
import {
parseForwardRules,
portForwardStorageKey,
resolveForwardTargetHost,
resolveLocalBindHost,
resolveRemoteBindHost,
type ForwardRule,
} from '../connection/forwardRules'
import {
forgetForwardIntent,
forwardIntentKey,
rememberForwardIntent,
restoreForwardIntent,
tunnelCoversRule,
} from '../connection/forwardIntent'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
// ============== Status Display Config ==============
@@ -34,37 +44,37 @@ const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey
connected: {
icon: <Wifi className="w-3 h-3" />,
labelKey: 'status.connected',
colorClass: 'text-green-400',
colorClass: 'text-feedback-success',
animateClass: '',
},
connecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
labelKey: 'status.connecting',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse',
},
background: {
icon: <Wifi className="w-3 h-3" />,
labelKey: 'status.background',
colorClass: 'text-green-400',
colorClass: 'text-feedback-success',
animateClass: '',
},
suspended: {
icon: <WifiOff className="w-3 h-3" />,
labelKey: 'status.suspended',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: '',
},
reconnecting: {
icon: <Loader2 className="w-3 h-3 animate-spin" />,
labelKey: 'status.reconnecting',
colorClass: 'text-yellow-400',
colorClass: 'text-feedback-warning',
animateClass: 'animate-pulse',
},
error: {
icon: <AlertCircle className="w-3 h-3" />,
labelKey: 'status.error',
colorClass: 'text-red-400',
colorClass: 'text-feedback-error',
animateClass: '',
},
disconnected: {
@@ -127,7 +137,7 @@ function formatCpuPercent(pct: number): string {
function pctClass(pct?: number): string {
if (pct == null) return 'text-txt-faint'
if (pct >= WARN_PCT) return 'text-amber-400'
if (pct >= WARN_PCT) return 'text-feedback-warning'
return 'text-txt-muted'
}
@@ -382,7 +392,6 @@ export default function StatusBar() {
connectionState: undefined,
hostLabel: undefined,
stats: undefined,
tabKind: activeTab?.type as string | undefined,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -399,7 +408,6 @@ export default function StatusBar() {
: activeTab.title
: activeTab.title,
stats: undefined,
tabKind: activeTab.type,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -411,7 +419,6 @@ export default function StatusBar() {
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
hostLabel: t('terminal:toolbar.labelLocal'),
stats: undefined,
tabKind: activeTab.type,
connectionId: undefined,
isRemoteSsh: false,
sessionOsPretty: undefined as string | undefined,
@@ -431,7 +438,6 @@ export default function StatusBar() {
connectionState: conn.state,
hostLabel: `${conn.config.username}@${conn.config.host}:${conn.config.port || 22}`,
stats: conn.stats,
tabKind: activeTab.type,
connectionId: instance.connectionId,
isRemoteSsh: true,
sessionOsPretty,
@@ -444,7 +450,6 @@ export default function StatusBar() {
connectionState: mapTabStatusToConnectionState(activeTab.connectionStatus),
hostLabel: `${session.username}@${session.host}:${session.port || 22}`,
stats: undefined,
tabKind: activeTab.type,
connectionId: instance?.connectionId,
isRemoteSsh: Boolean(instance?.connectionId),
sessionOsPretty,
@@ -461,7 +466,6 @@ export default function StatusBar() {
connectionState: state,
hostLabel: label,
stats: undefined,
tabKind: activeTab.type,
connectionId: instance?.connectionId,
isRemoteSsh: Boolean(instance?.connectionId),
sessionOsPretty,
@@ -481,6 +485,11 @@ export default function StatusBar() {
return portForwardStorageKey(activeTab?.sessionId, activeTabId, connectionId)
}, [activeTab?.sessionId, activeTabId, connectionId])
const intentKey = useMemo(
() => (activeTabId ? forwardIntentKey(activeTab?.sessionId, activeTabId) : null),
[activeTab?.sessionId, activeTabId]
)
const forwardRuntimes = useMemo(() => {
const map = new Map<string, { running: boolean; boundPort?: number }>()
for (const tunnel of forwardTunnels) {
@@ -489,10 +498,13 @@ export default function StatusBar() {
boundPort: tunnel.boundPort,
})
}
const dynamic = forwardRules.find((rule) => rule.type === 'dynamic')
const dynamicTunnel = forwardTunnels.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
if (dynamic && dynamicTunnel && !map.get(dynamic.id)?.running) {
map.set(dynamic.id, { running: true, boundPort: dynamicTunnel.boundPort })
// Rule ids can diverge from runtime ids (reconnect / legacy). Match by kind+listen port.
for (const rule of forwardRules) {
if (map.get(rule.id)?.running) continue
const tunnel = forwardTunnels.find((item) => tunnelCoversRule([item], rule))
if (tunnel) {
map.set(rule.id, { running: true, boundPort: tunnel.boundPort })
}
}
return map
}, [forwardRules, forwardTunnels])
@@ -585,6 +597,11 @@ export default function StatusBar() {
return () => window.clearInterval(timer)
}, [socksAvailable, loadForwardRules, refreshForwardState])
const previousForwardConnectionRef = useRef<string | undefined>(undefined)
useEffect(() => {
previousForwardConnectionRef.current = undefined
}, [intentKey])
const openForwardDrawer = useCallback(() => {
if (!connectionId || !activeTabId) return
if (!requirePro('portForward')) return
@@ -594,6 +611,10 @@ export default function StatusBar() {
...emptyForwardDraft(),
type: 'local',
name: t('common:portForward.defaultLocalName'),
localBindHost: '127.0.0.1',
localPort: 8080,
remoteHost: '127.0.0.1',
remotePort: 80,
})
setForwardDrawerError(null)
setForwardDrawerOpen(true)
@@ -617,17 +638,19 @@ export default function StatusBar() {
result = await window.opsxApi.sshForwardLocal(
connectionId,
rule.localPort,
rule.remoteHost || '',
resolveForwardTargetHost(rule.remoteHost),
rule.remotePort || 0,
rule.id
rule.id,
resolveLocalBindHost(rule.localBindHost)
)
} else if (rule.type === 'remote') {
result = await window.opsxApi.sshForwardRemote(
connectionId,
rule.remotePort || 0,
'127.0.0.1',
resolveForwardTargetHost(rule.remoteHost),
rule.localPort,
rule.id
rule.id,
resolveRemoteBindHost(rule.remoteBindHost)
)
} else {
result = await window.opsxApi.sshForwardDynamic(
@@ -637,48 +660,73 @@ export default function StatusBar() {
)
}
if (!result?.success) {
setForwardDrawerError(result?.error || t('common:portForward.errorStartFailed'))
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStartFailed')
)
return false
}
if (intentKey) rememberForwardIntent(intentKey, rule)
await refreshForwardState()
return true
},
[connectionId, refreshForwardState, t]
[connectionId, intentKey, refreshForwardState, t]
)
useEffect(() => {
if (!socksAvailable || !connectionId || !intentKey) return
const previous = previousForwardConnectionRef.current
previousForwardConnectionRef.current = connectionId
if (!previous || previous === connectionId) return
void (async () => {
const listed = await window.opsxApi?.sshForwardList?.(connectionId)
const tunnels = listed?.success ? listed.tunnels || [] : []
const restored = await restoreForwardIntent(
intentKey,
(rule) => tunnelCoversRule(tunnels, rule),
(rule) => startForwardRule(rule)
)
if (restored > 0) {
toast.success(t('common:statusBar.forwardResumed', { count: restored }))
}
})()
}, [socksAvailable, connectionId, intentKey, startForwardRule, t, toast])
const stopForwardRule = useCallback(
async (rule: ForwardRule): Promise<boolean> => {
if (!window.opsxApi?.sshForwardStop) return false
const result = await window.opsxApi.sshForwardStop(rule.id)
if (!result?.success) {
// Dynamic may have been started with a different id historically.
if (rule.type === 'dynamic' && connectionId) {
const listed = await window.opsxApi.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnel.kind === 'dynamic' && tunnel.running)
if (match) {
const stopMatch = await window.opsxApi.sshForwardStop(match.forwardId)
if (!stopMatch?.success) {
setForwardDrawerError(
stopMatch?.error || result?.error || t('common:portForward.errorStopFailed')
)
return false
}
await refreshForwardState()
return true
}
let result = await window.opsxApi.sshForwardStop(rule.id)
if (!result?.success && connectionId) {
// Runtime id may differ from persisted rule id after reconnect.
const listed = await window.opsxApi.sshForwardList(connectionId)
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
if (match) {
result = await window.opsxApi.sshForwardStop(match.forwardId)
}
setForwardDrawerError(result?.error || t('common:portForward.errorStopFailed'))
}
if (!result?.success) {
setForwardDrawerError(
result?.error
? translateErrorMessage(result.error)
: t('common:portForward.errorStopFailed')
)
return false
}
if (intentKey) forgetForwardIntent(intentKey, rule.id)
await refreshForwardState()
return true
},
[connectionId, refreshForwardState, t]
[connectionId, intentKey, refreshForwardState, t]
)
const handleStartRule = useCallback(
async (rule: ForwardRule) => {
if (!connectionId || forwardSaving) return
if (forwardRuntimes.get(rule.id)?.running) {
toast.success(t('common:statusBar.forwardStarted'))
return
}
setForwardSaving(true)
setForwardDrawerError(null)
try {
@@ -701,16 +749,18 @@ export default function StatusBar() {
}
}
const ok = await startForwardRule(target)
if (ok && target.type === 'dynamic') {
if (ok) {
toast.success(
t('common:statusBar.forwardStartedDynamic', {
port: resolveDynamicPort(target.localPort),
})
target.type === 'dynamic'
? t('common:statusBar.forwardStartedDynamic', {
port: resolveDynamicPort(target.localPort),
})
: t('common:statusBar.forwardStarted')
)
}
} catch (error) {
setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) ||
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
t('common:portForward.errorStartFailed')
)
} finally {
@@ -720,6 +770,7 @@ export default function StatusBar() {
[
connectionId,
forwardRules,
forwardRuntimes,
forwardSaving,
persistForwardRules,
startForwardRule,
@@ -800,11 +851,19 @@ export default function StatusBar() {
return
}
if (!validPort(forwardDraft.remotePort)) {
setForwardDrawerError(t('common:portForward.errorTargetPort'))
setForwardDrawerError(
forwardDraft.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorRemoteListenAddress')
)
return
}
if (forwardDraft.type === 'local' && !forwardDraft.remoteHost?.trim()) {
setForwardDrawerError(t('common:portForward.errorRemoteHost'))
if (!forwardDraft.remoteHost?.trim()) {
setForwardDrawerError(
forwardDraft.type === 'local'
? t('common:portForward.errorTargetAddress')
: t('common:portForward.errorLocalTargetAddress')
)
return
}
@@ -813,7 +872,15 @@ export default function StatusBar() {
type: forwardDraft.type || 'local',
name: forwardDraft.name.trim(),
localPort: forwardDraft.localPort,
remoteHost: forwardDraft.remoteHost?.trim(),
localBindHost:
forwardDraft.type === 'local'
? resolveLocalBindHost(forwardDraft.localBindHost)
: undefined,
remoteBindHost:
forwardDraft.type === 'remote'
? resolveRemoteBindHost(forwardDraft.remoteBindHost)
: undefined,
remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost),
remotePort: forwardDraft.remotePort,
}
@@ -836,7 +903,7 @@ export default function StatusBar() {
toast.success(t('common:statusBar.forwardStarted'))
} catch (error) {
setForwardDrawerError(
(error instanceof Error ? error.message : String(error)) ||
translateErrorMessage(error instanceof Error ? error.message : String(error)) ||
t('common:portForward.errorStartFailed')
)
} finally {
@@ -955,7 +1022,9 @@ export default function StatusBar() {
}`}
>
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
<span className="tabular-nums">{t('common:statusBar.socksLabel')}</span>
<span className="tabular-nums">
{t('common:statusBar.socksLabel')} :{dynamicListenPort}
</span>
</button>
)}
@@ -972,6 +1041,7 @@ export default function StatusBar() {
rules={forwardRules}
runtimes={forwardRuntimes}
busy={forwardSaving}
viaSession={hostLabel}
onChange={(next) => {
setForwardDraft({
type: next.type || 'local',
@@ -980,6 +1050,14 @@ export default function StatusBar() {
next.type === 'dynamic'
? resolveDynamicPort(next.localPort)
: next.localPort || 0,
localBindHost:
next.type === 'local'
? resolveLocalBindHost(next.localBindHost)
: '',
remoteBindHost:
next.type === 'remote'
? resolveRemoteBindHost(next.remoteBindHost)
: '',
remoteHost: next.remoteHost || '',
remotePort: next.remotePort || 0,
})
+20 -2
View File
@@ -121,7 +121,11 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
>
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
<div className="flex min-w-0 flex-1 items-stretch overflow-x-auto">
<div
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
role="tablist"
aria-label={t('tab.listAria')}
>
{tabs.map((tab, index) => (
<TabItem
key={tab.id}
@@ -147,7 +151,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
<button
type="button"
onClick={onOpenLauncher}
className="bg-accent-blue/90 shadow-accent-blue/20 mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md text-white shadow-sm transition-colors hover:bg-accent-blue"
className="mx-1 my-auto flex h-9 w-10 flex-shrink-0 items-center justify-center rounded-md bg-dark-600 text-white transition-colors hover:bg-accent-blue"
title={t('launcher.title')}
aria-label={t('launcher.title')}
>
@@ -175,6 +179,8 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
sessionId: currentTab.sessionId,
connectionConfig: currentTab.connectionConfig,
serialConfig: currentTab.serialConfig,
rdpConfig: currentTab.rdpConfig,
vncConfig: currentTab.vncConfig,
isLocal: currentTab.isLocal,
localShellKind: currentTab.localShellKind,
localShellPath: currentTab.localShellPath,
@@ -182,6 +188,18 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
})
}
}}
onOpenSftp={
currentTab?.type === 'terminal' && currentTab.sessionId && !currentTab.isLocal
? () => {
const name = currentTab.title.replace(/^SFTP:\s*/i, '')
addTab({
title: `SFTP: ${name}`,
type: 'sftp',
sessionId: currentTab.sessionId,
})
}
: undefined
}
/>
</div>
)
+5 -1
View File
@@ -117,8 +117,12 @@ function TabContent({
return (
<div
key={tab.id}
aria-hidden={!isActive}
{...(!isActive ? { inert: '' } : {})}
className={`absolute inset-0 transition-opacity duration-150 ${
isActive ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
isActive
? 'visible opacity-100 z-10'
: 'invisible opacity-0 z-0 pointer-events-none'
}`}
>
{content}
@@ -77,9 +77,9 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
)
const badgeClass = (kind: string) => {
if (kind === 'zsh') return 'bg-teal-700 text-teal-100'
if (kind === 'bash') return 'bg-dark-600 text-emerald-300'
if (kind === 'fish') return 'bg-violet-700 text-violet-100'
if (kind === 'zsh') return 'bg-teal-700/90 text-teal-50'
if (kind === 'bash') return 'bg-accent-green/15 text-accent-green'
if (kind === 'fish') return 'bg-accent-purple/15 text-accent-purple'
return 'bg-dark-600 text-content-secondary'
}
@@ -108,6 +108,8 @@ export function SessionTree({
<button
type="button"
data-group-row
aria-expanded={isExpanded}
aria-controls={`session-group-${group}`}
onClick={() => onToggleGroup(group)}
onContextMenu={(e) => {
e.preventDefault()
@@ -126,10 +128,12 @@ export function SessionTree({
</button>
<div
id={`session-group-${group}`}
className="grid transition-[grid-template-rows] duration-200 ease-in-out"
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
aria-hidden={!isExpanded}
>
<div className="overflow-hidden">
<div className="overflow-hidden" {...(!isExpanded ? { inert: '' } : {})}>
{groupSessions.length === 0 ? (
<div className="px-9 py-2 text-[11px] text-txt-faint">{t('tree.emptyTitle')}</div>
) : (
@@ -142,7 +146,7 @@ export function SessionTree({
key={session.id}
data-session-row
role="button"
tabIndex={0}
tabIndex={isExpanded ? 0 : -1}
onClick={() => {
if (multiSelect) onToggleSelection?.(session)
else onSelectSession(session)
@@ -22,6 +22,7 @@ interface TabContextMenuProps {
onCloseLeft: () => void
onCloseAll: () => void
onClone?: () => void
onOpenSftp?: () => void
}
export function TabContextMenu({
@@ -34,19 +35,30 @@ export function TabContextMenu({
onCloseLeft,
onCloseAll,
onClone,
onOpenSftp,
}: TabContextMenuProps) {
const { t } = useTranslation('common')
const menuRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!isOpen) return
const handlePointerDown = (e: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
onCloseRef.current()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [onClose])
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCloseRef.current()
}
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [isOpen])
if (!isOpen) return null
@@ -61,16 +73,21 @@ export function TabContextMenu({
if (onClone) {
items.unshift({ label: t('tab.menu.clone'), action: onClone })
}
if (onOpenSftp) {
items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp })
}
return (
<div
ref={menuRef}
style={{ left: position.x, top: position.y }}
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[160px]"
onPointerDown={(e) => e.stopPropagation()}
>
{items.map((item, index) => (
<button
key={index}
type="button"
onClick={() => {
item.action()
onClose()
+27 -4
View File
@@ -16,7 +16,7 @@ interface TabItemProps {
isDragging: boolean
dragOverIndex: number | null
onActivate: () => void
onClose: (e: React.MouseEvent) => void
onClose: (e: { stopPropagation(): void }) => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent, index: number) => void
onDragOver: (e: React.DragEvent, index: number) => void
@@ -38,7 +38,7 @@ export function TabItem({
onDrop,
onDragEnd,
}: TabItemProps) {
const { t } = useTranslation('terminal')
const { t } = useTranslation(['terminal', 'common'])
const displayTitle = resolveTabTitle(tab)
const isLocalTerm = tab.type === 'terminal' && tab.isLocal
const session = useSessionStore((state) =>
@@ -49,7 +49,7 @@ export function TabItem({
if (isLocalTerm) {
return (
<Terminal
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-emerald-400' : 'text-emerald-500/80'}`}
className={`h-3.5 w-3.5 flex-shrink-0 ${isActive ? 'text-accent-green' : 'text-accent-green/70'}`}
aria-label={t('toolbar.labelLocal')}
/>
)
@@ -99,8 +99,28 @@ export function TabItem({
return (
<div
data-tab-item
role="tab"
aria-selected={isActive}
tabIndex={0}
draggable
onClick={onActivate}
onAuxClick={(e) => {
if (e.button === 1) {
e.preventDefault()
e.stopPropagation()
onClose(e)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onActivate()
}
if (e.key === 'Delete') {
e.preventDefault()
onClose(e)
}
}}
onContextMenu={onContextMenu}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
@@ -132,9 +152,12 @@ export function TabItem({
</span>
<button
type="button"
onClick={onClose}
aria-label={t('common:tab.closeAria', { title: displayTitle })}
className={`
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600 group-hover:opacity-100
rounded p-0.5 opacity-0 transition-opacity hover:bg-dark-600
group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100
${isActive ? 'opacity-100' : ''}
`}
>
+225 -54
View File
@@ -1,5 +1,5 @@
/**
* UpgradeView — in-app Pro comparison + one-time checkout / license activate
* UpgradeView — Free / Yearly / Perpetual plan picker + license activate
*/
import { useCallback, useEffect, useState } from 'react'
@@ -12,11 +12,11 @@ import {
Shield,
} from 'lucide-react'
import {
licenseServerBaseUrl,
proFeatureLabel,
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
import { checkout as licenseCheckout } from '../../platform/licenseApi'
import { createLogger } from '../../utils/logger'
const logger = createLogger('UpgradeView')
@@ -29,15 +29,49 @@ interface UpgradeViewProps {
const FREE_ITEM_KEYS = ['ssh', 'sftp', 'local', 'crud', 'import'] as const
const PRO_ITEM_KEYS = ['forward', 'proxy', 'serial', 'ai', 'batch', 'importExport', 'desktop'] as const
type SelectablePlan = 'free' | 'yearly' | 'perpetual'
function looksLikeValidLicenseKey(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
}
function activatedLabel(
t: (key: string) => string,
planType: string | null
): string {
switch (planType) {
case 'yearly':
return t('pro.activatedYearly')
case 'trial':
return t('pro.activatedTrial')
case 'perpetual':
case 'lifetime':
return t('pro.activatedPerpetual')
default:
return t('pro.activated')
}
}
export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
const { t } = useTranslation('common')
const plan = useEntitlementStore((s) => s.plan)
const deviceId = useEntitlementStore((s) => s.deviceId)
const email = useEntitlementStore((s) => s.email)
const expiresAt = useEntitlementStore((s) => s.expiresAt)
const maxDevices = useEntitlementStore((s) => s.maxDevices)
const planType = useEntitlementStore((s) => s.planType)
const activate = useEntitlementStore((s) => s.activate)
const setEmail = useEntitlementStore((s) => s.setEmail)
const clear = useEntitlementStore((s) => s.clear)
const hydrate = useEntitlementStore((s) => s.hydrate)
const [emailInput, setEmailInput] = useState(email)
const [selectedPlan, setSelectedPlan] = useState<SelectablePlan>(
plan === 'pro' ? (planType === 'yearly' ? 'yearly' : 'perpetual') : 'free'
)
const [checkoutLoading, setCheckoutLoading] = useState(false)
const [checkoutError, setCheckoutError] = useState<string | null>(null)
const [licenseInput, setLicenseInput] = useState('')
@@ -49,25 +83,44 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
void hydrate()
}, [hydrate])
useEffect(() => {
setEmailInput(email)
}, [email])
useEffect(() => {
if (plan === 'pro') {
setSelectedPlan(planType === 'yearly' ? 'yearly' : 'perpetual')
} else {
setSelectedPlan('free')
}
}, [plan, planType])
const highlight = feature ? proFeatureLabel(feature) : null
const startCheckout = useCallback(async () => {
const startCheckout = useCallback(async (planChoice: 'yearly' | 'perpetual') => {
setSelectedPlan(planChoice)
setCheckoutLoading(true)
setCheckoutError(null)
const trimmedEmail = emailInput.trim()
if (!trimmedEmail || !trimmedEmail.includes('@')) {
setCheckoutError(t('pro.emailRequired'))
setCheckoutLoading(false)
return
}
try {
await setEmail(trimmedEmail)
const id =
deviceId ||
(await window.opsxApi.licenseDeviceId().catch(() => ''))
const res = await fetch(`${licenseServerBaseUrl()}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: id }),
const result = await licenseCheckout({
customerEmail: trimmedEmail,
deviceId: id,
planType: planChoice,
})
const data = (await res.json()) as { checkoutUrl?: string; error?: string }
if (!res.ok || !data.checkoutUrl) {
throw new Error(data.error || t('pro.checkoutUnavailable', { status: res.status }))
if (!result.checkoutUrl) {
throw new Error(t('pro.checkoutUnavailable', { status: 'no-url' }))
}
await window.opsxApi.openExternal(data.checkoutUrl)
await window.opsxApi.openExternal(result.checkoutUrl)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn('checkout failed', message)
@@ -75,19 +128,36 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
} finally {
setCheckoutLoading(false)
}
}, [deviceId, t])
}, [deviceId, emailInput, setEmail, t])
const handleActivate = useCallback(async () => {
setActivateError(null)
setActivateOk(false)
const result = await activate(licenseInput)
if (!looksLikeValidLicenseKey(licenseInput)) {
setActivateError(t('pro.invalidKeyFormat'))
return
}
const trimmedEmail = emailInput.trim()
if (trimmedEmail) await setEmail(trimmedEmail)
const result = await activate(licenseInput, trimmedEmail || undefined)
if (result.success) {
setActivateOk(true)
setLicenseInput('')
} else {
setActivateError(result.error || t('pro.activateFailed'))
}
}, [activate, licenseInput, t])
}, [activate, emailInput, licenseInput, setEmail, t])
const cardClass = (id: SelectablePlan, accent = false) => {
const selected = selectedPlan === id
return `flex flex-col rounded-xl border px-4 py-4 text-left transition-colors ${
selected
? accent
? 'border-accent-blue bg-accent-blue/10'
: 'border-dark-500 bg-dark-800/60'
: 'border-dark-700/60 bg-dark-800/40 hover:border-dark-500'
}`
}
return (
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
@@ -104,72 +174,173 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
{comingSoon && highlight
? t('pro.subtitleComingSoon', { feature: highlight })
: highlight
? t('pro.subtitleUnlock', { feature: highlight })
? t('pro.subtitleUnlock')
: t('pro.subtitleDefault')}
</p>
{plan === 'pro' && (
{highlight && !comingSoon ? (
<p className="mt-1 text-xs text-txt-faint">{t('pro.subtitleUnlockDetail', { feature: highlight })}</p>
) : null}
{(plan === 'pro' || email) && (
<p className="mt-2 text-xs text-accent-green">
{t('pro.activated')}
{expiresAt
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('pro.lifetime')}
{plan === 'pro' ? activatedLabel(t, planType) : t('pro.accountEmail', { email })}
{plan === 'pro' && email ? ` · ${email}` : null}
{plan === 'pro'
? expiresAt
? t('pro.expiresAt', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('pro.lifetime')
: null}
{plan === 'pro' && maxDevices
? ` · ${t('pro.devices', { count: maxDevices })}`
: null}
</p>
)}
</div>
</header>
<div className="mb-8 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-dark-700/60 bg-dark-800/40 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-txt-faint">{t('pro.freeTier')}</div>
<ul className="space-y-2">
<p className="mb-3 text-center text-sm text-txt-muted">{t('pro.choosePlan')}</p>
{plan !== 'pro' && (
<div className="mb-4 space-y-2">
<label className="ui-label" htmlFor="license-email">
{t('pro.emailLabel')}
</label>
<input
id="license-email"
type="email"
autoComplete="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
placeholder="you@example.com"
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-content-primary outline-none focus:border-accent-blue"
/>
{checkoutError && (
<p className="text-xs text-red-400">{checkoutError}</p>
)}
</div>
)}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
{/* Free */}
<div className={cardClass('free')}>
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedPlan('free')}
>
<div className="text-xs font-medium uppercase tracking-wide text-txt-faint">
{t('pro.planFree')}
</div>
<div className="mt-1 text-xl font-semibold text-content-primary">{t('pro.planFreePrice')}</div>
<div className="mt-1 text-xs text-txt-muted">{t('pro.planFreeDesc')}</div>
</button>
<ul className="mt-3 flex-1 space-y-1.5">
{FREE_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-2 text-sm text-content-secondary">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-muted" />
<li key={key} className="flex items-start gap-1.5 text-xs text-content-secondary">
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-txt-muted" />
{t(`pro.freeItems.${key}`)}
</li>
))}
</ul>
<div className="mt-4 rounded-lg border border-dark-600 px-3 py-2 text-center text-xs text-txt-muted">
{plan !== 'pro' ? t('pro.planFreeCta') : t('pro.planFree')}
</div>
</div>
<div className="rounded-xl border border-accent-blue/35 bg-accent-blue/5 p-5">
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-accent-blue">{t('pro.proTier')}</div>
<ul className="space-y-2">
{/* Yearly */}
<div className={cardClass('yearly', true)}>
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedPlan('yearly')}
>
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
{t('pro.planYearly')}
</div>
<div className="mt-1 text-xl font-semibold text-content-primary">¥99</div>
<div className="mt-1 text-xs text-txt-muted">{t('pro.planYearlyDesc')}</div>
</button>
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
<ul className="mt-2 flex-1 space-y-1.5">
{PRO_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-2 text-sm text-content-primary">
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
<li key={key} className="flex items-start gap-1.5 text-xs text-content-primary">
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-accent-blue" />
{t(`pro.proItems.${key}`)}
</li>
))}
</ul>
{plan !== 'pro' && (
<button
type="button"
disabled={checkoutLoading}
onClick={() => void startCheckout('yearly')}
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-60"
>
{checkoutLoading && selectedPlan === 'yearly' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ExternalLink className="h-3.5 w-3.5" />
)}
{t('pro.goUpgradeYearly')}
</button>
)}
{plan === 'pro' && planType === 'yearly' && (
<div className="mt-4 rounded-lg border border-accent-blue/40 px-3 py-2 text-center text-xs text-accent-blue">
{t('pro.activatedYearly')}
</div>
)}
</div>
{/* Perpetual */}
<div className={cardClass('perpetual', true)}>
<button
type="button"
className="w-full text-left"
onClick={() => setSelectedPlan('perpetual')}
>
<div className="text-xs font-medium uppercase tracking-wide text-accent-blue">
{t('pro.planPerpetual')}
</div>
<div className="mt-1 text-xl font-semibold text-content-primary">¥199</div>
<div className="mt-1 text-xs text-txt-muted">{t('pro.planPerpetualDesc')}</div>
</button>
<p className="mt-3 text-xs text-content-secondary">{t('pro.planIncludesPro')}</p>
<ul className="mt-2 flex-1 space-y-1.5">
{PRO_ITEM_KEYS.map((key) => (
<li key={key} className="flex items-start gap-1.5 text-xs text-content-primary">
<Check className="mt-0.5 h-3 w-3 flex-shrink-0 text-accent-blue" />
{t(`pro.proItems.${key}`)}
</li>
))}
</ul>
{plan !== 'pro' && (
<button
type="button"
disabled={checkoutLoading}
onClick={() => void startCheckout('perpetual')}
className="mt-4 flex w-full items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-60"
>
{checkoutLoading && selectedPlan === 'perpetual' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ExternalLink className="h-3.5 w-3.5" />
)}
{t('pro.goUpgradePerpetual')}
</button>
)}
{plan === 'pro' && (planType === 'perpetual' || planType === 'lifetime' || (!planType && !expiresAt)) && (
<div className="mt-4 rounded-lg border border-accent-blue/40 px-3 py-2 text-center text-xs text-accent-blue">
{t('pro.activatedPerpetual')}
</div>
)}
</div>
</div>
{plan !== 'pro' && (
<>
<p className="mb-3 text-center text-sm text-txt-muted">
{t('pro.oneTimeFee')}
</p>
<button
type="button"
disabled={checkoutLoading}
onClick={() => void startCheckout()}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-blue px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-60"
>
{checkoutLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ExternalLink className="h-4 w-4" />
)}
{t('pro.goUpgrade')}
</button>
{checkoutError && (
<p className="mt-2 text-xs text-red-400">{checkoutError}</p>
)}
<button
type="button"
onClick={() => setShowActivate((v) => !v)}
className="mt-3 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
className="mt-1 w-full text-center text-sm text-txt-muted underline-offset-2 hover:text-content-secondary hover:underline"
>
{t('pro.haveLicense')}
</button>
@@ -185,7 +356,7 @@ export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
onChange={(e) => setLicenseInput(e.target.value)}
rows={3}
spellCheck={false}
placeholder="opsx1...."
placeholder="LIC-… / LICOFF-…"
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
/>
<button
+9 -3
View File
@@ -19,7 +19,7 @@ export default function UpgradeWall() {
if (!wall.open) return null
const feature = wall.feature as ProFeature | undefined
const label = feature ? proFeatureLabel(feature) : t('pro.feature')
const label = feature ? proFeatureLabel(feature) : null
const comingSoon = wall.comingSoon
return (
@@ -48,11 +48,17 @@ export default function UpgradeWall() {
<Crown className="h-5 w-5" />
</div>
<h2 id="upgrade-wall-title" className="text-base font-semibold text-content-primary">
{comingSoon ? `${label}${t('pro.wallComingSoonSuffix')}` : t('pro.wallUnlock', { feature: label })}
{comingSoon ? t('pro.wallUnlockComingSoon') : t('pro.wallUnlock')}
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
{wall.message ||
(comingSoon ? t('pro.wallMessageComingSoon') : t('pro.wallMessageDefault'))}
(comingSoon
? label
? t('pro.wallMessageComingSoonFeature', { feature: label })
: t('pro.wallMessageComingSoon')
: label
? t('pro.wallMessageFeature', { feature: label })
: t('pro.wallMessageDefault'))}
</p>
</div>
+9 -6
View File
@@ -7,6 +7,7 @@ import { Cable, RefreshCw } from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { safeTerminalDispose, safeTerminalFit } from '../../utils/safeTerminalFit'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
@@ -72,7 +73,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
terminal.open(container)
terminalRef.current = terminal
fitRef.current = fit
fit.fit()
safeTerminalFit(fit, terminal)
const pipeline = new TerminalPipeline({
input: async (data) => {
@@ -109,7 +110,9 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
}
container.addEventListener('keydown', handleKeyDown)
const handleResize = () => fit.fit()
const handleResize = () => {
safeTerminalFit(fit, terminal)
}
window.addEventListener('resize', handleResize)
updateTabStatus(tabId, 'connecting')
terminal.writeln(`\x1b[36m${t('serial:terminal.opening', { path: config.path, baud: config.baudRate })}\x1b[0m`)
@@ -167,15 +170,15 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
const sessionId = sessionIdRef.current
sessionIdRef.current = null
if (sessionId) void closeSerial(sessionId)
terminal.dispose()
terminalRef.current = null
fitRef.current = null
terminalRef.current = null
safeTerminalDispose(terminal)
}
}, [settings.fontFamily, settings.fontSize, settings.scrollback, settings.terminalTheme, tab?.serialConfig, tabId, updateTabStatus, t])
useEffect(() => {
if (isActive) {
fitRef.current?.fit()
safeTerminalFit(fitRef.current, terminalRef.current)
terminalRef.current?.focus()
}
}, [isActive])
@@ -189,7 +192,7 @@ export default function SerialTerminalView({ tabId, isActive }: SerialTerminalVi
{tab?.connectionStatus === 'connecting' && <RefreshCw className="w-3 h-3 animate-spin text-yellow-400" />}
{error && <span className="ml-auto text-red-400 truncate">{error}</span>}
</div>
<div ref={containerRef} className="flex-1 min-h-0 p-1" />
<div ref={containerRef} className="terminal-surface min-h-0 flex-1 p-1" />
</div>
)
}
@@ -121,7 +121,7 @@ export default function BatchCommandDialog({
} finally {
if (connectionId) await window.opsxApi.sshDisconnect(connectionId).catch(() => undefined)
}
}, [timeout, updateResult])
}, [timeout, updateResult, t])
const execute = useCallback(async () => {
const commandText = command.trim()
+1 -1
View File
@@ -126,7 +126,7 @@ export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchEx
}
}
},
[tabId, timeout, updateResult]
[tabId, timeout, updateResult, t]
)
const execute = useCallback(async () => {
+53 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Cable, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
import { Cable, Cast, Monitor, Plus, Search, LogIn, Server, Terminal } from 'lucide-react'
import Modal from '../common/Modal'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
@@ -9,7 +9,11 @@ import {
type AvailableLocalShell,
} from '../../platform/localTerminal'
import { fallbackLocalShells } from '../../utils/platformShells'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
import {
buildOpenTabPayload,
defaultPortForProtocol,
sessionProtocol,
} from '../../utils/sessionProtocol'
import { useEntitlementStore } from '../../stores/entitlementStore'
interface QuickLauncherProps {
@@ -18,6 +22,8 @@ interface QuickLauncherProps {
onNewSession: () => void
onQuickConnect: () => void
onSerialConnect: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
}
export default function QuickLauncher({
@@ -26,6 +32,8 @@ export default function QuickLauncher({
onNewSession,
onQuickConnect,
onSerialConnect,
onRdpConnect,
onVncConnect,
}: QuickLauncherProps) {
const { t } = useTranslation(['common', 'sessions'])
const [query, setQuery] = useState('')
@@ -86,7 +94,7 @@ export default function QuickLauncher({
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
<button type="button" onClick={() => run(onQuickConnect)} className="launcher-card">
<LogIn className="w-4 h-4 text-accent-blue" />
<span>
@@ -95,7 +103,7 @@ export default function QuickLauncher({
</span>
</button>
<button type="button" onClick={() => run(onNewSession)} className="launcher-card">
<Plus className="w-4 h-4 text-accent-green" />
<Plus className="w-4 h-4 shrink-0 text-accent-green" strokeWidth={2.5} />
<span>
<strong>{t('common:launcher.cards.saveSession.title')}</strong>
<small>{t('common:launcher.cards.saveSession.desc')}</small>
@@ -108,6 +116,42 @@ export default function QuickLauncher({
<small>{t('common:launcher.cards.serial.desc')}</small>
</span>
</button>
{onRdpConnect && (
<button
type="button"
onClick={() =>
run(() => {
if (!requirePro('rdp')) return
onRdpConnect()
})
}
className="launcher-card"
>
<Monitor className="w-4 h-4 text-accent-blue" />
<span>
<strong>{t('common:launcher.cards.rdp.title')}</strong>
<small>{t('common:launcher.cards.rdp.desc')}</small>
</span>
</button>
)}
{onVncConnect && (
<button
type="button"
onClick={() =>
run(() => {
if (!requirePro('vnc')) return
onVncConnect()
})
}
className="launcher-card"
>
<Cast className="w-4 h-4 text-accent-blue" />
<span>
<strong>{t('common:launcher.cards.vnc.title')}</strong>
<small>{t('common:launcher.cards.vnc.desc')}</small>
</span>
</button>
)}
</div>
<section>
@@ -131,7 +175,7 @@ export default function QuickLauncher({
}))}
className="launcher-row"
>
<Terminal className="w-4 h-4 text-emerald-400" />
<Terminal className="w-4 h-4 text-accent-green" />
<span className="flex-1 text-left"><strong>{shell.name}</strong><small>{shell.path}</small></span>
{shell.isDefault && <span className="launcher-badge">{t('common:launcher.systemDefault')}</span>}
</button>
@@ -161,7 +205,10 @@ export default function QuickLauncher({
<Server className="w-4 h-4 text-accent-blue" />
<span className="flex-1 min-w-0 text-left">
<strong className="truncate">{session.name}</strong>
<small className="truncate">{session.username}@{session.host}:{session.port || 22}</small>
<small className="truncate">
{session.username}@{session.host}:
{session.port || defaultPortForProtocol(sessionProtocol(session))}
</small>
</span>
<span className="launcher-badge">{session.group || 'default'}</span>
</button>
+78 -68
View File
@@ -20,6 +20,8 @@ import {
} from '../../utils/sessionGroups'
import { useSettingsStore } from '../../stores/settingsStore'
import { usePromptDialog } from '../common/PromptDialog'
import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
import { getDisplayResolution } from '../../utils/desktopResolution'
const logger = createLogger('SessionDialog')
@@ -152,14 +154,17 @@ export default function SessionDialog({
: [],
domain: editSession.domain || '',
security: editSession.security || 'nla',
width: editSession.width || 1920,
height: editSession.height || 1080,
width: editSession.width || getDisplayResolution().width,
height: editSession.height || getDisplayResolution().height,
})
setShowAdvanced(hasAdvanced)
} else {
const display = getDisplayResolution()
setFormData({
...emptyForm,
group: normalizeGroupId(defaultGroup),
width: display.width,
height: display.height,
})
setTarget('')
setShowAdvanced(false)
@@ -185,12 +190,15 @@ export default function SessionDialog({
prev.port === defaultPortForProtocol(prev.protocol)
? defaultPortForProtocol(protocol)
: prev.port
const display = getDisplayResolution()
const applyDisplaySize = protocol !== 'ssh' && prev.protocol === 'ssh'
return {
...prev,
protocol,
username: nextUser,
port: nextPort,
authType: 'password',
...(applyDisplaySize ? { width: display.width, height: display.height } : {}),
}
})
// Keep host-only target when switching protocol.
@@ -394,6 +402,50 @@ export default function SessionDialog({
{ value: 'key', label: t('dialog.authKey'), icon: Key },
]
const groupField = (
<div>
<label htmlFor="session-group" className="ui-label">
<Folder className="inline w-3 h-3 mr-0.5" />
{t('dialog.group')}
</label>
<select
id="session-group"
value={normalizeGroupId(formData.group)}
onChange={(e) => {
const value = e.target.value
if (value !== '__new__') {
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
return
}
void (async () => {
const raw = await prompt({
title: t('groups.new.title'),
message: t('groups.new.message'),
placeholder: t('groups.new.placeholder'),
confirmText: t('groups.new.confirm'),
})
if (raw == null) return
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
})()
}}
className="ui-input"
>
{knownGroups.map((group) => (
<option key={group} value={group}>
{groupDisplayName(group)}
</option>
))}
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
<option value={normalizeGroupId(formData.group)}>
{groupDisplayName(formData.group)}
</option>
)}
<option value="__new__">{t('groups.newOption')}</option>
</select>
<p className="mt-1 text-[11px] text-txt-faint">{t('groups.hint')}</p>
</div>
)
return (
<>
<div className="fixed inset-0 z-[100] flex justify-end">
@@ -549,73 +601,30 @@ export default function SessionDialog({
/>
</div>
)}
{formData.protocol !== 'ssh' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ui-label" htmlFor="session-width">{t('dialog.width')}</label>
<input
id="session-width"
type="number"
value={formData.width}
onChange={(e) => setFormData({ ...formData, width: Number(e.target.value) || 1920 })}
className="ui-input font-mono"
/>
</div>
<div>
<label className="ui-label" htmlFor="session-height">{t('dialog.height')}</label>
<input
id="session-height"
type="number"
value={formData.height}
onChange={(e) => setFormData({ ...formData, height: Number(e.target.value) || 1080 })}
className="ui-input font-mono"
/>
</div>
{formData.protocol === 'rdp' && (
<div>
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
<input
id="session-password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
className="ui-input"
autoComplete="current-password"
/>
</div>
)}
<div>
<label htmlFor="session-group" className="ui-label">
<Folder className="inline w-3 h-3 mr-0.5" />
{t('dialog.group')}
</label>
<select
id="session-group"
value={normalizeGroupId(formData.group)}
onChange={(e) => {
const value = e.target.value
if (value !== '__new__') {
setFormData((prev) => ({ ...prev, group: normalizeGroupId(value) }))
return
}
void (async () => {
const raw = await prompt({
title: t('groups.new.title'),
message: t('groups.new.message'),
placeholder: t('groups.new.placeholder'),
confirmText: t('groups.new.confirm'),
})
if (raw == null) return
setFormData((prev) => ({ ...prev, group: normalizeGroupId(raw) }))
})()
}}
className="ui-input"
>
{knownGroups.map((group) => (
<option key={group} value={group}>
{groupDisplayName(group)}
</option>
))}
{!knownGroups.includes(normalizeGroupId(formData.group)) && (
<option value={normalizeGroupId(formData.group)}>
{groupDisplayName(formData.group)}
</option>
)}
<option value="__new__">{t('groups.newOption')}</option>
</select>
<p className="mt-1 text-[11px] text-txt-faint">
{t('groups.hint')}
</p>
</div>
{formData.protocol !== 'ssh' && (
<DesktopResolutionPicker
ns="sessions"
width={formData.width}
height={formData.height}
onChange={(next) => setFormData((prev) => ({ ...prev, width: next.width, height: next.height }))}
/>
)}
{/* RDP:分组仍在连接区;SSH / VNC 放到密码下方 */}
{formData.protocol === 'rdp' && groupField}
</section>
{/* 凭据 */}
@@ -647,7 +656,7 @@ export default function SessionDialog({
</div>
)}
{(formData.authType === 'password' || !isSsh) && (
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
<div>
<label htmlFor="session-password" className="ui-label">{t('dialog.password')}</label>
<input
@@ -774,6 +783,7 @@ export default function SessionDialog({
</div>
)}
{(isSsh || formData.protocol === 'vnc') && groupField}
</section>
{/* 高级 */}
+7 -2
View File
@@ -10,7 +10,11 @@ import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShel
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { shellDisplayFromPath } from '../../utils/shellName'
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
import {
buildOpenTabPayload,
defaultPortForProtocol,
sessionProtocol,
} from '../../utils/sessionProtocol'
interface WelcomeViewProps {
onNewSession?: () => void
@@ -273,7 +277,8 @@ export default function WelcomeView({
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content-primary">{session.name}</span>
<span className="block truncate font-mono text-[11px] text-txt-faint">
{session.username}@{session.host}:{session.port || 22}
{session.username}@{session.host}:
{session.port || defaultPortForProtocol(sessionProtocol(session))}
</span>
</span>
{session.group ? (
+348
View File
@@ -0,0 +1,348 @@
/**
* AccountSettings — license email, plan status, activate / devices / clear
* (standalone settings nav tab, not under General)
*/
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import { useEntitlementStore } from '../../stores/entitlementStore'
import {
deactivateLicenseDevice,
listLicenseDevices,
type LicenseDevice,
} from '../../platform/licenseApi'
function looksLikeValidLicenseKey(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (trimmed.startsWith('opsx1.') || trimmed.startsWith('MII')) return false
return trimmed.startsWith('LIC-') || trimmed.startsWith('LICOFF-') || trimmed.length >= 8
}
function planLabel(
t: (key: string) => string,
plan: 'free' | 'pro',
planType: string | null
): string {
if (plan !== 'pro') return t('account.planFree')
switch (planType) {
case 'yearly':
return t('account.planYearly')
case 'trial':
return t('account.planTrial')
case 'perpetual':
case 'lifetime':
return t('account.planPerpetual')
default:
return t('account.planPerpetual')
}
}
export default function AccountSettings() {
const { t } = useTranslation('settings')
const { t: tc } = useTranslation('common')
const plan = useEntitlementStore((s) => s.plan)
const email = useEntitlementStore((s) => s.email)
const deviceId = useEntitlementStore((s) => s.deviceId)
const licenseKey = useEntitlementStore((s) => s.licenseKey)
const expiresAt = useEntitlementStore((s) => s.expiresAt)
const maxDevices = useEntitlementStore((s) => s.maxDevices)
const planType = useEntitlementStore((s) => s.planType)
const hydrate = useEntitlementStore((s) => s.hydrate)
const setEmail = useEntitlementStore((s) => s.setEmail)
const activate = useEntitlementStore((s) => s.activate)
const clear = useEntitlementStore((s) => s.clear)
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
const [emailInput, setEmailInput] = useState(email)
const [licenseInput, setLicenseInput] = useState('')
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [devices, setDevices] = useState<LicenseDevice[]>([])
const [deviceUsed, setDeviceUsed] = useState(0)
const [deviceMax, setDeviceMax] = useState(0)
const [devicesLoading, setDevicesLoading] = useState(false)
const [removingId, setRemovingId] = useState<string | null>(null)
useEffect(() => {
void hydrate()
}, [hydrate])
useEffect(() => {
setEmailInput(email)
}, [email])
const refreshDevices = useCallback(async () => {
if (plan !== 'pro' || !licenseKey) {
setDevices([])
setDeviceUsed(0)
setDeviceMax(0)
return
}
setDevicesLoading(true)
try {
const result = await listLicenseDevices(licenseKey)
setDevices(
result.devices.map((d) => ({
...d,
isCurrent: Boolean(deviceId && d.deviceId === deviceId),
}))
)
setDeviceUsed(result.used)
setDeviceMax(result.maxDevices || maxDevices || 0)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setDevicesLoading(false)
}
}, [deviceId, licenseKey, maxDevices, plan])
useEffect(() => {
void refreshDevices()
}, [refreshDevices])
const saveEmail = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
try {
await setEmail(emailInput.trim())
setMessage(t('account.saved'))
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}, [emailInput, setEmail, t])
const handleActivate = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
if (!looksLikeValidLicenseKey(licenseInput)) {
setError(tc('pro.invalidKeyFormat'))
setBusy(false)
return
}
if (emailInput.trim()) await setEmail(emailInput.trim())
const result = await activate(licenseInput, emailInput.trim() || undefined)
if (result.success) {
setMessage(t('account.activateOk'))
setLicenseInput('')
await refreshDevices()
} else {
setError(result.error || tc('pro.activateFailed'))
}
setBusy(false)
}, [activate, emailInput, licenseInput, refreshDevices, setEmail, t, tc])
const handleDeactivate = useCallback(async () => {
setBusy(true)
setError(null)
setMessage(null)
await clear()
setDevices([])
setMessage(t('account.saved'))
setBusy(false)
}, [clear, t])
const handleRemoveDevice = useCallback(
async (target: LicenseDevice) => {
if (!licenseKey || target.isCurrent) return
setRemovingId(target.deviceId)
setError(null)
setMessage(null)
try {
await deactivateLicenseDevice(licenseKey, target.deviceId)
setMessage(t('account.deviceRemoved'))
await refreshDevices()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setRemovingId(null)
}
},
[licenseKey, refreshDevices, t]
)
const atSeatLimit = deviceMax > 0 && deviceUsed >= deviceMax
return (
<div className="space-y-6">
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.title')}</h3>
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
<Row
label={t('account.plan')}
description={
plan === 'pro'
? expiresAt
? t('account.expires', { date: new Date(expiresAt * 1000).toLocaleDateString() })
: t('account.lifetime')
: undefined
}
>
<span className="text-sm text-content-primary">
{planLabel(t, plan, planType)}
{maxDevices ? ` · ${t('account.devices', { count: maxDevices })}` : ''}
</span>
</Row>
<Row label={t('account.email')} description={t('account.emailDesc')}>
<div className="flex min-w-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<input
type="email"
autoComplete="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
placeholder="you@example.com"
className="w-56 rounded-lg border border-dark-600 bg-dark-900 px-3 py-1.5 text-sm text-content-primary outline-none focus:border-accent-blue"
/>
<button
type="button"
disabled={busy}
onClick={() => void saveEmail()}
className="rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-content-primary hover:bg-dark-600 disabled:opacity-40"
>
{t('account.saveEmail')}
</button>
</div>
</Row>
<Row label={t('account.deviceId')}>
<code className="max-w-[14rem] truncate text-xs text-txt-muted" title={deviceId || '—'}>
{deviceId || '—'}
</code>
</Row>
</div>
</div>
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{t('account.activate')}</h3>
<div className="border-dark-700/45 bg-dark-800/50 space-y-3 overflow-hidden rounded-2xl border p-4">
<textarea
value={licenseInput}
onChange={(e) => setLicenseInput(e.target.value)}
rows={3}
spellCheck={false}
placeholder={t('account.pasteLicense')}
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 font-mono text-xs text-content-primary outline-none focus:border-accent-blue"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy || !licenseInput.trim()}
onClick={() => void handleActivate()}
className="rounded-lg bg-accent-blue px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600 disabled:opacity-40"
>
{t('account.activate')}
</button>
<button
type="button"
disabled={busy}
onClick={() => openUpgradeTab()}
className="rounded-lg border border-dark-600 px-3 py-1.5 text-xs text-content-secondary hover:bg-dark-700"
>
{t('account.upgrade')}
</button>
{plan === 'pro' && (
<button
type="button"
disabled={busy}
onClick={() => void handleDeactivate()}
className="rounded-lg border border-red-500/40 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
>
{t('account.deactivate')}
</button>
)}
</div>
{message && <p className="text-xs text-accent-green">{message}</p>}
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
</div>
{plan === 'pro' && licenseKey ? (
<div>
<h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">
{t('account.deviceList')}
{deviceMax > 0 ? ` · ${deviceUsed}/${deviceMax}` : ''}
</h3>
<div className="border-dark-700/45 bg-dark-800/50 overflow-hidden rounded-2xl border">
{atSeatLimit ? (
<p className="border-b border-dark-700/35 px-4 py-2.5 text-xs text-feedback-warning">
{t('account.seatFull')}
</p>
) : null}
{devicesLoading ? (
<div className="flex items-center gap-2 px-4 py-6 text-xs text-txt-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('account.loadingDevices')}
</div>
) : devices.length === 0 ? (
<p className="px-4 py-6 text-xs text-txt-muted">{t('account.noDevices')}</p>
) : (
<ul className="divide-dark-700/35 divide-y">
{devices.map((d) => (
<li
key={d.deviceId || String(d.id)}
className="flex flex-wrap items-center justify-between gap-2 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-content-primary">
{d.deviceName || 'OpsXShell'}
{d.isCurrent ? (
<span className="ml-2 text-xs text-accent-blue">
{t('account.thisDevice')}
</span>
) : null}
</div>
<code className="mt-0.5 block truncate text-[11px] text-txt-muted">
{d.deviceId}
</code>
</div>
{!d.isCurrent ? (
<button
type="button"
disabled={removingId === d.deviceId}
onClick={() => void handleRemoveDevice(d)}
className="rounded-lg border border-red-500/35 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
{removingId === d.deviceId ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
t('account.removeDevice')
)}
</button>
) : null}
</li>
))}
</ul>
)}
</div>
</div>
) : null}
</div>
)
}
function Row({
label,
description,
children,
}: {
label: string
description?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
<div className="min-w-[11rem] flex-1 basis-[14rem]">
<div className="text-sm font-medium text-content-primary">{label}</div>
{description ? <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div> : null}
</div>
<div className="ml-auto flex-shrink-0">{children}</div>
</div>
)
}
@@ -202,7 +202,7 @@ export default function AiProviderSettings() {
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
{presetsByFamily(familyId).map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
{preset.id === 'custom' ? t('ai.family.custom') : preset.name}
</option>
))}
</optgroup>
+66 -61
View File
@@ -5,7 +5,6 @@ import { useKeybindingStore } from '../../stores/keybindingStore'
import type { AppLanguage } from '../../i18n/detectLocale'
import {
Monitor,
Palette,
Terminal,
Keyboard,
Shield,
@@ -18,13 +17,16 @@ import {
Copy,
Check,
Sparkles,
UserRound,
} from 'lucide-react'
import AiProviderSettings from './AiProviderSettings'
import AccountSettings from './AccountSettings'
import { themes, type TerminalThemeId } from '../../themes/terminal'
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
// ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
interface TabMeta {
id: SettingsTab
@@ -41,11 +43,12 @@ interface RuntimeInfo {
sshBackend: string
dataPath: string
credentialPath: string
logPath: string
}
const TAB_LIST: TabMeta[] = [
{ id: 'general', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor },
{ id: 'appearance', labelKey: 'nav.appearance', descriptionKey: 'nav.appearanceDesc', icon: Palette },
{ id: 'account', labelKey: 'nav.account', descriptionKey: 'nav.accountDesc', icon: UserRound },
{ id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal },
{ id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
@@ -148,25 +151,13 @@ export default function SettingsView() {
// 当前 tab 元信息
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
// ── 搜索过滤 ──────────────────────────────────────────────
// ── 搜索过滤(跨语种:Language / 语言 / 言語 … 均可命中)────────
const filtered = useMemo((): SettingsTab[] | null => {
if (!searchQuery.trim()) return null
const q = searchQuery.toLowerCase()
const keywords: Record<SettingsTab, string[]> = {
general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'],
appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'],
terminal: [t('nav.terminal'), t('terminal.fontFamily'), 'font', 'terminal', 'nord', 'tokyo', 'dracula', 'one-dark', 'default'],
ai: ['ai', t('nav.ai'), 'provider', 'deepseek', 'glm', 'minimax', 'custom'],
shortcuts: [t('nav.shortcuts'), 'keyboard', 'shortcut', 'hotkey'],
ssh: ['ssh', t('ssh.connection'), t('ssh.pool'), 'timeout', 'reconnect'],
security: [t('nav.security'), t('security.credentials'), 'encrypt', 'known hosts'],
about: [t('nav.about'), 'version', 'about', 'license'],
}
const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][])
.filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k)))
.map(([id]) => id)
return matchIds.length > 0 ? matchIds : []
}, [searchQuery, t])
return filterSettingsTabsByQuery(
TAB_LIST.map((tab) => tab.id as SettingsSearchTab),
searchQuery
)
}, [searchQuery])
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
useEffect(() => {
@@ -182,6 +173,7 @@ export default function SettingsView() {
`OpsXShell v${runtimeInfo.appVersion}`,
`${runtimeInfo.os} ${runtimeInfo.arch}`,
`Data: ${runtimeInfo.dataPath}`,
`Log: ${runtimeInfo.logPath}`,
].join('\n')
: `OpsXShell v${appVersion}`
@@ -197,10 +189,39 @@ export default function SettingsView() {
// ── 渲染内容区 ────────────────────────────────────────────
const renderContent = () => {
switch (activeTab) {
// ──────── 常规 ─────────
// ──────── 通用(主题 / 语言 / 工作区)─────────
case 'general':
return (
<div className="space-y-6">
<SettingGroup title={t('appearance.themeGroup')}>
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
<select
value={settings.theme}
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
className="ui-select-pill"
>
<option value="dark">{t('appearance.dark')}</option>
<option value="light">{t('appearance.light')}</option>
<option value="system">{t('appearance.system')}</option>
</select>
</SettingRow>
<SettingRow label={t('language.label')} description={t('language.description')}>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
className="ui-select-pill"
>
<option value="zh-CN">{t('language.zhCN')}</option>
<option value="zh-TW">{t('language.zhTW')}</option>
<option value="en">{t('language.en')}</option>
<option value="ja">{t('language.ja')}</option>
<option value="ko">{t('language.ko')}</option>
<option value="vi">{t('language.vi')}</option>
<option value="ru">{t('language.ru')}</option>
</select>
</SettingRow>
</SettingGroup>
<SettingGroup title={t('general.layout')}>
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
<div className="flex items-center gap-2">
@@ -238,40 +259,8 @@ export default function SettingsView() {
</div>
)
// ──────── 外观 ─────────
case 'appearance':
return (
<div className="space-y-6">
<SettingGroup title={t('appearance.themeGroup')}>
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
<select
value={settings.theme}
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
className="ui-select-pill"
>
<option value="dark">{t('appearance.dark')}</option>
<option value="light">{t('appearance.light')}</option>
<option value="system">{t('appearance.system')}</option>
</select>
</SettingRow>
<SettingRow label={t('language.label')} description={t('language.description')}>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
className="ui-select-pill"
>
<option value="zh-CN">{t('language.zhCN')}</option>
<option value="zh-TW">{t('language.zhTW')}</option>
<option value="en">{t('language.en')}</option>
<option value="ja">{t('language.ja')}</option>
<option value="ko">{t('language.ko')}</option>
<option value="vi">{t('language.vi')}</option>
<option value="ru">{t('language.ru')}</option>
</select>
</SettingRow>
</SettingGroup>
</div>
)
case 'account':
return <AccountSettings />
// ──────── 终端 ─────────
case 'terminal':
@@ -520,6 +509,16 @@ export default function SettingsView() {
checked={settings.keepAlive}
onChange={(v) => setSetting('keepAlive', v)}
/>
<SettingRow label={t('ssh.keepAliveInterval')} description={t('ssh.keepAliveIntervalDesc')}>
<input
type="number"
min={5}
max={120}
value={settings.keepAliveIntervalSec}
onChange={(e) => setSetting('keepAliveIntervalSec', parseInt(e.target.value) || 15)}
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
<SettingToggle
label={t('ssh.autoReconnect')}
description={t('ssh.autoReconnectDesc')}
@@ -572,10 +571,10 @@ export default function SettingsView() {
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
<input
type="number"
min={1}
max={120}
min={5}
max={240}
value={Math.round(settings.idleTimeoutMs / 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 30) * 60000)}
className="ui-input-sm w-20 text-center"
/>
</SettingRow>
@@ -662,6 +661,12 @@ export default function SettingsView() {
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
</code>
</div>
<div>
<div className="mb-1 text-txt-muted">{t('about.logPath')}</div>
<code className="block break-all text-content-secondary">
{runtimeInfo?.logPath ?? t('common:status.reading')}
</code>
</div>
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
</div>
</SettingCard>
@@ -711,9 +716,9 @@ export default function SettingsView() {
key={tab.id}
data-testid={`settings-tab-${tab.id}`}
onClick={() => setActiveTab(tab.id)}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
>
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? 'text-accent-blue' : ''}`} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium leading-5">{t(tab.labelKey)}</div>
<div className={`truncate text-[11px] leading-4 ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
+3 -1
View File
@@ -25,13 +25,15 @@ export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: Breadc
onClick={onHome}
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
title={t('nav.home')}
aria-label={t('nav.home')}
>
<Home className="h-4 w-4 text-txt-muted" />
</button>
<button
type="button"
onClick={() => onNavigate(root || '/')}
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-dark-200"
className="rounded px-1.5 py-0.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-secondary"
title={rootLabel}
>
{rootLabel}
</button>
+28 -7
View File
@@ -13,9 +13,13 @@ interface ConnectionPanelProps {
isConnected: boolean
isConnecting: boolean
error: string | null
/** Last remote directory to resume after reconnect (shown as hint). */
resumePath?: string | null
sessions: Session[]
showSelector: boolean
onConnect: (sessionId: string) => void
/** Prefer same-session reconnect when the tab already knows sessionId. */
onReconnect?: () => void
onShowSelector: () => void
onCloseSelector: () => void
}
@@ -24,13 +28,16 @@ export function ConnectionPanel({
isConnected,
isConnecting,
error,
resumePath,
sessions,
showSelector,
onConnect,
onReconnect,
onShowSelector,
onCloseSelector,
}: ConnectionPanelProps) {
const { t } = useTranslation('sftp')
const canResumePath = Boolean(resumePath)
if (isConnected) {
return null
@@ -40,22 +47,36 @@ export function ConnectionPanel({
return (
<div className="flex items-center gap-2 border-b border-accent-blue/20 bg-accent-blue/10 px-4 py-2">
<Loader2 className="h-4 w-4 animate-spin text-accent-blue" />
<span className="text-sm text-accent-blue">{t('connection.connecting')}</span>
<span className="text-sm text-accent-blue">
{canResumePath
? t('connection.reconnectingResume', { path: resumePath })
: t('connection.connecting')}
</span>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-between border-b border-red-500/20 bg-red-500/10 px-4 py-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">{error}</span>
<div className="flex items-center justify-between gap-3 border-b border-red-500/20 bg-red-500/10 px-4 py-2">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2 min-w-0">
<AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0" />
<span className="text-sm text-red-400 truncate">{error}</span>
</div>
{canResumePath && (
<span className="truncate pl-6 text-[11px] text-red-300/80" title={resumePath ?? undefined}>
{t('connection.resumeHint', { path: resumePath })}
</span>
)}
</div>
<button
type="button"
onClick={onShowSelector}
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600"
onClick={() => {
if (onReconnect) onReconnect()
else onShowSelector()
}}
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600 flex-shrink-0"
>
{t('connection.reconnect')}
</button>
+6 -4
View File
@@ -5,6 +5,7 @@
import { Monitor, ChevronRight, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { Session } from '../../stores/connection'
import { isSshSession } from '../../utils/sessionProtocol'
interface ConnectionSelectorProps {
sessions: Session[]
@@ -14,6 +15,7 @@ interface ConnectionSelectorProps {
export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSelectorProps) {
const { t } = useTranslation('sftp')
const sshSessions = sessions.filter((session) => isSshSession(session))
return (
<div className="absolute inset-0 bg-dark-900/90 flex items-center justify-center z-40">
@@ -25,7 +27,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
</button>
</div>
<div className="p-4 max-h-80 overflow-y-auto">
{sessions.length === 0 ? (
{sshSessions.length === 0 ? (
<div className="text-center py-8 text-txt-muted">
<Monitor className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>{t('connection.noSessions')}</p>
@@ -33,7 +35,7 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
</div>
) : (
<div className="space-y-2">
{sessions.map((session) => (
{sshSessions.map((session) => (
<button
key={session.id}
onClick={() => onSelect(session.id)}
@@ -41,12 +43,12 @@ export function ConnectionSelector({ sessions, onSelect, onClose }: ConnectionSe
>
<Monitor className="w-8 h-8 text-cyan-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-slate-200 font-medium truncate group-hover:text-content-primary">{session.name}</div>
<div className="text-content-primary font-medium truncate group-hover:text-content-primary">{session.name}</div>
<div className="text-sm text-txt-muted truncate">
{session.username}@{session.host}:{session.port}
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-600 group-hover:text-txt-muted transition-colors" />
<ChevronRight className="w-5 h-5 text-txt-faint group-hover:text-txt-muted transition-colors" />
</button>
))}
</div>
+59 -8
View File
@@ -1,8 +1,19 @@
/**
* ContextMenu - 右键菜单组件
*
* Rendered via portal to document.body so parent overflow/transform cannot clip it.
* Position is fitted to the viewport (flips up/left when near edges).
*/
import { useEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CONTEXT_MENU_VIEWPORT_MARGIN, fitContextMenuToViewport } from './contextMenuPosition'
interface MenuPosition {
left: number
top: number
}
export interface MenuItem {
icon: React.ReactNode
@@ -21,9 +32,40 @@ interface ContextMenuProps {
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<MenuPosition>({ left: x, top: y })
const [positioned, setPositioned] = useState(false)
useLayoutEffect(() => {
const fitToViewport = () => {
const menu = menuRef.current
if (!menu) return
// Prefer scrollHeight/Width so we measure full content even if maxHeight
// temporarily constrains the box during the first paint.
const width = Math.max(menu.offsetWidth, menu.scrollWidth)
const height = Math.max(menu.offsetHeight, menu.scrollHeight)
setPosition(fitContextMenuToViewport(
x,
y,
width,
height,
window.innerWidth,
window.innerHeight,
))
setPositioned(true)
}
fitToViewport()
// Second pass after fonts/layout settle (still before paint when possible).
const raf = window.requestAnimationFrame(fitToViewport)
window.addEventListener('resize', fitToViewport)
return () => {
window.cancelAnimationFrame(raf)
window.removeEventListener('resize', fitToViewport)
}
}, [items, x, y])
useEffect(() => {
const handleClick = (e: MouseEvent) => {
const handlePointerDown = (e: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
@@ -31,19 +73,26 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('pointerdown', handlePointerDown, true)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('pointerdown', handlePointerDown, true)
document.removeEventListener('keydown', handleKeydown)
}
}, [onClose])
return (
return createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-dark-700 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[180px]"
style={{ left: Math.min(x, window.innerWidth - 200), top: Math.min(y, window.innerHeight - 300) }}
className="fixed z-[200] min-w-[180px] overflow-y-auto rounded-lg border border-dark-700 bg-dark-700 py-1 shadow-xl"
style={{
left: position.left,
top: position.top,
maxWidth: `calc(100vw - ${CONTEXT_MENU_VIEWPORT_MARGIN * 2}px)`,
maxHeight: `calc(100vh - ${CONTEXT_MENU_VIEWPORT_MARGIN * 2}px)`,
visibility: positioned ? 'visible' : 'hidden',
}}
onPointerDown={(e) => e.stopPropagation()}
>
{items.map((item, index) => (
<div key={index}>
@@ -51,6 +100,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
<div className="my-1 border-t border-dark-600/30" />
)}
<button
type="button"
onClick={() => {
item.action()
onClose()
@@ -64,6 +114,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
</button>
</div>
))}
</div>
</div>,
document.body
)
}
+415 -131
View File
@@ -1,29 +1,29 @@
/**
* FileListPanel - 文件列表面板
*
* 本地面板或远程面板的文件列表组件
* 远程面板支持拖拽上传
* 支持当前文件夹内文件名搜索
*
* 本地面板或远程面板的文件列表组件。
* 栏间传输:自定义指针拖动(不依赖 WKWebView HTML5 drop)。
* 系统文件拖入远程:仍走 HTML5 drop。
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useState, useCallback, useRef, useEffect, useMemo, memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, FolderOpen,
Search, X, Eye, EyeOff,
Folder, HardDrive, ArrowLeft, ArrowRight, ChevronDown, Upload, Download, FolderOpen,
Search, X, Eye, EyeOff, RefreshCw, FolderPlus,
} from 'lucide-react'
import { Breadcrumb } from './Breadcrumb'
import { SortHeader } from './SortHeader'
import { FileRow } from './FileRow'
import { ContextMenu, type MenuItem } from './ContextMenu'
import { FileListSkeleton } from '../common/Skeleton'
import EmptyState from '../common/EmptyState'
import { filterFilesByName, filterHiddenFiles, isHiddenFileName } from './utils'
import { parentPath } from '../../utils/localPath'
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
import {
hasSftpDragEntry,
readSftpDragEntry,
writeSftpDragEntry,
clearSftpDragEntry,
dndLog,
shortPath,
type SftpDragEntry,
} from './dragDrop'
@@ -31,6 +31,8 @@ interface FileListPanelProps {
title: string
isLocal: boolean
path: string
/** 主目录(Home 按钮目标);远程应传会话起始家目录,不是 / */
homePath?: string
files: FileItem[]
selected: Set<string>
history: string[]
@@ -45,20 +47,74 @@ interface FileListPanelProps {
onOpen: (file: FileItem) => void
onContextMenu: (e: React.MouseEvent, file: FileItem) => void
onSort: (field: SortField) => void
contextMenu: { x: number; y: number; items: MenuItem[] } | null
onCloseContextMenu: () => void
/** 操作系统文件拖入远程面板。 */
onDropFiles?: (files: FileList, targetPath: string) => void
/** OpsXShell 本地/远程栏之间的内部拖拽。 */
onDropEntry?: (entry: SftpDragEntry, targetPath: string) => void
/** 是否正在加载 */
/** 本地/远程栏之间的内部指针拖放(支持多选批量)。 */
onDropEntry?: (entries: SftpDragEntry[], targetPath: string) => void
/** 系统文件 drop 接受了但读不到路径时回调(用于 toast)。 */
onDropFailed?: () => void
onRefresh?: () => void
onNewFolder?: () => void
onFocusPane?: () => void
/** 是否正在加载(空列表时显示骨架) */
loading?: boolean
/** 刷新中:图标旋转 + 状态栏提示,不拆掉现有列表 */
refreshing?: boolean
listError?: string | null
}
export function FileListPanel({
function isAtFilesystemRoot(path: string, isLocal: boolean): boolean {
const parent = parentPath(path, isLocal)
return parent === path || path === '/' || /^[a-zA-Z]:\\?$/.test(path)
}
const POINTER_DRAG_EVENT = 'opsxshell:sftp-pointer-drag'
const POINTER_DRAG_THRESHOLD = 6
interface PointerDragDetail {
source: SftpDragEntry['source'] | null
target: SftpDragEntry['source'] | null
targetPath: string | null
}
interface PointerDragState {
pointerId: number
startX: number
startY: number
entries: SftpDragEntry[]
active: boolean
}
function hasOsFiles(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types || []).includes('Files')
}
function pointerDropTarget(clientX: number, clientY: number, source: SftpDragEntry['source']) {
const hit = document.elementFromPoint?.(clientX, clientY)
const pane = hit?.closest<HTMLElement>('[data-sftp-pane]')
if (!pane || pane.dataset.sftpPane === source) return null
const directory = hit?.closest<HTMLElement>('[data-sftp-directory-path]')
const targetPath = directory && pane.contains(directory)
? directory.dataset.sftpDirectoryPath
: pane.dataset.sftpPath
if (!targetPath) return null
return {
pane: pane.dataset.sftpPane as SftpDragEntry['source'],
path: targetPath,
}
}
function announcePointerDrag(detail: PointerDragDetail) {
window.dispatchEvent(new CustomEvent<PointerDragDetail>(POINTER_DRAG_EVENT, { detail }))
}
export const FileListPanel = memo(function FileListPanel({
title,
isLocal,
path,
homePath,
files,
selected,
history,
@@ -73,22 +129,26 @@ export function FileListPanel({
onOpen,
onContextMenu,
onSort,
contextMenu,
onCloseContextMenu,
onDropFiles,
onDropEntry,
onDropFailed,
onRefresh,
onNewFolder,
onFocusPane,
loading = false,
refreshing = false,
listError = null,
}: FileListPanelProps) {
const { t } = useTranslation('sftp')
const canGoBack = historyIndex > 0
const canGoForward = historyIndex < history.length - 1
const canGoUp = !isAtFilesystemRoot(path, isLocal)
const resolvedHome = homePath || (isLocal ? history[0] || path : '/')
// Current-folder filename filter + hidden files toggle
const [nameFilter, setNameFilter] = useState('')
const [showHidden, setShowHidden] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
// Clear name filter when directory changes (keep showHidden preference)
useEffect(() => {
setNameFilter('')
}, [path])
@@ -109,26 +169,61 @@ export function FileListPanel({
)
const isFiltering = nameFilter.trim().length > 0
// Drag state for remote panel upload
const parentEntry = useMemo((): FileItem | null => {
if (!canGoUp || isFiltering) return null
return {
name: '..',
path: parentPath(path, isLocal),
type: 'directory',
size: 0,
modified: 0,
permissions: 'drwxr-xr-x',
}
}, [canGoUp, isFiltering, isLocal, path])
const [isDragOver, setIsDragOver] = useState(false)
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null)
const [dragSource, setDragSource] = useState<SftpDragEntry['source'] | null>(null)
const dragCounterRef = useRef(0)
/** Nested dragenter/leave — OS file HTML5 drop only. */
const dragDepthRef = useRef(0)
const pointerDragRef = useRef<PointerDragState | null>(null)
const suppressNextClickRef = useRef(false)
const [pointerPreview, setPointerPreview] = useState<{
x: number
y: number
entries: SftpDragEntry[]
canDrop: boolean
} | null>(null)
const acceptsDrag = useCallback((dataTransfer: DataTransfer) => {
const internal = readSftpDragEntry(dataTransfer)
if (internal) return internal.source !== (isLocal ? 'local' : 'remote') && !!onDropEntry
if (hasSftpDragEntry(dataTransfer)) return !!onDropEntry
return !isLocal && !!onDropFiles && Array.from(dataTransfer.types || []).includes('Files')
}, [isLocal, onDropEntry, onDropFiles])
const acceptsOsFileDrag = useCallback((dataTransfer: DataTransfer) => {
if (isLocal || !onDropFiles) return false
return hasOsFiles(dataTransfer)
}, [isLocal, onDropFiles])
const resetDragState = useCallback(() => {
dragCounterRef.current = 0
const resetOsDragState = useCallback(() => {
dragDepthRef.current = 0
setIsDragOver(false)
setDropTargetPath(null)
setDragSource(null)
}, [])
// Cross-pane highlight for pointer-based internal dragging.
useEffect(() => {
const onPointerDrag = (event: Event) => {
const detail = (event as CustomEvent<PointerDragDetail>).detail
const pane = isLocal ? 'local' : 'remote'
const active = detail.target === pane
setIsDragOver(active)
setDragSource(active ? detail.source : null)
setDropTargetPath(active ? detail.targetPath : null)
}
window.addEventListener(POINTER_DRAG_EVENT, onPointerDrag)
return () => {
window.removeEventListener(POINTER_DRAG_EVENT, onPointerDrag)
document.body.classList.remove('sftp-pointer-dragging')
}
}, [isLocal])
const clearFilter = useCallback(() => {
setNameFilter('')
searchInputRef.current?.focus()
@@ -146,7 +241,6 @@ export function FileListPanel({
}
}, [nameFilter])
// Panel-level shortcut: focus search (Ctrl/Cmd+F)
const handlePanelKeyDown = useCallback((e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') {
e.preventDefault()
@@ -156,81 +250,213 @@ export function FileListPanel({
}
}, [])
const consumeOsDrop = useCallback((dataTransfer: DataTransfer, targetPath: string) => {
resetOsDragState()
const droppedFiles = dataTransfer.files
if (!isLocal && droppedFiles && droppedFiles.length > 0) {
dndLog('drop-os-files', {
pane: 'remote',
target: shortPath(targetPath),
count: droppedFiles.length,
})
onDropFiles?.(droppedFiles, targetPath)
return
}
dndLog('drop-os-failed', {
pane: isLocal ? 'local' : 'remote',
target: shortPath(targetPath),
types: Array.from(dataTransfer.types || []),
})
onDropFailed?.()
}, [isLocal, onDropFiles, onDropFailed, resetOsDragState])
const handleDragEnter = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
setDragSource(readSftpDragEntry(e.dataTransfer)?.source || null)
dragCounterRef.current++
if (dragCounterRef.current === 1) {
setIsDragOver(true)
dragDepthRef.current += 1
setDragSource(null)
setIsDragOver(true)
if (dragDepthRef.current === 1) {
dndLog('os-dragenter', { pane: 'remote', accept: true })
}
}, [acceptsDrag])
}, [acceptsOsFileDrag])
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer) && dragDepthRef.current === 0) return
e.preventDefault()
e.stopPropagation()
dragCounterRef.current--
if (dragCounterRef.current === 0) {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) {
setIsDragOver(false)
setDropTargetPath(null)
}
}, [acceptsDrag])
}, [acceptsOsFileDrag])
const handleDragOver = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}, [acceptsDrag])
if (!isDragOver) setIsDragOver(true)
}, [acceptsOsFileDrag, isDragOver])
const handleDrop = useCallback((e: React.DragEvent) => {
if (!acceptsDrag(e.dataTransfer)) return
if (!acceptsOsFileDrag(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
const entry = readSftpDragEntry(e.dataTransfer)
resetDragState()
consumeOsDrop(e.dataTransfer, path)
}, [acceptsOsFileDrag, consumeOsDrop, path])
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
onDropEntry?.(entry, path)
return
}
const droppedFiles = e.dataTransfer.files
if (droppedFiles && droppedFiles.length > 0) {
onDropFiles?.(droppedFiles, path)
}
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, path, resetDragState])
const dropOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
if (!acceptsDrag(event.dataTransfer)) return
const dropOsOnDirectory = useCallback((event: React.DragEvent, directory: FileItem) => {
if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
const entry = readSftpDragEntry(event.dataTransfer)
resetDragState()
if (entry && entry.source !== (isLocal ? 'local' : 'remote')) {
onDropEntry?.(entry, directory.path)
return
dndLog('drop-os-on-dir', { dir: shortPath(directory.path) })
consumeOsDrop(event.dataTransfer, directory.path)
}, [acceptsOsFileDrag, consumeOsDrop])
const dragEntriesFor = useCallback((file: FileItem): SftpDragEntry[] => {
const source: SftpDragEntry['source'] = isLocal ? 'local' : 'remote'
const selectedNames = selected.has(file.name) && selected.size > 1
? selected
: new Set([file.name])
const entries = files
.filter((item) => selectedNames.has(item.name))
.map((item) => ({
source,
name: item.name,
path: item.path,
type: item.type,
size: item.size,
}))
return entries.length > 0 ? entries : [{
source,
name: file.name,
path: file.path,
type: file.type,
size: file.size,
}]
}, [files, isLocal, selected])
const resetPointerDrag = useCallback(() => {
pointerDragRef.current = null
setPointerPreview(null)
announcePointerDrag({ source: null, target: null, targetPath: null })
clearSftpDragEntry()
document.body.classList.remove('sftp-pointer-dragging')
}, [])
const startPointerDrag = useCallback((file: FileItem, event: React.PointerEvent) => {
if (event.button !== 0 || !onDropEntry) return
// Stop the browser from starting a text selection before the drag threshold.
event.preventDefault()
window.getSelection()?.removeAllRanges()
const entries = dragEntriesFor(file)
pointerDragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
entries,
active: false,
}
if (!isLocal && event.dataTransfer.files?.length) {
onDropFiles?.(event.dataTransfer.files, directory.path)
event.currentTarget.setPointerCapture?.(event.pointerId)
}, [dragEntriesFor, onDropEntry])
const movePointerDrag = useCallback((event: React.PointerEvent) => {
const state = pointerDragRef.current
if (!state || state.pointerId !== event.pointerId) return
if (!state.active) {
const distance = Math.hypot(event.clientX - state.startX, event.clientY - state.startY)
if (distance < POINTER_DRAG_THRESHOLD) return
state.active = true
suppressNextClickRef.current = true
document.body.classList.add('sftp-pointer-dragging')
window.getSelection()?.removeAllRanges()
dndLog('pointer-drag-start', {
source: state.entries[0]?.source,
count: state.entries.length,
names: state.entries.slice(0, 8).map((entry) => entry.name),
})
}
}, [acceptsDrag, isLocal, onDropEntry, onDropFiles, resetDragState])
event.preventDefault()
window.getSelection()?.removeAllRanges()
const source = state.entries[0]?.source
if (!source) return
const target = pointerDropTarget(event.clientX, event.clientY, source)
setPointerPreview({
x: event.clientX,
y: event.clientY,
entries: state.entries,
canDrop: Boolean(target),
})
announcePointerDrag({
source,
target: target?.pane || null,
targetPath: target?.path || null,
})
}, [])
const finishPointerDrag = useCallback((event: React.PointerEvent) => {
const state = pointerDragRef.current
if (!state || state.pointerId !== event.pointerId) return
event.currentTarget.releasePointerCapture?.(event.pointerId)
if (state.active) {
event.preventDefault()
const source = state.entries[0]?.source
const target = source ? pointerDropTarget(event.clientX, event.clientY, source) : null
if (target) {
dndLog('pointer-drop', {
source,
target: target.pane,
targetPath: shortPath(target.path),
count: state.entries.length,
})
onDropEntry?.(state.entries, target.path)
} else {
dndLog('pointer-drop-cancelled', { source })
}
setTimeout(() => {
suppressNextClickRef.current = false
}, 0)
}
resetPointerDrag()
}, [onDropEntry, resetPointerDrag])
const cancelPointerDrag = useCallback((event: React.PointerEvent) => {
const state = pointerDragRef.current
if (!state || state.pointerId !== event.pointerId) return
event.currentTarget.releasePointerCapture?.(event.pointerId)
suppressNextClickRef.current = false
resetPointerDrag()
}, [resetPointerDrag])
const statusText = (() => {
if (refreshing) return t('status.refreshing')
if (loading) return t('status.loading')
// listError is shown in the banner above; keep the footer for selection/count.
if (selected.size > 0) return t('status.selected', { count: selected.size })
if (isFiltering) return t('status.filtered', { shown: filteredFiles.length, total: visibleFiles.length })
if (!showHidden && hiddenCount > 0) return t('status.hiddenCount', { total: visibleFiles.length, hidden: hiddenCount })
return t('status.total', { count: visibleFiles.length })
})()
const PreviewIcon = pointerPreview?.entries[0]?.source === 'remote' ? Download : Upload
return (
<div
aria-label={t('panel.ariaLabel', { title })}
className={`flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
data-sftp-pane={isLocal ? 'local' : 'remote'}
data-sftp-path={path}
className={`sftp-file-panel flex-1 flex flex-col bg-dark-800 rounded-lg border overflow-hidden transition-colors ${
isDragOver ? 'border-accent-blue shadow-lg shadow-accent-blue/20' : 'border-dark-700/40'
}`}
onMouseDown={() => onFocusPane?.()}
onFocusCapture={() => onFocusPane?.()}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
@@ -238,13 +464,36 @@ export function FileListPanel({
onKeyDown={handlePanelKeyDown}
tabIndex={-1}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-dark-700 border-b border-dark-600/30">
<div className="flex items-center gap-2 min-w-0">
{isLocal ? <HardDrive className="w-4 h-4 text-accent-blue flex-shrink-0" /> : <Folder className="w-4 h-4 text-accent-green flex-shrink-0" />}
<span className="text-sm font-medium truncate">{title}</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={refreshing || loading}
className="p-1.5 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary disabled:opacity-50 disabled:pointer-events-none"
title={t('toolbar.refresh')}
aria-label={t('toolbar.refreshAria')}
aria-busy={refreshing || loading}
>
<RefreshCw className={`w-4 h-4 ${(refreshing || loading) ? 'animate-spin' : ''}`} />
</button>
)}
{onNewFolder && (
<button
type="button"
onClick={onNewFolder}
className="p-1.5 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary"
title={t('toolbar.newFolder')}
aria-label={t('toolbar.newFolderAria')}
>
<FolderPlus className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={() => setShowHidden((v) => !v)}
@@ -277,22 +526,22 @@ export function FileListPanel({
</button>
<button
onClick={onNavigateUp}
className="p-1.5 hover:bg-dark-600 rounded"
title={t('nav.up')}
disabled={!canGoUp}
className="p-1.5 hover:bg-dark-600 rounded disabled:opacity-30"
title={canGoUp ? t('nav.up') : t('nav.upAtRoot')}
>
<ChevronDown className="w-4 h-4 rotate-180" />
</button>
</div>
</div>
{/* Breadcrumb + search */}
<div className="px-3 py-2 border-b border-dark-700/20 flex items-center gap-2 min-w-0">
<div className="flex-1 min-w-0">
<Breadcrumb
path={path}
isLocal={isLocal}
onNavigate={onNavigate}
onHome={() => onNavigate(isLocal ? (history[0] || path) : '/')}
onHome={() => onNavigate(resolvedHome)}
/>
</div>
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
@@ -321,27 +570,40 @@ export function FileListPanel({
</div>
</div>
{/* Column Headers — 与 FileRow 共用 px-4 对齐 */}
{listError && (
<div className="px-3 py-1.5 border-b border-red-500/20 bg-red-500/10 text-xs text-red-400 flex items-center justify-between gap-2">
<span className="truncate">{listError}</span>
{canGoUp && (
<button
type="button"
onClick={onNavigateUp}
className="flex-shrink-0 rounded bg-dark-700 px-2 py-0.5 text-content-secondary hover:bg-dark-600"
>
{t('empty.goUp')}
</button>
)}
</div>
)}
<div className="px-4 border-b border-dark-700/20 bg-dark-800/80">
<SortHeader sortField={sortField} sortOrder={sortOrder} onSort={onSort} />
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto relative">
{loading ? (
<FileListSkeleton rows={6} />
) : files.length === 0 ? (
) : files.length === 0 && !parentEntry ? (
<EmptyState
icon={FolderOpen}
title={t('empty.dirTitle')}
description={isLocal ? t('empty.dirDescLocal') : t('empty.dirDescRemote')}
action={
path !== '/'
canGoUp
? { label: t('empty.goUp'), onClick: onNavigateUp }
: undefined
}
/>
) : visibleFiles.length === 0 ? (
) : visibleFiles.length === 0 && !parentEntry ? (
<EmptyState
icon={EyeOff}
title={t('empty.hiddenOnlyTitle')}
@@ -351,7 +613,7 @@ export function FileListPanel({
onClick: () => setShowHidden(true),
}}
/>
) : filteredFiles.length === 0 ? (
) : filteredFiles.length === 0 && !parentEntry ? (
<EmptyState
icon={Search}
title={t('empty.noMatchTitle')}
@@ -359,54 +621,65 @@ export function FileListPanel({
action={{ label: t('nav.clearSearch'), onClick: clearFilter }}
/>
) : (
filteredFiles.map((file) => (
<FileRow
key={file.name}
file={file}
isSelected={selected.has(file.name)}
onSelect={() => onSelect(file.name)}
onOpen={() => onOpen(file)}
onContextMenu={(e) => onContextMenu(e, file)}
onDragStart={(event) => {
writeSftpDragEntry(event.dataTransfer, {
source: isLocal ? 'local' : 'remote',
name: file.name,
path: file.path,
type: file.type,
size: file.size,
})
}}
onDragEnd={resetDragState}
onDragEnter={file.type === 'directory' ? (event) => {
if (!acceptsDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
setDragSource(readSftpDragEntry(event.dataTransfer)?.source || null)
setIsDragOver(true)
setDropTargetPath(file.path)
} : undefined}
onDragLeave={file.type === 'directory' ? (event) => {
event.preventDefault()
event.stopPropagation()
setDropTargetPath((current) => current === file.path ? null : current)
} : undefined}
onDragOver={file.type === 'directory' ? (event) => {
if (!acceptsDrag(event.dataTransfer)) return
event.preventDefault()
event.stopPropagation()
event.dataTransfer.dropEffect = 'copy'
} : undefined}
onDrop={file.type === 'directory' ? (event) => dropOnDirectory(event, file) : undefined}
isDropTarget={dropTargetPath === file.path}
/>
))
<>
{parentEntry && (
<FileRow
key="__parent__"
file={parentEntry}
isSelected={false}
onSelect={() => {}}
onOpen={() => onNavigate(parentEntry.path)}
onContextMenu={(e) => {
e.preventDefault()
}}
/>
)}
{filteredFiles.map((file) => (
<FileRow
key={file.name}
file={file}
isSelected={selected.has(file.name)}
onSelect={() => {
if (suppressNextClickRef.current) return
onSelect(file.name)
}}
onOpen={() => onOpen(file)}
onContextMenu={(e) => onContextMenu(e, file)}
onPointerDown={(event) => startPointerDrag(file, event)}
onPointerMove={movePointerDrag}
onPointerUp={finishPointerDrag}
onPointerCancel={cancelPointerDrag}
onDragEnter={file.type === 'directory' ? (event) => {
if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
setDragSource(null)
setIsDragOver(true)
setDropTargetPath(file.path)
} : undefined}
onDragLeave={file.type === 'directory' ? (event) => {
event.preventDefault()
setDropTargetPath((current) => current === file.path ? null : current)
} : undefined}
onDragOver={file.type === 'directory' ? (event) => {
if (!acceptsOsFileDrag(event.dataTransfer)) return
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
} : undefined}
onDrop={file.type === 'directory' ? (event) => dropOsOnDirectory(event, file) : undefined}
isDropTarget={dropTargetPath === file.path}
/>
))}
</>
)}
{/* Drag overlay - pointer-events-none so it doesn't interfere with drop events */}
{isDragOver && (
<div className="absolute inset-0 bg-accent-blue/10 border-2 border-dashed border-accent-blue rounded-lg flex items-center justify-center pointer-events-none z-10">
<div className="flex flex-col items-center gap-2 text-accent-blue">
<Upload className="w-10 h-10" />
{isLocal || dragSource === 'remote' ? (
<Download className="w-10 h-10" />
) : (
<Upload className="w-10 h-10" />
)}
<span className="text-sm font-medium">
{isLocal || dragSource === 'remote'
? t('transfer.dropDownload', { path: dropTargetPath || path })
@@ -415,20 +688,31 @@ export function FileListPanel({
</div>
</div>
)}
{pointerPreview && (
<div
className={`pointer-events-none fixed z-[100] flex max-w-64 items-center gap-2 rounded-md border px-3 py-2 text-xs shadow-xl ${
pointerPreview.canDrop
? 'border-accent-blue bg-dark-700 text-content-primary'
: 'border-dark-600 bg-dark-800 text-txt-muted'
}`}
style={{ left: pointerPreview.x + 14, top: pointerPreview.y + 14 }}
>
<PreviewIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{pointerPreview.entries[0]?.name}</span>
{pointerPreview.entries.length > 1 && (
<span className="flex-shrink-0">+{pointerPreview.entries.length - 1}</span>
)}
</div>
)}
</div>
{/* Status Bar */}
<div className="px-4 py-2 border-t border-dark-700/20 text-xs text-txt-muted flex items-center justify-between gap-2">
<span>{statusText}</span>
<span className="truncate">{statusText}</span>
{isFiltering && (
<span className="text-txt-faint truncate">{t('status.filterHint', { query: nameFilter.trim() })}</span>
)}
</div>
{/* Context Menu */}
{contextMenu && (
<ContextMenu {...contextMenu} onClose={onCloseContextMenu} />
)}
</div>
)
}
})
+24 -7
View File
@@ -13,6 +13,10 @@ interface FileRowProps {
onSelect: () => void
onOpen: () => void
onContextMenu: (e: React.MouseEvent) => void
onPointerDown?: (e: React.PointerEvent) => void
onPointerMove?: (e: React.PointerEvent) => void
onPointerUp?: (e: React.PointerEvent) => void
onPointerCancel?: (e: React.PointerEvent) => void
onDragStart?: (e: React.DragEvent) => void
onDragEnd?: (e: React.DragEvent) => void
onDragEnter?: (e: React.DragEvent) => void
@@ -28,6 +32,10 @@ export function FileRow({
onSelect,
onOpen,
onContextMenu,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onDragStart,
onDragEnd,
onDragEnter,
@@ -42,22 +50,29 @@ export function FileRow({
onClick={onSelect}
onDoubleClick={file.type === 'directory' || file.type === 'symlink' ? onOpen : undefined}
onContextMenu={onContextMenu}
draggable={!!onDragStart}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
data-sftp-entry-path={file.path}
data-sftp-directory-path={file.type === 'directory' ? file.path : undefined}
draggable={false}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
className={`h-10 flex items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
className={`h-10 flex select-none items-center px-4 cursor-pointer border-b border-dark-700/20 transition-colors group last:border-b-0 ${
isDropTarget
? 'bg-accent-blue/25 ring-1 ring-inset ring-accent-blue'
: isSelected
? 'bg-accent-blue/20 hover:bg-accent-blue/25'
: 'hover:bg-dark-700/40'
}`}
style={{ WebkitUserSelect: 'none', userSelect: 'none', touchAction: 'none' }}
>
<div className="flex-1 flex items-center gap-2 min-w-0">
<div className="sftp-col-name flex-1 flex items-center gap-2 min-w-0">
{file.type === 'directory' ? (
<Folder className="w-4 h-4 text-accent-blue flex-shrink-0" />
) : file.type === 'symlink' ? (
@@ -66,13 +81,15 @@ export function FileRow({
<File className="w-4 h-4 text-txt-muted flex-shrink-0" />
)}
<span className="text-sm text-content-primary truncate group-hover:text-content-primary">{file.name}</span>
{file.type === 'symlink' && <span className="text-xs text-yellow-500">→</span>}
{file.type === 'symlink' && <span className="text-xs text-yellow-500 flex-shrink-0">→</span>}
</div>
<div className="w-32 text-right text-xs text-txt-muted">{formatDate(file.modified)}</div>
<div className="w-24 text-right text-xs text-txt-muted">
<div className="sftp-col-modified w-28 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
{formatDate(file.modified)}
</div>
<div className="sftp-col-size w-16 flex-shrink-0 text-right text-xs text-txt-muted tabular-nums">
{file.type === 'directory' ? '--' : formatSize(file.size)}
</div>
<div className="w-24 text-right text-xs text-txt-muted font-mono">
<div className="sftp-col-type w-16 flex-shrink-0 text-right text-xs text-txt-muted font-mono truncate">
{file.type === 'directory'
? t('fileType.folder')
: file.type === 'symlink'
+196 -94
View File
@@ -1,15 +1,25 @@
/**
* Remote text file editor (SFTP) with Monaco
* Remote text file editor (SFTP) with Monaco.
*
* Renders as an in-pane overlay (not viewport-fixed) so the app title bar /
* tab bar stay free for window dragging.
*/
import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'
import { useTranslation } from 'react-i18next'
import { X, Save, FileCode } from 'lucide-react'
import { X, Save, FileCode, RotateCcw } from 'lucide-react'
import type { editor } from 'monaco-editor'
import { guessLanguageFromPath } from '../../utils/fileLanguage'
import { translateErrorMessage } from '../../i18n/format'
import { readRemoteTextFile, remotePath as toRemotePath, writeRemoteTextFile } from '../../platform/sftp'
import { useConfirmDialog } from '../common/ConfirmDialog'
import { useTheme } from '../../hooks/useTheme'
const MonacoEditor = lazy(() => import('@monaco-editor/react'))
/** Above this, turn off expensive Monaco features (minimap, folding, etc.). */
const LIGHT_EDITOR_CHARS = 80_000
export interface RemoteTextEditorProps {
isOpen: boolean
sftpId: string
@@ -28,6 +38,8 @@ export default function RemoteTextEditor({
onSaved,
}: RemoteTextEditorProps) {
const { t } = useTranslation(['sftp', 'common'])
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
const { effective } = useTheme()
const [content, setContent] = useState('')
const [original, setOriginal] = useState('')
const [loading, setLoading] = useState(false)
@@ -36,6 +48,36 @@ export default function RemoteTextEditor({
const dirty = content !== original
const language = useMemo(() => guessLanguageFromPath(remotePath || fileName), [remotePath, fileName])
const lightMode = content.length > LIGHT_EDITOR_CHARS
const editorOptions = useMemo((): editor.IStandaloneEditorConstructionOptions => {
return {
fontSize: 13,
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
minimap: { enabled: !lightMode && content.length > 20_000, scale: 0.75 },
wordWrap: 'on',
automaticLayout: true,
scrollBeyondLastLine: false,
tabSize: 2,
renderWhitespace: lightMode ? 'none' : 'selection',
smoothScrolling: !lightMode,
padding: { top: 12, bottom: 12 },
folding: !lightMode,
links: !lightMode,
matchBrackets: lightMode ? 'never' : 'always',
occurrencesHighlight: lightMode ? 'off' : 'singleFile',
renderLineHighlight: lightMode ? 'none' : 'line',
quickSuggestions: !lightMode,
suggestOnTriggerCharacters: !lightMode,
parameterHints: { enabled: !lightMode },
hover: { enabled: !lightMode },
codeLens: false,
lightbulb: {
enabled: (lightMode ? 'off' : 'onCode') as editor.ShowLightbulbIconMode,
},
unicodeHighlight: { ambiguousCharacters: false },
}
}, [content.length, lightMode])
const load = useCallback(async () => {
if (!isOpen || !sftpId || !remotePath) return
@@ -49,33 +91,17 @@ export default function RemoteTextEditor({
setContent(result.content)
setOriginal(result.content)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
} finally {
setLoading(false)
}
}, [isOpen, sftpId, remotePath])
}, [isOpen, sftpId, remotePath, t])
useEffect(() => {
void load()
}, [load])
// Cmd/Ctrl+S
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
if (dirty && !saving && !loading) {
void handleSave()
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, dirty, saving, loading, content])
const handleSave = async () => {
const handleSave = useCallback(async () => {
setSaving(true)
setError(null)
try {
@@ -90,93 +116,169 @@ export default function RemoteTextEditor({
setOriginal(content)
onSaved?.()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
setError(translateErrorMessage(e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
}, [content, onSaved, remotePath, sftpId, t])
const handleClose = () => {
if (dirty && !confirm(t('editor.unsavedClose'))) return
const handleClose = useCallback(async () => {
if (dirty) {
const ok = await confirm({
title: t('editor.unsavedTitle'),
message: t('editor.unsavedClose'),
danger: true,
confirmText: t('editor.discardAndClose'),
})
if (!ok) return
}
onClose()
}
}, [dirty, confirm, onClose, t])
const handleReload = useCallback(async () => {
if (dirty) {
const ok = await confirm({
title: t('editor.reloadTitle'),
message: t('editor.unsavedReload'),
danger: true,
confirmText: t('editor.discardAndReload'),
})
if (!ok) return
}
void load()
}, [dirty, confirm, load, t])
// Cmd/Ctrl+S save · Escape close
useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
e.stopPropagation()
if (dirty && !saving && !loading) void handleSave()
return
}
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
void handleClose()
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [isOpen, dirty, saving, loading, handleSave, handleClose])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-dark-600 bg-dark-800 shadow-2xl">
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<FileCode className="h-4 w-4 flex-shrink-0 text-accent-blue" />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-content-primary">
{fileName}
{dirty && <span className="ml-1 text-yellow-400">•</span>}
<span className="ml-2 text-xs font-normal text-txt-faint">{language}</span>
</div>
<div className="truncate font-mono text-xs text-txt-faint">{remotePath}</div>
<div
className="absolute inset-0 z-30 flex flex-col bg-dark-900"
role="dialog"
aria-modal="true"
aria-label={t('editor.title', { name: fileName })}
>
<header className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/60 bg-dark-800 px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-accent-blue/15 text-accent-blue">
<FileCode className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 truncate text-sm font-medium text-content-primary">
<span className="truncate">{fileName}</span>
{dirty && (
<span className="flex-shrink-0 rounded bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-normal text-yellow-400">
{t('editor.dirtyBadge')}
</span>
)}
<span className="flex-shrink-0 rounded bg-dark-700 px-1.5 py-0.5 text-[10px] font-normal uppercase tracking-wide text-txt-faint">
{language}
</span>
</div>
<div className="truncate font-mono text-[11px] text-txt-faint" title={remotePath}>
{remotePath}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={!dirty || saving || loading}
onClick={() => void handleSave()}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-blue px-3 py-1.5 text-sm text-white hover:bg-blue-500 disabled:opacity-40"
>
<Save className="h-3.5 w-3.5" />
{saving ? t('editor.saving') : t('editor.save')}
</button>
<button type="button" onClick={handleClose} className="rounded-lg p-2 text-txt-muted hover:bg-dark-700">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => void handleReload()}
disabled={loading || saving}
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary disabled:opacity-40"
title={t('editor.reload')}
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('editor.reload')}</span>
</button>
<button
type="button"
disabled={!dirty || saving || loading}
onClick={() => void handleSave()}
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors disabled:opacity-40 ${
dirty
? 'bg-accent-blue text-white hover:bg-blue-500'
: 'bg-dark-700 text-txt-muted'
}`}
>
<Save className="h-3.5 w-3.5" />
{saving ? t('editor.saving') : t('editor.save')}
</button>
<button
type="button"
onClick={() => void handleClose()}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
title={t('common:action.close')}
aria-label={t('common:action.close')}
>
<X className="h-4 w-4" />
</button>
</div>
</header>
{error && (
<div className="flex-shrink-0 border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">
{error}
</div>
)}
<div className="min-h-0 flex-1 bg-[#1e1e1e]">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
{t('common:status.loading')}
</div>
</div>
{error && (
<div className="border-b border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</div>
) : error && !content ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-txt-muted">
{t('editor.loadBlocked')}
</div>
) : (
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-txt-muted">
{t('editor.loadingEditor')}
</div>
}
>
<MonacoEditor
height="100%"
language={language}
theme={effective === 'light' ? 'vs' : 'vs-dark'}
value={content}
onChange={(v) => setContent(v ?? '')}
options={editorOptions}
/>
</Suspense>
)}
<div className="min-h-0 flex-1">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('common:status.loading')}</div>
) : (
<Suspense
fallback={
<div className="flex h-full items-center justify-center text-sm text-txt-muted">{t('editor.loadingEditor')}</div>
}
>
<MonacoEditor
height="100%"
language={language}
theme="vs-dark"
value={content}
onChange={(v) => setContent(v ?? '')}
options={{
fontSize: 13,
fontFamily: 'JetBrains Mono, Menlo, Monaco, monospace',
minimap: { enabled: true, scale: 0.75 },
wordWrap: 'on',
automaticLayout: true,
scrollBeyondLastLine: false,
tabSize: 2,
renderWhitespace: 'selection',
smoothScrolling: true,
}}
/>
</Suspense>
)}
</div>
<div className="flex justify-between border-t border-dark-700 px-4 py-2 text-xs text-txt-faint">
<span>{t('editor.footer')}</span>
<span>
{t('editor.charCount', { count: content.length.toLocaleString() })}
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
</span>
</div>
</div>
<footer className="flex flex-shrink-0 items-center justify-between gap-3 border-t border-dark-700/60 bg-dark-800 px-4 py-1.5 text-[11px] text-txt-faint">
<span>{t('editor.footer')}</span>
<span>
{t('editor.charCount', { count: content.length.toLocaleString() })}
{dirty ? t('editor.unsavedMark') : t('editor.savedMark')}
</span>
</footer>
{ConfirmDialogComponent}
</div>
)
}
+346 -81
View File
@@ -4,30 +4,39 @@
* 使用子组件拆分后的简化版本
*/
import { useState, useCallback, useEffect, useRef } from 'react'
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
FolderOpen, Upload, Download, Trash2,
Edit3, Copy, FileText, ShieldCheck,
Edit3, FileText, ShieldCheck,
Unplug, FileCode,
} from 'lucide-react'
import { useSFTP } from '../../hooks'
import { useToast } from '../../hooks/useToast'
import { getConnectionStore } from '../../stores/connection'
import { FileListPanel } from './FileListPanel'
import { TransferPanel } from './TransferPanel'
import { Toolbar } from './Toolbar'
import { ConnectionPanel } from './ConnectionPanel'
import { TransferRail } from './TransferRail'
import { ContextMenu, type MenuItem } from './ContextMenu'
import { ToastContainer } from '../common/ToastContainer'
import EmptyState from '../common/EmptyState'
import { sortFiles } from './utils'
import { usePromptDialog } from '../common/PromptDialog'
import { useConfirmDialog } from '../common/ConfirmDialog'
import FilePropertiesDialog from './FilePropertiesDialog'
import ChmodDialog from './ChmodDialog'
import RemoteTextEditor from './RemoteTextEditor'
import type { FileItem, SortField, SortOrder } from '../../../shared/types/sftp.types'
import { parentPath } from '../../utils/localPath'
import { parentPath, joinLocalPath } from '../../utils/localPath'
import type { SftpDragEntry } from './dragDrop'
import { dndLog, shortPath } from './dragDrop'
import { isEditableRemoteTextFile } from '../../utils/fileLanguage'
import { isWithinRemoteTextEditLimit } from '../../utils/remoteTextLimits'
import {
conflictNamesFromListing,
formatOverwriteMessage,
transferDestinationPath,
} from './transferConflicts'
interface SFTPViewProps {
tabId: string
@@ -35,8 +44,8 @@ interface SFTPViewProps {
sessionId?: string
}
export default function SFTPView({ tabId, isActive: _isActive = true, sessionId }: SFTPViewProps) {
const { t } = useTranslation('sftp')
export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPViewProps) {
const { t } = useTranslation(['sftp', 'errors'])
const {
sessions,
localPath,
@@ -59,23 +68,32 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
navigateRemoteBack,
navigateRemoteForward,
loadRemoteDirectory,
remoteHomePath,
isConnected,
isConnecting,
connect,
transferQueue,
uploadFiles,
downloadFiles,
cancelTransfer,
removeTransfer,
retryTransfer,
deleteRemote,
renameRemote,
mkdirRemote,
getRemoteStat,
chmodRemote,
error,
clearError,
sftpId,
connectionId,
localLoading,
remoteLoading,
} = useSFTP({ tabId })
const { toasts, toast, removeToast } = useToast()
const { toast } = useToast()
const { prompt, PromptDialogComponent } = usePromptDialog()
const { confirm, ConfirmDialogComponent } = useConfirmDialog()
// UI State
const [sortField, setSortField] = useState<SortField>('name')
@@ -83,6 +101,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
const [isTransferPanelOpen, setIsTransferPanelOpen] = useState(true)
const [showConnectionSelector, setShowConnectionSelector] = useState(false)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
const hadConnectedRef = useRef(false)
useEffect(() => {
if (isConnected) hadConnectedRef.current = true
}, [isConnected])
// Properties dialog state
const [propertiesFile, setPropertiesFile] = useState<FileItem | null>(null)
@@ -93,6 +115,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
// Remote text editor
const [editFile, setEditFile] = useState<FileItem | null>(null)
/** 工具栏「新建文件夹」作用于当前焦点面板 */
const [, setActivePane] = useState<'local' | 'remote'>('remote')
/** 仅远程列表错误展示在远程面板(连接错误仍走 ConnectionPanel) */
const remoteListError = isConnected ? error : null
const lastListErrorToast = useRef<string | null>(null)
// Surface list failures as a short toast (status strip alone is easy to miss).
useEffect(() => {
if (!remoteListError) {
lastListErrorToast.current = null
return
}
if (lastListErrorToast.current === remoteListError) return
lastListErrorToast.current = remoteListError
toast.warning(remoteListError, 4000)
}, [remoteListError, toast])
// Auto-connect when sessionId is provided and not connected
const autoConnectAttempted = useRef(false)
@@ -109,6 +147,12 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}
}, [sessionId])
// Keep pool idle-suspend from arming while this SFTP tab is in front.
useEffect(() => {
if (!isActive || !connectionId || !isConnected) return
getConnectionStore().setActiveTab(connectionId, tabId)
}, [isActive, connectionId, isConnected, tabId])
// Sort handler
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
@@ -155,14 +199,107 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}, [t, toast])
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
const result = await uploadFiles(sources, targetPath)
const dest = targetPath ?? remotePath
let pending = sources
const names = sources.map((source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path)
let conflicts =
dest === remotePath
? conflictNamesFromListing(names, remoteFiles)
: []
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
const hits: string[] = []
for (const name of names) {
const remoteTarget = transferDestinationPath('upload', dest, name)
try {
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
}
}
conflicts = hits
}
if (conflicts.length > 0) {
const overwrite = await confirm({
title: t('dialog.overwriteTitle'),
message: formatOverwriteMessage(conflicts, 'upload', t),
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'),
danger: true,
})
if (!overwrite) {
const skip = new Set(conflicts)
pending = sources.filter((source) => {
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
return !skip.has(name)
})
if (pending.length === 0) {
toast.info(t('toast.allSkippedExisting'))
return
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
}
}
setIsTransferPanelOpen(true)
const result = await uploadFiles(pending, dest)
reportTransfer('upload', result)
}, [reportTransfer, uploadFiles])
}, [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles])
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
const result = await downloadFiles(files, targetPath)
const dest = targetPath ?? localPath
const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name))
let pending = selected
const names = selected.map((file) => file.name)
let conflicts =
dest === localPath ? conflictNamesFromListing(names, localFiles) : []
if (dest !== localPath && window.opsxApi?.localGetStats) {
const hits: string[] = []
for (const name of names) {
const localTarget = transferDestinationPath('download', dest, name)
try {
const result = (await window.opsxApi.localGetStats(localTarget)) as {
success?: boolean
stats?: unknown
}
if (result?.success && result.stats) hits.push(name)
} catch {
// treat as missing
}
}
conflicts = hits
}
if (conflicts.length > 0) {
const overwrite = await confirm({
title: t('dialog.overwriteTitle'),
message: formatOverwriteMessage(conflicts, 'download', t),
confirmText: t('dialog.overwriteConfirm'),
cancelText: t('dialog.overwriteSkip'),
danger: true,
})
if (!overwrite) {
const skip = new Set(conflicts)
pending = selected.filter((file) => !skip.has(file.name))
if (pending.length === 0) {
toast.info(t('toast.allSkippedExisting'))
return
}
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
}
}
setIsTransferPanelOpen(true)
const result = await downloadFiles(pending, dest)
reportTransfer('download', result)
}, [downloadFiles, reportTransfer])
}, [
confirm,
downloadFiles,
localFiles,
localPath,
remoteFiles,
remoteSelected,
reportTransfer,
t,
toast,
])
// ===== Context menu handlers =====
@@ -184,21 +321,28 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}
}, [prompt, renameRemote, t, toast])
// Copy path handler
const handleCopyPath = useCallback(async (filePath: string) => {
try {
await navigator.clipboard.writeText(filePath)
toast.success(t('toast.pathCopied'))
} catch {
toast.error(t('toast.pathCopyFailed'))
}
}, [t, toast])
// Properties handler
const handleShowProperties = useCallback((file: FileItem) => {
setPropertiesFile(file)
}, [])
const handleDeleteRemote = useCallback(async (files: FileItem[]) => {
if (files.length === 0) return
const hasDir = files.some((file) => file.type === 'directory')
const names = files.slice(0, 3).map((file) => file.name).join(', ')
const ok = await confirm({
title: t('dialog.deleteTitle'),
message:
t('dialog.deleteMessage', { count: files.length, names }) +
(hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''),
danger: true,
confirmText: t('contextMenu.delete'),
})
if (!ok) return
const success = await deleteRemote(files)
if (!success) toast.error(t('toast.deleteFailed'))
}, [confirm, deleteRemote, t, toast])
// Permissions (chmod) handler — fetch stat first, then show dialog
const handleChmod = useCallback(async (file: FileItem) => {
const stats = await getRemoteStat(file.path)
@@ -217,7 +361,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
toast.error(t('toast.chmodFailed'))
return false
}
}, [chmodRemote, toast])
}, [chmodRemote, toast, t])
// Local context menu
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
@@ -235,15 +379,15 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
} as MenuItem]
: []),
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), separator: true, action: () => handleCopyPath(file.path) },
{
icon: <FileText className="w-4 h-4" />,
label: t('contextMenu.properties'),
separator: true,
action: () => handleShowProperties(file),
},
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
}, [navigateLocal, handleCopyPath, handleShowProperties, performUpload])
}, [navigateLocal, handleShowProperties, performUpload, t])
// Remote context menu
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
@@ -259,17 +403,22 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
label: t('contextMenu.download'),
action: () => void performDownload([file]),
},
...(file.type === 'file'
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
? [
{
icon: <FileCode className="w-4 h-4" />,
label: t('contextMenu.edit'),
action: () => setEditFile(file),
action: () => {
if (!isWithinRemoteTextEditLimit(file.size)) {
toast.warning(t('errors:sftp.editTooLarge'))
return
}
setEditFile(file)
},
} as MenuItem,
]
: []),
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) },
{ icon: <Copy className="w-4 h-4" />, label: t('contextMenu.copyPath'), action: () => handleCopyPath(file.path) },
{
icon: <FileText className="w-4 h-4" />,
label: t('contextMenu.properties'),
@@ -280,10 +429,10 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
label: t('contextMenu.permissions'),
action: () => handleChmod(file),
},
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => deleteRemote([file]) },
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => void handleDeleteRemote([file]) },
]
setContextMenu({ x: e.clientX, y: e.clientY, items })
}, [navigateRemote, performDownload, deleteRemote, handleRename, handleCopyPath, handleShowProperties, handleChmod])
}, [navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t])
// Navigate up
const navigateLocalUp = useCallback(() => {
@@ -294,6 +443,48 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
navigateRemote(parentPath(remotePath, false))
}, [remotePath, navigateRemote])
const sortedLocalFiles = useMemo(
() => sortFiles(localFiles, sortField, sortOrder),
[localFiles, sortField, sortOrder]
)
const sortedRemoteFiles = useMemo(
() => sortFiles(remoteFiles, sortField, sortOrder),
[remoteFiles, sortField, sortOrder]
)
const refreshLocal = useCallback(() => {
void loadLocalDirectory(localPath)
}, [loadLocalDirectory, localPath])
const refreshRemote = useCallback(() => {
if (!isConnected) return
clearError()
void loadRemoteDirectory(remotePath)
}, [clearError, isConnected, loadRemoteDirectory, remotePath])
const focusLocalPane = useCallback(() => setActivePane('local'), [])
const focusRemotePane = useCallback(() => setActivePane('remote'), [])
const navigateRemoteClear = useCallback((p: string) => {
clearError()
navigateRemote(p)
}, [clearError, navigateRemote])
const navigateRemoteBackClear = useCallback(() => {
clearError()
navigateRemoteBack()
}, [clearError, navigateRemoteBack])
const navigateRemoteForwardClear = useCallback(() => {
clearError()
navigateRemoteForward()
}, [clearError, navigateRemoteForward])
const navigateRemoteUpClear = useCallback(() => {
clearError()
navigateRemoteUp()
}, [clearError, navigateRemoteUp])
// Handle file open
const handleLocalOpen = useCallback((file: FileItem) => {
if (file.type === 'directory') {
@@ -333,6 +524,14 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
}
}, [localFiles, localPath, localSelected, performUpload, t])
const railUpload = useCallback(() => {
void handleUpload()
}, [handleUpload])
const railDownload = useCallback(() => {
void performDownload()
}, [performDownload])
// Handle drag-and-drop upload on remote panel
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
if (!isConnected) return
@@ -340,6 +539,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
const path = (file as File & { path?: string }).path
return path ? [{ path, name: file.name, size: file.size }] : []
})
dndLog('dispatch-os-upload', {
target: shortPath(targetPath),
fileCount: files.length,
withPath: sources.length,
})
if (sources.length === 0) {
toast.warning(t('toast.noDragPath'))
return
@@ -347,83 +551,128 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
void performUpload(sources, targetPath)
}, [isConnected, performUpload, toast, t])
const handleDropEntry = useCallback((entry: SftpDragEntry, targetPath: string) => {
if (!isConnected) return
if (entry.source === 'local') {
const handleDropEntry = useCallback((entries: SftpDragEntry[], targetPath: string) => {
if (!isConnected || entries.length === 0) return
const localEntries = entries.filter((entry) => entry.source === 'local')
const remoteEntries = entries.filter((entry) => entry.source === 'remote')
dndLog('dispatch', {
target: shortPath(targetPath),
upload: localEntries.length,
download: remoteEntries.length,
uploadNames: localEntries.slice(0, 8).map((e) => e.name),
downloadNames: remoteEntries.slice(0, 8).map((e) => e.name),
via: 'pointer',
})
if (localEntries.length > 0) {
void performUpload(
[{ path: entry.path, name: entry.name, size: entry.size, isDirectory: entry.type === 'directory' }],
localEntries.map((entry) => ({
path: entry.path,
name: entry.name,
size: entry.size,
isDirectory: entry.type === 'directory',
})),
targetPath
)
}
if (remoteEntries.length > 0) {
void performDownload(
remoteEntries.map((entry) => ({
name: entry.name,
path: entry.path,
type: entry.type,
size: entry.size,
modified: 0,
permissions: '',
})),
targetPath
)
} else {
void performDownload([{
name: entry.name,
path: entry.path,
type: entry.type,
size: entry.size,
modified: 0,
permissions: '',
}], targetPath)
}
}, [isConnected, performDownload, performUpload])
// Handle new folder
const handleNewFolder = useCallback(async () => {
const handleDropFailed = useCallback(() => {
dndLog('toast-drop-failed')
toast.warning(t('toast.dropFailed'))
}, [t, toast])
const handleNewLocalFolder = useCallback(async () => {
const name = await prompt({
title: t('dialog.newFolderTitle'),
message: t('dialog.newFolderMessage'),
placeholder: t('dialog.newFolderPlaceholder'),
})
if (name && window.opsxApi?.localMkdir) {
window.opsxApi.localMkdir(`${localPath}/${name}`).then(() => {
loadLocalDirectory(localPath)
})
if (!name || !window.opsxApi?.localMkdir) return
const fullPath = joinLocalPath(localPath, name.trim())
try {
await window.opsxApi.localMkdir(fullPath)
await loadLocalDirectory(localPath)
toast.success(t('toast.folderCreated', { name: name.trim() }))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('errors.mkdirFailed'))
}
}, [localPath, loadLocalDirectory, prompt, t])
}, [localPath, loadLocalDirectory, prompt, t, toast])
const handleNewRemoteFolder = useCallback(async () => {
const name = await prompt({
title: t('dialog.newFolderTitle'),
message: t('dialog.newFolderMessage'),
placeholder: t('dialog.newFolderPlaceholder'),
})
if (!name) return
const ok = await mkdirRemote(name.trim(), remotePath)
if (ok) {
toast.success(t('toast.folderCreated', { name: name.trim() }))
} else {
toast.error(t('errors.mkdirFailed'))
}
}, [mkdirRemote, prompt, remotePath, t, toast])
return (
<div className="h-full flex flex-col bg-dark-900">
<div className="relative flex h-full flex-col bg-dark-900">
{/* Connection Status */}
<ConnectionPanel
isConnected={isConnected}
isConnecting={isConnecting}
error={error}
resumePath={!isConnected && hadConnectedRef.current ? remotePath : null}
sessions={sessions}
showSelector={showConnectionSelector}
onConnect={(sessionId) => sessionId && connect(sessionId)}
onConnect={(id) => id && connect(id)}
onReconnect={() => {
clearError()
autoConnectAttempted.current = false
if (sessionId) {
autoConnectAttempted.current = true
void connect(sessionId)
return
}
setShowConnectionSelector(true)
}}
onShowSelector={() => setShowConnectionSelector(true)}
onCloseSelector={() => setShowConnectionSelector(false)}
/>
{/* Toolbar */}
<Toolbar
isConnected={isConnected}
onUpload={handleUpload}
canDownload={remoteFiles.some((file) => remoteSelected.has(file.name))}
onDownload={() => void performDownload()}
onRefresh={() => {
loadLocalDirectory(localPath)
if (isConnected) loadRemoteDirectory(remotePath)
}}
onNewFolder={handleNewFolder}
/>
{/* File Panels */}
{!isConnected ? (
<div className="flex-1 flex items-center justify-center">
<EmptyState
icon={Unplug}
title={t('panel.notConnectedTitle')}
description={t('panel.notConnectedDesc')}
description={
hadConnectedRef.current && remotePath
? t('panel.reconnectResumeDesc', { path: remotePath })
: t('panel.notConnectedDesc')
}
/>
</div>
) : (
<div className="flex-1 flex gap-4 p-4 min-h-0">
<div className="flex-1 flex gap-2 p-4 min-h-0">
{/* Local Panel */}
<FileListPanel
title={t('panel.local')}
isLocal={true}
path={localPath}
files={sortFiles(localFiles, sortField, sortOrder)}
homePath={localHistory[0] || localPath}
files={sortedLocalFiles}
selected={localSelected}
history={localHistory}
historyIndex={localHistoryIndex}
@@ -437,9 +686,20 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
onOpen={handleLocalOpen}
onContextMenu={showLocalContextMenu}
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
onDropEntry={handleDropEntry}
onDropFailed={handleDropFailed}
onRefresh={refreshLocal}
onNewFolder={handleNewLocalFolder}
onFocusPane={focusLocalPane}
refreshing={localLoading}
loading={localLoading && localFiles.length === 0}
/>
<TransferRail
canUpload={isConnected}
canDownload={isConnected && remoteSelected.size > 0}
onUpload={railUpload}
onDownload={railDownload}
/>
{/* Remote Panel */}
@@ -447,25 +707,30 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
title={t('panel.remote')}
isLocal={false}
path={remotePath}
files={sortFiles(remoteFiles, sortField, sortOrder)}
homePath={remoteHomePath}
files={sortedRemoteFiles}
selected={remoteSelected}
history={remoteHistory}
historyIndex={remoteHistoryIndex}
sortField={sortField}
sortOrder={sortOrder}
onNavigate={navigateRemote}
onNavigateBack={navigateRemoteBack}
onNavigateForward={navigateRemoteForward}
onNavigateUp={navigateRemoteUp}
onNavigate={navigateRemoteClear}
onNavigateBack={navigateRemoteBackClear}
onNavigateForward={navigateRemoteForwardClear}
onNavigateUp={navigateRemoteUpClear}
onSelect={toggleRemoteSelect}
onOpen={handleRemoteOpen}
onContextMenu={showRemoteContextMenu}
onSort={handleSort}
contextMenu={contextMenu}
onCloseContextMenu={() => setContextMenu(null)}
onDropFiles={isConnected ? handleDropFiles : undefined}
onDropEntry={handleDropEntry}
loading={isConnecting}
onDropFailed={handleDropFailed}
onRefresh={isConnected ? refreshRemote : undefined}
onNewFolder={handleNewRemoteFolder}
onFocusPane={focusRemotePane}
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
refreshing={remoteLoading}
listError={remoteListError}
/>
</div>
)}
@@ -475,7 +740,9 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
tasks={transferQueue}
isOpen={isTransferPanelOpen}
onToggle={() => setIsTransferPanelOpen(!isTransferPanelOpen)}
onCancel={cancelTransfer}
onRemove={removeTransfer}
onRetry={retryTransfer}
/>
{/* Global Context Menu */}
@@ -488,6 +755,7 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
/>
)}
{PromptDialogComponent}
{ConfirmDialogComponent}
{/* File Properties Dialog */}
{propertiesFile && (
@@ -527,9 +795,6 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
onSaved={() => toast.success(t('editor.saved'))}
/>
)}
{/* Toast Notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
)
}
+4 -4
View File
@@ -13,10 +13,10 @@ interface SortHeaderProps {
}
const FIELDS: { key: SortField; className: string }[] = [
{ key: 'name', className: 'flex-1 min-w-0 justify-start' },
{ key: 'modified', className: 'w-32 justify-end' },
{ key: 'size', className: 'w-24 justify-end' },
{ key: 'type', className: 'w-24 justify-end' },
{ key: 'name', className: 'sftp-col-name flex-1 min-w-0 justify-start' },
{ key: 'modified', className: 'sftp-col-modified w-28 flex-shrink-0 justify-end' },
{ key: 'size', className: 'sftp-col-size w-16 flex-shrink-0 justify-end' },
{ key: 'type', className: 'sftp-col-type w-16 flex-shrink-0 justify-end' },
]
export function SortHeader({ onSort, sortField, sortOrder }: SortHeaderProps) {
-74
View File
@@ -1,74 +0,0 @@
/**
* Toolbar - SFTP 工具栏(图标优先,文案放 title/aria-label)
*/
import { useTranslation } from 'react-i18next'
import { Upload, Download, RefreshCw, FolderPlus } from 'lucide-react'
interface ToolbarProps {
isConnected: boolean
canDownload: boolean
onUpload: () => void
onDownload: () => void
onRefresh: () => void
onNewFolder: () => void
}
const iconBtn =
'inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:opacity-40'
export function Toolbar({
isConnected,
canDownload,
onUpload,
onDownload,
onRefresh,
onNewFolder,
}: ToolbarProps) {
const { t } = useTranslation('sftp')
return (
<div className="flex items-center gap-1 border-b border-dark-700/30 bg-dark-800 px-3 py-1.5">
<button
type="button"
onClick={onUpload}
disabled={!isConnected}
title={t('toolbar.upload')}
aria-label={t('toolbar.uploadAria')}
className={`${iconBtn} bg-accent-blue text-white hover:bg-blue-600 disabled:bg-dark-700 disabled:text-txt-muted`}
>
<Upload className="h-4 w-4" />
</button>
<button
type="button"
onClick={onDownload}
disabled={!isConnected || !canDownload}
title={canDownload ? t('toolbar.downloadHint') : t('toolbar.downloadNeedSelection')}
aria-label={t('toolbar.downloadAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<Download className="h-4 w-4" />
</button>
<button
type="button"
onClick={onRefresh}
disabled={!isConnected}
title={t('toolbar.refresh')}
aria-label={t('toolbar.refreshAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<RefreshCw className="h-4 w-4" />
</button>
<button
type="button"
onClick={onNewFolder}
disabled={!isConnected}
title={t('toolbar.newFolder')}
aria-label={t('toolbar.newFolderAria')}
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<FolderPlus className="h-4 w-4" />
</button>
</div>
)
}
+35 -10
View File
@@ -2,7 +2,7 @@
* TransferItem - 传输任务项组件
*/
import { Download, Upload, Clock, CheckCircle, XCircle, X } from 'lucide-react'
import { Download, Upload, Clock, CheckCircle, XCircle, X, RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TransferTask } from '../../../shared/types/sftp.types'
import { formatSpeed } from './utils'
@@ -11,21 +11,27 @@ interface TransferItemProps {
task: TransferTask
onCancel?: () => void
onRemove: () => void
onRetry?: () => void
}
export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
export function TransferItem({ task, onCancel, onRemove, onRetry }: TransferItemProps) {
const { t } = useTranslation('sftp')
const isActive = task.status === 'transferring' || task.status === 'pending'
const canCancel = isActive && Boolean(onCancel)
const canRetry =
Boolean(onRetry) &&
(task.status === 'failed' || task.status === 'canceled') &&
Boolean(task.destinationDir)
return (
<div className="flex items-center gap-3 px-3 py-2 bg-dark-700/50 rounded-lg group">
<div className={`flex-shrink-0 ${task.type === 'download' ? 'text-accent-blue' : 'text-green-400'}`}>
<div className={`flex-shrink-0 ${task.type === 'download' ? 'text-accent-blue' : 'text-accent-green'}`}>
{task.type === 'download' ? <Download className="w-4 h-4" /> : <Upload className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-content-primary truncate">{task.fileName}</div>
<div className="flex items-center gap-2 mt-0.5">
{isActive && (
{task.status === 'transferring' && (
<>
<div className="flex-1 h-1.5 bg-dark-700 rounded-full overflow-hidden max-w-[120px]">
<div className="h-full bg-accent-blue transition-all duration-300" style={{ width: `${task.progress}%` }} />
@@ -41,15 +47,15 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
</span>
)}
{task.status === 'completed' && (
<span className="text-xs text-green-400 flex items-center gap-1">
<span className="text-xs text-feedback-success flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
{t('transfer.statusDone')}
</span>
)}
{task.status === 'failed' && (
<span className="text-xs text-red-400 flex items-center gap-1" title={task.error}>
<XCircle className="w-3 h-3" />
{t('transfer.statusFailed')}
<span className="text-xs text-feedback-error flex items-center gap-1 truncate" title={task.error}>
<XCircle className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{task.error || t('transfer.statusFailed')}</span>
</span>
)}
{task.status === 'canceled' && (
@@ -60,21 +66,40 @@ export function TransferItem({ task, onCancel, onRemove }: TransferItemProps) {
)}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{task.status === 'transferring' && onCancel && (
<div
className={`flex-shrink-0 flex items-center gap-1 ${
canCancel || canRetry ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
} transition-opacity`}
>
{canRetry && (
<button
type="button"
onClick={onRetry}
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-accent-blue"
title={t('transfer.retry')}
aria-label={t('transfer.retry')}
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
{canCancel && (
<button
type="button"
onClick={onCancel}
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-red-400"
title={t('transfer.cancel')}
aria-label={t('transfer.cancel')}
>
<X className="w-3.5 h-3.5" />
</button>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'canceled') && (
<button
type="button"
onClick={onRemove}
className="p-1.5 hover:bg-dark-700 rounded text-txt-muted hover:text-content-primary"
title={t('transfer.remove')}
aria-label={t('transfer.remove')}
>
<X className="w-3.5 h-3.5" />
</button>
+41 -8
View File
@@ -1,9 +1,10 @@
/**
* TransferPanel - 传输队列面板
*
* 显示文件上传/下载的传输队列
*
* 显示文件上传/下载的传输队列;进行中任务置顶。
*/
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { TransferItem } from './TransferItem'
@@ -15,16 +16,47 @@ interface TransferPanelProps {
onToggle: () => void
onCancel?: (id: string) => void
onRemove: (id: string) => void
onRetry?: (id: string) => void
}
export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: TransferPanelProps) {
/** transferring → pending → failed/canceled → completed(组内保持原相对顺序) */
// eslint-disable-next-line react-refresh/only-export-components -- pure helper used by tests and panel
export function sortTransferTasks(tasks: TransferTask[]): TransferTask[] {
const rank = (status: TransferTask['status']): number => {
switch (status) {
case 'transferring':
return 0
case 'pending':
return 1
case 'failed':
case 'canceled':
return 2
case 'completed':
return 3
default:
return 4
}
}
return tasks
.map((task, index) => ({ task, index }))
.sort((a, b) => {
const byStatus = rank(a.task.status) - rank(b.task.status)
return byStatus !== 0 ? byStatus : a.index - b.index
})
.map(({ task }) => task)
}
export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove, onRetry }: TransferPanelProps) {
const { t } = useTranslation('sftp')
const activeCount = tasks.filter(t => t.status === 'pending' || t.status === 'transferring').length
const completedCount = tasks.filter(t => t.status === 'completed').length
const orderedTasks = useMemo(() => sortTransferTasks(tasks), [tasks])
const activeCount = tasks.filter((task) => task.status === 'pending' || task.status === 'transferring').length
const completedCount = tasks.filter((task) => task.status === 'completed').length
return (
<div className="bg-dark-800 border-t border-dark-700/30">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center justify-between px-4 py-2 hover:bg-dark-700 transition-colors"
>
@@ -36,7 +68,7 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
</span>
)}
{completedCount > 0 && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-full">
<span className="px-2 py-0.5 bg-feedback-success/15 text-feedback-success text-xs rounded-full">
{t('transfer.completed', { count: completedCount })}
</span>
)}
@@ -47,14 +79,15 @@ export function TransferPanel({ tasks, isOpen, onToggle, onCancel, onRemove }: T
</div>
</button>
{isOpen && tasks.length > 0 && (
{isOpen && orderedTasks.length > 0 && (
<div className="max-h-48 overflow-y-auto border-t border-dark-700/30">
{tasks.map((task) => (
{orderedTasks.map((task) => (
<TransferItem
key={task.id}
task={task}
onCancel={onCancel ? () => onCancel(task.id) : undefined}
onRemove={() => onRemove(task.id)}
onRetry={onRetry ? () => onRetry(task.id) : undefined}
/>
))}
</div>
+51
View File
@@ -0,0 +1,51 @@
/**
* Dual-pane transfer rail (WinSCP / Total Commander style).
* Explicit upload/download buttons; cross-pane drag uses pointer drag in FileListPanel.
* OS file drops onto the remote pane still use HTML5 drop.
*/
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
interface TransferRailProps {
canUpload: boolean
canDownload: boolean
onUpload: () => void
onDownload: () => void
}
const btn =
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-dark-600/50 bg-dark-700 text-content-secondary transition-colors hover:bg-dark-600 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-35'
export function TransferRail({ canUpload, canDownload, onUpload, onDownload }: TransferRailProps) {
const { t } = useTranslation('sftp')
return (
<div
className="flex flex-col items-center justify-center gap-2 self-stretch px-1"
role="group"
aria-label={t('transfer.railAria')}
>
<button
type="button"
className={btn}
disabled={!canUpload}
title={t('transfer.railUpload')}
aria-label={t('transfer.railUploadAria')}
onClick={onUpload}
>
<ArrowRight className="h-4 w-4" />
</button>
<button
type="button"
className={btn}
disabled={!canDownload}
title={canDownload ? t('transfer.railDownload') : t('transfer.railDownloadNeedSelection')}
aria-label={t('transfer.railDownloadAria')}
onClick={onDownload}
>
<ArrowLeft className="h-4 w-4" />
</button>
</div>
)
}
@@ -0,0 +1,41 @@
export const CONTEXT_MENU_VIEWPORT_MARGIN = 8
interface MenuPosition {
left: number
top: number
}
/**
* Place a context menu inside the viewport.
* Prefer aligning the top-left to the click; if it would overflow the bottom/right,
* shift (flip) so the full menu stays visible.
*/
export function fitContextMenuToViewport(
x: number,
y: number,
menuWidth: number,
menuHeight: number,
viewportWidth: number,
viewportHeight: number,
): MenuPosition {
const margin = CONTEXT_MENU_VIEWPORT_MARGIN
const maxLeft = Math.max(margin, viewportWidth - menuWidth - margin)
const maxTop = Math.max(margin, viewportHeight - menuHeight - margin)
let left = x
let top = y
// Horizontal: flip left of cursor when not enough room on the right.
if (left + menuWidth + margin > viewportWidth) {
left = x - menuWidth
}
left = Math.min(Math.max(left, margin), maxLeft)
// Vertical: flip above cursor when not enough room below.
if (top + menuHeight + margin > viewportHeight) {
top = y - menuHeight
}
top = Math.min(Math.max(top, margin), maxTop)
return { left, top }
}
+236 -10
View File
@@ -1,32 +1,69 @@
import type { FileItem } from '../../../shared/types/sftp.types'
/** Legacy custom MIME — kept for older in-flight drags / tests. */
export const SFTP_ENTRY_MIME = 'application/x-opsxshell-sftp-entry'
/** text/plain prefix — WebKit/Tauri often strips unknown MIME until drop (or rejects them). */
export const SFTP_ENTRY_TEXT_PREFIX = 'opsxshell-sftp:'
export interface SftpDragEntry extends Pick<FileItem, 'name' | 'path' | 'type' | 'size'> {
source: 'local' | 'remote'
}
export function writeSftpDragEntry(dataTransfer: DataTransfer, entry: SftpDragEntry): void {
dataTransfer.effectAllowed = 'copy'
dataTransfer.setData(SFTP_ENTRY_MIME, JSON.stringify(entry))
dataTransfer.setData('text/plain', entry.path)
/**
* Module mirror of the in-flight drag payload.
* Critical on macOS Tauri (WKWebView): dragend may fire before drop, and
* dataTransfer.getData is empty during dragover for custom types.
* Same idea as Netcatty's in-memory `draggedFiles` (identity lives in React/module
* state; text/plain is only a browser formality).
*/
let activeDragEntries: SftpDragEntry[] = []
let clearTimer: ReturnType<typeof setTimeout> | null = null
let dragGeneration = 0
/** DEV-only DnD breadcrumb — no passwords; paths truncated to last 2 segments. */
export function dndLog(event: string, detail?: Record<string, unknown>): void {
if (!import.meta.env.DEV) return
if (detail === undefined) {
console.log('[sftp-dnd]', event)
return
}
console.log('[sftp-dnd]', event, detail)
}
export function hasSftpDragEntry(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types || []).includes(SFTP_ENTRY_MIME)
export function shortPath(path: string): string {
const parts = path.replace(/\\/g, '/').split('/').filter(Boolean)
if (parts.length <= 2) return path || '/'
return `…/${parts.slice(-2).join('/')}`
}
export function readSftpDragEntry(dataTransfer: DataTransfer): SftpDragEntry | null {
function summarizeEntries(entries: SftpDragEntry[]) {
return {
count: entries.length,
source: entries[0]?.source,
names: entries.slice(0, 8).map((e) => e.name),
types: entries.slice(0, 8).map((e) => e.type),
}
}
function cancelScheduledClear() {
if (clearTimer !== null) {
clearTimeout(clearTimer)
clearTimer = null
}
}
function parseEntry(raw: string): SftpDragEntry | null {
try {
const raw = dataTransfer.getData(SFTP_ENTRY_MIME)
if (!raw) return null
const value = JSON.parse(raw) as Partial<SftpDragEntry>
if (
(value.source !== 'local' && value.source !== 'remote') ||
typeof value.path !== 'string' ||
typeof value.name !== 'string' ||
!['file', 'directory', 'symlink'].includes(value.type || '')
) return null
) {
return null
}
return {
source: value.source,
path: value.path,
@@ -38,3 +75,192 @@ export function readSftpDragEntry(dataTransfer: DataTransfer): SftpDragEntry | n
return null
}
}
export function writeSftpDragEntry(dataTransfer: DataTransfer, entry: SftpDragEntry): void {
writeSftpDragEntries(dataTransfer, [entry])
}
/**
* Seed the in-memory payload before the browser's `dragstart` event.
* WKWebView can hide DataTransfer data while a drag is being negotiated, so the
* opposite pane needs an identity it can consult during the first dragover.
*/
export function primeSftpDragEntries(entries: SftpDragEntry[]): void {
if (entries.length === 0) return
cancelScheduledClear()
dragGeneration += 1
activeDragEntries = entries
dndLog('drag-prime', { gen: dragGeneration, ...summarizeEntries(entries) })
}
/** Prefer this when dragging a multi-selection (Netcatty-style cross-pane batch). */
export function writeSftpDragEntries(dataTransfer: DataTransfer, entries: SftpDragEntry[]): void {
if (entries.length === 0) return
primeSftpDragEntries(entries)
dataTransfer.effectAllowed = 'copy'
// Prefer text/plain — reliable across WKWebView dragover/drop.
const encoded = `${SFTP_ENTRY_TEXT_PREFIX}${JSON.stringify(entries.length === 1 ? entries[0] : entries)}`
let plainOk = false
let mimeOk = false
try {
dataTransfer.setData('text/plain', encoded)
plainOk = true
} catch {
// Some hosts throw on setData; mirror is enough for in-app DnD.
}
try {
dataTransfer.setData(SFTP_ENTRY_MIME, JSON.stringify(entries.length === 1 ? entries[0] : entries))
mimeOk = true
} catch {
// Custom MIME is optional (WebKit may ignore / hide it).
}
dndLog('drag-start', {
gen: dragGeneration,
...summarizeEntries(entries),
plainOk,
mimeOk,
})
}
export function clearSftpDragEntry(): void {
if (activeDragEntries.length > 0) {
dndLog('clear', { gen: dragGeneration, had: activeDragEntries.length })
}
cancelScheduledClear()
activeDragEntries = []
}
/**
* Clear after dragend. Use a long delay so a late `drop` (WKWebView order quirk)
* can still read the mirror. Successful drops should call clearSftpDragEntry() themselves.
*/
export function scheduleClearSftpDragEntry(delayMs = 400): void {
cancelScheduledClear()
const gen = dragGeneration
const pending = activeDragEntries.length
if (pending > 0) {
dndLog('schedule-clear', { gen, delayMs, pending })
}
clearTimer = setTimeout(() => {
// Ignore stale timers from an older drag if a new one started.
if (gen === dragGeneration) {
if (activeDragEntries.length > 0) {
dndLog('delayed-clear', { gen, cleared: activeDragEntries.length })
}
activeDragEntries = []
}
clearTimer = null
}, delayMs)
}
export function peekSftpDragEntry(): SftpDragEntry | null {
return activeDragEntries[0] || null
}
export function peekSftpDragEntries(): SftpDragEntry[] {
return activeDragEntries.slice()
}
export function hasSftpDragEntry(dataTransfer: DataTransfer): boolean {
// Prefer the module mirror — custom MIME / text types are often hidden during WebKit dragover.
if (activeDragEntries.length > 0) return true
const types = Array.from(dataTransfer.types || [])
if (types.includes(SFTP_ENTRY_MIME)) return true
return false
}
function parsePayload(raw: string): SftpDragEntry[] {
try {
const value = JSON.parse(raw) as unknown
if (Array.isArray(value)) {
return value.map((item) => parseEntry(JSON.stringify(item))).filter((item): item is SftpDragEntry => Boolean(item))
}
const single = parseEntry(raw)
return single ? [single] : []
} catch {
return []
}
}
export function readSftpDragEntry(dataTransfer: DataTransfer): SftpDragEntry | null {
return readSftpDragEntries(dataTransfer)[0] || null
}
export function readSftpDragEntries(dataTransfer: DataTransfer): SftpDragEntry[] {
if (activeDragEntries.length > 0) return activeDragEntries.slice()
try {
const plain = dataTransfer.getData('text/plain') || dataTransfer.getData('Text')
if (plain?.trim().startsWith(SFTP_ENTRY_TEXT_PREFIX)) {
const parsed = parsePayload(plain.trim().slice(SFTP_ENTRY_TEXT_PREFIX.length))
if (parsed.length > 0) return parsed
}
} catch {
// getData empty/throws during dragover
}
try {
const raw = dataTransfer.getData(SFTP_ENTRY_MIME)
if (raw) {
const parsed = parsePayload(raw)
if (parsed.length > 0) return parsed
}
} catch {
// ignore
}
return []
}
/** Where the drop payload came from — for diagnostics at drop time only. */
export function resolveSftpDragEntries(dataTransfer: DataTransfer): {
entries: SftpDragEntry[]
via: 'mirror' | 'text/plain' | 'mime' | 'empty'
} {
if (activeDragEntries.length > 0) {
return { entries: activeDragEntries.slice(), via: 'mirror' }
}
try {
const plain = dataTransfer.getData('text/plain') || dataTransfer.getData('Text')
if (plain?.trim().startsWith(SFTP_ENTRY_TEXT_PREFIX)) {
const parsed = parsePayload(plain.trim().slice(SFTP_ENTRY_TEXT_PREFIX.length))
if (parsed.length > 0) return { entries: parsed, via: 'text/plain' }
}
} catch {
// ignore
}
try {
const raw = dataTransfer.getData(SFTP_ENTRY_MIME)
if (raw) {
const parsed = parsePayload(raw)
if (parsed.length > 0) return { entries: parsed, via: 'mime' }
}
} catch {
// ignore
}
return { entries: [], via: 'empty' }
}
/** True when an in-app SFTP drag (or OS Files) should allow drop on a pane. */
export function isCrossPaneSftpDrag(
dataTransfer: DataTransfer,
paneIsLocal: boolean
): boolean {
const entry = peekSftpDragEntry() || readSftpDragEntry(dataTransfer)
if (entry) {
return entry.source !== (paneIsLocal ? 'local' : 'remote')
}
// OS file drop only onto remote
if (!paneIsLocal && Array.from(dataTransfer.types || []).includes('Files')) {
return true
}
// WebKit may expose only text/plain (and keep it unreadable) until drop.
// Accept it provisionally so the drop can report a useful error rather than
// disappearing without feedback when no mirror is available.
const types = Array.from(dataTransfer.types || [])
return types.includes(SFTP_ENTRY_MIME) || types.includes('text/plain') || types.includes('Text')
}
+1 -1
View File
@@ -10,4 +10,4 @@ export { TransferItem } from './TransferItem'
export { ConnectionSelector } from './ConnectionSelector'
export { FileListPanel } from './FileListPanel'
export { TransferPanel } from './TransferPanel'
export { Toolbar } from './Toolbar'
export { TransferRail } from './TransferRail'
+44
View File
@@ -0,0 +1,44 @@
import { joinLocalPath } from '../../utils/localPath'
export type ConflictSide = 'upload' | 'download'
/** Destination path for a single transfer name. */
export function transferDestinationPath(
side: ConflictSide,
destinationDir: string,
fileName: string
): string {
if (side === 'upload') {
return destinationDir === '/' ? `/${fileName}` : `${destinationDir.replace(/\/+$/, '')}/${fileName}`
}
return joinLocalPath(destinationDir, fileName)
}
/** Names that already exist in the current directory listing. */
export function conflictNamesFromListing(
names: string[],
listing: Array<{ name: string }>
): string[] {
const existing = new Set(listing.map((entry) => entry.name))
return names.filter((name) => existing.has(name))
}
export function formatOverwriteMessage(
names: string[],
side: ConflictSide,
t: (key: string, opts?: Record<string, unknown>) => string
): string {
if (names.length === 1) {
return t(
side === 'upload' ? 'dialog.overwriteOneRemote' : 'dialog.overwriteOneLocal',
{ name: names[0] }
)
}
const preview = names.slice(0, 5).map((name) => `• ${name}`).join('\n')
const more =
names.length > 5 ? `\n${t('dialog.overwriteMore', { count: names.length - 5 })}` : ''
return `${t(
side === 'upload' ? 'dialog.overwriteManyRemote' : 'dialog.overwriteManyLocal',
{ count: names.length }
)}\n${preview}${more}`
}
@@ -1,9 +1,10 @@
import { useEffect, type ComponentType } from 'react'
import { useEffect, useRef, type ComponentType } from 'react'
import { useTranslation } from 'react-i18next'
import {
Clipboard,
ClipboardPaste,
Eraser,
FolderOpen,
Maximize2,
Minimize2,
MousePointer2,
@@ -38,6 +39,7 @@ interface TerminalContextMenuProps {
onSplitDown: () => void
onToggleMaximize: () => void
onClosePane: () => void
onOpenSftp?: () => void
}
export default function TerminalContextMenu({
@@ -57,29 +59,43 @@ export default function TerminalContextMenu({
onSplitDown,
onToggleMaximize,
onClosePane,
onOpenSftp,
}: TerminalContextMenuProps) {
const { t } = useTranslation('terminal')
const menuRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
useEffect(() => {
const dismiss = () => onClose()
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
window.addEventListener('mousedown', dismiss)
const dismissOutside = (event: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onCloseRef.current()
}
}
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCloseRef.current()
}
const dismiss = () => onCloseRef.current()
document.addEventListener('pointerdown', dismissOutside, true)
window.addEventListener('blur', dismiss)
window.addEventListener('resize', dismiss)
window.addEventListener('keydown', escape)
return () => {
window.removeEventListener('mousedown', dismiss)
document.removeEventListener('pointerdown', dismissOutside, true)
window.removeEventListener('blur', dismiss)
window.removeEventListener('resize', dismiss)
window.removeEventListener('keydown', escape)
}
}, [onClose])
}, [])
const actions: MenuAction[] = [
{ label: t('contextMenu.copy'), icon: Clipboard, onSelect: onCopy, disabled: !hasSelection },
{ label: t('contextMenu.paste'), icon: ClipboardPaste, onSelect: onPaste },
{ label: t('contextMenu.selectAll'), icon: MousePointer2, onSelect: onSelectAll },
{ label: t('contextMenu.clear'), icon: Eraser, onSelect: onClear },
...(onOpenSftp
? [{ label: t('contextMenu.openSftp'), icon: FolderOpen, onSelect: onOpenSftp, dividerBefore: true } satisfies MenuAction]
: []),
{ label: t('contextMenu.splitRight'), icon: SplitSquareHorizontal, onSelect: onSplitRight, disabled: !canSplit, dividerBefore: true },
{ label: t('contextMenu.splitDown'), icon: SeparatorHorizontal, onSelect: onSplitDown, disabled: !canSplit },
{
@@ -96,10 +112,11 @@ export default function TerminalContextMenu({
return (
<div
ref={menuRef}
role="menu"
aria-label={t('contextMenu.aria')}
style={{ left, top }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
className="fixed z-[140] w-52 overflow-hidden rounded-lg border border-dark-600/90 bg-dark-800 py-1.5 shadow-2xl shadow-black/45"
>
{actions.map((action) => (
@@ -110,8 +127,8 @@ export default function TerminalContextMenu({
role="menuitem"
disabled={action.disabled}
onClick={() => {
onClose()
action.onSelect()
onClose()
}}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-35 ${
action.danger
+16 -16
View File
@@ -14,7 +14,7 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
const { t } = useTranslation('terminal')
return (
<div className="bg-dark-900/92 absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="terminal-overlay-scrim absolute inset-0 z-20 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-5 text-center">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border">
{idleTimeout ? (
@@ -23,11 +23,11 @@ export function ShellClosedOverlay({ reason, idleTimeout, restarting = false, on
<TerminalIcon className="h-6 w-6 text-accent-blue" />
)}
</div>
<div className="mb-1.5 text-sm font-medium text-content-primary">
<div className="mb-1.5 text-sm font-medium text-slate-100">
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
</div>
<div className="mb-2 text-xs leading-relaxed text-txt-muted">{reason}</div>
<div className="mb-4 text-[11px] text-emerald-400">{t('overlay.shellClosedHint')}</div>
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div>
<div className="mb-4 text-[11px] text-accent-green">{t('overlay.shellClosedHint')}</div>
<button
type="button"
disabled={restarting}
@@ -67,13 +67,13 @@ function TerminalOverlay({
return (
<>
{showDisconnectBanner && (
<div className="bg-dark-900/92 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
<div className="max-w-sm px-6 text-center">
<div className="bg-accent-blue/15 border-accent-blue/25 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<div className="border-accent-blue/25 bg-accent-blue/15 mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border">
<WifiOff className="h-7 w-7 text-accent-blue" />
</div>
<div className="mb-1.5 text-base font-medium text-content-primary">{t('overlay.disconnected')}</div>
<div className="mb-6 text-sm leading-relaxed text-txt-muted">
<div className="mb-1.5 text-base font-medium text-slate-100">{t('overlay.disconnected')}</div>
<div className="mb-6 text-sm leading-relaxed text-slate-400">
{statusMessage?.trim() ? statusMessage : t('overlay.disconnectedHint')}
</div>
<button
@@ -89,10 +89,10 @@ function TerminalOverlay({
)}
{showSuspendedOverlay && (
<div className="bg-dark-900/90 absolute inset-0 z-10 flex items-center justify-center">
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-sm px-6 text-center">
<div className="mb-2 text-base font-medium text-yellow-400">{t('overlay.suspended')}</div>
<div className="mb-5 text-sm text-txt-muted">{t('overlay.suspendedHint')}</div>
<div className="mb-2 text-base font-medium text-feedback-warning">{t('overlay.suspended')}</div>
<div className="mb-5 text-sm text-slate-400">{t('overlay.suspendedHint')}</div>
<div className="flex items-center justify-center gap-2">
<button
type="button"
@@ -104,7 +104,7 @@ function TerminalOverlay({
<button
type="button"
onClick={onReconnect}
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-content-primary transition-colors hover:bg-dark-600"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm text-slate-100 transition-colors hover:bg-slate-600"
>
{t('overlay.reconnect')}
</button>
@@ -114,13 +114,13 @@ function TerminalOverlay({
)}
{connectionState === 'error' && (
<div className="bg-dark-900/88 absolute inset-0 z-10 flex items-center justify-center">
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-md px-6 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/25 bg-red-500/15">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<div className="mb-1.5 font-medium text-red-300">{t('overlay.connectFailed')}</div>
<div className="mb-5 text-sm leading-relaxed text-txt-muted">
<div className="mb-5 text-sm leading-relaxed text-slate-400">
{statusMessage || t('overlay.connectFailedHint')}
</div>
<button
@@ -136,7 +136,7 @@ function TerminalOverlay({
)}
{(connectionState === 'connecting' || connectionState === 'reconnecting') && (
<div className="bg-dark-900/45 pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-terminal-bg)_45%,transparent)]">
<div className="px-4 text-center">
<div className="inline-flex animate-pulse items-center gap-2 text-sm font-medium text-accent-blue">
<TerminalIcon className="h-4 w-4" />
@@ -149,7 +149,7 @@ function TerminalOverlay({
: t('overlay.connecting')}
</div>
{statusMessage && connectionState === 'reconnecting' && (
<div className="mt-2 max-w-sm text-xs text-txt-muted">{statusMessage}</div>
<div className="mt-2 max-w-sm text-xs text-slate-400">{statusMessage}</div>
)}
</div>
</div>
+14 -2
View File
@@ -26,7 +26,10 @@ function TerminalSearchBar({
const { t } = useTranslation('terminal')
return (
<div className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm">
<div
role="search"
className="absolute top-0 left-0 right-0 z-30 flex items-center gap-2 px-3 py-2 bg-dark-800/95 border-b border-dark-600 backdrop-blur-sm"
>
<Search size={14} className="text-txt-muted shrink-0" />
<input
ref={searchInputRef}
@@ -47,28 +50,37 @@ function TerminalSearchBar({
className="flex-1 bg-dark-900 text-content-primary text-sm px-2 py-1 rounded border border-dark-600 focus:border-accent-blue focus:outline-none placeholder-ph"
/>
{searchResultCount !== undefined && (
<span className="text-xs text-txt-muted whitespace-nowrap min-w-[60px] text-center">
<span
className="text-xs text-txt-muted whitespace-nowrap min-w-[60px] text-center"
aria-live="polite"
>
{searchResultIndex ?? 0}/{searchResultCount}
</span>
)}
<button
type="button"
onClick={onSearchPrevious}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title={t('search.previous')}
aria-label={t('search.previous')}
>
<ChevronUp size={16} />
</button>
<button
type="button"
onClick={onSearchNext}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title={t('search.next')}
aria-label={t('search.next')}
>
<ChevronDown size={16} />
</button>
<button
type="button"
onClick={onClose}
className="p-1 hover:bg-dark-600 rounded text-txt-muted hover:text-content-primary transition-colors"
title={t('search.close')}
aria-label={t('search.close')}
>
<X size={16} />
</button>
+10 -5
View File
@@ -6,6 +6,7 @@ import { SearchAddon } from '@xterm/addon-search'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { safeTerminalDispose, safeTerminalFit, safeTerminalWrite } from '../../utils/safeTerminalFit'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { getConnectionStore } from '../../stores/connection'
@@ -100,7 +101,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
}
const handleResize = () => {
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, xtermRef.current)
if (shellIdRef.current && xtermRef.current) {
const { cols, rows } = xtermRef.current
window.opsxApi?.sshShellResize(shellIdRef.current, cols, rows)
@@ -132,7 +133,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
term.loadAddon(webLinksAddon)
term.open(terminalRef.current)
fitAddon.fit()
safeTerminalFit(fitAddon, term)
xtermRef.current = term
fitAddonRef.current = fitAddon
@@ -148,7 +149,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
}
},
output: {
write: (data) => term.write(data),
write: (data) => safeTerminalWrite(term, data),
transform: (data) => {
const currentSettings = settingsRef.current
return currentSettings.keywordHighlightEnabled && currentSettings.keywordHighlightRules?.length
@@ -220,7 +221,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
term.options.fontFamily = buildTerminalFontFamily(settings.fontFamily)
term.options.scrollback = settings.scrollback || 10000
Object.assign(term.options, XTERM_RENDER_OPTIONS)
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, term)
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
// 初始连接
@@ -277,6 +278,10 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
zmodemRef.current = null
pipelineRef.current?.dispose()
pipelineRef.current = null
fitAddonRef.current = null
const term = xtermRef.current
xtermRef.current = null
safeTerminalDispose(term)
if (registeredShellHandlerRef.current) {
window.opsxApi?.removeListener(
'ssh:shell-data',
@@ -303,7 +308,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
return (
<div className="flex h-full flex-col bg-dark-900">
<div className="relative flex-1 overflow-hidden p-2">
<div ref={terminalRef} className="h-full w-full rounded bg-dark-900" />
<div ref={terminalRef} className="terminal-surface h-full w-full rounded" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
+85 -15
View File
@@ -22,6 +22,7 @@ import {
Sparkles,
} from 'lucide-react'
import { resolveTerminalTheme, toXtermTheme, XTERM_RENDER_OPTIONS } from '../../themes/terminal'
import { safeTerminalClear, safeTerminalDispose, safeTerminalFit, safeTerminalWrite } from '../../utils/safeTerminalFit'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { getConnectionStore } from '../../stores/connection'
@@ -38,7 +39,9 @@ import { shellPayloadByteLength } from '../../utils/shellData'
import { shellWrite, shellResize, shellCreate, shellKill, flushSshShellBootstrap } from '../../utils/shellIO'
import { buildTerminalFontFamily } from '../../utils/terminalFont'
import { useTerminalConnection } from '../../hooks/useTerminalConnection'
import { useTerminalSearch } from '../../hooks/useTerminalSearch'
import TerminalOverlay, { ShellClosedOverlay } from './TerminalOverlay'
import TerminalSearchBar from './TerminalSearchBar'
import { createLogger } from '../../utils/logger'
import type { TerminalViewProps } from './types'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
@@ -70,6 +73,7 @@ interface PaneRuntime {
capture: () => TerminalAiContext
getSize: () => { cols: number; rows: number }
getTerminal: () => Terminal | null
getSearchAddon: () => SearchAddon | null
copySelection: () => void
pasteClipboard: () => void
selectAll: () => void
@@ -132,6 +136,7 @@ function PaneTerminal({
const containerRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
const searchAddonRef = useRef<SearchAddon | null>(null)
const shellIdRef = useRef(shellId)
const connectionIdRef = useRef(connectionId)
const onInputRef = useRef(onInput)
@@ -172,9 +177,10 @@ function PaneTerminal({
term.loadAddon(fitAddon)
term.loadAddon(searchAddon)
term.loadAddon(webLinks)
searchAddonRef.current = searchAddon
const container = containerRef.current
term.open(container)
fitAddon.fit()
safeTerminalFit(fitAddon, term)
const pasteClipboard = async () => {
try {
@@ -205,6 +211,9 @@ function PaneTerminal({
void pasteClipboard()
return false
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
return false
}
return true
})
@@ -223,13 +232,14 @@ function PaneTerminal({
fitAddonRef.current = fitAddon
setXtermReady(true)
let disposed = false
const highlightStream = new StreamingKeywordHighlight()
const pipeline = new TerminalPipeline({
input: (data) => {
if (shellIdRef.current) onInputRef.current(paneId, data)
},
output: {
write: (text) => term.write(text),
write: (text) => safeTerminalWrite(term, text),
transform: (text) => {
const currentSettings = settingsRef.current
if (!currentSettings.keywordHighlightEnabled || !currentSettings.keywordHighlightRules?.length) {
@@ -245,6 +255,7 @@ function PaneTerminal({
? null
: new ZmodemSftpBridge({
writeToTerminal: (data) => {
if (disposed) return
pipeline.output(typeof data === 'string' ? new TextEncoder().encode(data) : data)
},
sendToPeer: (data) => {
@@ -256,12 +267,14 @@ function PaneTerminal({
onStatus: setZmodemStatus,
})
term.onData((data) => {
if (disposed) return
zmodem?.noteInput(data)
pipeline.input(data)
})
const onResize = () => {
fitAddon.fit()
if (disposed) return
if (!safeTerminalFit(fitAddon, term)) return
const sid = shellIdRef.current
if (sid && term) {
void shellResize(sid, term.cols, term.rows)
@@ -270,10 +283,10 @@ function PaneTerminal({
window.addEventListener('resize', onResize)
// Fit before advertising size so the primary shell is not born at 80×24.
fitAddon.fit()
safeTerminalFit(fitAddon, term)
registerShellHandler(paneId, (sid, data) => {
if (sid !== shellIdRef.current || !xtermRef.current) return
if (disposed || sid !== shellIdRef.current || !xtermRef.current) return
if (zmodem) {
const bytes =
data instanceof Uint8Array
@@ -290,13 +303,15 @@ function PaneTerminal({
capture: () => captureTerminal(term),
getSize: () => ({ cols: term.cols, rows: term.rows }),
getTerminal: () => xtermRef.current,
getSearchAddon: () => searchAddonRef.current,
copySelection,
pasteClipboard: () => { void pasteClipboard() },
selectAll: () => term.selectAll(),
clear: () => term.clear(),
clear: () => safeTerminalClear(term),
})
return () => {
disposed = true
setXtermReady(false)
window.removeEventListener('resize', onResize)
container.removeEventListener('contextmenu', handleContextMenu)
@@ -304,8 +319,10 @@ function PaneTerminal({
unregisterRuntime(paneId)
zmodem?.dispose()
pipeline.dispose()
term.dispose()
fitAddonRef.current = null
searchAddonRef.current = null
xtermRef.current = null
safeTerminalDispose(term)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -319,14 +336,14 @@ function PaneTerminal({
term.options.fontFamily = buildTerminalFontFamily(settings.fontFamily)
term.options.scrollback = settings.scrollback || 10000
Object.assign(term.options, XTERM_RENDER_OPTIONS)
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, term)
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
// Resize when shell id attaches
useEffect(() => {
if (!shellId || !xtermRef.current) return
shellIdRef.current = shellId
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, xtermRef.current)
void shellResize(shellId, xtermRef.current.cols, xtermRef.current.rows)
}, [shellId])
@@ -381,11 +398,11 @@ function PaneTerminal({
creatingRef.current = false
}
})()
}, [autoCreateShell, connectionId, shellId, paneId, onShellReady, isLocal, tabId, xtermReady])
}, [autoCreateShell, connectionId, shellId, paneId, onShellReady, isLocal, tabId, xtermReady, t])
useEffect(() => {
if (isActive && isFocused) {
fitAddonRef.current?.fit()
safeTerminalFit(fitAddonRef.current, xtermRef.current)
xtermRef.current?.focus()
}
}, [isActive, isFocused])
@@ -403,7 +420,7 @@ function PaneTerminal({
return (
<div className={`relative h-full w-full ${isFocused ? 'ring-accent-blue/40 ring-1' : ''}`} onMouseDown={onFocus}>
<div ref={containerRef} className="h-full w-full" />
<div ref={containerRef} className="terminal-surface h-full w-full" />
{zmodemBanner && (
<div className="pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 rounded-md border border-accent-blue/30 bg-dark-800/95 px-3 py-1 text-[11px] text-content-secondary shadow-lg">
{zmodemBanner}
@@ -416,7 +433,7 @@ function PaneTerminal({
export default function TerminalWorkspace({ tabId, isActive = true }: TerminalViewProps) {
const { t } = useTranslation('terminal')
const { loadSettings } = useSettingsStore()
const { getTabById, updateTab, updateTabConnectionInstance, closeTab, tabs } = useTabStore()
const { getTabById, updateTab, updateTabConnectionInstance, closeTab, tabs, addTab } = useTabStore()
const snapshotStore = useTerminalSnapshotStore()
const currentTab = getTabById(tabId)
const savedLayout = currentTab?.splitLayout
@@ -472,6 +489,31 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const shellHandlersRef = useRef<Map<string, (shellId: string, data: unknown) => void>>(new Map())
const paneRuntimesRef = useRef<Map<string, PaneRuntime>>(new Map())
const focusedXtermRef = useRef<Terminal | null>(null)
const focusedSearchAddonRef = useRef<SearchAddon | null>(null)
useEffect(() => {
const runtime = paneRuntimesRef.current.get(focusedPaneId)
focusedXtermRef.current = runtime?.getTerminal() ?? null
focusedSearchAddonRef.current = runtime?.getSearchAddon() ?? null
}, [focusedPaneId, panes])
const {
showSearch,
searchQuery,
searchResultCount,
searchResultIndex,
searchInputRef,
handleSearchChange,
handleSearchNext,
handleSearchPrevious,
handleCloseSearch,
} = useTerminalSearch({
xtermRef: focusedXtermRef,
searchAddonRef: focusedSearchAddonRef,
isActive,
targetKey: focusedPaneId,
})
const {
connectionState,
@@ -531,7 +573,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
: [])
: []
registerBroadcastWorkspace({ tabId, targets })
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId, t])
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
@@ -685,7 +727,11 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
primaryXtermRef.current = runtime.getTerminal()
armInitialConnect()
}
}, [armInitialConnect])
if (paneId === focusedPaneId) {
focusedXtermRef.current = runtime.getTerminal()
focusedSearchAddonRef.current = runtime.getSearchAddon()
}
}, [armInitialConnect, focusedPaneId])
const unregisterRuntime = useCallback((paneId: string) => {
paneRuntimesRef.current.delete(paneId)
if (panesRef.current[0]?.id === paneId) {
@@ -1155,6 +1201,18 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden p-1">
{showSearch && (
<TerminalSearchBar
searchQuery={searchQuery}
searchResultCount={searchResultCount}
searchResultIndex={searchResultIndex}
searchInputRef={searchInputRef}
onSearchChange={handleSearchChange}
onSearchNext={handleSearchNext}
onSearchPrevious={handleSearchPrevious}
onClose={handleCloseSearch}
/>
)}
<div
className={`h-full min-h-0 w-full ${
multi ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex'
@@ -1259,6 +1317,18 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
onSplitDown={() => setSplitDirection('vertical')}
onToggleMaximize={() => setMaximizedPaneId((current) => current ? null : contextMenu.paneId)}
onClosePane={() => closePaneOrTab(contextMenu.paneId)}
onOpenSftp={
!isLocal && currentTab?.sessionId
? () => {
const sessionId = currentTab.sessionId!
addTab({
title: `SFTP: ${currentTab.title}`,
type: 'sftp',
sessionId,
})
}
: undefined
}
/>
)}
</div>
+50 -4
View File
@@ -10,12 +10,16 @@ export interface DesktopApiContract {
sshBackend: string
dataPath: string
credentialPath: string
logPath: string
}>
// SSH
sshConnect: (
config: import('../shared/types/ipc.types').SSHConnectRequest
) => Promise<import('../shared/types/ipc.types').SSHConnectResponse>
sshDisconnect: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshDisconnect: (
connectionId: string,
options?: { retainForwards?: boolean }
) => Promise<{ success: boolean; error?: string }>
sshExec: (
connectionId: string,
command: string
@@ -62,14 +66,16 @@ export interface DesktopApiContract {
localPort: number,
remoteHost: string,
remotePort: number,
forwardId?: string
forwardId?: string,
localBindHost?: string
) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
sshForwardRemote: (
connectionId: string,
remotePort: number,
localHost: string,
localPort: number,
forwardId?: string
forwardId?: string,
remoteBindHost?: string
) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
sshForwardDynamic: (
connectionId: string,
@@ -99,11 +105,13 @@ export interface DesktopApiContract {
// SFTP
sftpConnect: (connectionId: string) => Promise<{ success: boolean; sftpId?: string; error?: string }>
sftpDisconnect: (sftpId: string) => Promise<{ success: boolean; error?: string }>
sftpListDirectory: (sftpId: string, path: string) => Promise<{ success: boolean; files?: unknown[]; error?: string }>
sftpUpload: (sftpId: string, localPath: string, remotePath: string) => Promise<{ success: boolean; error?: string }>
sftpUploadDir: (sftpId: string, localPath: string, remotePath: string) => Promise<{ success: boolean; error?: string }>
sftpDownload: (sftpId: string, remotePath: string, localPath: string) => Promise<{ success: boolean; error?: string }>
sftpDownloadDir: (sftpId: string, remotePath: string, localPath: string) => Promise<{ success: boolean; error?: string }>
sftpCancelTransfer: (sftpId: string) => Promise<{ success: boolean; error?: string }>
sftpRename: (sftpId: string, oldPath: string, newPath: string) => Promise<{ success: boolean; error?: string }>
sftpDelete: (sftpId: string, path: string, isDirectory: boolean) => Promise<{ success: boolean; error?: string }>
sftpMkdir: (sftpId: string, path: string) => Promise<{ success: boolean; error?: string }>
@@ -127,14 +135,23 @@ export interface DesktopApiContract {
expiresAt?: number
features: string[]
token?: string
email?: string
maxDevices?: number
planType?: string
}>
licenseActivate: (token: string) => Promise<{
licenseActivate: (
token: string,
email?: string
) => Promise<{
plan: string
deviceId: string
activated: boolean
expiresAt?: number
features: string[]
token?: string
email?: string
maxDevices?: number
planType?: string
}>
licenseClear: () => Promise<{
plan: string
@@ -142,8 +159,34 @@ export interface DesktopApiContract {
activated: boolean
expiresAt?: number
features: string[]
email?: string
maxDevices?: number
planType?: string
}>
licenseDeviceId: () => Promise<string>
licenseSetEmail: (email: string) => Promise<{
plan: string
deviceId: string
activated: boolean
expiresAt?: number
features: string[]
token?: string
email?: string
maxDevices?: number
planType?: string
}>
/** Create Waffo checkout order via Rust → opsx-licence (avoids WebView CSP). */
licenseCheckout: (input: {
customerEmail: string
planType: string
returnUrl?: string
}) => Promise<{
orderNo: string
checkoutUrl: string
amount?: number
currency?: string
status?: string
}>
// Terminal AI (API keys stay in the Rust encrypted secret store)
aiProviderSaveKey: (providerId: string, apiKey: string) => Promise<{ success: boolean; error?: string }>
@@ -190,7 +233,10 @@ export interface DesktopApiContract {
// Window
toggleMaximize: () => Promise<void>
setFullscreen: (fullscreen: boolean) => Promise<void>
isFullscreen: () => Promise<boolean>
toggleDevtools: () => Promise<boolean>
applyNativeMenuLocale: (locale: string) => Promise<string>
// Events
onMenuNewSession: (callback: () => void) => void
+15 -11
View File
@@ -20,6 +20,7 @@ import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useCommandPaletteStore } from '../stores/commandPaletteStore'
import { useKeybindingStore } from '../stores/keybindingStore'
import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore'
import { KeybindingAction } from '../constants/keybindings'
import { createLogger } from '../utils/logger'
import i18n from '../i18n'
@@ -189,20 +190,23 @@ export function useKeyboardShortcuts(): void {
}
}
// ---- F11: 全屏 ----
// ---- F11: 全屏(RDP/VNC 会话全屏;其他标签走窗口全屏)----
if (matchesKeySequence(e, keys[KeybindingAction.VIEW_FULLSCREEN])) {
e.preventDefault()
if (window.opsxApi?.settingsSet) {
window.opsxApi
.settingsGet('fullscreen')
.then((res: { success: boolean; value?: unknown }) => {
const isFullscreen = res?.value === true
window.opsxApi?.settingsSet('fullscreen', !isFullscreen)
})
.catch(() => {
logger.warn('Failed to toggle fullscreen')
})
const { tabs, activeTabId } = useTabStore.getState()
const activeTab = tabs.find((tab) => tab.id === activeTabId)
if (activeTab && (activeTab.type === 'rdp' || activeTab.type === 'vnc')) {
void useDesktopFullscreenStore.getState().toggle(activeTab.id)
return
}
void (async () => {
try {
const current = (await window.opsxApi?.isFullscreen?.()) === true
await window.opsxApi?.setFullscreen?.(!current)
} catch {
logger.warn('Failed to toggle fullscreen')
}
})()
return
}
+632 -233
View File
File diff suppressed because it is too large. Load diff
+31 -30
View File
@@ -404,7 +404,9 @@ export function useTerminalConnection({
clearReconnectTimer()
reconnectTimerRef.current = setTimeout(() => {
if (connectionIdRef.current) {
getConnectionStore().disconnectConnection(connectionIdRef.current, 'auto reconnect cleanup')
getConnectionStore().disconnectConnection(connectionIdRef.current, 'auto reconnect cleanup', {
retainForwards: true,
})
}
connectionIdRef.current = null
shellIdRef.current = null
@@ -453,39 +455,36 @@ export function useTerminalConnection({
setConnectionState('reconnecting')
const resumed = await getConnectionStore().resumeConnection(connectionIdRef.current)
if (resumed) {
const currentTab = getTabById(tabId)
if (currentTab?.connectionInstance?.connectionId) {
connectionIdRef.current = currentTab.connectionInstance.connectionId
if (resumed.success && resumed.connectionId) {
connectionIdRef.current = resumed.connectionId
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
shellIdRef.current = newShellId
const newShellId = `shell-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
shellIdRef.current = newShellId
const shellResult = await window.opsxApi?.sshCreateShell(connectionIdRef.current, {
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
}, newShellId)
const shellResult = await window.opsxApi?.sshCreateShell(connectionIdRef.current, {
cols: xtermRef.current?.cols || 80,
rows: xtermRef.current?.rows || 24,
}, newShellId)
if (shellResult?.success && shellResult.shellId) {
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
if (shellResult?.success && shellResult.shellId) {
getConnectionStore().addShell(connectionIdRef.current, shellResult.shellId)
updateTabConnectionInstance(tabId, {
connectionId: connectionIdRef.current,
shellId: shellResult.shellId,
})
updateTabConnectionInstance(tabId, {
connectionId: connectionIdRef.current,
shellId: shellResult.shellId,
})
try {
const boot = await flushSshShellBootstrap(shellResult.shellId)
if (boot) xtermRef.current?.write(boot)
} catch (err) {
logger.warn('Failed to flush resumed shell bootstrap', err)
}
setConnectionState('connected')
updateTabStatus(tabId, 'connected')
setShowSuspendedOverlay(false)
return
try {
const boot = await flushSshShellBootstrap(shellResult.shellId)
if (boot) xtermRef.current?.write(boot)
} catch (err) {
logger.warn('Failed to flush resumed shell bootstrap', err)
}
setConnectionState('connected')
updateTabStatus(tabId, 'connected')
setShowSuspendedOverlay(false)
return
}
}
@@ -493,7 +492,7 @@ export function useTerminalConnection({
hasConnectedRef.current = false
setShowSuspendedOverlay(false)
connect()
}, [connect, updateTabConnectionInstance, updateTabStatus, getTabById, tabId, xtermRef])
}, [connect, updateTabConnectionInstance, updateTabStatus, tabId, xtermRef])
// 手动重连
const handleReconnect = useCallback(() => {
@@ -509,7 +508,9 @@ export function useTerminalConnection({
}
} else if (connectionIdRef.current) {
intentionalDisconnectRef.current = true
getConnectionStore().disconnectConnection(connectionIdRef.current, 'manual reconnect')
getConnectionStore().disconnectConnection(connectionIdRef.current, 'manual reconnect', {
retainForwards: true,
})
intentionalDisconnectRef.current = false
}
connectionIdRef.current = null
+4 -1
View File
@@ -15,6 +15,8 @@ export interface UseTerminalSearchParams {
xtermRef: React.MutableRefObject<Terminal | null>
searchAddonRef: React.MutableRefObject<SearchAddon | null>
isActive: boolean
/** Re-bind result listeners when the focused pane changes. */
targetKey?: string
}
export interface UseTerminalSearchReturn {
@@ -33,6 +35,7 @@ export function useTerminalSearch({
xtermRef,
searchAddonRef,
isActive,
targetKey,
}: UseTerminalSearchParams): UseTerminalSearchReturn {
const [showSearch, setShowSearch] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
@@ -107,7 +110,7 @@ export function useTerminalSearch({
setSearchResultIndex(e.resultIndex !== undefined ? e.resultIndex + 1 : undefined)
})
return () => disposable.dispose()
}, [searchAddonRef])
}, [searchAddonRef, targetKey])
return {
showSearch,
+36 -30
View File
@@ -1,14 +1,14 @@
/**
* useToast - Toast 通知系统
*
* 轻量级通知提示,用于操作反馈
* useToast - Toast 通知(全局 toastStore 薄封装)
*
* 任意组件调用 toast.* 都会进同一队列;只需在 App 挂载一处 ToastContainer。
*/
import { useState, useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { useToastStore, type ToastType } from '../stores/toastStore'
export type ToastType = 'info' | 'success' | 'error' | 'warning'
export interface Toast {
export type { ToastType }
export type Toast = {
id: string
type: ToastType
message: string
@@ -18,31 +18,37 @@ export interface Toast {
const DEFAULT_DURATION = 3000
export function useToast() {
const [toasts, setToasts] = useState<Toast[]>([])
const toasts = useToastStore((s) => s.toasts)
const addToastStore = useToastStore((s) => s.addToast)
const removeToast = useToastStore((s) => s.removeToast)
const addToast = useCallback((type: ToastType, message: string, duration?: number) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`
const toast: Toast = { id, type, message, duration: duration ?? DEFAULT_DURATION }
setToasts(prev => [...prev, toast])
const addToast = useCallback(
(type: ToastType, message: string, duration?: number) => {
return addToastStore(type, message, {
duration: duration ?? DEFAULT_DURATION,
})
},
[addToastStore]
)
// Auto-remove after duration
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, toast.duration)
const toast = useMemo(
() => ({
info: (message: string, duration?: number) => addToast('info', message, duration),
success: (message: string, duration?: number) => addToast('success', message, duration),
error: (message: string, duration?: number) => addToast('error', message, duration ?? 5000),
warning: (message: string, duration?: number) => addToast('warning', message, duration),
}),
[addToast]
)
return id
}, [])
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const toast = {
info: (message: string, duration?: number) => addToast('info', message, duration),
success: (message: string, duration?: number) => addToast('success', message, duration),
error: (message: string, duration?: number) => addToast('error', message, duration ?? 5000),
warning: (message: string, duration?: number) => addToast('warning', message, duration),
return {
toasts: toasts.map((item) => ({
id: item.id,
type: item.type,
message: item.message,
duration: item.duration,
})),
toast,
removeToast,
}
return { toasts, toast, removeToast }
}
+16 -2
View File
@@ -4,18 +4,32 @@ import { intlLocale } from './detectLocale'
/** Map stable backend / FE error phrases to i18n keys; fall back to raw text. */
const ERROR_PHRASE_MAP: Array<{ match: RegExp | string; key: string }> = [
{ match: /连接失败,请检查网络|connection failed/i, key: 'errors:connection.failed' },
{ match: /timed out waiting for input|TMOUT|auto-logout/i, key: 'errors:connection.idleKicked' },
{ match: /连接超时|timed?\s*out/i, key: 'errors:connection.timeout' },
{ match: /认证失败|auth(entication)? failed/i, key: 'errors:auth.failed' },
{ match: /主机不可达|unreachable/i, key: 'errors:connection.hostUnreachable' },
{ match: /保活失败|keepalive failed/i, key: 'errors:connection.keepaliveFailed' },
{ match: /连接已关闭|connection closed/i, key: 'errors:connection.closed' },
{ match: /终端创建失败/i, key: 'errors:shell.createFailed' },
{ match: /命令执行失败/i, key: 'errors:shell.execFailed' },
{ match: /端口转发设置失败/i, key: 'errors:forward.failed' },
{ match: /SFTP 连接失败|sftp.*fail/i, key: 'errors:sftp.connectFailed' },
{ match: /目标地址不能是 0\.0\.0\.0|目标不能是 0\.0\.0\.0/i, key: 'errors:forward.invalidTargetHost' },
{ match: /建立远程端口转发失败: 远端拒绝监听特权端口|建立远程端口转发失败: 远端拒绝特权端口/i, key: 'errors:forward.remoteDeniedPrivileged' },
{ match: /建立远程端口转发失败: 远端拒绝|Unable to complete request for forward-listen|REQUEST_DENIED|Session\(-32\)/i, key: 'errors:forward.remoteDenied' },
{ match: /建立远程端口转发失败|端口转发设置失败/i, key: 'errors:forward.failed' },
{ match: /Address already in use|os error 48|EADDRINUSE|监听本地端口失败|本地端口已被占用/i, key: 'errors:forward.alreadyInUse' },
{ match: /SFTP 正在传输文件|transfer is in progress/i, key: 'errors:sftp.busy' },
{ match: /Unable to startup channel|创建 SFTP 通道失败|MaxSessions|channel failure/i, key: 'errors:sftp.channelFailed' },
{ match: /SFTP 会话不存在|SSH 连接不存在/i, key: 'errors:sftp.sessionGone' },
{ match: /文件过大|编辑上限约 2MB|too large|edit limit/i, key: 'errors:sftp.editTooLarge' },
{ match: /像是二进制|binary content/i, key: 'errors:sftp.editBinary' },
{ match: /不是有效 UTF-8|not valid UTF-8|invalid utf-8/i, key: 'errors:sftp.editNotUtf8' },
{ match: /传输已取消|transfer (was )?cancel/i, key: 'errors:sftp.transferCancelled' },
{ match: /No space|磁盘空间|quota exceeded|ENOSPC/i, key: 'errors:sftp.diskFull' },
{ match: /文件不存在|no such file/i, key: 'errors:sftp.fileNotFound' },
{ match: /权限不足|permission denied/i, key: 'errors:sftp.permissionDenied' },
{ match: /文件传输失败/i, key: 'errors:sftp.transferFailed' },
{ match: /无法读取目录/i, key: 'errors:sftp.listFailed' },
{ match: /SFTP 连接失败|sftp.*fail/i, key: 'errors:sftp.connectFailed' },
{ match: /会话不存在/i, key: 'errors:session.notFound' },
{ match: /保存会话失败/i, key: 'errors:session.saveFailed' },
{ match: /加载会话失败/i, key: 'errors:session.loadFailed' },
+120 -36
View File
@@ -90,6 +90,8 @@
"terminal": "Terminal",
"batch": "Batch exec",
"newTerminal": "New terminal",
"closeAria": "Close {{title}}",
"listAria": "Open tabs",
"kind": {
"terminal": "Terminal",
"sftp": "SFTP",
@@ -105,7 +107,8 @@
"closeRight": "Close tabs to the right",
"closeLeft": "Close tabs to the left",
"closeAll": "Close all",
"clone": "Duplicate tab"
"clone": "Duplicate tab",
"openSftp": "Open SFTP"
}
},
"sidebar": {
@@ -149,37 +152,42 @@
"disconnect": "Disconnect"
},
"pro": {
"upgrade": "Upgrade Pro",
"upgrade": "Upgrade to Pro",
"feature": "Pro features",
"unlock": "Unlock {{feature}}",
"unlock": "Unlock Pro advanced features",
"comingSoon": "Coming soon",
"activateSuccess": "Activated",
"activateFailed": "Activation failed",
"licenseActivated": "Pro license activated",
"paymentPending": "After payment, paste your license here or try activating again later",
"invalidLicense": "Activation failed: invalid license",
"goUpgrade": "Upgrade",
"goUpgrade": "Upgrade to Pro",
"haveLicense": "Have a license? Activate",
"title": "OpsXShell Pro · Unlock pro ops features",
"subtitleComingSoon": "{{feature}} coming soon · upgrade Pro for early access",
"subtitleUnlock": "Unlocking: {{feature}}",
"subtitleDefault": "Forwarding, proxy, serial, AI, batch, and broadcast",
"title": "OpsXShell · Unlock Pro",
"subtitleComingSoon": "Advanced features coming soon · upgrade to Pro for early access",
"subtitleUnlock": "Unlocking advanced Pro features",
"subtitleDefault": "Port forwarding, terminal AI, batch ops, host lists, and remote desktop in Pro",
"activated": "Pro activated",
"expiresAt": " · valid until {{date}}",
"lifetime": " · lifetime license",
"oneTimeFee": "One-time purchase · lifetime on this device · no subscription",
"oneTimeFee": "Free / Pro Yearly / Pro Perpetual",
"pasteLicense": "Paste license",
"activateButton": "Activate",
"clearLicense": "Clear local license",
"paymentNote": "Checkout is handled by Waffo; this app does not collect card details.",
"testDeviceId": "Test environment · device ID: {{deviceId}}",
"later": "Maybe later",
"viewPro": "View Pro",
"wallComingSoonSuffix": " · coming soon",
"wallUnlock": "Unlock {{feature}}",
"wallMessageComingSoon": "This Pro capability is still in development. Upgrade for early access.",
"wallMessageDefault": "This feature requires OpsXShell Pro. Upgrade to use it on this device.",
"viewPro": "View Pro plans",
"wallComingSoonSuffix": "coming soon",
"wallUnlock": "Unlock Pro advanced features",
"wallMessageComingSoon": "This capability is part of OpsXShell Pro and is still being finished. Upgrade to Pro Yearly or Perpetual for early access.",
"wallMessageDefault": "Port forwarding, terminal AI, batch execution, host lists, and remote desktop are included in Pro. Upgrade to Pro Yearly or Perpetual to use them on this device.",
"checkoutUnavailable": "Checkout unavailable ({{status}})",
"emailLabel": "Email",
"emailRequired": "Enter your email before checkout",
"claimPending": "Claiming your license…",
"invalidKeyFormat": "Invalid key format — paste a LIC- license key",
"accountEmail": "Account: {{email}}",
"freeTier": "Free",
"proTier": "Pro",
"freeItems": {
@@ -192,21 +200,59 @@
"proItems": {
"forward": "Port forwarding",
"proxy": "Session proxy / SOCKS",
"serial": "Serial terminal",
"serial": "Serial console",
"ai": "Terminal AI assistant",
"batch": "Batch exec / broadcast sync",
"broadcast": "Broadcast input sync",
"importExport": "Bulk import/export hosts",
"desktop": "RDP / VNC remote desktop"
}
"batch": "Batch execute / broadcast sync",
"broadcast": "Broadcast synchronized input",
"importExport": "Batch import/export host list",
"desktop": "Remote desktop (RDP / VNC)"
},
"planYearly": "Pro Yearly",
"planYearlyDesc": "¥99 / year · 2 devices",
"planPerpetual": "Pro Perpetual",
"planPerpetualDesc": "¥199 · 5 devices · lifetime",
"planTrial": "Pro Trial",
"devices": "Up to {{count}} devices",
"goUpgradeYearly": "Buy Pro Yearly",
"goUpgradePerpetual": "Buy Pro Perpetual",
"activatedYearly": "Pro Yearly activated",
"activatedPerpetual": "Pro Perpetual activated",
"activatedTrial": "Pro Trial activated",
"choosePlan": "Choose: Free / Pro Yearly / Pro Perpetual",
"planFreePrice": "¥0",
"planFreeDesc": "SSH · SFTP · local terminal",
"planFreeCta": "Current plan",
"planIncludesPro": "Includes all Pro features",
"planFree": "Free",
"subtitleUnlockDetail": "Related capability: {{feature}}",
"wallUnlockComingSoon": "Pro advanced features coming soon",
"wallMessageComingSoonFeature": "“{{feature}}” is a Pro advanced capability still being finished. Upgrade to Pro Yearly or Perpetual for early access.",
"wallMessageFeature": "“{{feature}}” is a Pro advanced capability. Upgrade to Pro Yearly or Perpetual to use it on this device."
},
"launcher": {
"title": "Quick launch",
"description": "Sessions, local shells, and device consoles",
"description": "Start here: sessions, local shells, desktop, and serial",
"cards": {
"quickConnect": { "title": "Quick connect", "desc": "user@host:port" },
"saveSession": { "title": "Save session", "desc": "Credentials, groups, proxy" },
"serial": { "title": "Serial terminal", "desc": "Baud rate and port settings" }
"quickConnect": {
"title": "Quick connect",
"desc": "user@host:port"
},
"saveSession": {
"title": "Save session",
"desc": "Credentials, groups, proxy"
},
"serial": {
"title": "Serial terminal",
"desc": "Baud rate and port settings"
},
"rdp": {
"title": "RDP desktop",
"desc": "Remote Windows desktop"
},
"vnc": {
"title": "VNC desktop",
"desc": "Remote graphical desktop"
}
},
"sectionLocalShell": "Local shell",
"sectionSavedSessions": "Saved sessions",
@@ -220,6 +266,7 @@
"forwardOpenRunning": "SOCKS5 running (:{{port}}) · click to manage · right-click to copy",
"forwardStarted": "Forward started",
"forwardStartedDynamic": "SOCKS5 started: socks5://127.0.0.1:{{port}}",
"forwardResumed": "Restored {{count}} port forward(s)",
"socksStartFailed": "Start failed: {{message}}",
"socksStopFailed": "Stop failed: {{message}}",
"socksCopied": "Copied socks5://127.0.0.1:{{port}}"
@@ -240,7 +287,7 @@
"typeDynamic": "Dynamic",
"running": "Running",
"socksProxy": "SOCKS5: 127.0.0.1:{{port}}",
"remoteToLocal": "Remote {{remote}} → local {{local}}",
"remoteToLocal": "remote {{bind}}:{{remote}} → {{host}}:{{local}}",
"stats": "{{connections}} conn · ↑{{up}} · ↓{{down}}",
"copyProxy": "Copy proxy",
"copyRule": "Duplicate rule",
@@ -250,15 +297,15 @@
"delete": "Delete",
"editTitle": "Edit forward",
"addTitle": "Add forward",
"typeLocalDesc": "Local → remote",
"typeRemoteDesc": "Remote → local",
"typeLocalDesc": "Reach a remote service",
"typeRemoteDesc": "Expose a local service",
"typeDynamicDesc": "SOCKS5",
"selectType": "Type",
"drawerSubtitle": "Tunnel via the current SSH session",
"ruleFields": "Details",
"dynamicHint": "Local SOCKS5, default 127.0.0.1:1080 (editable). Traffic exits via this SSH server's public IP.",
"dynamicSingleHint": "One Dynamic rule per SSH host; change the listen port while stopped.",
"dynamicPortHint": "If the port is busy, pick another local port (e.g. 1081).",
"dynamicHint": "Local SOCKS5 (default :1080). Traffic exits via this SSH server.",
"dynamicSingleHint": "One Dynamic rule per SSH host; change the port while stopped.",
"dynamicPortHint": "If busy, try another port (e.g. 1081).",
"ruleList": "Rules",
"ruleListEmpty": "No Local / Remote rules yet",
"addSection": "Add new",
@@ -292,17 +339,30 @@
"internet": "Internet",
"localCaption": "Local: reach a remote port as if it were local.",
"remoteCaption": "Remote: remote access is forwarded to a local service.",
"dynamicCaption": "Dynamic: this machine becomes a SOCKS5 proxy that exits via the current SSH server's public IP."
"dynamicCaption": "Dynamic: local SOCKS5 that exits via this SSH server."
},
"ruleName": "Name",
"ruleNamePlaceholder": "e.g. MySQL",
"localListenPort": "Local listen port",
"localListenPortHint": "Defaults to localhost — same as ssh -L port:…. Usually just enter the port.",
"localBindAdvanced": "Advanced: change local bind",
"localTargetPort": "Local target port",
"remoteTargetPort": "Remote target port",
"remoteTargetPort": "Target port",
"remoteListenPort": "Remote listen port",
"remoteListenPortHint": "Defaults to remote localhost — same as ssh -R port:…. Usually just enter the port.",
"remoteListenAddress": "Remote listen",
"remoteListenAddressPlaceholder": "127.0.0.1:18080",
"remoteListenAddressHint": "127.0.0.1 = remote host only; 0.0.0.0 = remote LAN (needs GatewayPorts).",
"errorRemoteListenAddress": "Enter a valid remote listen address, e.g. 127.0.0.1:18080",
"localTargetAddress": "Local / LAN target",
"localTargetAddressPlaceholder": "127.0.0.1:8080",
"localTargetAddressHint": "Forwards to a local or LAN service, e.g. 127.0.0.1:8080. Do not use 0.0.0.0.",
"viaSessionRemote": "Listen on {{target}} (localhost by default)",
"errorRemoteListenPort": "Enter a valid remote listen port (1–65535)",
"errorLocalTargetAddress": "Enter a valid local/LAN target, e.g. 127.0.0.1:8080",
"portPlaceholder": "e.g. 3306",
"remoteHost": "Remote host",
"remoteHostPlaceholder": "localhost or IP",
"remoteHost": "Target host",
"remoteHostPlaceholder": "127.0.0.1",
"socksRuleName": "SOCKS5 proxy",
"defaultLocalName": "Local forward",
"defaultRemoteName": "Remote forward",
@@ -316,11 +376,35 @@
"deleteConfirm": "Delete this rule?",
"errorNamePort": "Enter a name and a valid port (1–65535)",
"errorTargetPort": "Enter a valid target port (1–65535)",
"errorRemoteHost": "Enter a remote host",
"errorRemoteHost": "Enter a target host (relative to the SSH server)",
"errorNotConnected": "Not connected",
"errorStartFailed": "Start failed",
"errorStopFailed": "Stop failed",
"errorSocksRunning": "SOCKS5 already running",
"copySuffix": "Copy"
"copySuffix": "Copy",
"remoteBindHint": "0.0.0.0 needs GatewayPorts; without it sshd often still binds 127.0.0.1. Target must not be 0.0.0.0.",
"localToRemote": "local {{bind}}:{{local}} → remote {{host}}:{{remote}}",
"localToRemotePort": "local :{{local}} → remote {{host}}:{{remote}}",
"remoteHostHint": "Relative to the SSH server. Use 127.0.0.1 for that host, or a reachable LAN IP. Not 0.0.0.0.",
"localBindHost": "Local bind",
"localBindHostPlaceholder": "127.0.0.1",
"localBindHostHint": "This machine only by default. Use 0.0.0.0 or a LAN IP for network access.",
"errorTargetHostInvalid": "Target cannot be 0.0.0.0 — use 127.0.0.1 or a reachable internal IP",
"listenAddress": "Local listen",
"listenAddressPlaceholder": "127.0.0.1:8080",
"listenAddressHint": "This machine only by default; 0.0.0.0 allows LAN access.",
"targetAddress": "Remote target",
"targetAddressPlaceholder": "127.0.0.1:80",
"targetAddressHint": "As seen from the SSH server. Use 127.0.0.1:port for a service on that host.",
"errorListenAddress": "Enter a valid listen address, e.g. 127.0.0.1:8080",
"errorTargetAddress": "Enter a valid target, e.g. 127.0.0.1:80",
"targetAddressHintVia": "Via {{target}}. Use 127.0.0.1:port for a service on that host (does not open a new remote port).",
"viaSession": "Tunnel via {{target}}",
"remoteToLocalPort": "remote :{{remote}} → {{host}}:{{local}}",
"remoteBindAdvanced": "Advanced: change remote bind",
"remoteBindHost": "Remote bind",
"remoteBindHostPlaceholder": "127.0.0.1",
"remoteBindHostHint": "Remote host only by default. 0.0.0.0 needs GatewayPorts; without it most sshd still bind 127.0.0.1 only.",
"remoteGatewayPortsWarning": "Requested remote 0.0.0.0 listen. Without GatewayPorts, sshd often silently binds 127.0.0.1 only."
}
}
+11 -4
View File
@@ -15,10 +15,12 @@
"passwordOptional": "Password (optional)",
"width": "Width",
"height": "Height",
"rdpFootnote": "Native IronRDP: TCP → X.224 → TLS → CredSSP/NLA → MCS; 45s handshake timeout. Certificate verification is relaxed during development.",
"vncFootnote": "Native vnc-rs RFB with Tight JPEG decoding; clipboard is sent as UTF-8 (very old servers may garble text).",
"rdpFootnote": "Fullscreen or maximize dynamically requests remote resolution to match the window. You can also pick an initial size before connecting. Certificate verification is relaxed during development.",
"vncFootnote": "Most VNC servers keep a fixed remote resolution; fullscreen only scales locally. Preferred size at connect is advisory. Clipboard is sent as UTF-8.",
"errorHostRequired": "Enter a host address",
"errorUsernameRequired": "Enter a username"
"errorUsernameRequired": "Enter a username",
"resolution": "Resolution",
"resolutionCustom": "Custom"
},
"toolbar": {
"pasteClipboard": "Paste local clipboard to remote",
@@ -28,7 +30,12 @@
"openShareDir": "Open shared folder locally (Downloads)",
"openShareDirAria": "Open shared folder",
"disconnect": "Disconnect",
"disconnectAria": "Disconnect"
"disconnectAria": "Disconnect",
"fullscreen": "Fullscreen",
"fullscreenAria": "Enter fullscreen",
"exitFullscreen": "Exit fullscreen",
"exitFullscreenAria": "Exit fullscreen",
"exitFullscreenHint": "Esc / F11 to exit"
},
"status": {
"connecting": "Connecting…",
+16 -3
View File
@@ -39,7 +39,9 @@
"closed": "Connection closed",
"dnsFailed": "Could not resolve host",
"refused": "Connection refused",
"disconnected": "Disconnected"
"disconnected": "Disconnected",
"keepaliveFailed": "SSH keep-alive failed repeatedly; connection closed",
"idleKicked": "Remote shell logged out due to idle timeout (TMOUT)"
},
"auth": {
"failed": "Authentication failed. Check the username and password.",
@@ -68,7 +70,10 @@
"forward": {
"failed": "Port forward setup failed",
"bindFailed": "Failed to bind port",
"alreadyInUse": "Port already in use"
"alreadyInUse": "Local port is already in use. Stop the running forward (or the other process on that port), then start again.",
"remoteDenied": "Remote port forward refused by the server (AllowTcpForwarding / PermitListen, port in use, or bind limits). OpsXShell listens on remote 127.0.0.1 only.",
"remoteDeniedPrivileged": "Remote refused a privileged listen port (<1024). Use a port ≥1024, or root on the server, and check AllowTcpForwarding / PermitListen.",
"invalidTargetHost": "Target cannot be 0.0.0.0 — use 127.0.0.1 or a reachable internal IP"
},
"sftp": {
"connectFailed": "SFTP connection failed",
@@ -80,7 +85,15 @@
"writeFailed": "Save failed",
"renameFailed": "Rename failed",
"chmodFailed": "Failed to update permissions",
"apiUnavailable": "SFTP API unavailable"
"apiUnavailable": "SFTP API unavailable",
"channelFailed": "Could not open SFTP channel (server limit or busy session). Reconnect and try again",
"sessionGone": "SFTP session ended; reconnect to continue",
"busy": "A file transfer is in progress. Try again when it finishes.",
"editTooLarge": "File is too large for the text editor (max ~2MB). Download it and open locally.",
"editBinary": "This file looks binary and cannot be opened in the text editor",
"editNotUtf8": "Remote file is not valid UTF-8 text",
"transferCancelled": "Transfer cancelled",
"diskFull": "Not enough disk space on the remote or local side"
},
"session": {
"notFound": "Session not found",
+6 -4
View File
@@ -69,17 +69,17 @@
"confirm": "Delete"
},
"welcome": {
"tagline": "Remote terminal · SFTP · Port forwarding",
"tagline": "Simple & practical · terminal · files · desktop",
"sectionStart": "Get started",
"sectionDesktop": "Remote desktop",
"sectionRecent": "Recent sessions",
"quickConnect": {
"title": "Quick connect",
"desc": "One-off SSH; not saved to the library"
"desc": "One-shot SSH — not saved to the session library"
},
"newSession": {
"title": "New session",
"desc": "Save hosts; double-click in the sidebar to connect"
"desc": "Save a host — double-click in the sidebar to connect"
},
"localShell": {
"title": "Local shell",
@@ -167,7 +167,9 @@
"jumpHost": "Jump host (optional)",
"jumpNone": "No jump host",
"jumpNoneAlt": "No jump host",
"jumpHint": "One saved session can be used as a jump host"
"jumpHint": "One saved session can be used as a jump host",
"resolution": "Resolution",
"resolutionCustom": "Custom"
},
"batch": {
"title": "Batch exec",
+50 -18
View File
@@ -1,9 +1,7 @@
{
"nav": {
"general": "Workspace",
"generalDesc": "Layout and resources",
"appearance": "Appearance",
"appearanceDesc": "Theme and language",
"general": "General",
"generalDesc": "Theme, language, and layout",
"terminal": "Terminal",
"terminalDesc": "Font and colors",
"shortcuts": "Shortcuts",
@@ -15,11 +13,13 @@
"ai": "AI models",
"aiDesc": "AI providers",
"about": "About",
"aboutDesc": "Version and data"
"aboutDesc": "Version and data",
"account": "Account",
"accountDesc": "Email, license, and device"
},
"title": "Settings",
"search": {
"placeholder": "Search settings..."
"placeholder": "Search settings…"
},
"language": {
"label": "Language",
@@ -89,7 +89,7 @@
"ssh": {
"connection": "Connection",
"keepAlive": "Keep-alive",
"keepAliveDesc": "Send keep-alive when idle to reduce disconnects",
"keepAliveDesc": "Send SSH keep-alive while idle; disconnect only after several missed replies",
"autoReconnect": "Auto reconnect",
"autoReconnectDesc": "Automatically retry after unexpected disconnects",
"timeout": "Connection timeout",
@@ -102,7 +102,9 @@
"maxBackground": "Background connection limit",
"maxBackgroundDesc": "Evict least recently used connections when over limit",
"suspend": "Background suspend",
"suspendDesc": "Minutes before releasing active channels when a tab is in background"
"suspendDesc": "Minutes before idle background tabs suspend (active Shell/SFTP stay connected)",
"keepAliveInterval": "Keep-alive interval (sec)",
"keepAliveIntervalDesc": "How often to ping the server (like ClientAliveInterval). Use 15s or less on flaky Wi‑Fi / NAT"
},
"security": {
"credentials": "Credential protection",
@@ -119,7 +121,7 @@
"clearConfirm": "Clear all known host keys? You will be prompted again on next connect."
},
"about": {
"tagline": "A lightweight native SSH / SFTP ops terminal",
"tagline": "Simple, practical native ops terminal",
"appVersion": "App v{{version}}",
"copyVersion": "Copy version info",
"runtime": "Runtime",
@@ -127,8 +129,9 @@
"localData": "Local data",
"sessionsSettings": "Sessions and settings",
"masterKeyPath": "Master key",
"privacyNote": "Passwords and key passphrases are encrypted with AES-256-GCM in Rust. OpsXShell does not save terminal command history.",
"license": "MIT License · Rust · XTerm.js"
"privacyNote": "Passwords and key passphrases are AES-256-GCM encrypted in Rust. No terminal history, no bastion audit trail.",
"license": "MIT License · Rust · XTerm.js",
"logPath": "Application log"
},
"ai": {
"presets": "Provider presets",
@@ -162,13 +165,42 @@
"apiKeySaveFailed": "Failed to save API key",
"providerNotFound": "Provider not found",
"modelCatalogReadFailed": "Failed to read model catalog",
"catalogUnavailable": "models.dev is unavailable"
"catalogUnavailable": "models.dev is unavailable",
"family": {
"openai_compatible": "OpenAI compatible",
"standalone": "Native APIs",
"plan": "Subscription plans",
"local": "Self-hosted",
"custom": "Custom"
}
},
"family": {
"openai_compatible": "OpenAI compatible",
"standalone": "────────",
"plan": "Plan",
"local": "Self-hosted",
"custom": "Custom"
"account": {
"title": "Account",
"email": "Email",
"emailDesc": "Used for checkout and license claims",
"plan": "Current plan",
"planFree": "Free",
"planYearly": "Pro Yearly",
"planPerpetual": "Pro Perpetual",
"planTrial": "Pro Trial",
"expires": "Expires {{date}}",
"lifetime": "Lifetime",
"devices": "Seats: up to {{count}} devices",
"deviceId": "This device ID",
"saveEmail": "Save email",
"activate": "Activate license",
"pasteLicense": "Paste LIC- license key",
"clear": "Clear local license",
"deactivate": "Deactivate this device",
"upgrade": "Upgrade",
"saved": "Saved",
"activateOk": "Activated",
"deviceList": "Bound devices",
"loadingDevices": "Loading devices…",
"noDevices": "No bound devices",
"thisDevice": "This device",
"removeDevice": "Unbind",
"deviceRemoved": "Device unbound",
"seatFull": "All seats are in use. Unbind another device before activating on a new machine."
}
}
+54 -24
View File
@@ -3,8 +3,9 @@
"local": "Local",
"remote": "Remote",
"notConnectedTitle": "Not connected to remote server",
"notConnectedDesc": "Connect an SSH session first to use SFTP",
"ariaLabel": "{{title}} file panel"
"notConnectedDesc": "Connect SSH first, then transfer files or edit configs",
"ariaLabel": "{{title}} file panel",
"reconnectResumeDesc": "Disconnected. One-click reconnect resumes at {{path}}"
},
"connection": {
"selectServer": "Select server",
@@ -12,7 +13,9 @@
"createSessionHint": "Create a session in the sidebar first",
"reconnect": "Reconnect",
"notConnected": "Not connected",
"connecting": "Connecting..."
"connecting": "Connecting...",
"resumeHint": "Reconnect will resume at {{path}}",
"reconnectingResume": "Reconnecting and resuming {{path}}…"
},
"errors": {
"notConnected": "SFTP is not connected",
@@ -23,19 +26,14 @@
"selectDownloadFiles": "Select files to download first",
"uploadFailed": "Upload failed",
"downloadFailed": "Download failed",
"fileError": "{{fileName}}: {{message}}"
"fileError": "{{fileName}}: {{message}}",
"mkdirFailed": "Failed to create folder"
},
"toolbar": {
"upload": "Upload",
"uploadAria": "Upload",
"download": "Download",
"downloadAria": "Download",
"downloadNeedSelection": "Select remote files first",
"downloadHint": "Download selected remote files",
"refresh": "Refresh",
"refreshAria": "Refresh",
"refreshAria": "Refresh this pane",
"newFolder": "New folder",
"newFolderAria": "New folder"
"newFolderAria": "New folder in this pane"
},
"sort": {
"name": "Name",
@@ -55,7 +53,8 @@
"hideHidden": "Hide dotfiles (.xxx)",
"showHiddenAria": "Show hidden files",
"hideHiddenAria": "Hide dotfiles",
"searchAria": "Search in {{title}}"
"searchAria": "Search in {{title}}",
"upAtRoot": "Already at root"
},
"status": {
"loading": "Loading...",
@@ -63,7 +62,8 @@
"filtered": "Showing {{shown}} / {{total}}",
"hiddenCount": "{{total}} items ({{hidden}} hidden)",
"total": "{{count}} items",
"filterHint": "Filter: {{query}}"
"filterHint": "Filter: {{query}}",
"refreshing": "Refreshing..."
},
"empty": {
"dirTitle": "Empty directory",
@@ -99,7 +99,6 @@
"download": "Download",
"edit": "Edit",
"rename": "Rename",
"copyPath": "Copy path",
"properties": "Properties",
"permissions": "Permissions",
"delete": "Delete"
@@ -116,7 +115,18 @@
"chmodTitle": "Edit permissions",
"chmodApply": "Apply",
"pickUploadTitle": "Choose files or folders to upload",
"pickUploadRzTitle": "Choose file to upload (rz)"
"pickUploadRzTitle": "Choose file to upload (rz)",
"overwriteTitle": "Overwrite existing files?",
"overwriteConfirm": "Overwrite",
"overwriteSkip": "Skip",
"overwriteOneRemote": "「{{name}}」 already exists remotely. Overwrite?",
"overwriteOneLocal": "「{{name}}」 already exists locally. Overwrite?",
"overwriteManyRemote": "{{count}} items already exist remotely. Overwrite all?",
"overwriteManyLocal": "{{count}} items already exist locally. Overwrite all?",
"overwriteMore": "…and {{count}} more",
"deleteTitle": "Delete",
"deleteMessage": "Delete {{count}} item(s): {{names}}",
"deleteRecursiveWarn": "This includes folders. Deletion is recursive and cannot be undone."
},
"properties": {
"name": "Name",
@@ -152,27 +162,41 @@
"statusDone": "Done",
"statusFailed": "Failed",
"statusCancelled": "Cancelled",
"dropUpload": "Upload to {{path}}",
"dropDownload": "Download to {{path}}"
"dropUpload": "Drop to upload to {{path}}",
"dropDownload": "Drop to download to {{path}}",
"railAria": "Transfer between local and remote",
"railUpload": "Upload selected local files (or choose files if none selected)",
"railDownload": "Download selected remote files to local",
"railUploadAria": "Upload to remote",
"railDownloadAria": "Download to local",
"railDownloadNeedSelection": "Select remote files first",
"retry": "Retry"
},
"editor": {
"readFailed": "Read failed",
"saveFailed": "Save failed",
"unsavedClose": "Unsaved changes. Close anyway?",
"unsavedTitle": "Unsaved changes",
"discardAndClose": "Discard and close",
"reloadTitle": "Reload",
"discardAndReload": "Discard and reload",
"saving": "Saving...",
"save": "Save",
"saved": "File saved",
"loadingEditor": "Loading editor...",
"footer": "UTF-8 · Monaco · ⌘/Ctrl+S save · ~2MB max",
"loadBlocked": "Could not load file content",
"footer": "UTF-8 · ⌘/Ctrl+S save · Esc close · max ~2MB",
"charCount": "{{count}} characters",
"unsavedMark": " · unsaved",
"savedMark": " · saved"
"savedMark": " · saved",
"title": "Edit {{name}}",
"dirtyBadge": "Unsaved",
"reload": "Reload",
"unsavedReload": "Discard unsaved changes and reload?"
},
"toast": {
"renamed": "Renamed to {{name}}",
"renameFailed": "Rename failed",
"pathCopied": "Path copied to clipboard",
"pathCopyFailed": "Failed to copy path",
"chmodUpdated": "Permissions updated to {{mode}}",
"chmodFailed": "Failed to update permissions",
"uploadPartial": "{{action}} finished {{completed}}, failed {{failed}}: {{detail}}",
@@ -180,7 +204,13 @@
"uploadAction": "Upload",
"downloadAction": "Download",
"noFolderUpload": "Folder upload failed",
"noDragPath": "Drag-and-drop paths are unavailable; use Upload",
"noFolderRecursive": "Recursive folder transfer failed; retry or use Upload"
"noDragPath": "This system did not provide a drag path. Use the → arrow or choose files to upload.",
"noFolderRecursive": "Folder transfer failed. Retry or use the → arrow / file picker.",
"dropFailed": "Drop failed: could not read the file info. Try again or use the transfer arrows.",
"folderCreated": "Created folder {{name}}",
"allSkippedExisting": "Skipped all files that already exist",
"skippedExisting": "Skipped {{count}} existing file(s)",
"deleteFailed": "Delete failed",
"deleted": "Deleted"
}
}
+3 -2
View File
@@ -34,7 +34,7 @@
"reopenShell": "Reopen shell",
"reopening": "Opening…",
"disconnected": "Disconnected",
"disconnectedHint": "The SSH session ended or the network dropped. Click below to reconnect.",
"disconnectedHint": "The SSH session ended or the network dropped. One click below reconnects so you can keep working.",
"reconnect": "Reconnect",
"suspended": "Connection suspended",
"suspendedHint": "Inactive for a while; connection was suspended to save resources",
@@ -70,7 +70,8 @@
"splitRight": "Split right",
"splitDown": "Split down",
"maximizePane": "Maximize current pane",
"restoreLayout": "Restore split layout"
"restoreLayout": "Restore split layout",
"openSftp": "Open SFTP"
},
"search": {
"placeholder": "Search...",
+106 -31
View File
@@ -90,6 +90,8 @@
"terminal": "ターミナル",
"batch": "一括実行",
"newTerminal": "新規ターミナル",
"closeAria": "{{title}} を閉じる",
"listAria": "開いているタブ",
"kind": {
"terminal": "ターミナル",
"sftp": "SFTP",
@@ -105,7 +107,8 @@
"closeRight": "右側のタブを閉じる",
"closeLeft": "左側のタブを閉じる",
"closeAll": "すべて閉じる",
"clone": "タブを複製"
"clone": "タブを複製",
"openSftp": "SFTP を開く"
}
},
"sidebar": {
@@ -151,7 +154,7 @@
"pro": {
"upgrade": "Pro にアップグレード",
"feature": "Pro 機能",
"unlock": "{{feature}} のロック解除",
"unlock": "Pro の高度な機能を解除",
"comingSoon": "近日公開",
"activateSuccess": "アクティベート済み",
"activateFailed": "アクティベートに失敗",
@@ -160,28 +163,33 @@
"invalidLicense": "アクティベート失敗: 無効なライセンス",
"goUpgrade": "アップグレード",
"haveLicense": "ライセンスをお持ちですか? アクティベート",
"title": "OpsXShell Pro · プロ向け運用機能をアンロック",
"subtitleComingSoon": "{{feature}} は近日公開 · Pro にアップグレードで早期アクセス",
"subtitleUnlock": "アンロック中: {{feature}}",
"subtitleDefault": "転送、プロキシ、シリアル、AI、一括実行、ブロードキャスト",
"title": "OpsXShell · Pro を解除",
"subtitleComingSoon": "高度な機能は近日公開 · Pro で先行体験",
"subtitleUnlock": "高度な機能を解除しています",
"subtitleDefault": "ポート転送、ターミナル AI、一括運用、ホスト一覧、リモートデスクトップなどの Pro 機能",
"activated": "Pro アクティベート済み",
"expiresAt": " · 有効期限 {{date}}",
"lifetime": " · 永久ライセンス",
"oneTimeFee": "1 回限りの購入 · このデバイスで永久利用 · サブスクなし",
"oneTimeFee": "無料 / Pro 年額 / Pro 永久",
"pasteLicense": "ライセンスを貼り付け",
"activateButton": "アクティベート",
"clearLicense": "ローカルライセンスを削除",
"paymentNote": "決済は Waffo が処理します。本アプリはカード情報を収集しません。",
"testDeviceId": "テスト環境 · デバイス ID: {{deviceId}}",
"later": "後で",
"viewPro": "Pro を見る",
"wallComingSoonSuffix": " · 近日公開",
"wallUnlock": "{{feature}} のロックを解除",
"wallMessageComingSoon": "この Pro 機能はまだ開発中です。アップグレードで早期アクセス。",
"wallMessageDefault": "この機能には OpsXShell Pro が必要です。このデバイスで利用するにはアップグレードしてください。",
"viewPro": "Pro プランを見る",
"wallComingSoonSuffix": "近日公開",
"wallUnlock": "Pro の高度な機能を解除",
"wallMessageComingSoon": "この能力は OpsXShell Pro に含まれ、実装整備中です。Pro 年額または永久で先行体験できます。",
"wallMessageDefault": "ポート転送、ターミナル AI、一括実行、ホスト一覧、リモートデスクトップは Pro に含まれます。Pro 年額または永久でこの端末で利用できます。",
"checkoutUnavailable": "チェックアウト不可 ({{status}})",
"emailLabel": "メールアドレス",
"emailRequired": "チェックアウト前にメールアドレスを入力してください",
"claimPending": "ライセンスを取得しています…",
"invalidKeyFormat": "キー形式が無効です。LIC- で始まるライセンスキーを貼り付けてください",
"accountEmail": "アカウント: {{email}}",
"freeTier": "無料",
"proTier": "Pro",
"proTier": "プロフェッショナル",
"freeItems": {
"ssh": "SSH ターミナル",
"sftp": "SFTP ファイルマネージャー",
@@ -192,17 +200,38 @@
"proItems": {
"forward": "ポート転送",
"proxy": "セッションプロキシ / SOCKS",
"serial": "シリアルターミナル",
"serial": "シリアル端末",
"ai": "ターミナル AI アシスタント",
"batch": "一括実行 / ブロードキャスト同期",
"broadcast": "ブロードキャスト入力同期",
"importExport": "ホストの一括インポート/エクスポート",
"desktop": "RDP / VNC リモートデスクトップ"
}
"broadcast": "ブロードキャスト同期入力",
"importExport": "ホスト一覧の一括インポート/エクスポート",
"desktop": "リモートデスクトップ(RDP / VNC)"
},
"planYearly": "Pro 年額",
"planYearlyDesc": "¥99 / 年 · 2 台",
"planPerpetual": "Pro 永久",
"planPerpetualDesc": "¥199 · 5 台 · 生涯",
"planTrial": "Pro 体験",
"devices": "最大 {{count}} 台",
"goUpgradeYearly": "Pro 年額を購入",
"goUpgradePerpetual": "Pro 永久を購入",
"activatedYearly": "Pro 年額が有効",
"activatedPerpetual": "Pro 永久が有効",
"activatedTrial": "Pro 体験が有効",
"choosePlan": "選択:無料 / Pro 年額 / Pro 永久",
"planFreePrice": "¥0",
"planFreeDesc": "SSH · SFTP · ローカル端末",
"planFreeCta": "現在のプラン",
"planIncludesPro": "全 Pro 機能を含む",
"planFree": "無料",
"subtitleUnlockDetail": "関連機能:{{feature}}",
"wallUnlockComingSoon": "Pro の高度な機能は近日公開",
"wallMessageComingSoonFeature": "「{{feature}}」は Pro の高度な機能で、実装整備中です。Pro 年額または永久で先行体験できます。",
"wallMessageFeature": "「{{feature}}」は Pro の高度な機能です。Pro 年額または永久でこの端末で利用できます。"
},
"launcher": {
"title": "クイック起動",
"description": "セッション、ローカルシェル、デバイスコンソール",
"description": "セッション・ローカル Shell・デスクトップ・シリアル",
"cards": {
"quickConnect": {
"title": "クイック接続",
@@ -215,6 +244,14 @@
"serial": {
"title": "シリアルターミナル",
"desc": "ボーレートとポート設定"
},
"rdp": {
"title": "RDP デスクトップ",
"desc": "リモート Windows"
},
"vnc": {
"title": "VNC デスクトップ",
"desc": "リモート画面"
}
},
"sectionLocalShell": "ローカルシェル",
@@ -229,6 +266,7 @@
"forwardOpenRunning": "SOCKS5 実行中 (:{{port}}) · クリックで管理 · 右クリックでコピー",
"forwardStarted": "転送を開始しました",
"forwardStartedDynamic": "SOCKS5 を開始: socks5://127.0.0.1:{{port}}",
"forwardResumed": "{{count}} 件のポート転送を復旧しました",
"socksStartFailed": "開始失敗: {{message}}",
"socksStopFailed": "停止失敗: {{message}}",
"socksCopied": "socks5://127.0.0.1:{{port}} をコピーしました"
@@ -249,7 +287,7 @@
"typeDynamic": "Dynamic",
"running": "実行中",
"socksProxy": "SOCKS5: 127.0.0.1:{{port}}",
"remoteToLocal": "リモート {{remote}} → ローカル {{local}}",
"remoteToLocal": "リモート {{bind}}:{{remote}} → {{host}}:{{local}}",
"stats": "{{connections}} 接続 · ↑{{up}} · ↓{{down}}",
"copyProxy": "プロキシをコピー",
"copyRule": "ルールを複製",
@@ -259,15 +297,15 @@
"delete": "削除",
"editTitle": "転送を編集",
"addTitle": "転送を追加",
"typeLocalDesc": "Local → リモート",
"typeRemoteDesc": "リモート → Local",
"typeLocalDesc": "リモートサービスへアクセス",
"typeRemoteDesc": "ローカルサービスを公開",
"typeDynamicDesc": "SOCKS5",
"selectType": "タイプ",
"drawerSubtitle": "現在の SSH セッション経由でトンネル",
"ruleFields": "詳細",
"dynamicHint": "127.0.0.1:1080 のローカル SOCKS5。トラフィックはこの SSH サーバーのパブリック IP から出ていきます。",
"dynamicSingleHint": "SSH ホストごとに 1 つの Dynamic ルール、固定ポート 1080。",
"dynamicPortHint": "If the port is busy, pick another local port (e.g. 1081).",
"dynamicHint": "ローカル SOCKS5(既定 :1080)。トラフィックはこの SSH 出口経由。",
"dynamicSingleHint": "SSH ホストごとに動的は1つ。停止中にポート変更可。",
"dynamicPortHint": "使用中なら別ポート(例 1081)。",
"ruleList": "ルール",
"ruleListEmpty": "Local / Remote ルールはまだありません",
"addSection": "新規追加",
@@ -301,17 +339,30 @@
"internet": "インターネット",
"localCaption": "Local: リモートポートをローカルにあるかのように利用できます。",
"remoteCaption": "Remote: リモートからのアクセスをローカルサービスに転送します。",
"dynamicCaption": "Dynamic: このマシンが SOCKS5 プロキシとなり、現在の SSH サーバーのパブリック IP から出ていきます。"
"dynamicCaption": "動的: ローカル SOCKS5。この SSH 出口経由で外へ。"
},
"ruleName": "名前",
"ruleNamePlaceholder": "例: MySQL",
"localListenPort": "ローカルリッスンポート",
"localListenPortHint": "既定は localhost。ssh -L ポート:… と同じ。通常はポートだけ。",
"localBindAdvanced": "詳細: ローカルバインド変更",
"localTargetPort": "ローカルターゲットポート",
"remoteTargetPort": "リモートターゲットポート",
"remoteTargetPort": "転送先ポート",
"remoteListenPort": "リモートリッスンポート",
"remoteListenPortHint": "既定はリモート localhost(ssh -R ポート:… と同じ)。通常はポートだけ入力。",
"remoteListenAddress": "リモート待ち受け",
"remoteListenAddressPlaceholder": "127.0.0.1:18080",
"remoteListenAddressHint": "127.0.0.1 = リモート本体のみ / 0.0.0.0 = リモート LAN(GatewayPorts 必要)。",
"errorRemoteListenAddress": "有効なリモート待ち受けを入力(例: 127.0.0.1:18080)",
"localTargetAddress": "ローカル / LAN ターゲット",
"localTargetAddressPlaceholder": "127.0.0.1:8080",
"localTargetAddressHint": "本機または LAN のサービスへ転送(例: 127.0.0.1:8080)。0.0.0.0 は不可。",
"viaSessionRemote": "{{target}} で待ち受け(既定は localhost)",
"errorRemoteListenPort": "有効なリモートリッスンポート(1–65535)を入力してください",
"errorLocalTargetAddress": "有効なローカル/LAN ターゲットを入力(例: 127.0.0.1:8080)",
"portPlaceholder": "例: 3306",
"remoteHost": "リモートホスト",
"remoteHostPlaceholder": "localhost または IP",
"remoteHost": "転送先ホスト",
"remoteHostPlaceholder": "127.0.0.1",
"socksRuleName": "SOCKS5 プロキシ",
"defaultLocalName": "Local 転送",
"defaultRemoteName": "Remote 転送",
@@ -325,11 +376,35 @@
"deleteConfirm": "このルールを削除しますか?",
"errorNamePort": "名前と有効なポート (1–65535) を入力してください",
"errorTargetPort": "有効なターゲットポート (1–65535) を入力してください",
"errorRemoteHost": "リモートホストを入力してください",
"errorRemoteHost": "転送先ホストを入力してください(SSH サーバ基準)",
"errorNotConnected": "未接続",
"errorStartFailed": "開始失敗",
"errorStopFailed": "停止失敗",
"errorSocksRunning": "SOCKS5 は既に実行中",
"copySuffix": "コピー"
"copySuffix": "コピー",
"remoteBindHint": "0.0.0.0 は GatewayPorts が必要。未設定だと 127.0.0.1 のみになることが多い。転送先に 0.0.0.0 は不可。",
"localToRemote": "ローカル {{bind}}:{{local}} → リモート {{host}}:{{remote}}",
"localToRemotePort": "ローカル :{{local}} → リモート {{host}}:{{remote}}",
"remoteHostHint": "SSH サーバー基準。その本体は 127.0.0.1、LAN は到達可能な IP。0.0.0.0 不可。",
"localBindHost": "ローカルバインド",
"localBindHostPlaceholder": "127.0.0.1",
"localBindHostHint": "既定はこの端末のみ。0.0.0.0 や LAN IP で他端末から接続可。",
"errorTargetHostInvalid": "ターゲットに 0.0.0.0 は不可。127.0.0.1 か到達可能な IP を指定",
"listenAddress": "ローカル待ち受け",
"listenAddressPlaceholder": "127.0.0.1:8080",
"listenAddressHint": "既定はこの端末のみ。0.0.0.0 で LAN 許可。",
"targetAddress": "リモート転送先",
"targetAddressPlaceholder": "127.0.0.1:80",
"targetAddressHint": "SSH サーバーから見た宛先。その本体のサービスは 127.0.0.1:ポート。",
"errorListenAddress": "有効な待ち受けを入力(例: 127.0.0.1:8080)",
"errorTargetAddress": "有効なターゲットを入力(例: 127.0.0.1:80)",
"targetAddressHintVia": "{{target}} 経由。その本体のサービスは 127.0.0.1:ポート(新しい待ち受けは作らない)。",
"viaSession": "セッション経由 {{target}}",
"remoteToLocalPort": "リモート :{{remote}} → {{host}}:{{local}}",
"remoteBindAdvanced": "詳細: リモートバインドを変更",
"remoteBindHost": "リモートバインド",
"remoteBindHostPlaceholder": "127.0.0.1",
"remoteBindHostHint": "既定はリモート本機のみ。0.0.0.0 は GatewayPorts が必要。未設定だと多くの sshd は 127.0.0.1 のみにバインドします。",
"remoteGatewayPortsWarning": "リモート 0.0.0.0 待ち受けを要求しました。GatewayPorts が無いと、sshd はしばしば黙って 127.0.0.1 のみにバインドします。"
}
}
+11 -4
View File
@@ -15,10 +15,12 @@
"passwordOptional": "パスワード (任意)",
"width": "幅",
"height": "高さ",
"rdpFootnote": "ネイティブ IronRDP: TCP → X.224 → TLS → CredSSP/NLA → MCS · ハンドシェイクタイムアウト 45 秒。開発中は証明書の検証が緩和されています。",
"vncFootnote": "ネイティブ vnc-rs RFB · Tight JPEG デコード · クリップボードは UTF-8 で送信 (非常に古いサーバーでは文字化けする場合があります)。",
"rdpFootnote": "フルスクリーンまたは最大化時は、現在のウィンドウ領域に合わせてリモート解像度を動的に要求します。接続前に初期解像度を選ぶこともできます。証明書の検証は開発中は緩和されています。",
"vncFootnote": "多くの VNC サーバーではリモート解像度が固定です。フルスクリーンはローカルでの拡大のみで、接続時の希望サイズは参考値です。クリップボードは UTF-8 で送信します。",
"errorHostRequired": "ホストアドレスを入力してください",
"errorUsernameRequired": "ユーザー名を入力してください"
"errorUsernameRequired": "ユーザー名を入力してください",
"resolution": "解像度",
"resolutionCustom": "カスタム"
},
"toolbar": {
"pasteClipboard": "ローカルのクリップボードをリモートに貼り付け",
@@ -28,7 +30,12 @@
"openShareDir": "共有フォルダーをローカルで開く (Downloads)",
"openShareDirAria": "共有フォルダーを開く",
"disconnect": "切断",
"disconnectAria": "切断"
"disconnectAria": "切断",
"fullscreen": "全画面",
"fullscreenAria": "全画面表示にする",
"exitFullscreen": "全画面を終了",
"exitFullscreenAria": "全画面を終了",
"exitFullscreenHint": "Esc / F11 で終了"
},
"status": {
"connecting": "接続中…",
+16 -3
View File
@@ -39,7 +39,9 @@
"closed": "接続が閉じられました",
"dnsFailed": "ホストを解決できませんでした",
"refused": "接続が拒否されました",
"disconnected": "切断されました"
"disconnected": "切断されました",
"keepaliveFailed": "SSH キープアライブが連続失敗し、切断されました",
"idleKicked": "リモート Shell がアイドルタイムアウトで終了しました(TMOUT)"
},
"auth": {
"failed": "認証に失敗しました。ユーザー名とパスワードを確認してください。",
@@ -68,7 +70,10 @@
"forward": {
"failed": "ポート転送の設定に失敗しました",
"bindFailed": "ポートのバインドに失敗しました",
"alreadyInUse": "ポートは既に使用中です"
"alreadyInUse": "ローカルポートは使用中です。実行中の転送や他プロセスを停止してから再起動してください。",
"remoteDenied": "リモート転送をサーバーが拒否しました(AllowTcpForwarding / PermitListen、ポート使用中、またはバインド制限)。リモートは 127.0.0.1 のみで待ち受けます。",
"remoteDeniedPrivileged": "特権ポート(<1024)の待ち受けを拒否されました。≥1024 を使うか root で実行し、AllowTcpForwarding / PermitListen を確認してください。",
"invalidTargetHost": "転送先に 0.0.0.0 は指定できません。127.0.0.1 か到達可能な内網 IP を使ってください"
},
"sftp": {
"connectFailed": "SFTP 接続に失敗しました",
@@ -80,7 +85,15 @@
"writeFailed": "保存に失敗しました",
"renameFailed": "名前の変更に失敗しました",
"chmodFailed": "権限の更新に失敗しました",
"apiUnavailable": "SFTP API は利用できません"
"apiUnavailable": "SFTP API は利用できません",
"channelFailed": "SFTP チャネルを開けません(上限またはビジー)。再接続して再試行してください",
"sessionGone": "SFTP セッションが終了しました。再接続してください",
"busy": "ファイル転送中です。完了してから再試行してください。",
"editTooLarge": "ファイルが大きすぎます(テキスト編集の上限は約 2MB)。ダウンロードしてローカルで開いてください。",
"editBinary": "バイナリの可能性があるため、テキストエディタでは開けません",
"editNotUtf8": "リモートファイルは有効な UTF-8 テキストではありません",
"transferCancelled": "転送をキャンセルしました",
"diskFull": "ディスク容量不足(ローカルまたはリモート)"
},
"session": {
"notFound": "セッションが見つかりません",
+6 -4
View File
@@ -69,17 +69,17 @@
"confirm": "削除"
},
"welcome": {
"tagline": "リモートターミナル · SFTP · ポート転送",
"tagline": "シンプル実用 · ターミナル · ファイル · デスクトップ",
"sectionStart": "はじめる",
"sectionDesktop": "リモートデスクトップ",
"sectionRecent": "最近のセッション",
"quickConnect": {
"title": "クイック接続",
"desc": "単発の SSH · ライブラリに保存しない"
"desc": "一時 SSH。セッション一覧には保存しません"
},
"newSession": {
"title": "新規セッション",
"desc": "ホストを保存 · サイドバーでダブルクリックして接続"
"desc": "ホストを保存。サイドバーをダブルクリックで接続"
},
"localShell": {
"title": "ローカルシェル",
@@ -167,7 +167,9 @@
"jumpHost": "ジャンプホスト (任意)",
"jumpNone": "ジャンプホストなし",
"jumpNoneAlt": "ジャンプホストなし",
"jumpHint": "保存済みセッションをジャンプホストとして使用可能"
"jumpHint": "保存済みセッションをジャンプホストとして使用可能",
"resolution": "解像度",
"resolutionCustom": "カスタム"
},
"batch": {
"title": "一括実行",
+51 -19
View File
@@ -1,9 +1,7 @@
{
"nav": {
"general": "ワークスペース",
"generalDesc": "レイアウトとリソース",
"appearance": "外観",
"appearanceDesc": "テーマと言語",
"general": "一般",
"generalDesc": "テーマ、言語、レイアウト",
"terminal": "ターミナル",
"terminalDesc": "フォントと配色",
"shortcuts": "ショートカット",
@@ -15,11 +13,13 @@
"ai": "AI モデル",
"aiDesc": "AI プロバイダー",
"about": "情報",
"aboutDesc": "バージョンとデータ"
"aboutDesc": "バージョンとデータ",
"account": "アカウント",
"accountDesc": "メール、ライセンス、デバイス"
},
"title": "設定",
"search": {
"placeholder": "設定を検索..."
"placeholder": "設定を検索…"
},
"language": {
"label": "言語",
@@ -89,7 +89,7 @@
"ssh": {
"connection": "接続",
"keepAlive": "キープアライブ",
"keepAliveDesc": "アイドル時にキープアライブを送り、切断を減らします",
"keepAliveDesc": "アイドル時に SSH キープアライブを送信。数回連続失敗してから切断",
"autoReconnect": "自動再接続",
"autoReconnectDesc": "予期しない切断後に自動で再試行します",
"timeout": "接続タイムアウト",
@@ -101,8 +101,10 @@
"maxConnectionsDesc": "SSH・SFTP・一括接続で共有する上限",
"maxBackground": "バックグラウンド接続上限",
"maxBackgroundDesc": "上限超過時は最も長く未使用の接続から回収します",
"suspend": "バックグラウンド一時停止",
"suspendDesc": "タブがバックグラウンドになってからアクティブチャネルを解放するまでの分数"
"suspend": "バックグラウンド休止",
"suspendDesc": "アイドルのバックグラウンドタブを何分後に休止するか(稼働中の Shell/SFTP は維持)",
"keepAliveInterval": "キープアライブ間隔(秒)",
"keepAliveIntervalDesc": "サーバーへの ping 間隔(ClientAliveInterval 相当)。不安定な回線では 15 秒以下を推奨"
},
"security": {
"credentials": "資格情報の保護",
@@ -119,7 +121,7 @@
"clearConfirm": "既知のホスト鍵をすべて消去しますか?次回接続時に再度確認されます。"
},
"about": {
"tagline": "軽量ネイティブの SSH / SFTP 運用ターミナル",
"tagline": "シンプルで使いやすいネイティブ運用端末",
"appVersion": "アプリ v{{version}}",
"copyVersion": "バージョン情報をコピー",
"runtime": "ランタイム",
@@ -127,8 +129,9 @@
"localData": "ローカルデータ",
"sessionsSettings": "セッションと設定",
"masterKeyPath": "マスターキー",
"privacyNote": "パスワードと鍵パスフレーズは Rust が AES-256-GCM で暗号化します。OpsXShell はターミナルのコマンド履歴を保存しません。",
"license": "MIT License · Rust · XTerm.js"
"privacyNote": "パスワードと鍵パスフレーズは Rust 側で AES-256-GCM 暗号化。コマンド履歴も監査ログも保存しません。",
"license": "MIT License · Rust · XTerm.js",
"logPath": "アプリログ"
},
"ai": {
"presets": "プロバイダープリセット",
@@ -162,13 +165,42 @@
"apiKeySaveFailed": "API Key の保存に失敗しました",
"providerNotFound": "プロバイダーが見つかりません",
"modelCatalogReadFailed": "モデルカタログの読み取りに失敗しました",
"catalogUnavailable": "models.dev を利用できません"
"catalogUnavailable": "models.dev を利用できません",
"family": {
"openai_compatible": "OpenAI 互換",
"standalone": "独自プロトコル",
"plan": "プラン",
"local": "セルフホスト",
"custom": "カスタム"
}
},
"family": {
"openai_compatible": "OpenAI 互換",
"standalone": "────────",
"plan": "Plan",
"local": "セルフホスト",
"custom": "カスタム"
"account": {
"title": "Account",
"email": "Email",
"emailDesc": "Used for checkout and license claims",
"plan": "Current plan",
"planFree": "Free",
"planYearly": "Pro 年額",
"planPerpetual": "Pro 永久",
"planTrial": "Pro 体験",
"expires": "Expires {{date}}",
"lifetime": "Lifetime",
"devices": "Seats: up to {{count}} devices",
"deviceId": "This device ID",
"saveEmail": "Save email",
"activate": "Activate license",
"pasteLicense": "Paste LIC- license key",
"clear": "Clear local license",
"deactivate": "Deactivate this device",
"upgrade": "Upgrade",
"saved": "Saved",
"activateOk": "Activated",
"deviceList": "紐付けデバイス",
"loadingDevices": "デバイスを読み込み中…",
"noDevices": "紐付けデバイスはありません",
"thisDevice": "このデバイス",
"removeDevice": "解除",
"deviceRemoved": "デバイスの紐付けを解除しました",
"seatFull": "デバイス枠がいっぱいです。新しい端末で有効化する前に、別のデバイスを解除してください。"
}
}
+55 -25
View File
@@ -3,8 +3,9 @@
"local": "ローカル",
"remote": "リモート",
"notConnectedTitle": "リモートサーバーに接続されていません",
"notConnectedDesc": "SFTP を使用するにはまず SSH セッションを接続してください",
"ariaLabel": "{{title}} ファイルパネル"
"notConnectedDesc": "まず SSH 接続してから転送・編集",
"ariaLabel": "{{title}} ファイルパネル",
"reconnectResumeDesc": "切断されました。再接続で {{path}} に戻ります"
},
"connection": {
"selectServer": "サーバーを選択",
@@ -12,7 +13,9 @@
"createSessionHint": "まずサイドバーでセッションを作成してください",
"reconnect": "再接続",
"notConnected": "未接続",
"connecting": "接続中..."
"connecting": "接続中...",
"resumeHint": "再接続後は {{path}} に戻ります",
"reconnectingResume": "{{path}} を復元して再接続中…"
},
"errors": {
"notConnected": "SFTP に接続されていません",
@@ -23,19 +26,14 @@
"selectDownloadFiles": "ダウンロードするファイルを先に選択してください",
"uploadFailed": "アップロード失敗",
"downloadFailed": "ダウンロード失敗",
"fileError": "{{fileName}}: {{message}}"
"fileError": "{{fileName}}: {{message}}",
"mkdirFailed": "フォルダの作成に失敗しました"
},
"toolbar": {
"upload": "アップロード",
"uploadAria": "アップロード",
"download": "ダウンロード",
"downloadAria": "ダウンロード",
"downloadNeedSelection": "リモートファイルを先に選択してください",
"downloadHint": "選択したリモートファイルをダウンロード",
"refresh": "更新",
"refreshAria": "更新",
"newFolder": "新規フォルダー",
"newFolderAria": "新規フォルダー"
"refreshAria": "このパネルを更新",
"newFolder": "新規フォルダ",
"newFolderAria": "このパネルにフォルダを作成"
},
"sort": {
"name": "名前",
@@ -55,7 +53,8 @@
"hideHidden": "ドットファイルを非表示 (.xxx)",
"showHiddenAria": "隠しファイルを表示",
"hideHiddenAria": "ドットファイルを非表示",
"searchAria": "{{title}} 内を検索"
"searchAria": "{{title}} 内を検索",
"upAtRoot": "すでにルートです"
},
"status": {
"loading": "読み込み中...",
@@ -63,7 +62,8 @@
"filtered": "{{shown}} / {{total}} 件を表示",
"hiddenCount": "{{total}} 件 ({{hidden}} 件非表示)",
"total": "{{count}} 件",
"filterHint": "絞り込み: {{query}}"
"filterHint": "絞り込み: {{query}}",
"refreshing": "更新中..."
},
"empty": {
"dirTitle": "空のディレクトリ",
@@ -99,7 +99,6 @@
"download": "ダウンロード",
"edit": "編集",
"rename": "名前を変更",
"copyPath": "パスをコピー",
"properties": "プロパティ",
"permissions": "権限",
"delete": "削除"
@@ -116,7 +115,18 @@
"chmodTitle": "権限を編集",
"chmodApply": "適用",
"pickUploadTitle": "アップロードするファイルまたはフォルダーを選択",
"pickUploadRzTitle": "アップロードするファイルを選択 (rz)"
"pickUploadRzTitle": "アップロードするファイルを選択 (rz)",
"overwriteTitle": "既存ファイルを上書きしますか?",
"overwriteConfirm": "上書き",
"overwriteSkip": "スキップ",
"overwriteOneRemote": "リモートに「{{name}}」があります。上書きしますか?",
"overwriteOneLocal": "ローカルに「{{name}}」があります。上書きしますか?",
"overwriteManyRemote": "リモートに同名が {{count}} 件あります。すべて上書きしますか?",
"overwriteManyLocal": "ローカルに同名が {{count}} 件あります。すべて上書きしますか?",
"overwriteMore": "…ほか {{count}} 件",
"deleteTitle": "削除の確認",
"deleteMessage": "{{count}} 件を削除します:{{names}}",
"deleteRecursiveWarn": "フォルダが含まれます。再帰的に削除され、元に戻せません。"
},
"properties": {
"name": "名前",
@@ -152,27 +162,41 @@
"statusDone": "完了",
"statusFailed": "失敗",
"statusCancelled": "キャンセル済み",
"dropUpload": "{{path}} へアップロード",
"dropDownload": "{{path}} へダウンロード"
"dropUpload": "{{path}} にドロップしてアップロード",
"dropDownload": "{{path}} にドロップしてダウンロード",
"railAria": "ローカルとリモート間の転送",
"railUpload": "選択したローカルファイルをアップロード(未選択時はファイル選択)",
"railDownload": "選択したリモートファイルをダウンロード",
"railUploadAria": "リモートへアップロード",
"railDownloadAria": "ローカルへダウンロード",
"railDownloadNeedSelection": "先にリモートファイルを選択してください",
"retry": "再試行"
},
"editor": {
"readFailed": "読み取り失敗",
"saveFailed": "保存失敗",
"unsavedClose": "未保存の変更があります。それでも閉じますか?",
"unsavedTitle": "未保存の変更",
"discardAndClose": "破棄して閉じる",
"reloadTitle": "再読み込み",
"discardAndReload": "破棄して再読み込み",
"saving": "保存中...",
"save": "保存",
"saved": "ファイルを保存しました",
"loadingEditor": "エディターを読み込み中...",
"footer": "UTF-8 · Monaco · ⌘/Ctrl+S で保存 · 最大約 2MB",
"loadBlocked": "ファイル内容を読み込めませんでした",
"footer": "UTF-8 · ⌘/Ctrl+S 保存 · Esc 閉じる · 最大約 2MB",
"charCount": "{{count}} 文字",
"unsavedMark": " · 未保存",
"savedMark": " · 保存済み"
"savedMark": " · 保存済み",
"title": "{{name}} を編集",
"dirtyBadge": "未保存",
"reload": "再読み込み",
"unsavedReload": "未保存の変更を破棄して再読み込みしますか?"
},
"toast": {
"renamed": "{{name}} に名前を変更しました",
"renameFailed": "名前の変更に失敗",
"pathCopied": "パスをクリップボードにコピー",
"pathCopyFailed": "パスのコピーに失敗",
"chmodUpdated": "権限を {{mode}} に更新しました",
"chmodFailed": "権限の更新に失敗",
"uploadPartial": "{{action}} 完了 {{completed}} · 失敗 {{failed}}: {{detail}}",
@@ -180,7 +204,13 @@
"uploadAction": "アップロード",
"downloadAction": "ダウンロード",
"noFolderUpload": "フォルダーのアップロードに失敗",
"noDragPath": "ドラッグ&ドロップのパスは利用できません · アップロードを使用してください",
"noFolderRecursive": "フォルダーの再帰転送に失敗 · 再試行するかアップロードを使用してください"
"noDragPath": "この環境ではドラッグパスを取得できません。中央の → かファイル選択を使ってください。",
"noFolderRecursive": "フォルダ転送に失敗しました。再試行するか、中央の → / ファイル選択を使ってください。",
"dropFailed": "ドロップに失敗しました。再試行するか、中央の矢印で転送してください。",
"folderCreated": "フォルダ {{name}} を作成しました",
"allSkippedExisting": "既存ファイルはすべてスキップしました",
"skippedExisting": "既存 {{count}} 件をスキップしました",
"deleteFailed": "削除に失敗しました",
"deleted": "削除しました"
}
}
+3 -2
View File
@@ -34,7 +34,7 @@
"reopenShell": "シェルを再開",
"reopening": "開いています…",
"disconnected": "切断されました",
"disconnectedHint": "SSH セッションが終了したか、ネットワークが切断されました。下のボタンで再接続できます。",
"disconnectedHint": "SSH セッション終了またはネットワーク切断。下のボタンで再接続して作業を続けられます。",
"reconnect": "再接続",
"suspended": "接続が一時停止",
"suspendedHint": "しばらく操作がなかったため、リソース節約のため接続を一時停止しました",
@@ -70,7 +70,8 @@
"splitRight": "右に分割",
"splitDown": "下に分割",
"maximizePane": "現在のペインを最大化",
"restoreLayout": "分割レイアウトを復元"
"restoreLayout": "分割レイアウトを復元",
"openSftp": "SFTP を開く"
},
"search": {
"placeholder": "検索...",
+107 -32
View File
@@ -78,7 +78,7 @@
"closeTab": "현재 탭 닫기",
"closeAllTabs": "모든 탭 닫기",
"portForward": "새 포트 포워딩",
"upgradePro": "OpsXShell Pro 업그레이드",
"upgradePro": "OpsXShell 프로로 업그레이드",
"editSession": "세션 편집: {{name}}"
}
},
@@ -90,6 +90,8 @@
"terminal": "터미널",
"batch": "일괄 실행",
"newTerminal": "새 터미널",
"closeAria": "{{title}} 닫기",
"listAria": "열린 탭",
"kind": {
"terminal": "터미널",
"sftp": "SFTP",
@@ -105,7 +107,8 @@
"closeRight": "오른쪽 탭 닫기",
"closeLeft": "왼쪽 탭 닫기",
"closeAll": "모두 닫기",
"clone": "탭 복제"
"clone": "탭 복제",
"openSftp": "SFTP 열기"
}
},
"sidebar": {
@@ -149,9 +152,9 @@
"disconnect": "연결 끊기"
},
"pro": {
"upgrade": "Pro 업그레이드",
"upgrade": "프로로 업그레이드",
"feature": "Pro 기능",
"unlock": "{{feature}} 잠금 해제",
"unlock": "프로 고급 기능 잠금 해제",
"comingSoon": "출시 예정",
"activateSuccess": "활성화됨",
"activateFailed": "활성화 실패",
@@ -160,28 +163,33 @@
"invalidLicense": "활성화 실패: 잘못된 라이선스",
"goUpgrade": "업그레이드",
"haveLicense": "라이선스가 있나요? 활성화",
"title": "OpsXShell Pro · Pro 운영 기능 잠금 해제",
"subtitleComingSoon": "{{feature}} 출시 예정 · 조기 액세스는 Pro 업그레이드",
"subtitleUnlock": "잠금 해제 중: {{feature}}",
"subtitleDefault": "포워딩, 프록시, 시리얼, AI, 일괄 실행, 브로드캐스트",
"title": "OpsXShell · 프로 잠금 해제",
"subtitleComingSoon": "고급 기능 곧 제공 · 프로로 먼저 체험",
"subtitleUnlock": "고급 기능을 잠금 해제하는 중",
"subtitleDefault": "포트 포워딩, 터미널 AI, 일괄 운영, 호스트 목록, 원격 데스크톱 등 프로 기능",
"activated": "Pro 활성화됨",
"expiresAt": " · {{date}}까지 유효",
"lifetime": " · 평생 라이선스",
"oneTimeFee": "1회 구매 · 이 기기에서 평생 사용 · 구독 없음",
"oneTimeFee": "무료 / 프로 연간 / 프로 영구",
"pasteLicense": "라이선스 붙여넣기",
"activateButton": "활성화",
"clearLicense": "로컬 라이선스 삭제",
"paymentNote": "결제는 Waffo가 처리합니다. 이 앱은 카드 정보를 수집하지 않습니다.",
"testDeviceId": "테스트 환경 · 기기 ID: {{deviceId}}",
"later": "나중에 하기",
"viewPro": "Pro 보기",
"wallComingSoonSuffix": " · 출시 예정",
"wallUnlock": "{{feature}} 잠금 해제",
"wallMessageComingSoon": "이 Pro 기능은 아직 개발 중입니다. 조기 액세스를 위해 업그레이드하세요.",
"wallMessageDefault": "이 기능은 OpsXShell Pro가 필요합니다. 이 기기에서 사용하려면 업그레이드하세요.",
"viewPro": "프로 요금제 보기",
"wallComingSoonSuffix": "곧 제공",
"wallUnlock": "프로 고급 기능 잠금 해제",
"wallMessageComingSoon": "이 기능은 OpsXShell 프로에 포함되며 아직 준비 중입니다. 프로 연간 또는 영구로 먼저 체험하세요.",
"wallMessageDefault": "포트 포워딩, 터미널 AI, 일괄 실행, 호스트 목록, 원격 데스크톱은 프로에 포함됩니다. 프로 연간 또는 영구로 이 기기에서 사용하세요.",
"checkoutUnavailable": "결제를 사용할 수 없습니다 ({{status}})",
"emailLabel": "이메일",
"emailRequired": "결제 전에 이메일을 입력하세요",
"claimPending": "라이선스를 받는 중…",
"invalidKeyFormat": "키 형식이 올바르지 않습니다. LIC- 라이선스 키를 붙여넣으세요",
"accountEmail": "계정: {{email}}",
"freeTier": "무료",
"proTier": "Pro",
"proTier": "프로",
"freeItems": {
"ssh": "SSH 터미널",
"sftp": "SFTP 파일 관리자",
@@ -195,14 +203,35 @@
"serial": "시리얼 터미널",
"ai": "터미널 AI 어시스턴트",
"batch": "일괄 실행 / 브로드캐스트 동기화",
"broadcast": "브로드캐스트 입력 동기화",
"importExport": "호스트 일괄 가져오기/내보내기",
"desktop": "RDP / VNC 원격 데스크톱"
}
"broadcast": "브로드캐스트 동기 입력",
"importExport": "호스트 목록 일괄 가져오기/내보내기",
"desktop": "원격 데스크톱 (RDP / VNC)"
},
"planYearly": "프로 연간",
"planYearlyDesc": "¥99 / 년 · 기기 2대",
"planPerpetual": "프로 영구",
"planPerpetualDesc": "¥199 · 기기 5대 · 평생",
"planTrial": "프로 체험",
"devices": "최대 {{count}}대",
"goUpgradeYearly": "프로 연간 구매",
"goUpgradePerpetual": "프로 영구 구매",
"activatedYearly": "프로 연간 활성화됨",
"activatedPerpetual": "프로 영구 활성화됨",
"activatedTrial": "프로 체험 활성화됨",
"choosePlan": "선택: 무료 / 프로 연간 / 프로 영구",
"planFreePrice": "¥0",
"planFreeDesc": "SSH · SFTP · 로컬 터미널",
"planFreeCta": "현재 요금제",
"planIncludesPro": "모든 프로 기능 포함",
"planFree": "무료",
"subtitleUnlockDetail": "관련 기능: {{feature}}",
"wallUnlockComingSoon": "프로 고급 기능 곧 제공",
"wallMessageComingSoonFeature": "「{{feature}}」는 프로 고급 기능이며 아직 준비 중입니다. 프로 연간 또는 영구로 먼저 체험하세요.",
"wallMessageFeature": "「{{feature}}」는 프로 고급 기능입니다. 프로 연간 또는 영구로 이 기기에서 사용하세요."
},
"launcher": {
"title": "빠른 실행",
"description": "세션, 로컬 셸 및 기기 콘솔",
"description": "세션, 로컬 Shell, 데스크톱, 시리얼",
"cards": {
"quickConnect": {
"title": "빠른 연결",
@@ -215,6 +244,14 @@
"serial": {
"title": "시리얼 터미널",
"desc": "보레이트 및 포트 설정"
},
"rdp": {
"title": "RDP 데스크톱",
"desc": "원격 Windows"
},
"vnc": {
"title": "VNC 데스크톱",
"desc": "원격 그래픽"
}
},
"sectionLocalShell": "로컬 셸",
@@ -229,6 +266,7 @@
"forwardOpenRunning": "SOCKS5 실행 중 (:{{port}}) · 클릭하여 관리 · 우클릭으로 복사",
"forwardStarted": "포워딩 시작됨",
"forwardStartedDynamic": "SOCKS5 시작됨: socks5://127.0.0.1:{{port}}",
"forwardResumed": "포트 포워딩 {{count}}개를 복구했습니다",
"socksStartFailed": "시작 실패: {{message}}",
"socksStopFailed": "중지 실패: {{message}}",
"socksCopied": "socks5://127.0.0.1:{{port}} 복사됨"
@@ -249,7 +287,7 @@
"typeDynamic": "동적",
"running": "실행 중",
"socksProxy": "SOCKS5: 127.0.0.1:{{port}}",
"remoteToLocal": "원격 {{remote}} → 로컬 {{local}}",
"remoteToLocal": "원격 {{bind}}:{{remote}} → {{host}}:{{local}}",
"stats": "{{connections}}개 연결 · ↑{{up}} · ↓{{down}}",
"copyProxy": "프록시 복사",
"copyRule": "규칙 복제",
@@ -259,15 +297,15 @@
"delete": "삭제",
"editTitle": "포워딩 편집",
"addTitle": "포워딩 추가",
"typeLocalDesc": "로컬 → 원격",
"typeRemoteDesc": "원격 → 로컬",
"typeLocalDesc": "원격 서비스 접근",
"typeRemoteDesc": "로컬 서비스 노출",
"typeDynamicDesc": "SOCKS5",
"selectType": "유형",
"drawerSubtitle": "현재 SSH 세션을 통한 터널",
"ruleFields": "세부 정보",
"dynamicHint": "127.0.0.1:1080의 로컬 SOCKS5. 트래픽은 이 SSH 서버의 공인 IP를 통해 나갑니다.",
"dynamicSingleHint": "SSH 호스트당 하나의 동적 규칙, 고정 포트 1080.",
"dynamicPortHint": "If the port is busy, pick another local port (e.g. 1081).",
"dynamicHint": "로컬 SOCKS5(기본 :1080). 트래픽은 현재 SSH 출구를 통해 나갑니다.",
"dynamicSingleHint": "SSH 호스트당 동적 규칙 1개. 중지 후 포트 변경.",
"dynamicPortHint": "사용 중이면 다른 포트(예: 1081).",
"ruleList": "규칙",
"ruleListEmpty": "아직 로컬/원격 규칙이 없습니다",
"addSection": "새로 추가",
@@ -301,17 +339,30 @@
"internet": "인터넷",
"localCaption": "로컬: 원격 포트를 로컬 포트인 것처럼 접근합니다.",
"remoteCaption": "원격: 원격 접근을 로컬 서비스로 포워딩합니다.",
"dynamicCaption": "동적: 이 컴퓨터가 현재 SSH 서버의 공인 IP를 통해 나가는 SOCKS5 프록시가 됩니다."
"dynamicCaption": "동적: 로컬 SOCKS5, 현재 SSH 출구로 외부 접속."
},
"ruleName": "이름",
"ruleNamePlaceholder": "예: MySQL",
"localListenPort": "로컬 수신 포트",
"localListenPortHint": "기본 localhost — ssh -L 포트:… 와 같음. 보통 포트만 입력.",
"localBindAdvanced": "고급: 로컬 바인드 변경",
"localTargetPort": "로컬 대상 포트",
"remoteTargetPort": "원격 대상 포트",
"remoteTargetPort": "대상 포트",
"remoteListenPort": "원격 수신 포트",
"remoteListenPortHint": "기본은 원격 localhost — ssh -R 포트:… 와 같습니다. 보통 포트만 입력하세요.",
"remoteListenAddress": "원격 수신",
"remoteListenAddressPlaceholder": "127.0.0.1:18080",
"remoteListenAddressHint": "127.0.0.1 = 원격 본기만 / 0.0.0.0 = 원격 LAN(GatewayPorts 필요).",
"errorRemoteListenAddress": "유효한 원격 수신 주소 입력(예: 127.0.0.1:18080)",
"localTargetAddress": "로컬 / LAN 대상",
"localTargetAddressPlaceholder": "127.0.0.1:8080",
"localTargetAddressHint": "로컬/LAN 서비스로 전달 (예: 127.0.0.1:8080). 0.0.0.0 금지.",
"viaSessionRemote": "{{target}} 에서 수신 (기본 localhost)",
"errorRemoteListenPort": "유효한 원격 수신 포트(1–65535)를 입력하세요",
"errorLocalTargetAddress": "유효한 로컬/LAN 대상 입력(예: 127.0.0.1:8080)",
"portPlaceholder": "예: 3306",
"remoteHost": "원격 호스트",
"remoteHostPlaceholder": "localhost 또는 IP",
"remoteHost": "대상 호스트",
"remoteHostPlaceholder": "127.0.0.1",
"socksRuleName": "SOCKS5 프록시",
"defaultLocalName": "로컬 포워딩",
"defaultRemoteName": "원격 포워딩",
@@ -325,11 +376,35 @@
"deleteConfirm": "이 규칙을 삭제하시겠습니까?",
"errorNamePort": "이름과 유효한 포트(1–65535)를 입력하세요",
"errorTargetPort": "유효한 대상 포트(1–65535)를 입력하세요",
"errorRemoteHost": "원격 호스트를 입력하세요",
"errorRemoteHost": "대상 호스트를 입력하세요(SSH 서버 기준)",
"errorNotConnected": "연결되지 않음",
"errorStartFailed": "시작 실패",
"errorStopFailed": "중지 실패",
"errorSocksRunning": "SOCKS5가 이미 실행 중입니다",
"copySuffix": "복사"
"copySuffix": "복사",
"remoteBindHint": "0.0.0.0 은 GatewayPorts 필요. 없으면 종종 127.0.0.1 만 바인드. 대상에 0.0.0.0 금지.",
"localToRemote": "로컬 {{bind}}:{{local}} → 원격 {{host}}:{{remote}}",
"localToRemotePort": "로컬 :{{local}} → 원격 {{host}}:{{remote}}",
"remoteHostHint": "SSH 서버 기준. 본기는 127.0.0.1, LAN은 도달 가능한 IP. 0.0.0.0 불가.",
"localBindHost": "로컬 바인드",
"localBindHostPlaceholder": "127.0.0.1",
"localBindHostHint": "기본은 이 기기만. 0.0.0.0 또는 LAN IP로 네트워크 접근 허용.",
"errorTargetHostInvalid": "대상은 0.0.0.0 불가 — 127.0.0.1 또는 도달 가능한 IP",
"listenAddress": "로컬 수신",
"listenAddressPlaceholder": "127.0.0.1:8080",
"listenAddressHint": "기본은 이 기기만. 0.0.0.0이면 LAN 접근 가능.",
"targetAddress": "원격 대상",
"targetAddressPlaceholder": "127.0.0.1:80",
"targetAddressHint": "SSH 서버에서 본 주소. 본기 서비스는 127.0.0.1:포트.",
"errorListenAddress": "유효한 수신 주소 입력(예: 127.0.0.1:8080)",
"errorTargetAddress": "유효한 대상 입력(예: 127.0.0.1:80)",
"targetAddressHintVia": "{{target}} 경유. 본기 서비스는 127.0.0.1:포트(새 원격 포트 생성 안 함).",
"viaSession": "세션 경유 {{target}}",
"remoteToLocalPort": "원격 :{{remote}} → {{host}}:{{local}}",
"remoteBindAdvanced": "고급: 원격 바인드 변경",
"remoteBindHost": "원격 바인드",
"remoteBindHostPlaceholder": "127.0.0.1",
"remoteBindHostHint": "기본은 원격 호스트만. 0.0.0.0 은 GatewayPorts 필요. 없으면 대부분 sshd 가 여전히 127.0.0.1 만 바인드합니다.",
"remoteGatewayPortsWarning": "원격 0.0.0.0 수신을 요청했습니다. GatewayPorts 가 없으면 sshd 가 조용히 127.0.0.1 만 바인드하는 경우가 많습니다."
}
}
+11 -4
View File
@@ -15,10 +15,12 @@
"passwordOptional": "비밀번호 (선택 사항)",
"width": "너비",
"height": "높이",
"rdpFootnote": "네이티브 IronRDP: TCP → X.224 → TLS → CredSSP/NLA → MCS; 45초 핸드셰이크 시간 초과. 개발 중 인증서 확인은 완화됩니다.",
"vncFootnote": "네이티브 vnc-rs RFB(Tight JPEG 디코딩); 클립보드는 UTF-8로 전송됩니다(매우 오래된 서버는 텍스트가 깨질 수 있음).",
"rdpFootnote": "전체 화면 또는 최대화 시 현재 창 영역에 맞춰 원격 해상도를 동적으로 요청합니다. 연결 전에 초기 해상도를 선택할 수도 있습니다. 인증서 검증은 개발 중에는 완화되어 있습니다.",
"vncFootnote": "대부분 VNC 서버는 원격 해상도가 고정됩니다. 전체 화면은 로컬 확대만 하며, 연결 시 선호 크기는 참고용입니다. 클립보드는 UTF-8로 전송됩니다.",
"errorHostRequired": "호스트 주소를 입력하세요",
"errorUsernameRequired": "사용자 이름을 입력하세요"
"errorUsernameRequired": "사용자 이름을 입력하세요",
"resolution": "해상도",
"resolutionCustom": "사용자 지정"
},
"toolbar": {
"pasteClipboard": "로컬 클립보드를 원격에 붙여넣기",
@@ -28,7 +30,12 @@
"openShareDir": "공유 폴더를 로컬에서 열기 (다운로드)",
"openShareDirAria": "공유 폴더 열기",
"disconnect": "연결 끊기",
"disconnectAria": "연결 끊기"
"disconnectAria": "연결 끊기",
"fullscreen": "전체 화면",
"fullscreenAria": "전체 화면으로 전환",
"exitFullscreen": "전체 화면 종료",
"exitFullscreenAria": "전체 화면 종료",
"exitFullscreenHint": "Esc / F11로 종료"
},
"status": {
"connecting": "연결 중…",
+16 -3
View File
@@ -39,7 +39,9 @@
"closed": "연결이 닫혔습니다",
"dnsFailed": "호스트를 확인할 수 없습니다",
"refused": "연결이 거부되었습니다",
"disconnected": "연결 끊김"
"disconnected": "연결 끊김",
"keepaliveFailed": "SSH keep-alive가 연속 실패하여 연결이 닫혔습니다",
"idleKicked": "원격 Shell이 유휴 시간 초과로 종료되었습니다(TMOUT)"
},
"auth": {
"failed": "인증 실패. 사용자 이름과 비밀번호를 확인하세요.",
@@ -68,7 +70,10 @@
"forward": {
"failed": "포트 포워딩 설정 실패",
"bindFailed": "포트 바인딩 실패",
"alreadyInUse": "포트가 이미 사용 중입니다"
"alreadyInUse": "로컬 포트가 이미 사용 중입니다. 실행 중인 포워딩이나 다른 프로세스를 중지한 뒤 다시 시작하세요.",
"remoteDenied": "서버가 원격 포트 포워딩을 거부했습니다(AllowTcpForwarding / PermitListen, 포트 사용 중 또는 바인드 제한). 원격은 127.0.0.1에서만 수신합니다.",
"remoteDeniedPrivileged": "특권 수신 포트(<1024)가 거부되었습니다. ≥1024를 사용하거나 root로 실행하고 AllowTcpForwarding / PermitListen을 확인하세요.",
"invalidTargetHost": "대상은 0.0.0.0일 수 없습니다. 127.0.0.1 또는 도달 가능한 내부 IP를 사용하세요"
},
"sftp": {
"connectFailed": "SFTP 연결 실패",
@@ -80,7 +85,15 @@
"writeFailed": "저장 실패",
"renameFailed": "이름 변경 실패",
"chmodFailed": "권한 업데이트 실패",
"apiUnavailable": "SFTP API를 사용할 수 없음"
"apiUnavailable": "SFTP API를 사용할 수 없음",
"channelFailed": "SFTP 채널을 열 수 없습니다(서버 제한 또는 세션 바쁨). 다시 연결하세요",
"sessionGone": "SFTP 세션이 종료되었습니다. 다시 연결하세요",
"busy": "파일 전송 중입니다. 완료 후 다시 시도하세요.",
"editTooLarge": "파일이 너무 큽니다(텍스트 편집 한도 약 2MB). 다운로드 후 로컬에서 여세요.",
"editBinary": "이진 파일로 보이며 텍스트 편집기로 열 수 없습니다",
"editNotUtf8": "원격 파일이 유효한 UTF-8 텍스트가 아닙니다",
"transferCancelled": "전송이 취소되었습니다",
"diskFull": "디스크 공간 부족(로컬 또는 원격)"
},
"session": {
"notFound": "세션을 찾을 수 없음",
+6 -4
View File
@@ -69,17 +69,17 @@
"confirm": "삭제"
},
"welcome": {
"tagline": "원격 터미널 · SFTP · 포트 포워딩",
"tagline": "간단 실용 · 터미널 · 파일 · 데스크톱",
"sectionStart": "시작하기",
"sectionDesktop": "원격 데스크톱",
"sectionRecent": "최근 세션",
"quickConnect": {
"title": "빠른 연결",
"desc": "1회성 SSH; 라이브러리에 저장되지 않음"
"desc": "임시 SSH — 세션 목록에 저장하지 않음"
},
"newSession": {
"title": "새 세션",
"desc": "호스트 저장; 사이드바에서 더블클릭하여 연결"
"desc": "호스트 저장 — 사이드바에서 더블클릭으로 연결"
},
"localShell": {
"title": "로컬 셸",
@@ -167,7 +167,9 @@
"jumpHost": "점프 호스트 (선택 사항)",
"jumpNone": "점프 호스트 없음",
"jumpNoneAlt": "점프 호스트 없음",
"jumpHint": "저장된 세션 하나를 점프 호스트로 사용할 수 있습니다"
"jumpHint": "저장된 세션 하나를 점프 호스트로 사용할 수 있습니다",
"resolution": "해상도",
"resolutionCustom": "사용자 지정"
},
"batch": {
"title": "일괄 실행",
+51 -19
View File
@@ -1,9 +1,7 @@
{
"nav": {
"general": "워크스페이스",
"generalDesc": "레이아웃 및 리소스",
"appearance": "외관",
"appearanceDesc": "테마 및 언어",
"general": "일반",
"generalDesc": "테마, 언어, 레이아웃",
"terminal": "터미널",
"terminalDesc": "글꼴 및 색상",
"shortcuts": "단축키",
@@ -15,11 +13,13 @@
"ai": "AI 모델",
"aiDesc": "AI 제공자",
"about": "정보",
"aboutDesc": "버전 및 데이터"
"aboutDesc": "버전 및 데이터",
"account": "계정",
"accountDesc": "이메일, 라이선스, 기기"
},
"title": "설정",
"search": {
"placeholder": "설정 검색..."
"placeholder": "설정 검색…"
},
"language": {
"label": "언어",
@@ -89,7 +89,7 @@
"ssh": {
"connection": "연결",
"keepAlive": "연결 유지",
"keepAliveDesc": "연결 끊김을 줄이기 위해 유휴 시 keep-alive 전송",
"keepAliveDesc": "유휴 시 SSH keep-alive를 보냅니다. 연속 실패 후에만 연결을 끊습니다",
"autoReconnect": "자동 재연결",
"autoReconnectDesc": "예기치 않은 연결 끊김 후 자동으로 재시도",
"timeout": "연결 시간 초과",
@@ -101,8 +101,10 @@
"maxConnectionsDesc": "SSH, SFTP 및 일괄 연결에 공유되는 상한",
"maxBackground": "백그라운드 연결 제한",
"maxBackgroundDesc": "제한 초과 시 가장 오래전 사용한 연결 제거",
"suspend": "백그라운드 일시 중단",
"suspendDesc": "탭이 백그라운드에 있을 때 활성 채널을 해제하기까지의 시간(분)"
"suspend": "백그라운드 일시 중지",
"suspendDesc": "유휴 백그라운드 탭을 몇 분 후 일시 중지할지(활성 Shell/SFTP는 유지)",
"keepAliveInterval": "유지 간격(초)",
"keepAliveIntervalDesc": "서버 ping 주기(ClientAliveInterval과 유사). 불안정한 네트워크에서는 15초 이하 권장"
},
"security": {
"credentials": "자격 증명 보호",
@@ -119,7 +121,7 @@
"clearConfirm": "모든 알려진 호스트 키를 지우시겠습니까? 다음 연결 시 다시 확인 메시지가 표시됩니다."
},
"about": {
"tagline": "가벼운 네이티브 SSH / SFTP 운영 터미널",
"tagline": "간단하고 실용적인 네이티브 운영 터미널",
"appVersion": "앱 v{{version}}",
"copyVersion": "버전 정보 복사",
"runtime": "런타임",
@@ -127,8 +129,9 @@
"localData": "로컬 데이터",
"sessionsSettings": "세션 및 설정",
"masterKeyPath": "마스터 키",
"privacyNote": "비밀번호와 키 암호는 Rust에서 AES-256-GCM으로 암호화됩니다. OpsXShell은 터미널 명령 히스토리를 저장하지 않습니다.",
"license": "MIT 라이선스 · Rust · XTerm.js"
"privacyNote": "비밀번호·키 암호는 Rust에서 AES-256-GCM으로 암호화합니다. 명령 기록·감사 로그는 저장하지 않습니다.",
"license": "MIT 라이선스 · Rust · XTerm.js",
"logPath": "앱 로그"
},
"ai": {
"presets": "제공자 프리셋",
@@ -162,13 +165,42 @@
"apiKeySaveFailed": "API 키 저장 실패",
"providerNotFound": "제공자를 찾을 수 없음",
"modelCatalogReadFailed": "모델 카탈로그 읽기 실패",
"catalogUnavailable": "models.dev를 사용할 수 없습니다"
"catalogUnavailable": "models.dev를 사용할 수 없습니다",
"family": {
"openai_compatible": "OpenAI 호환",
"standalone": "독립 프로토콜",
"plan": "구독 플랜",
"local": "자체 호스팅",
"custom": "사용자 지정"
}
},
"family": {
"openai_compatible": "OpenAI 호환",
"standalone": "────────",
"plan": "플랜",
"local": "셀프 호스팅",
"custom": "사용자 지정"
"account": {
"title": "Account",
"email": "Email",
"emailDesc": "Used for checkout and license claims",
"plan": "Current plan",
"planFree": "Free",
"planYearly": "프로 연간",
"planPerpetual": "프로 영구",
"planTrial": "프로 체험",
"expires": "Expires {{date}}",
"lifetime": "Lifetime",
"devices": "Seats: up to {{count}} devices",
"deviceId": "This device ID",
"saveEmail": "Save email",
"activate": "Activate license",
"pasteLicense": "Paste LIC- license key",
"clear": "Clear local license",
"deactivate": "Deactivate this device",
"upgrade": "Upgrade",
"saved": "Saved",
"activateOk": "Activated",
"deviceList": "바인딩된 기기",
"loadingDevices": "기기 목록 불러오는 중…",
"noDevices": "바인딩된 기기 없음",
"thisDevice": "이 기기",
"removeDevice": "해제",
"deviceRemoved": "기기 바인딩이 해제됨",
"seatFull": "기기 좌석이 가득 찼습니다. 새 기기에서 활성화하기 전에 다른 기기를 해제하세요."
}
}
+54 -24
View File
@@ -3,8 +3,9 @@
"local": "로컬",
"remote": "원격",
"notConnectedTitle": "원격 서버에 연결되지 않음",
"notConnectedDesc": "SFTP를 사용하려면 먼저 SSH 세션을 연결하세요",
"ariaLabel": "{{title}} 파일 패널"
"notConnectedDesc": "먼저 SSH에 연결한 뒤 전송·편집",
"ariaLabel": "{{title}} 파일 패널",
"reconnectResumeDesc": "연결이 끊겼습니다. 다시 연결하면 {{path}}(으)로 복귀합니다"
},
"connection": {
"selectServer": "서버 선택",
@@ -12,7 +13,9 @@
"createSessionHint": "먼저 사이드바에서 세션을 생성하세요",
"reconnect": "재연결",
"notConnected": "연결되지 않음",
"connecting": "연결 중..."
"connecting": "연결 중...",
"resumeHint": "다시 연결하면 {{path}}(으)로 복귀합니다",
"reconnectingResume": "{{path}} 복원하며 다시 연결 중…"
},
"errors": {
"notConnected": "SFTP가 연결되지 않았습니다",
@@ -23,19 +26,14 @@
"selectDownloadFiles": "먼저 다운로드할 파일을 선택하세요",
"uploadFailed": "업로드 실패",
"downloadFailed": "다운로드 실패",
"fileError": "{{fileName}}: {{message}}"
"fileError": "{{fileName}}: {{message}}",
"mkdirFailed": "폴더 만들기 실패"
},
"toolbar": {
"upload": "업로드",
"uploadAria": "업로드",
"download": "다운로드",
"downloadAria": "다운로드",
"downloadNeedSelection": "먼저 원격 파일을 선택하세요",
"downloadHint": "선택한 원격 파일 다운로드",
"refresh": "새로고침",
"refreshAria": "새로고침",
"refreshAria": "이 패널 새로고침",
"newFolder": "새 폴더",
"newFolderAria": "새 폴더"
"newFolderAria": "이 패널에 새 폴더 만들기"
},
"sort": {
"name": "이름",
@@ -55,7 +53,8 @@
"hideHidden": "도트파일 숨기기 (.xxx)",
"showHiddenAria": "숨김 파일 표시",
"hideHiddenAria": "도트파일 숨기기",
"searchAria": "{{title}}에서 검색"
"searchAria": "{{title}}에서 검색",
"upAtRoot": "이미 루트입니다"
},
"status": {
"loading": "불러오는 중...",
@@ -63,7 +62,8 @@
"filtered": "{{shown}} / {{total}} 표시 중",
"hiddenCount": "전체 {{total}}개 항목 ({{hidden}}개 숨김)",
"total": "{{count}}개 항목",
"filterHint": "필터: {{query}}"
"filterHint": "필터: {{query}}",
"refreshing": "새로 고치는 중..."
},
"empty": {
"dirTitle": "빈 디렉터리",
@@ -99,7 +99,6 @@
"download": "다운로드",
"edit": "편집",
"rename": "이름 변경",
"copyPath": "경로 복사",
"properties": "속성",
"permissions": "권한",
"delete": "삭제"
@@ -116,7 +115,18 @@
"chmodTitle": "권한 편집",
"chmodApply": "적용",
"pickUploadTitle": "업로드할 파일 또는 폴더 선택",
"pickUploadRzTitle": "업로드할 파일 선택 (rz)"
"pickUploadRzTitle": "업로드할 파일 선택 (rz)",
"overwriteTitle": "기존 파일을 덮어쓸까요?",
"overwriteConfirm": "덮어쓰기",
"overwriteSkip": "건너뛰기",
"overwriteOneRemote": "원격에 「{{name}}」이(가) 있습니다. 덮어쓸까요?",
"overwriteOneLocal": "로컬에 「{{name}}」이(가) 있습니다. 덮어쓸까요?",
"overwriteManyRemote": "원격에 같은 이름 {{count}}개가 있습니다. 모두 덮어쓸까요?",
"overwriteManyLocal": "로컬에 같은 이름 {{count}}개가 있습니다. 모두 덮어쓸까요?",
"overwriteMore": "…외 {{count}}개",
"deleteTitle": "삭제 확인",
"deleteMessage": "{{count}}개 항목을 삭제합니다: {{names}}",
"deleteRecursiveWarn": "폴더가 포함되어 있습니다. 재귀 삭제되며 되돌릴 수 없습니다."
},
"properties": {
"name": "이름",
@@ -152,27 +162,41 @@
"statusDone": "완료",
"statusFailed": "실패",
"statusCancelled": "취소됨",
"dropUpload": "{{path}}에 업로드",
"dropDownload": "{{path}}에 다운로드"
"dropUpload": "{{path}}(으)로 업로드하려면 여기에 놓으세요",
"dropDownload": "{{path}}(으)로 다운로드하려면 여기에 놓으세요",
"railAria": "로컬과 원격 간 전송",
"railUpload": "선택한 로컬 파일 업로드(선택 없으면 파일 선택)",
"railDownload": "선택한 원격 파일을 로컬로 다운로드",
"railUploadAria": "원격으로 업로드",
"railDownloadAria": "로컬로 다운로드",
"railDownloadNeedSelection": "먼저 원격 파일을 선택하세요",
"retry": "다시 시도"
},
"editor": {
"readFailed": "읽기 실패",
"saveFailed": "저장 실패",
"unsavedClose": "저장되지 않은 변경 사항이 있습니다. 그래도 닫으시겠습니까?",
"unsavedTitle": "저장되지 않은 변경 사항",
"discardAndClose": "버리고 닫기",
"reloadTitle": "다시 로드",
"discardAndReload": "버리고 다시 로드",
"saving": "저장 중...",
"save": "저장",
"saved": "파일 저장됨",
"loadingEditor": "에디터 로드 중...",
"footer": "UTF-8 · Monaco · ⌘/Ctrl+S 저장 · 최대 ~2MB",
"loadBlocked": "파일 내용을 불러오지 못했습니다",
"footer": "UTF-8 · ⌘/Ctrl+S 저장 · Esc 닫기 · 최대 약 2MB",
"charCount": "{{count}}자",
"unsavedMark": " · 저장 안 됨",
"savedMark": " · 저장됨"
"savedMark": " · 저장됨",
"title": "{{name}} 편집",
"dirtyBadge": "저장 안 됨",
"reload": "다시 불러오기",
"unsavedReload": "저장되지 않은 변경을 버리고 다시 불러올까요?"
},
"toast": {
"renamed": "{{name}}(으)로 이름 변경됨",
"renameFailed": "이름 변경 실패",
"pathCopied": "경로가 클립보드에 복사됨",
"pathCopyFailed": "경로 복사 실패",
"chmodUpdated": "권한이 {{mode}}(으)로 업데이트됨",
"chmodFailed": "권한 업데이트 실패",
"uploadPartial": "{{action}} 완료 {{completed}}, 실패 {{failed}}: {{detail}}",
@@ -180,7 +204,13 @@
"uploadAction": "업로드",
"downloadAction": "다운로드",
"noFolderUpload": "폴더 업로드 실패",
"noDragPath": "드래그 앤 드롭 경로를 사용할 수 없습니다; 업로드를 사용하세요",
"noFolderRecursive": "재귀 폴더 전송 실패; 재시도하거나 업로드를 사용하세요"
"noDragPath": "이 환경에서는 드래그 경로를 제공하지 않습니다. 가운데 → 또는 파일 선택을 사용하세요.",
"noFolderRecursive": "폴더 전송에 실패했습니다. 다시 시도하거나 가운데 → / 파일 선택을 사용하세요.",
"dropFailed": "끌어다 놓기 실패: 파일 정보를 읽지 못했습니다. 다시 시도하거나 가운데 화살표를 사용하세요.",
"folderCreated": "폴더 {{name}} 생성됨",
"allSkippedExisting": "이미 있는 파일을 모두 건너뛰었습니다",
"skippedExisting": "기존 파일 {{count}}개를 건너뛰었습니다",
"deleteFailed": "삭제 실패",
"deleted": "삭제됨"
}
}
+3 -2
View File
@@ -34,7 +34,7 @@
"reopenShell": "셸 다시 열기",
"reopening": "여는 중…",
"disconnected": "연결 끊김",
"disconnectedHint": "SSH 세션이 종료되었거나 네트워크가 끊겼습니다. 아래를 클릭하여 재연결하세요.",
"disconnectedHint": "SSH 세션이 종료되었거나 네트워크가 끊겼습니다. 아래를 누르면 다시 연결해 작업을 이어갑니다.",
"reconnect": "재연결",
"suspended": "연결 일시 중단됨",
"suspendedHint": "오랫동안 활동이 없어 리소스 절약을 위해 연결이 일시 중단되었습니다",
@@ -70,7 +70,8 @@
"splitRight": "오른쪽으로 분할",
"splitDown": "아래로 분할",
"maximizePane": "현재 창 최대화",
"restoreLayout": "분할 레이아웃 복원"
"restoreLayout": "분할 레이아웃 복원",
"openSftp": "SFTP 열기"
},
"search": {
"placeholder": "검색...",
+122 -38
View File
@@ -90,6 +90,8 @@
"terminal": "Терминал",
"batch": "Пакетное выполнение",
"newTerminal": "Новый терминал",
"closeAria": "Закрыть {{title}}",
"listAria": "Открытые вкладки",
"kind": {
"terminal": "Терминал",
"sftp": "SFTP",
@@ -105,7 +107,8 @@
"closeRight": "Закрыть вкладки справа",
"closeLeft": "Закрыть вкладки слева",
"closeAll": "Закрыть все",
"clone": "Дублировать вкладку"
"clone": "Дублировать вкладку",
"openSftp": "Открыть SFTP"
}
},
"sidebar": {
@@ -149,9 +152,9 @@
"disconnect": "Отключиться"
},
"pro": {
"upgrade": "Обновить Pro",
"upgrade": "Обновить до Pro",
"feature": "Возможности Pro",
"unlock": "Разблокировать {{feature}}",
"unlock": "Разблокировать расширенные функции Pro",
"comingSoon": "Скоро",
"activateSuccess": "Активировано",
"activateFailed": "Сбой активации",
@@ -160,28 +163,33 @@
"invalidLicense": "Сбой активации: недействительная лицензия",
"goUpgrade": "Обновить",
"haveLicense": "Есть лицензия? Активировать",
"title": "OpsXShell Pro · Оперативные возможности Pro",
"subtitleComingSoon": "{{feature}} скоро · обновите Pro для раннего доступа",
"subtitleUnlock": "Разблокировка: {{feature}}",
"subtitleDefault": "Проброс портов, прокси, serial, AI, пакетное выполнение и рассылка",
"title": "OpsXShell · Разблокировать Pro",
"subtitleComingSoon": "Расширенные функции скоро · обновите до Pro для раннего доступа",
"subtitleUnlock": "Разблокируются расширенные функции",
"subtitleDefault": "Проброс портов, AI в терминале, пакетные операции, списки хостов и удалённый рабочий стол в Pro",
"activated": "Pro активирован",
"expiresAt": " · действует до {{date}}",
"lifetime": " · бессрочная лицензия",
"oneTimeFee": "Разовая покупка · бессрочно на этом устройстве · без подписки",
"oneTimeFee": "Бесплатно / Pro год / Pro бессрочно",
"pasteLicense": "Вставить лицензию",
"activateButton": "Активировать",
"clearLicense": "Очистить локальную лицензию",
"paymentNote": "Оплата обрабатывается Waffo; это приложение не собирает данные карт.",
"testDeviceId": "Тестовая среда · ID устройства: {{deviceId}}",
"later": "Может быть позже",
"viewPro": "Посмотреть Pro",
"wallComingSoonSuffix": " · скоро",
"wallUnlock": "Разблокировать {{feature}}",
"wallMessageComingSoon": "Эта возможность Pro ещё в разработке. Обновите для раннего доступа.",
"wallMessageDefault": "Эта функция требует OpsXShell Pro. Обновите, чтобы использовать её на этом устройстве.",
"later": "Позже",
"viewPro": "Смотреть тарифы Pro",
"wallComingSoonSuffix": "скоро",
"wallUnlock": "Разблокировать расширенные функции Pro",
"wallMessageComingSoon": "Эта возможность входит в OpsXShell Pro и ещё дорабатывается. Обновите до Pro на год или бессрочно для раннего доступа.",
"wallMessageDefault": "Проброс портов, AI в терминале, пакетный запуск, списки хостов и удалённый рабочий стол входят в Pro. Обновите до Pro на год или бессрочно, чтобы использовать на этом устройстве.",
"checkoutUnavailable": "Оформление недоступно ({{status}})",
"emailLabel": "Email",
"emailRequired": "Введите email перед оформлением заказа",
"claimPending": "Получение лицензии…",
"invalidKeyFormat": "Неверный формат ключа — вставьте лицензию LIC-",
"accountEmail": "Аккаунт: {{email}}",
"freeTier": "Бесплатно",
"proTier": "Pro",
"proTier": "Профессиональная",
"freeItems": {
"ssh": "SSH-терминал",
"sftp": "Менеджер файлов SFTP",
@@ -191,22 +199,60 @@
},
"proItems": {
"forward": "Проброс портов",
"proxy": "Прокси сеанса / SOCKS",
"proxy": "Прокси сессии / SOCKS",
"serial": "Последовательный терминал",
"ai": "Терминальный AI-ассистент",
"batch": "Пакетное выполнение / синхронизация рассылки",
"broadcast": "Синхронизация ввода рассылки",
"importExport": "Массовый импорт/экспорт хостов",
"desktop": "Удалённый рабочий стол RDP / VNC"
}
"ai": "AI-помощник в терминале",
"batch": "Пакетный запуск / синхронный broadcast",
"broadcast": "Синхронный ввод broadcast",
"importExport": "Пакетный импорт/экспорт списка хостов",
"desktop": "Удалённый рабочий стол (RDP / VNC)"
},
"planYearly": "Pro на год",
"planYearlyDesc": "¥99 / год · 2 устройства",
"planPerpetual": "Pro бессрочно",
"planPerpetualDesc": "¥199 · 5 устройств · навсегда",
"planTrial": "Pro пробный",
"devices": "До {{count}} устройств",
"goUpgradeYearly": "Купить Pro на год",
"goUpgradePerpetual": "Купить Pro бессрочно",
"activatedYearly": "Активирован Pro на год",
"activatedPerpetual": "Активирован Pro бессрочно",
"activatedTrial": "Активирован Pro пробный",
"choosePlan": "Выбор: бесплатно / Pro год / Pro бессрочно",
"planFreePrice": "¥0",
"planFreeDesc": "SSH · SFTP · локальный терминал",
"planFreeCta": "Текущий план",
"planIncludesPro": "Все функции Pro",
"planFree": "Бесплатно",
"subtitleUnlockDetail": "Связанная возможность: {{feature}}",
"wallUnlockComingSoon": "Расширенные функции Pro скоро",
"wallMessageComingSoonFeature": "«{{feature}}» — расширенная функция Pro, ещё дорабатывается. Обновите до Pro на год или бессрочно для раннего доступа.",
"wallMessageFeature": "«{{feature}}» — расширенная функция Pro. Обновите до Pro на год или бессрочно, чтобы использовать на этом устройстве."
},
"launcher": {
"title": "Быстрый запуск",
"description": "Сеансы, локальные оболочки и консоли устройств",
"description": "Сессии, локальный Shell, рабочий стол, serial",
"cards": {
"quickConnect": { "title": "Быстрое подключение", "desc": "user@host:port" },
"saveSession": { "title": "Сохранить сеанс", "desc": "Учётные данные, группы, прокси" },
"serial": { "title": "Последовательный терминал", "desc": "Скорость (baud) и настройки порта" }
"quickConnect": {
"title": "Быстрое подключение",
"desc": "user@host:port"
},
"saveSession": {
"title": "Сохранить сеанс",
"desc": "Учётные данные, группы, прокси"
},
"serial": {
"title": "Последовательный терминал",
"desc": "Скорость (baud) и настройки порта"
},
"rdp": {
"title": "RDP рабочий стол",
"desc": "Удалённый Windows"
},
"vnc": {
"title": "VNC рабочий стол",
"desc": "Удалённый графический"
}
},
"sectionLocalShell": "Локальная оболочка",
"sectionSavedSessions": "Сохранённые сеансы",
@@ -220,6 +266,7 @@
"forwardOpenRunning": "SOCKS5 работает (:{{port}}) · клик для управления · правый клик для копирования",
"forwardStarted": "Проброс запущен",
"forwardStartedDynamic": "SOCKS5 запущен: socks5://127.0.0.1:{{port}}",
"forwardResumed": "Восстановлено перенаправлений: {{count}}",
"socksStartFailed": "Сбой запуска: {{message}}",
"socksStopFailed": "Сбой остановки: {{message}}",
"socksCopied": "Скопировано socks5://127.0.0.1:{{port}}"
@@ -240,7 +287,7 @@
"typeDynamic": "Dynamic",
"running": "Выполняется",
"socksProxy": "SOCKS5: 127.0.0.1:{{port}}",
"remoteToLocal": "Удалённый {{remote}} → локальный {{local}}",
"remoteToLocal": "удалённо {{bind}}:{{remote}} → {{host}}:{{local}}",
"stats": "{{connections}} подкл. · ↑{{up}} · ↓{{down}}",
"copyProxy": "Копировать прокси",
"copyRule": "Дублировать правило",
@@ -250,15 +297,15 @@
"delete": "Удалить",
"editTitle": "Изменить проброс",
"addTitle": "Добавить проброс",
"typeLocalDesc": "Локальный → удалённый",
"typeRemoteDesc": "Удалённый → локальный",
"typeLocalDesc": "Доступ к удалённому сервису",
"typeRemoteDesc": "Открыть локальный сервис",
"typeDynamicDesc": "SOCKS5",
"selectType": "Тип",
"drawerSubtitle": "Туннель через текущий SSH-сеанс",
"ruleFields": "Подробности",
"dynamicHint": "Локальный SOCKS5, по умолчанию 127.0.0.1:1080 (можно менять). Трафик выходит через публичный IP этого SSH-сервера.",
"dynamicSingleHint": "Одно правило Dynamic на SSH-хост; измените порт прослушивания при остановке.",
"dynamicPortHint": "Если порт занят, выберите другой локальный порт (напр. 1081).",
"dynamicHint": "Локальный SOCKS5 (по умолчанию :1080). Трафик выходит через этот SSH.",
"dynamicSingleHint": "Одно динамическое правило на SSH-хост; порт меняйте, когда остановлено.",
"dynamicPortHint": "Если занят — другой порт (напр. 1081).",
"ruleList": "Правила",
"ruleListEmpty": "Правил Local / Remote пока нет",
"addSection": "Добавить новое",
@@ -292,17 +339,30 @@
"internet": "Интернет",
"localCaption": "Local: обращение к удалённому порту как к локальному.",
"remoteCaption": "Remote: удалённый доступ пробрасывается к локальному сервису.",
"dynamicCaption": "Dynamic: эта машина становится прокси SOCKS5, выходящим через публичный IP текущего SSH-сервера."
"dynamicCaption": "Динамический: локальный SOCKS5 через выход этого SSH."
},
"ruleName": "Название",
"ruleNamePlaceholder": "напр. MySQL",
"localListenPort": "Локальный порт прослушивания",
"localListenPortHint": "По умолчанию localhost — как ssh -L порт:…. Обычно достаточно порта.",
"localBindAdvanced": "Дополнительно: сменить локальный bind",
"localTargetPort": "Локальный целевой порт",
"remoteTargetPort": "Удалённый целевой порт",
"remoteTargetPort": "Целевой порт",
"remoteListenPort": "Удалённый порт прослушивания",
"remoteListenPortHint": "По умолчанию localhost на SSH-сервере — как ssh -R порт:…. Обычно достаточно порта.",
"remoteListenAddress": "Удалённый listen",
"remoteListenAddressPlaceholder": "127.0.0.1:18080",
"remoteListenAddressHint": "127.0.0.1 — только удалённый хост; 0.0.0.0 — удалённая LAN (нужен GatewayPorts).",
"errorRemoteListenAddress": "Укажите корректный удалённый listen, напр. 127.0.0.1:18080",
"localTargetAddress": "Локальная / LAN цель",
"localTargetAddressPlaceholder": "127.0.0.1:8080",
"localTargetAddressHint": "На локальный/LAN сервис, напр. 127.0.0.1:8080. Не используйте 0.0.0.0.",
"viaSessionRemote": "Слушать на {{target}} (по умолчанию localhost)",
"errorRemoteListenPort": "Укажите корректный удалённый порт прослушивания (1–65535)",
"errorLocalTargetAddress": "Укажите корректную локальную/LAN цель, напр. 127.0.0.1:8080",
"portPlaceholder": "напр. 3306",
"remoteHost": "Удалённый хост",
"remoteHostPlaceholder": "localhost или IP",
"remoteHost": "Целевой хост",
"remoteHostPlaceholder": "127.0.0.1",
"socksRuleName": "Прокси SOCKS5",
"defaultLocalName": "Локальный проброс",
"defaultRemoteName": "Удалённый проброс",
@@ -316,11 +376,35 @@
"deleteConfirm": "Удалить это правило?",
"errorNamePort": "Введите имя и корректный порт (1–65535)",
"errorTargetPort": "Введите корректный целевой порт (1–65535)",
"errorRemoteHost": "Введите удалённый хост",
"errorRemoteHost": "Укажите целевой хост (относительно SSH-сервера)",
"errorNotConnected": "Нет подключения",
"errorStartFailed": "Сбой запуска",
"errorStopFailed": "Сбой остановки",
"errorSocksRunning": "SOCKS5 уже работает",
"copySuffix": "Копировать"
"copySuffix": "Копировать",
"remoteBindHint": "0.0.0.0 нужен GatewayPorts; иначе часто только 127.0.0.1. Цель не должна быть 0.0.0.0.",
"localToRemote": "локально {{bind}}:{{local}} → удалённо {{host}}:{{remote}}",
"localToRemotePort": "локально :{{local}} → удалённо {{host}}:{{remote}}",
"remoteHostHint": "Относительно SSH-сервера. Сам хост — 127.0.0.1, LAN — достижимый IP. Не 0.0.0.0.",
"localBindHost": "Локальный bind",
"localBindHostPlaceholder": "127.0.0.1",
"localBindHostHint": "По умолчанию только этот ПК. 0.0.0.0 или LAN IP — доступ из сети.",
"errorTargetHostInvalid": "Цель не может быть 0.0.0.0 — укажите 127.0.0.1 или достижимый IP",
"listenAddress": "Локальный listen",
"listenAddressPlaceholder": "127.0.0.1:8080",
"listenAddressHint": "По умолчанию только этот ПК; 0.0.0.0 разрешает LAN.",
"targetAddress": "Удалённая цель",
"targetAddressPlaceholder": "127.0.0.1:80",
"targetAddressHint": "С точки зрения SSH-сервера. Сервис на нём — 127.0.0.1:порт.",
"errorListenAddress": "Укажите корректный listen, напр. 127.0.0.1:8080",
"errorTargetAddress": "Укажите корректную цель, напр. 127.0.0.1:80",
"targetAddressHintVia": "Через {{target}}. Сервис на том хосте — 127.0.0.1:порт (новый порт не открывается).",
"viaSession": "Через сессию {{target}}",
"remoteToLocalPort": "удал. :{{remote}} → {{host}}:{{local}}",
"remoteBindAdvanced": "Дополнительно: сменить remote bind",
"remoteBindHost": "Remote bind",
"remoteBindHostPlaceholder": "127.0.0.1",
"remoteBindHostHint": "По умолчанию только на SSH-сервере. 0.0.0.0 требует GatewayPorts; без него sshd часто тихо слушает только 127.0.0.1.",
"remoteGatewayPortsWarning": "Запрошен remote-listen 0.0.0.0. Без GatewayPorts sshd часто молча слушает только 127.0.0.1."
}
}
+11 -4
View File
@@ -15,10 +15,12 @@
"passwordOptional": "Пароль (необязательно)",
"width": "Ширина",
"height": "Высота",
"rdpFootnote": "Нативный IronRDP: TCP → X.224 → TLS → CredSSP/NLA → MCS; тайм-аут рукопожатия 45 с. Проверка сертификата во время разработки ослаблена.",
"vncFootnote": "Нативный vnc-rs RFB с декодированием Tight JPEG; буфер обмена передаётся в UTF-8 (очень старые серверы могут искажать текст).",
"rdpFootnote": "В полноэкранном или развёрнутом режиме удалённое разрешение запрашивается под текущую область окна. Начальный размер можно выбрать и до подключения. Проверка сертификата сейчас ослаблена для разработки.",
"vncFootnote": "У большинства VNC-серверов удалённое разрешение фиксировано; полноэкранный режим только масштабирует изображение локально. Предпочтительный размер при подключении носит справочный характер. Буфер обмена отправляется в UTF-8.",
"errorHostRequired": "Введите адрес хоста",
"errorUsernameRequired": "Введите имя пользователя"
"errorUsernameRequired": "Введите имя пользователя",
"resolution": "Разрешение",
"resolutionCustom": "Свой размер"
},
"toolbar": {
"pasteClipboard": "Вставить локальный буфер обмена на удалённый сервер",
@@ -28,7 +30,12 @@
"openShareDir": "Открыть общую папку локально (Downloads)",
"openShareDirAria": "Открыть общую папку",
"disconnect": "Отключиться",
"disconnectAria": "Отключиться"
"disconnectAria": "Отключиться",
"fullscreen": "Полный экран",
"fullscreenAria": "Развернуть на весь экран",
"exitFullscreen": "Выйти из полного экрана",
"exitFullscreenAria": "Выйти из полного экрана",
"exitFullscreenHint": "Esc / F11 — выход"
},
"status": {
"connecting": "Подключение…",
+16 -3
View File
@@ -39,7 +39,9 @@
"closed": "Подключение закрыто",
"dnsFailed": "Не удалось разрешить имя хоста",
"refused": "В подключении отказано",
"disconnected": "Отключено"
"disconnected": "Отключено",
"keepaliveFailed": "SSH keep-alive несколько раз не удался; соединение закрыто",
"idleKicked": "Удалённая оболочка завершилась по таймауту простоя (TMOUT)"
},
"auth": {
"failed": "Сбой аутентификации. Проверьте имя пользователя и пароль.",
@@ -68,7 +70,10 @@
"forward": {
"failed": "Сбой настройки проброса портов",
"bindFailed": "Не удалось привязать порт",
"alreadyInUse": "Порт уже занят"
"alreadyInUse": "Локальный порт уже занят. Остановите этот проброс или другой процесс на порту, затем запустите снова.",
"remoteDenied": "Сервер отклонил удалённый проброс (AllowTcpForwarding / PermitListen, порт занят или ограничение bind). Слушаем только 127.0.0.1 на удалённой стороне.",
"remoteDeniedPrivileged": "Отклонён привилегированный порт (<1024). Используйте ≥1024 или root и проверьте AllowTcpForwarding / PermitListen.",
"invalidTargetHost": "Цель не может быть 0.0.0.0 — укажите 127.0.0.1 или доступный внутренний IP"
},
"sftp": {
"connectFailed": "Сбой SFTP-подключения",
@@ -80,7 +85,15 @@
"writeFailed": "Сбой сохранения",
"renameFailed": "Сбой переименования",
"chmodFailed": "Не удалось обновить права доступа",
"apiUnavailable": "SFTP API недоступен"
"apiUnavailable": "SFTP API недоступен",
"channelFailed": "Не удалось открыть канал SFTP (лимит сервера или занятая сессия). Переподключитесь",
"sessionGone": "Сессия SFTP завершена; переподключитесь",
"busy": "Идёт передача файла. Повторите позже.",
"editTooLarge": "Файл слишком большой для текстового редактора (макс. ~2 МБ). Скачайте и откройте локально.",
"editBinary": "Файл похож на двоичный и не открывается в текстовом редакторе",
"editNotUtf8": "Удалённый файл не является допустимым текстом UTF-8",
"transferCancelled": "Передача отменена",
"diskFull": "Недостаточно места на диске (локально или удалённо)"
},
"session": {
"notFound": "Сеанс не найден",
+6 -4
View File
@@ -69,17 +69,17 @@
"confirm": "Удалить"
},
"welcome": {
"tagline": "Удалённый терминал · SFTP · Проброс портов",
"tagline": "Просто и по делу · терминал · файлы · рабочий стол",
"sectionStart": "Начало работы",
"sectionDesktop": "Удалённый рабочий стол",
"sectionRecent": "Недавние сеансы",
"quickConnect": {
"title": "Быстрое подключение",
"desc": "Разовое SSH; не сохраняется в библиотеке"
"desc": "Разовый SSH — не сохраняется в библиотеке"
},
"newSession": {
"title": "Новый сеанс",
"desc": "Сохраняйте хосты; двойной клик в боковой панели для подключения"
"desc": "Сохраните хост — двойной клик в сайдбаре для подключения"
},
"localShell": {
"title": "Локальная оболочка",
@@ -167,7 +167,9 @@
"jumpHost": "Jump-хост (необязательно)",
"jumpNone": "Без jump-хоста",
"jumpNoneAlt": "Без jump-хоста",
"jumpHint": "Сохранённый сеанс можно использовать как jump-хост"
"jumpHint": "Сохранённый сеанс можно использовать как jump-хост",
"resolution": "Разрешение",
"resolutionCustom": "Свой размер"
},
"batch": {
"title": "Пакетное выполнение",
+50 -18
View File
@@ -1,9 +1,7 @@
{
"nav": {
"general": "Рабочее пространство",
"generalDesc": "Макет и ресурсы",
"appearance": "Внешний вид",
"appearanceDesc": "Тема и язык",
"general": "Основные",
"generalDesc": "Тема, язык и макет",
"terminal": "Терминал",
"terminalDesc": "Шрифт и цвета",
"shortcuts": "Горячие клавиши",
@@ -15,11 +13,13 @@
"ai": "AI-модели",
"aiDesc": "AI-провайдеры",
"about": "О приложении",
"aboutDesc": "Версия и данные"
"aboutDesc": "Версия и данные",
"account": "Аккаунт",
"accountDesc": "Email, лицензия и устройство"
},
"title": "Настройки",
"search": {
"placeholder": "Поиск по настройкам..."
"placeholder": "Поиск настроек…"
},
"language": {
"label": "Язык",
@@ -89,7 +89,7 @@
"ssh": {
"connection": "Подключение",
"keepAlive": "Keep-alive",
"keepAliveDesc": "Отправлять keep-alive при простое для уменьшения разрывов",
"keepAliveDesc": "Отправлять SSH keep-alive в простое; отключать только после нескольких сбоев",
"autoReconnect": "Авто-переподключение",
"autoReconnectDesc": "Автоматически повторять после неожиданного разрыва",
"timeout": "Тайм-аут подключения",
@@ -102,7 +102,9 @@
"maxBackground": "Лимит фоновых подключений",
"maxBackgroundDesc": "Вытеснять давно не использованные подключения при превышении лимита",
"suspend": "Фоновая приостановка",
"suspendDesc": "Минуты до освобождения активных каналов, когда вкладка в фоне"
"suspendDesc": "Через сколько минут приостанавливать простаивающие фоновые вкладки (активные Shell/SFTP остаются)",
"keepAliveInterval": "Интервал keep-alive (сек)",
"keepAliveIntervalDesc": "Как часто опрашивать сервер (как ClientAliveInterval). При нестабильной сети — ≤15 с"
},
"security": {
"credentials": "Защита учётных данных",
@@ -119,7 +121,7 @@
"clearConfirm": "Очистить все известные ключи хостов? При следующем подключении запрос появится снова."
},
"about": {
"tagline": "Лёгкий нативный терминал эксплуатации SSH / SFTP",
"tagline": "Простой и удобный нативный терминал для эксплуатации",
"appVersion": "Приложение v{{version}}",
"copyVersion": "Копировать сведения о версии",
"runtime": "Среда выполнения",
@@ -127,8 +129,9 @@
"localData": "Локальные данные",
"sessionsSettings": "Сеансы и настройки",
"masterKeyPath": "Мастер-ключ",
"privacyNote": "Пароли и парольные фразы ключей шифруются AES-256-GCM в Rust. OpsXShell не сохраняет историю команд терминала.",
"license": "Лицензия MIT · Rust · XTerm.js"
"privacyNote": "Пароли и passphrase шифруются в Rust (AES-256-GCM). Без истории команд и без аудита-бастиона.",
"license": "Лицензия MIT · Rust · XTerm.js",
"logPath": "Журнал приложения"
},
"ai": {
"presets": "Пресеты провайдеров",
@@ -162,13 +165,42 @@
"apiKeySaveFailed": "Не удалось сохранить API-ключ",
"providerNotFound": "Провайдер не найден",
"modelCatalogReadFailed": "Не удалось прочитать каталог моделей",
"catalogUnavailable": "models.dev недоступен"
"catalogUnavailable": "models.dev недоступен",
"family": {
"openai_compatible": "Совместимые с OpenAI",
"standalone": "Нативные API",
"plan": "Подписки",
"local": "Локальные",
"custom": "Свой"
}
},
"family": {
"openai_compatible": "Совместимый с OpenAI",
"standalone": "────────",
"plan": "План",
"local": "Self-hosted",
"custom": "Своё"
"account": {
"title": "Account",
"email": "Email",
"emailDesc": "Used for checkout and license claims",
"plan": "Current plan",
"planFree": "Free",
"planYearly": "Pro на год",
"planPerpetual": "Pro бессрочно",
"planTrial": "Pro пробный",
"expires": "Expires {{date}}",
"lifetime": "Lifetime",
"devices": "Seats: up to {{count}} devices",
"deviceId": "This device ID",
"saveEmail": "Save email",
"activate": "Activate license",
"pasteLicense": "Paste LIC- license key",
"clear": "Clear local license",
"deactivate": "Deactivate this device",
"upgrade": "Upgrade",
"saved": "Saved",
"activateOk": "Activated",
"deviceList": "Привязанные устройства",
"loadingDevices": "Загрузка устройств…",
"noDevices": "Нет привязанных устройств",
"thisDevice": "Это устройство",
"removeDevice": "Отвязать",
"deviceRemoved": "Устройство отвязано",
"seatFull": "Все места заняты. Отвяжите другое устройство перед активацией на новой машине."
}
}
+54 -24
View File
@@ -3,8 +3,9 @@
"local": "Локально",
"remote": "Удалённо",
"notConnectedTitle": "Нет подключения к удалённому серверу",
"notConnectedDesc": "Сначала подключите SSH-сеанс, чтобы использовать SFTP",
"ariaLabel": "Панель файлов: {{title}}"
"notConnectedDesc": "Сначала подключите SSH — потом файлы и правки",
"ariaLabel": "Панель файлов: {{title}}",
"reconnectResumeDesc": "Соединение потеряно. Переподключение вернёт в {{path}}"
},
"connection": {
"selectServer": "Выберите сервер",
@@ -12,7 +13,9 @@
"createSessionHint": "Сначала создайте сеанс на боковой панели",
"reconnect": "Переподключиться",
"notConnected": "Нет подключения",
"connecting": "Подключение..."
"connecting": "Подключение...",
"resumeHint": "После переподключения вернёмся в {{path}}",
"reconnectingResume": "Переподключение и восстановление {{path}}…"
},
"errors": {
"notConnected": "SFTP не подключён",
@@ -23,19 +26,14 @@
"selectDownloadFiles": "Сначала выберите файлы для скачивания",
"uploadFailed": "Сбой загрузки",
"downloadFailed": "Сбой скачивания",
"fileError": "{{fileName}}: {{message}}"
"fileError": "{{fileName}}: {{message}}",
"mkdirFailed": "Не удалось создать папку"
},
"toolbar": {
"upload": "Загрузить",
"uploadAria": "Загрузить",
"download": "Скачать",
"downloadAria": "Скачать",
"downloadNeedSelection": "Сначала выберите удалённые файлы",
"downloadHint": "Скачать выбранные удалённые файлы",
"refresh": "Обновить",
"refreshAria": "Обновить",
"refreshAria": "Обновить эту панель",
"newFolder": "Новая папка",
"newFolderAria": "Новая папка"
"newFolderAria": "Создать папку на этой панели"
},
"sort": {
"name": "Имя",
@@ -55,7 +53,8 @@
"hideHidden": "Скрыть dot-файлы (.xxx)",
"showHiddenAria": "Показать скрытые файлы",
"hideHiddenAria": "Скрыть dot-файлы",
"searchAria": "Поиск в {{title}}"
"searchAria": "Поиск в {{title}}",
"upAtRoot": "Уже в корне"
},
"status": {
"loading": "Загрузка...",
@@ -63,7 +62,8 @@
"filtered": "Показано {{shown}} / {{total}}",
"hiddenCount": "{{total}} объектов ({{hidden}} скрыто)",
"total": "Объектов: {{count}}",
"filterHint": "Фильтр: {{query}}"
"filterHint": "Фильтр: {{query}}",
"refreshing": "Обновление..."
},
"empty": {
"dirTitle": "Пустой каталог",
@@ -99,7 +99,6 @@
"download": "Скачать",
"edit": "Изменить",
"rename": "Переименовать",
"copyPath": "Копировать путь",
"properties": "Свойства",
"permissions": "Права доступа",
"delete": "Удалить"
@@ -116,7 +115,18 @@
"chmodTitle": "Изменить права доступа",
"chmodApply": "Применить",
"pickUploadTitle": "Выберите файлы или папки для загрузки",
"pickUploadRzTitle": "Выберите файл для загрузки (rz)"
"pickUploadRzTitle": "Выберите файл для загрузки (rz)",
"overwriteTitle": "Перезаписать существующие файлы?",
"overwriteConfirm": "Перезаписать",
"overwriteSkip": "Пропустить",
"overwriteOneRemote": "«{{name}}» уже есть на удалённой стороне. Перезаписать?",
"overwriteOneLocal": "«{{name}}» уже есть локально. Перезаписать?",
"overwriteManyRemote": "На удалённой стороне уже есть {{count}} одноимённых. Перезаписать все?",
"overwriteManyLocal": "Локально уже есть {{count}} одноимённых. Перезаписать все?",
"overwriteMore": "…и ещё {{count}}",
"deleteTitle": "Подтвердите удаление",
"deleteMessage": "Удалить {{count}} элемент(ов): {{names}}",
"deleteRecursiveWarn": "Есть каталоги. Удаление рекурсивное и необратимо."
},
"properties": {
"name": "Имя",
@@ -152,27 +162,41 @@
"statusDone": "Готово",
"statusFailed": "Сбой",
"statusCancelled": "Отменено",
"dropUpload": "Загрузить в {{path}}",
"dropDownload": "Скачать в {{path}}"
"dropUpload": "Отпустите здесь, чтобы загрузить в {{path}}",
"dropDownload": "Отпустите здесь, чтобы скачать в {{path}}",
"railAria": "Передача между локальным и удалённым",
"railUpload": "Загрузить выбранные локальные файлы (или выбрать файлы, если ничего не выбрано)",
"railDownload": "Скачать выбранные удалённые файлы локально",
"railUploadAria": "Загрузить на удалённый",
"railDownloadAria": "Скачать локально",
"railDownloadNeedSelection": "Сначала выберите удалённые файлы",
"retry": "Повторить"
},
"editor": {
"readFailed": "Сбой чтения",
"saveFailed": "Сбой сохранения",
"unsavedClose": "Есть несохранённые изменения. Всё равно закрыть?",
"unsavedTitle": "Несохранённые изменения",
"discardAndClose": "Отменить и закрыть",
"reloadTitle": "Перезагрузить",
"discardAndReload": "Отменить и перезагрузить",
"saving": "Сохранение...",
"save": "Сохранить",
"saved": "Файл сохранён",
"loadingEditor": "Загрузка редактора...",
"footer": "UTF-8 · Monaco · ⌘/Ctrl+S сохранить · максимум ~2 МБ",
"loadBlocked": "Не удалось загрузить содержимое файла",
"footer": "UTF-8 · ⌘/Ctrl+S сохранить · Esc закрыть · макс. ~2 МБ",
"charCount": "Символов: {{count}}",
"unsavedMark": " · не сохранено",
"savedMark": " · сохранено"
"savedMark": " · сохранено",
"title": "Редактирование {{name}}",
"dirtyBadge": "Не сохранено",
"reload": "Перезагрузить",
"unsavedReload": "Отменить несохранённые изменения и перезагрузить?"
},
"toast": {
"renamed": "Переименовано в {{name}}",
"renameFailed": "Сбой переименования",
"pathCopied": "Путь скопирован в буфер обмена",
"pathCopyFailed": "Не удалось скопировать путь",
"chmodUpdated": "Права доступа изменены на {{mode}}",
"chmodFailed": "Не удалось обновить права доступа",
"uploadPartial": "{{action}} завершено для {{completed}}, сбой {{failed}}: {{detail}}",
@@ -180,7 +204,13 @@
"uploadAction": "Загрузка",
"downloadAction": "Скачивание",
"noFolderUpload": "Сбой загрузки папки",
"noDragPath": "Пути перетаскивания недоступны; используйте «Загрузить»",
"noFolderRecursive": "Сбой рекурсивной передачи папки; повторите или используйте «Загрузить»"
"noDragPath": "Система не предоставила путь при перетаскивании. Используйте стрелку → или выбор файлов.",
"noFolderRecursive": "Передача папки не удалась. Повторите или используйте → / выбор файлов.",
"dropFailed": "Перетаскивание не удалось: не удалось прочитать файл. Повторите или используйте стрелки.",
"folderCreated": "Создана папка {{name}}",
"allSkippedExisting": "Все уже существующие файлы пропущены",
"skippedExisting": "Пропущено существующих: {{count}}",
"deleteFailed": "Не удалось удалить",
"deleted": "Удалено"
}
}
+3 -2
View File
@@ -34,7 +34,7 @@
"reopenShell": "Открыть оболочку снова",
"reopening": "Открытие…",
"disconnected": "Отключено",
"disconnectedHint": "SSH-сеанс завершился или сеть упала. Нажмите ниже, чтобы переподключиться.",
"disconnectedHint": "SSH-сеанс завершился или сеть упала. Один клик ниже — и можно продолжать работу.",
"reconnect": "Переподключиться",
"suspended": "Подключение приостановлено",
"suspendedHint": "Долгое отсутствие активности; подключение приостановлено для экономии ресурсов",
@@ -70,7 +70,8 @@
"splitRight": "Разделить справа",
"splitDown": "Разделить вниз",
"maximizePane": "Развернуть текущую панель",
"restoreLayout": "Восстановить разделённый макет"
"restoreLayout": "Восстановить разделённый макет",
"openSftp": "Открыть SFTP"
},
"search": {
"placeholder": "Поиск...",
+107 -32
View File
@@ -90,6 +90,8 @@
"terminal": "Terminal",
"batch": "Thực thi hàng loạt",
"newTerminal": "Terminal mới",
"closeAria": "Đóng {{title}}",
"listAria": "Các tab đang mở",
"kind": {
"terminal": "Terminal",
"sftp": "SFTP",
@@ -105,7 +107,8 @@
"closeRight": "Đóng các tab bên phải",
"closeLeft": "Đóng các tab bên trái",
"closeAll": "Đóng tất cả",
"clone": "Nhân đôi tab"
"clone": "Nhân đôi tab",
"openSftp": "Mở SFTP"
}
},
"sidebar": {
@@ -151,7 +154,7 @@
"pro": {
"upgrade": "Nâng cấp Pro",
"feature": "Tính năng Pro",
"unlock": "Mở khóa {{feature}}",
"unlock": "Mở khóa tính năng nâng cao Pro",
"comingSoon": "Sắp ra mắt",
"activateSuccess": "Đã kích hoạt",
"activateFailed": "Kích hoạt thất bại",
@@ -160,28 +163,33 @@
"invalidLicense": "Kích hoạt thất bại: giấy phép không hợp lệ",
"goUpgrade": "Nâng cấp",
"haveLicense": "Có giấy phép? Kích hoạt",
"title": "OpsXShell Pro · Mở khóa các tính năng vận hành chuyên nghiệp",
"subtitleComingSoon": "{{feature}} sắp ra mắt · nâng cấp Pro để truy cập sớm",
"subtitleUnlock": "Đang mở khóa: {{feature}}",
"subtitleDefault": "Chuyển tiếp cổng, proxy, serial, AI, hàng loạt và phát sóng",
"title": "OpsXShell · Mở khóa Pro",
"subtitleComingSoon": "Tính năng nâng cao sắp có · nâng cấp Pro để dùng sớm",
"subtitleUnlock": "Đang mở khóa tính năng nâng cao",
"subtitleDefault": "Chuyển tiếp cổng, AI terminal, vận hành hàng loạt, danh sách host và máy tính từ xa trong Pro",
"activated": "Đã kích hoạt Pro",
"expiresAt": " · hợp lệ đến {{date}}",
"lifetime": " · giấy phép trọn đời",
"oneTimeFee": "Mua một lần · trọn đời trên thiết bị này · không cần đăng ký",
"oneTimeFee": "Miễn phí / Pro năm / Pro vĩnh viễn",
"pasteLicense": "Dán giấy phép",
"activateButton": "Kích hoạt",
"clearLicense": "Xóa giấy phép cục bộ",
"paymentNote": "Thanh toán được xử lý bởi Waffo; ứng dụng này không thu thập thông tin thẻ.",
"testDeviceId": "Môi trường thử nghiệm · ID thiết bị: {{deviceId}}",
"later": "Để sau",
"viewPro": "Xem Pro",
"wallComingSoonSuffix": " · sắp ra mắt",
"wallUnlock": "Mở khóa {{feature}}",
"wallMessageComingSoon": "Tính năng Pro này vẫn đang được phát triển. Nâng cấp để truy cập sớm.",
"wallMessageDefault": "Tính năng này yêu cầu OpsXShell Pro. Nâng cấp để sử dụng trên thiết bị này.",
"viewPro": "Xem gói Pro",
"wallComingSoonSuffix": "sắp có",
"wallUnlock": "Mở khóa tính năng nâng cao Pro",
"wallMessageComingSoon": "Khả năng này thuộc OpsXShell Pro và đang hoàn thiện. Nâng cấp Pro năm hoặc vĩnh viễn để dùng sớm.",
"wallMessageDefault": "Chuyển tiếp cổng, AI terminal, chạy hàng loạt, danh sách host và máy tính từ xa nằm trong Pro. Nâng cấp Pro năm hoặc vĩnh viễn để dùng trên máy này.",
"checkoutUnavailable": "Không thể thanh toán ({{status}})",
"emailLabel": "Email",
"emailRequired": "Nhập email trước khi thanh toán",
"claimPending": "Đang nhận giấy phép…",
"invalidKeyFormat": "Định dạng khóa không hợp lệ — dán khóa giấy phép LIC-",
"accountEmail": "Tài khoản: {{email}}",
"freeTier": "Miễn phí",
"proTier": "Pro",
"proTier": "Chuyên nghiệp",
"freeItems": {
"ssh": "Terminal SSH",
"sftp": "Quản lý tệp SFTP",
@@ -192,17 +200,38 @@
"proItems": {
"forward": "Chuyển tiếp cổng",
"proxy": "Proxy phiên / SOCKS",
"serial": "Terminal Serial",
"serial": "Terminal serial",
"ai": "Trợ lý AI terminal",
"batch": "Thực thi hàng loạt / đồng bộ phát sóng",
"broadcast": "Đồng bộ đầu vào phát sóng",
"importExport": "Nhập/xuất máy chủ hàng loạt",
"desktop": "Màn hình từ xa RDP / VNC"
}
"batch": "Chạy hàng loạt / đồng bộ phát sóng",
"broadcast": "Đồng bộ nhập phát sóng",
"importExport": "Nhập/xuất hàng loạt danh sách host",
"desktop": "Máy tính từ xa (RDP / VNC)"
},
"planYearly": "Pro theo năm",
"planYearlyDesc": "¥99 / năm · 2 thiết bị",
"planPerpetual": "Pro vĩnh viễn",
"planPerpetualDesc": "¥199 · 5 thiết bị · trọn đời",
"planTrial": "Pro dùng thử",
"devices": "Tối đa {{count}} thiết bị",
"goUpgradeYearly": "Mua Pro theo năm",
"goUpgradePerpetual": "Mua Pro vĩnh viễn",
"activatedYearly": "Đã kích hoạt Pro năm",
"activatedPerpetual": "Đã kích hoạt Pro vĩnh viễn",
"activatedTrial": "Đã kích hoạt Pro dùng thử",
"choosePlan": "Chọn: Miễn phí / Pro năm / Pro vĩnh viễn",
"planFreePrice": "¥0",
"planFreeDesc": "SSH · SFTP · terminal local",
"planFreeCta": "Gói hiện tại",
"planIncludesPro": "Đầy đủ tính năng Pro",
"planFree": "Miễn phí",
"subtitleUnlockDetail": "Khả năng liên quan: {{feature}}",
"wallUnlockComingSoon": "Tính năng nâng cao Pro sắp có",
"wallMessageComingSoonFeature": "「{{feature}}」 thuộc tính năng nâng cao Pro và đang hoàn thiện. Nâng cấp Pro năm hoặc vĩnh viễn để dùng sớm.",
"wallMessageFeature": "「{{feature}}」 thuộc tính năng nâng cao Pro. Nâng cấp Pro năm hoặc vĩnh viễn để dùng trên máy này."
},
"launcher": {
"title": "Khởi động nhanh",
"description": "Phiên, shell cục bộ và bảng điều khiển thiết bị",
"description": "Phiên, shell cục bộ, desktop, serial",
"cards": {
"quickConnect": {
"title": "Kết nối nhanh",
@@ -215,6 +244,14 @@
"serial": {
"title": "Terminal Serial",
"desc": "Tốc độ baud và cài đặt cổng"
},
"rdp": {
"title": "Máy tính RDP",
"desc": "Desktop Windows từ xa"
},
"vnc": {
"title": "Máy tính VNC",
"desc": "Desktop đồ họa từ xa"
}
},
"sectionLocalShell": "Shell cục bộ",
@@ -229,6 +266,7 @@
"forwardOpenRunning": "SOCKS5 đang chạy (:{{port}}) · nhấp để quản lý · nhấp chuột phải để sao chép",
"forwardStarted": "Đã bắt đầu chuyển tiếp",
"forwardStartedDynamic": "Đã khởi động SOCKS5: socks5://127.0.0.1:{{port}}",
"forwardResumed": "Đã khôi phục {{count}} chuyển tiếp cổng",
"socksStartFailed": "Khởi động thất bại: {{message}}",
"socksStopFailed": "Dừng thất bại: {{message}}",
"socksCopied": "Đã sao chép socks5://127.0.0.1:{{port}}"
@@ -249,7 +287,7 @@
"typeDynamic": "Động",
"running": "Đang chạy",
"socksProxy": "SOCKS5: 127.0.0.1:{{port}}",
"remoteToLocal": "Từ xa {{remote}} → cục bộ {{local}}",
"remoteToLocal": "từ xa {{bind}}:{{remote}} → {{host}}:{{local}}",
"stats": "{{connections}} kết nối · ↑{{up}} · ↓{{down}}",
"copyProxy": "Sao chép proxy",
"copyRule": "Nhân đôi quy tắc",
@@ -259,15 +297,15 @@
"delete": "Xóa",
"editTitle": "Sửa chuyển tiếp",
"addTitle": "Thêm chuyển tiếp",
"typeLocalDesc": "Cục bộ → từ xa",
"typeRemoteDesc": "Từ xa → cục bộ",
"typeLocalDesc": "Truy cập dịch vụ từ xa",
"typeRemoteDesc": "Công khai dịch vụ cục bộ",
"typeDynamicDesc": "SOCKS5",
"selectType": "Loại",
"drawerSubtitle": "Đường hầm qua phiên SSH hiện tại",
"ruleFields": "Chi tiết",
"dynamicHint": "SOCKS5 cục bộ tại 127.0.0.1:1080. Lưu lượng thoát qua địa chỉ IP công cộng của máy chủ SSH này.",
"dynamicSingleHint": "Một quy tắc Động cho mỗi máy chủ SSH, cổng cố định 1080.",
"dynamicPortHint": "If the port is busy, pick another local port (e.g. 1081).",
"dynamicHint": "SOCKS5 cục bộ (mặc định :1080). Lưu lượng ra qua SSH hiện tại.",
"dynamicSingleHint": "Mỗi SSH một quy tắc động; đổi cổng khi đã dừng.",
"dynamicPortHint": "Nếu bận, chọn cổng khác (vd. 1081).",
"ruleList": "Quy tắc",
"ruleListEmpty": "Chưa có quy tắc Cục bộ / Từ xa nào",
"addSection": "Thêm mới",
@@ -301,17 +339,30 @@
"internet": "Internet",
"localCaption": "Cục bộ: truy cập một cổng từ xa như thể nó là cổng cục bộ.",
"remoteCaption": "Từ xa: quyền truy cập từ xa được chuyển tiếp tới dịch vụ cục bộ.",
"dynamicCaption": "Động: máy này trở thành proxy SOCKS5 thoát qua IP công cộng của máy chủ SSH hiện tại."
"dynamicCaption": "Động: SOCKS5 cục bộ, ra ngoài qua SSH hiện tại."
},
"ruleName": "Tên",
"ruleNamePlaceholder": "vd. MySQL",
"localListenPort": "Cổng lắng nghe cục bộ",
"localListenPortHint": "Mặc định localhost — giống ssh -L cổng:…. Thường chỉ cần cổng.",
"localBindAdvanced": "Nâng cao: đổi bind cục bộ",
"localTargetPort": "Cổng đích cục bộ",
"remoteTargetPort": "Cổng đích từ xa",
"remoteTargetPort": "Cổng đích",
"remoteListenPort": "Cổng lắng nghe từ xa",
"remoteListenPortHint": "Mặc định localhost phía remote — giống ssh -R cổng:…. Thường chỉ cần nhập cổng.",
"remoteListenAddress": "Lắng nghe từ xa",
"remoteListenAddressPlaceholder": "127.0.0.1:18080",
"remoteListenAddressHint": "127.0.0.1 = chỉ máy remote; 0.0.0.0 = LAN remote (cần GatewayPorts).",
"errorRemoteListenAddress": "Nhập địa chỉ lắng nghe remote hợp lệ, vd. 127.0.0.1:18080",
"localTargetAddress": "Đích cục bộ / LAN",
"localTargetAddressPlaceholder": "127.0.0.1:8080",
"localTargetAddressHint": "Chuyển tới dịch vụ local/LAN, vd. 127.0.0.1:8080. Không dùng 0.0.0.0.",
"viaSessionRemote": "Lắng nghe trên {{target}} (mặc định localhost)",
"errorRemoteListenPort": "Nhập cổng lắng nghe từ xa hợp lệ (1–65535)",
"errorLocalTargetAddress": "Nhập đích local/LAN hợp lệ, vd. 127.0.0.1:8080",
"portPlaceholder": "vd. 3306",
"remoteHost": "Máy chủ từ xa",
"remoteHostPlaceholder": "localhost hoặc IP",
"remoteHost": "Máy đích",
"remoteHostPlaceholder": "127.0.0.1",
"socksRuleName": "Proxy SOCKS5",
"defaultLocalName": "Chuyển tiếp cục bộ",
"defaultRemoteName": "Chuyển tiếp từ xa",
@@ -325,11 +376,35 @@
"deleteConfirm": "Xóa quy tắc này?",
"errorNamePort": "Nhập tên và cổng hợp lệ (1–65535)",
"errorTargetPort": "Nhập cổng đích hợp lệ (1–65535)",
"errorRemoteHost": "Nhập máy chủ từ xa",
"errorRemoteHost": "Nhập máy đích (tính theo máy SSH)",
"errorNotConnected": "Chưa kết nối",
"errorStartFailed": "Khởi động thất bại",
"errorStopFailed": "Dừng thất bại",
"errorSocksRunning": "SOCKS5 đã đang chạy",
"copySuffix": "Sao chép"
"copySuffix": "Sao chép",
"remoteBindHint": "0.0.0.0 cần GatewayPorts; không có thì thường chỉ bind 127.0.0.1. Đích không được là 0.0.0.0.",
"localToRemote": "cục bộ {{bind}}:{{local}} → remote {{host}}:{{remote}}",
"localToRemotePort": "cục bộ :{{local}} → remote {{host}}:{{remote}}",
"remoteHostHint": "Theo máy SSH. Máy đó dùng 127.0.0.1, LAN dùng IP tới được. Không 0.0.0.0.",
"localBindHost": "Bind cục bộ",
"localBindHostPlaceholder": "127.0.0.1",
"localBindHostHint": "Mặc định chỉ máy này. 0.0.0.0 hoặc IP LAN để mạng truy cập.",
"errorTargetHostInvalid": "Đích không được 0.0.0.0 — dùng 127.0.0.1 hoặc IP nội bộ",
"listenAddress": "Lắng nghe cục bộ",
"listenAddressPlaceholder": "127.0.0.1:8080",
"listenAddressHint": "Mặc định chỉ máy này; 0.0.0.0 cho phép LAN.",
"targetAddress": "Đích từ xa",
"targetAddressPlaceholder": "127.0.0.1:80",
"targetAddressHint": "Theo góc nhìn máy SSH. Dịch vụ trên máy đó: 127.0.0.1:cổng.",
"errorListenAddress": "Nhập địa chỉ lắng nghe hợp lệ, vd. 127.0.0.1:8080",
"errorTargetAddress": "Nhập đích hợp lệ, vd. 127.0.0.1:80",
"targetAddressHintVia": "Qua {{target}}. Dịch vụ trên máy đó: 127.0.0.1:cổng (không mở cổng remote mới).",
"viaSession": "Qua phiên {{target}}",
"remoteToLocalPort": "remote :{{remote}} → {{host}}:{{local}}",
"remoteBindAdvanced": "Nâng cao: đổi bind phía remote",
"remoteBindHost": "Bind phía remote",
"remoteBindHostPlaceholder": "127.0.0.1",
"remoteBindHostHint": "Mặc định chỉ máy remote. 0.0.0.0 cần GatewayPorts; nếu không, hầu hết sshd vẫn chỉ bind 127.0.0.1.",
"remoteGatewayPortsWarning": "Đã yêu cầu lắng nghe 0.0.0.0 phía remote. Không có GatewayPorts, sshd thường vẫn chỉ bind 127.0.0.1."
}
}
+11 -4
View File
@@ -15,10 +15,12 @@
"passwordOptional": "Mật khẩu (tùy chọn)",
"width": "Chiều rộng",
"height": "Chiều cao",
"rdpFootnote": "IronRDP gốc: TCP → X.224 → TLS → CredSSP/NLA → MCS; thời gian chờ bắt tay 45 giây. Việc xác minh chứng chỉ được nới lỏng trong quá trình phát triển.",
"vncFootnote": "RFB vnc-rs gốc với giải mã Tight JPEG; clipboard được gửi dưới dạng UTF-8 (các máy chủ rất cũ có thể làm hỏng văn bản).",
"rdpFootnote": "Khi toàn màn hình hoặc phóng to, ứng dụng sẽ yêu cầu độ phân giải từ xa theo vùng cửa sổ hiện tại. Bạn cũng có thể chọn kích thước ban đầu trước khi kết nối. Xác minh chứng chỉ đang được nới lỏng trong giai đoạn phát triển.",
"vncFootnote": "Hầu hết máy chủ VNC giữ độ phân giải từ xa cố định; toàn màn hình chỉ phóng to cục bộ. Kích thước ưu tiên khi kết nối mang tính tham khảo. Nội dung bảng tạm được gửi dạng UTF-8.",
"errorHostRequired": "Nhập địa chỉ máy chủ",
"errorUsernameRequired": "Nhập tên người dùng"
"errorUsernameRequired": "Nhập tên người dùng",
"resolution": "Độ phân giải",
"resolutionCustom": "Tùy chỉnh"
},
"toolbar": {
"pasteClipboard": "Dán clipboard cục bộ sang máy từ xa",
@@ -28,7 +30,12 @@
"openShareDir": "Mở thư mục dùng chung cục bộ (Downloads)",
"openShareDirAria": "Mở thư mục dùng chung",
"disconnect": "Ngắt kết nối",
"disconnectAria": "Ngắt kết nối"
"disconnectAria": "Ngắt kết nối",
"fullscreen": "Toàn màn hình",
"fullscreenAria": "Vào toàn màn hình",
"exitFullscreen": "Thoát toàn màn hình",
"exitFullscreenAria": "Thoát toàn màn hình",
"exitFullscreenHint": "Esc / F11 để thoát"
},
"status": {
"connecting": "Đang kết nối…",
+16 -3
View File
@@ -39,7 +39,9 @@
"closed": "Kết nối đã đóng",
"dnsFailed": "Không thể phân giải máy chủ",
"refused": "Kết nối bị từ chối",
"disconnected": "Đã ngắt kết nối"
"disconnected": "Đã ngắt kết nối",
"keepaliveFailed": "Keep-alive SSH thất bại liên tiếp; đã đóng kết nối",
"idleKicked": "Shell từ xa đã thoát do hết thời gian chờ (TMOUT)"
},
"auth": {
"failed": "Xác thực thất bại. Kiểm tra tên người dùng và mật khẩu.",
@@ -68,7 +70,10 @@
"forward": {
"failed": "Thiết lập chuyển tiếp cổng thất bại",
"bindFailed": "Không thể ràng buộc cổng",
"alreadyInUse": "Cổng đã được sử dụng"
"alreadyInUse": "Cổng local đã bị chiếm. Hãy dừng chuyển tiếp đang chạy (hoặc tiến trình khác) rồi khởi động lại.",
"remoteDenied": "Máy chủ từ chối chuyển tiếp cổng từ xa (AllowTcpForwarding / PermitListen, cổng đang dùng hoặc hạn chế bind). Chỉ lắng nghe trên 127.0.0.1 phía remote.",
"remoteDeniedPrivileged": "Máy chủ từ chối cổng đặc quyền (<1024). Dùng cổng ≥1024 hoặc root, và kiểm tra AllowTcpForwarding / PermitListen.",
"invalidTargetHost": "Đích không thể là 0.0.0.0 — dùng 127.0.0.1 hoặc IP nội bộ tới được"
},
"sftp": {
"connectFailed": "Kết nối SFTP thất bại",
@@ -80,7 +85,15 @@
"writeFailed": "Lưu thất bại",
"renameFailed": "Đổi tên thất bại",
"chmodFailed": "Không thể cập nhật quyền",
"apiUnavailable": "API SFTP không khả dụng"
"apiUnavailable": "API SFTP không khả dụng",
"channelFailed": "Không mở được kênh SFTP (giới hạn máy chủ hoặc phiên bận). Hãy kết nối lại",
"sessionGone": "Phiên SFTP đã kết thúc; hãy kết nối lại",
"busy": "Đang truyền tệp. Vui lòng thử lại sau.",
"editTooLarge": "Tệp quá lớn cho trình soạn thảo văn bản (tối đa ~2MB). Hãy tải về và mở cục bộ.",
"editBinary": "Tệp có vẻ là nhị phân, không mở được trong trình soạn thảo văn bản",
"editNotUtf8": "Tệp từ xa không phải văn bản UTF-8 hợp lệ",
"transferCancelled": "Đã hủy truyền tệp",
"diskFull": "Không đủ dung lượng đĩa (cục bộ hoặc từ xa)"
},
"session": {
"notFound": "Không tìm thấy phiên",
+6 -4
View File
@@ -69,17 +69,17 @@
"confirm": "Xóa"
},
"welcome": {
"tagline": "Terminal từ xa · SFTP · Chuyển tiếp cổng",
"tagline": "Đơn giản · terminal · tệp · desktop",
"sectionStart": "Bắt đầu",
"sectionDesktop": "Màn hình từ xa",
"sectionRecent": "Phiên gần đây",
"quickConnect": {
"title": "Kết nối nhanh",
"desc": "SSH dùng một lần; không lưu vào thư viện"
"desc": "SSH tạm thời — không ghi vào thư viện phiên"
},
"newSession": {
"title": "Phiên mới",
"desc": "Lưu máy chủ; nhấp đúp trong thanh bên để kết nối"
"desc": "Lưu máy chủ — double-click ở thanh bên để kết nối"
},
"localShell": {
"title": "Shell cục bộ",
@@ -167,7 +167,9 @@
"jumpHost": "Jump host (tùy chọn)",
"jumpNone": "Không có jump host",
"jumpNoneAlt": "Không có jump host",
"jumpHint": "Một phiên đã lưu có thể được dùng làm jump host"
"jumpHint": "Một phiên đã lưu có thể được dùng làm jump host",
"resolution": "Độ phân giải",
"resolutionCustom": "Tùy chỉnh"
},
"batch": {
"title": "Thực thi hàng loạt",
+51 -19
View File
@@ -1,9 +1,7 @@
{
"nav": {
"general": "Không gian làm việc",
"generalDesc": "Bố cục và tài nguyên",
"appearance": "Giao diện",
"appearanceDesc": "Chủ đề và ngôn ngữ",
"general": "Chung",
"generalDesc": "Giao diện, ngôn ngữ và bố cục",
"terminal": "Terminal",
"terminalDesc": "Phông chữ và màu sắc",
"shortcuts": "Phím tắt",
@@ -15,11 +13,13 @@
"ai": "Mô hình AI",
"aiDesc": "Nhà cung cấp AI",
"about": "Giới thiệu",
"aboutDesc": "Phiên bản và dữ liệu"
"aboutDesc": "Phiên bản và dữ liệu",
"account": "Tài khoản",
"accountDesc": "Email, giấy phép và thiết bị"
},
"title": "Cài đặt",
"search": {
"placeholder": "Tìm cài đặt..."
"placeholder": "Tìm cài đặt…"
},
"language": {
"label": "Ngôn ngữ",
@@ -89,7 +89,7 @@
"ssh": {
"connection": "Kết nối",
"keepAlive": "Keep-alive",
"keepAliveDesc": "Gửi keep-alive khi nhàn rỗi để giảm ngắt kết nối",
"keepAliveDesc": "Gửi keep-alive SSH khi nhàn rỗi; chỉ ngắt sau vài lần không phản hồi",
"autoReconnect": "Tự động kết nối lại",
"autoReconnectDesc": "Tự động thử lại sau khi ngắt kết nối bất ngờ",
"timeout": "Thời gian chờ kết nối",
@@ -101,8 +101,10 @@
"maxConnectionsDesc": "Giới hạn dùng chung cho kết nối SSH, SFTP và hàng loạt",
"maxBackground": "Giới hạn kết nối chạy nền",
"maxBackgroundDesc": "Loại bỏ các kết nối ít dùng gần nhất khi vượt quá giới hạn",
"suspend": "Tạm dừng ở nền",
"suspendDesc": "Số phút trước khi giải phóng các kênh đang hoạt động khi một tab chạy nền"
"suspend": "Tạm dừng nền",
"suspendDesc": "Số phút trước khi tạm dừng tab nền nhàn rỗi (Shell/SFTP đang chạy vẫn giữ kết nối)",
"keepAliveInterval": "Khoảng keep-alive (giây)",
"keepAliveIntervalDesc": "Tần suất ping máy chủ (giống ClientAliveInterval). Mạng yếu nên ≤15 giây"
},
"security": {
"credentials": "Bảo vệ thông tin xác thực",
@@ -119,7 +121,7 @@
"clearConfirm": "Xóa tất cả khóa máy chủ đã biết? Bạn sẽ được nhắc lại vào lần kết nối sau."
},
"about": {
"tagline": "Terminal vận hành SSH / SFTP gốc, nhẹ",
"tagline": "Terminal vận hành gốc — đơn giản, tiện, dễ dùng",
"appVersion": "Ứng dụng v{{version}}",
"copyVersion": "Sao chép thông tin phiên bản",
"runtime": "Runtime",
@@ -127,8 +129,9 @@
"localData": "Dữ liệu cục bộ",
"sessionsSettings": "Phiên và cài đặt",
"masterKeyPath": "Khóa chính",
"privacyNote": "Mật khẩu và cụm mật khẩu khóa được mã hóa bằng AES-256-GCM trong Rust. OpsXShell không lưu lịch sử lệnh terminal.",
"license": "Giấy phép MIT · Rust · XTerm.js"
"privacyNote": "Mật khẩu và passphrase được Rust mã hóa AES-256-GCM. Không lưu lịch sử lệnh, không audit kiểu bastion.",
"license": "Giấy phép MIT · Rust · XTerm.js",
"logPath": "Nhật ký ứng dụng"
},
"ai": {
"presets": "Cài sẵn nhà cung cấp",
@@ -162,13 +165,42 @@
"apiKeySaveFailed": "Không thể lưu API key",
"providerNotFound": "Không tìm thấy nhà cung cấp",
"modelCatalogReadFailed": "Không thể đọc danh mục mô hình",
"catalogUnavailable": "models.dev không khả dụng"
"catalogUnavailable": "models.dev không khả dụng",
"family": {
"openai_compatible": "Tương thích OpenAI",
"standalone": "API gốc",
"plan": "Gói đăng ký",
"local": "Tự lưu trữ",
"custom": "Tùy chỉnh"
}
},
"family": {
"openai_compatible": "Tương thích OpenAI",
"standalone": "────────",
"plan": "Gói",
"local": "Tự lưu trú",
"custom": "Tùy chỉnh"
"account": {
"title": "Account",
"email": "Email",
"emailDesc": "Used for checkout and license claims",
"plan": "Current plan",
"planFree": "Free",
"planYearly": "Pro theo năm",
"planPerpetual": "Pro vĩnh viễn",
"planTrial": "Pro dùng thử",
"expires": "Expires {{date}}",
"lifetime": "Lifetime",
"devices": "Seats: up to {{count}} devices",
"deviceId": "This device ID",
"saveEmail": "Save email",
"activate": "Activate license",
"pasteLicense": "Paste LIC- license key",
"clear": "Clear local license",
"deactivate": "Deactivate this device",
"upgrade": "Upgrade",
"saved": "Saved",
"activateOk": "Activated",
"deviceList": "Thiết bị đã gắn",
"loadingDevices": "Đang tải danh sách thiết bị…",
"noDevices": "Chưa có thiết bị",
"thisDevice": "Thiết bị này",
"removeDevice": "Gỡ gắn",
"deviceRemoved": "Đã gỡ gắn thiết bị",
"seatFull": "Đã hết chỗ thiết bị. Hãy gỡ thiết bị khác trước khi kích hoạt máy mới."
}
}
+54 -24
View File
@@ -3,8 +3,9 @@
"local": "Cục bộ",
"remote": "Từ xa",
"notConnectedTitle": "Chưa kết nối tới máy chủ từ xa",
"notConnectedDesc": "Kết nối một phiên SSH trước để dùng SFTP",
"ariaLabel": "Bảng tệp {{title}}"
"notConnectedDesc": "Kết nối SSH trước, rồi truyền tệp hoặc sửa cấu hình",
"ariaLabel": "Bảng tệp {{title}}",
"reconnectResumeDesc": "Đã ngắt. Kết nối lại một chạm sẽ về {{path}}"
},
"connection": {
"selectServer": "Chọn máy chủ",
@@ -12,7 +13,9 @@
"createSessionHint": "Tạo phiên trong thanh bên trước tiên",
"reconnect": "Kết nối lại",
"notConnected": "Chưa kết nối",
"connecting": "Đang kết nối..."
"connecting": "Đang kết nối...",
"resumeHint": "Kết nối lại sẽ quay về {{path}}",
"reconnectingResume": "Đang kết nối lại và khôi phục {{path}}…"
},
"errors": {
"notConnected": "SFTP chưa được kết nối",
@@ -23,19 +26,14 @@
"selectDownloadFiles": "Chọn tệp để tải xuống trước",
"uploadFailed": "Tải lên thất bại",
"downloadFailed": "Tải xuống thất bại",
"fileError": "{{fileName}}: {{message}}"
"fileError": "{{fileName}}: {{message}}",
"mkdirFailed": "Không tạo được thư mục"
},
"toolbar": {
"upload": "Tải lên",
"uploadAria": "Tải lên",
"download": "Tải xuống",
"downloadAria": "Tải xuống",
"downloadNeedSelection": "Chọn tệp từ xa trước",
"downloadHint": "Tải xuống các tệp từ xa đã chọn",
"refresh": "Làm mới",
"refreshAria": "Làm mới",
"refreshAria": "Làm mới bảng này",
"newFolder": "Thư mục mới",
"newFolderAria": "Thư mục mới"
"newFolderAria": "Tạo thư mục trên bảng này"
},
"sort": {
"name": "Tên",
@@ -55,7 +53,8 @@
"hideHidden": "Ẩn tệp chấm (.xxx)",
"showHiddenAria": "Hiển thị tệp ẩn",
"hideHiddenAria": "Ẩn tệp chấm",
"searchAria": "Tìm trong {{title}}"
"searchAria": "Tìm trong {{title}}",
"upAtRoot": "Đã ở thư mục gốc"
},
"status": {
"loading": "Đang tải...",
@@ -63,7 +62,8 @@
"filtered": "Hiển thị {{shown}} / {{total}}",
"hiddenCount": "{{total}} mục ({{hidden}} ẩn)",
"total": "{{count}} mục",
"filterHint": "Lọc: {{query}}"
"filterHint": "Lọc: {{query}}",
"refreshing": "Đang làm mới..."
},
"empty": {
"dirTitle": "Thư mục rỗng",
@@ -99,7 +99,6 @@
"download": "Tải xuống",
"edit": "Sửa",
"rename": "Đổi tên",
"copyPath": "Sao chép đường dẫn",
"properties": "Thuộc tính",
"permissions": "Quyền",
"delete": "Xóa"
@@ -116,7 +115,18 @@
"chmodTitle": "Sửa quyền",
"chmodApply": "Áp dụng",
"pickUploadTitle": "Chọn tệp hoặc thư mục để tải lên",
"pickUploadRzTitle": "Chọn tệp để tải lên (rz)"
"pickUploadRzTitle": "Chọn tệp để tải lên (rz)",
"overwriteTitle": "Ghi đè tệp đã có?",
"overwriteConfirm": "Ghi đè",
"overwriteSkip": "Bỏ qua",
"overwriteOneRemote": "Từ xa đã có「{{name}}」. Ghi đè?",
"overwriteOneLocal": "Cục bộ đã có「{{name}}」. Ghi đè?",
"overwriteManyRemote": "Từ xa đã có {{count}} mục trùng tên. Ghi đè tất cả?",
"overwriteManyLocal": "Cục bộ đã có {{count}} mục trùng tên. Ghi đè tất cả?",
"overwriteMore": "…và {{count}} mục nữa",
"deleteTitle": "Xác nhận xóa",
"deleteMessage": "Xóa {{count}} mục: {{names}}",
"deleteRecursiveWarn": "Có thư mục. Việc xóa là đệ quy và không hoàn tác được."
},
"properties": {
"name": "Tên",
@@ -152,27 +162,41 @@
"statusDone": "Xong",
"statusFailed": "Thất bại",
"statusCancelled": "Đã hủy",
"dropUpload": "Tải lên {{path}}",
"dropDownload": "Tải xuống vào {{path}}"
"dropUpload": "Thả vào đây để tải lên {{path}}",
"dropDownload": "Thả vào đây để tải xuống {{path}}",
"railAria": "Truyền giữa cục bộ và từ xa",
"railUpload": "Tải lên tệp cục bộ đã chọn (hoặc chọn tệp nếu chưa chọn)",
"railDownload": "Tải xuống tệp từ xa đã chọn về máy cục bộ",
"railUploadAria": "Tải lên máy từ xa",
"railDownloadAria": "Tải về máy cục bộ",
"railDownloadNeedSelection": "Hãy chọn tệp từ xa trước",
"retry": "Thử lại"
},
"editor": {
"readFailed": "Đọc thất bại",
"saveFailed": "Lưu thất bại",
"unsavedClose": "Có thay đổi chưa lưu. Vẫn đóng?",
"unsavedTitle": "Thay đổi chưa lưu",
"discardAndClose": "Bỏ và đóng",
"reloadTitle": "Tải lại",
"discardAndReload": "Bỏ và tải lại",
"saving": "Đang lưu...",
"save": "Lưu",
"saved": "Đã lưu tệp",
"loadingEditor": "Đang tải trình soạn thảo...",
"footer": "UTF-8 · Monaco · ⌘/Ctrl+S lưu · tối đa ~2MB",
"loadBlocked": "Không tải được nội dung tệp",
"footer": "UTF-8 · ⌘/Ctrl+S lưu · Esc đóng · tối đa ~2MB",
"charCount": "{{count}} ký tự",
"unsavedMark": " · chưa lưu",
"savedMark": " · đã lưu"
"savedMark": " · đã lưu",
"title": "Sửa {{name}}",
"dirtyBadge": "Chưa lưu",
"reload": "Tải lại",
"unsavedReload": "Hủy thay đổi chưa lưu và tải lại?"
},
"toast": {
"renamed": "Đã đổi tên thành {{name}}",
"renameFailed": "Đổi tên thất bại",
"pathCopied": "Đường dẫn đã được sao chép vào clipboard",
"pathCopyFailed": "Không thể sao chép đường dẫn",
"chmodUpdated": "Đã cập nhật quyền thành {{mode}}",
"chmodFailed": "Không thể cập nhật quyền",
"uploadPartial": "{{action}} đã hoàn tất {{completed}}, thất bại {{failed}}: {{detail}}",
@@ -180,7 +204,13 @@
"uploadAction": "Tải lên",
"downloadAction": "Tải xuống",
"noFolderUpload": "Tải lên thư mục thất bại",
"noDragPath": "Đường dẫn kéo-thả không khả dụng; hãy dùng Tải lên",
"noFolderRecursive": "Truyền thư mục đệ quy thất bại; thử lại hoặc dùng Tải lên"
"noDragPath": "Hệ thống không cung cấp đường dẫn kéo thả. Dùng mũi tên → giữa hoặc chọn tệp để tải lên.",
"noFolderRecursive": "Truyền thư mục thất bại. Thử lại hoặc dùng → / hộp thoại chọn tệp.",
"dropFailed": "Kéo thả thất bại: không đọc được thông tin tệp. Thử lại hoặc dùng mũi tên giữa.",
"folderCreated": "Đã tạo thư mục {{name}}",
"allSkippedExisting": "Đã bỏ qua mọi tệp trùng tên",
"skippedExisting": "Đã bỏ qua {{count}} tệp trùng tên",
"deleteFailed": "Xóa thất bại",
"deleted": "Đã xóa"
}
}
+3 -2
View File
@@ -34,7 +34,7 @@
"reopenShell": "Mở lại shell",
"reopening": "Đang mở…",
"disconnected": "Đã ngắt kết nối",
"disconnectedHint": "Phiên SSH đã kết thúc hoặc mạng bị ngắt. Nhấn bên dưới để kết nối lại.",
"disconnectedHint": "Phiên SSH đã kết thúc hoặc mạng bị ngắt. Một chạm bên dưới để kết nối lại và tiếp tục.",
"reconnect": "Kết nối lại",
"suspended": "Kết nối đã bị tạm dừng",
"suspendedHint": "Không hoạt động một thời gian; kết nối đã bị tạm dừng để tiết kiệm tài nguyên",
@@ -70,7 +70,8 @@
"splitRight": "Chia bên phải",
"splitDown": "Chia xuống dưới",
"maximizePane": "Phóng to pane hiện tại",
"restoreLayout": "Khôi phục bố cục chia"
"restoreLayout": "Khôi phục bố cục chia",
"openSftp": "Mở SFTP"
},
"search": {
"placeholder": "Tìm kiếm...",
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more