feat: Pro 买断门禁、SFTP 精简与批量多机改造

新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-07 11:38:30 +08:00
1 parent 101d2164b4
commit fea6d8db04
72 files changed
+3307 -396

No files matched your search

+21 -7
View File
@@ -20,7 +20,8 @@ import {
PanelLeft,
} from 'lucide-react'
import { useTabStore } from '../../stores/tabStore'
import { useSessionStore, useSnippetStore, useCommandPaletteStore, type Session } from '../../stores'
import { useSessionStore, useSnippetStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../../stores'
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
// ─── 类型定义 ────────────────────────────────────────────────────────
@@ -67,7 +68,7 @@ export function CommandPalette({
const { sessions } = useSessionStore()
const { snippets } = useSnippetStore()
const { isOpen, close } = useCommandPaletteStore()
const { addTab, closeTab, tabs, activeTabId } = useTabStore()
const { addTab, tabs, activeTabId } = useTabStore()
// ── 构建命令列表 ──
const buildCommands = useCallback((): CommandPaletteItem[] => {
@@ -136,7 +137,7 @@ export function CommandPalette({
icon: <X size={16} />,
action: () => {
if (activeTabId && tabs.length > 1) {
closeTab(activeTabId)
void disposeAndCloseTab(activeTabId)
}
},
},
@@ -146,15 +147,28 @@ export function CommandPalette({
label: '关闭所有标签',
description: '关闭所有标签页并回到欢迎页',
icon: <X size={16} />,
action: () => useTabStore.getState().closeAllTabs(),
action: () => {
void disposeAndCloseAllTabs()
},
},
{
id: 'cmd:new-portforward',
type: 'command',
label: '新建端口转发',
description: '打开端口转发管理标签',
description: '打开端口转发管理标签(Pro)',
icon: <ChevronRight size={16} />,
action: () => addTab({ title: '端口转发', type: 'portforward' }),
action: () => {
if (!useEntitlementStore.getState().requirePro('portForward')) return
addTab({ title: '端口转发', type: 'portforward' })
},
},
{
id: 'cmd:upgrade-pro',
type: 'command',
label: '升级 OpsXShell Pro',
description: '查看专业能力对比与激活',
icon: <ChevronRight size={16} />,
action: () => useEntitlementStore.getState().openUpgradeTab(),
},
]
@@ -207,7 +221,7 @@ export function CommandPalette({
}
return items
}, [sessions, snippets, addTab, closeTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession])
}, [sessions, snippets, addTab, activeTabId, tabs.length, onNewSession, onQuickConnect, onOpenSettings, onEditSession])
// ── 过滤结果 ──
const filteredItems = useMemo(() => {
+40 -21
View File
@@ -5,13 +5,13 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Plus, Zap, Settings, ListChecks, Terminal, FolderSync, X, TerminalSquare, PanelLeftClose } from 'lucide-react'
import { useSessionStore, Session } from '../../stores'
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore } from '../../stores/tabStore'
import { useTerminalBroadcastStore } from '../../stores/terminalBroadcastStore'
import { SessionTree } from './sidebar/SessionTree'
import { SearchBox } from './sidebar/SearchBox'
import { useConfirmDialog } from '../common/ConfirmDialog'
import BatchCommandDialog from '../sessions/BatchCommandDialog'
import appIconUrl from '../../assets/opsxshell-app-icon.png'
const MIN_SIDEBAR_WIDTH = 180
@@ -39,8 +39,9 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
const [sessionsLoading, setSessionsLoading] = useState(true)
const [batchMode, setBatchMode] = useState(false)
const [batchSelection, setBatchSelection] = useState<Set<string>>(new Set())
const [batchCommandSessions, setBatchCommandSessions] = useState<Session[]>([])
const [batchCommandOpen, setBatchCommandOpen] = useState(false)
const requirePro = useEntitlementStore((s) => s.requirePro)
const setBroadcastSelectedSessionIds = useTerminalBroadcastStore((s) => s.setSelectedSessionIds)
const setBroadcastScope = useTerminalBroadcastStore((s) => s.setScope)
const contextMenuRef = useRef<HTMLDivElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null)
@@ -174,7 +175,14 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
const finishBatchMode = useCallback(() => {
setBatchMode(false)
setBatchSelection(new Set())
}, [])
setBroadcastSelectedSessionIds([])
}, [setBroadcastSelectedSessionIds])
useEffect(() => {
if (!batchMode) return
setBroadcastSelectedSessionIds(Array.from(batchSelection))
setBroadcastScope('selected-sessions')
}, [batchMode, batchSelection, setBroadcastScope, setBroadcastSelectedSessionIds])
const handleBatchTerminal = useCallback(() => {
for (const session of selectedSessions) {
@@ -185,21 +193,30 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
connectOnMount: true,
})
}
finishBatchMode()
}, [addTab, finishBatchMode, selectedSessions])
// Keep selection for sync-input / further batch actions
}, [addTab, selectedSessions])
const handleBatchSftp = useCallback(() => {
for (const session of selectedSessions) {
addTab({ title: `SFTP: ${session.name}`, type: 'sftp', sessionId: session.id })
}
finishBatchMode()
}, [addTab, finishBatchMode, selectedSessions])
}, [addTab, selectedSessions])
const handleBatchCommand = useCallback(() => {
setBatchCommandSessions(selectedSessions)
setBatchCommandOpen(true)
finishBatchMode()
}, [finishBatchMode, selectedSessions])
if (!requirePro('batchExec')) return
addTab({
title: '批量执行',
type: 'batch',
batchSessionIds: selectedSessions.map((session) => session.id),
})
}, [addTab, requirePro, selectedSessions])
const handleBatchSyncInput = useCallback(() => {
if (!requirePro('broadcast')) return
setBroadcastSelectedSessionIds(selectedSessions.map((s) => s.id))
setBroadcastScope('selected-sessions')
useTerminalBroadcastStore.getState().setEnabled(true)
}, [requirePro, selectedSessions, setBroadcastScope, setBroadcastSelectedSessionIds])
const handleContextMenu = (e: React.MouseEvent, session: Session) => {
e.preventDefault()
@@ -365,13 +382,21 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
className="col-span-2 flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
>
<TerminalSquare className="h-3.5 w-3.5" />
批量命令
批量执行 · Pro
</button>
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchSyncInput}
className="col-span-2 flex h-9 items-center justify-center gap-1.5 rounded border border-accent-orange/40 bg-accent-orange/10 text-xs font-medium text-accent-orange disabled:cursor-not-allowed disabled:opacity-40"
>
同步输入 · Pro
</button>
<button
type="button"
disabled={selectedSessions.length === 0}
onClick={handleBatchTerminal}
className="flex h-9 items-center justify-center gap-1.5 rounded bg-accent-blue text-xs font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
className="flex h-9 items-center justify-center gap-1.5 rounded bg-dark-700 text-xs font-medium text-content-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Terminal className="h-3.5 w-3.5" />
打开终端
@@ -450,12 +475,6 @@ export default function Sidebar({ onNewSession, onQuickConnect, onEditSession, o
/>
</div>
{ConfirmDialogComponent}
<BatchCommandDialog
isOpen={batchCommandOpen}
sessions={sessions}
initialSelectedIds={batchCommandSessions.map((session) => session.id)}
onClose={() => setBatchCommandOpen(false)}
/>
</>
)
}
+16 -17
View File
@@ -5,7 +5,13 @@
import { Plus } from 'lucide-react'
import React, { useState, useCallback, useRef } from 'react'
import { useTabStore } from '../../stores/tabStore'
import { useConnectionPool } from '../../stores/connection'
import {
disposeAndCloseAllTabs,
disposeAndCloseLeftTabs,
disposeAndCloseOtherTabs,
disposeAndCloseRightTabs,
disposeAndCloseTab,
} from '../../utils/disposeTab'
import { TabItem } from './tabbar/TabItem'
import { TabContextMenu } from './tabbar/TabContextMenu'
@@ -18,17 +24,10 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
tabs,
activeTabId,
activateTab,
closeTab,
addTab,
closeOtherTabs,
closeRightTabs,
closeLeftTabs,
closeAllTabs,
moveTab,
} = useTabStore()
const { disconnectConnection } = useConnectionPool()
// Drag state
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
@@ -41,11 +40,7 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
})
const handleCloseTab = (tabId: string) => {
const tab = tabs.find((t) => t.id === tabId)
if (tab?.connectionInstance?.connectionId) {
disconnectConnection(tab.connectionInstance.connectionId)
}
closeTab(tabId)
void disposeAndCloseTab(tabId)
}
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
@@ -164,10 +159,10 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
position={menuState.position}
onClose={() => setMenuState((prev) => ({ ...prev, isOpen: false }))}
onCloseTab={() => menuState.tabId && handleCloseTab(menuState.tabId)}
onCloseOthers={() => menuState.tabId && closeOtherTabs(menuState.tabId)}
onCloseRight={() => menuState.tabId && closeRightTabs(menuState.tabId)}
onCloseLeft={() => menuState.tabId && closeLeftTabs(menuState.tabId)}
onCloseAll={closeAllTabs}
onCloseOthers={() => menuState.tabId && void disposeAndCloseOtherTabs(menuState.tabId)}
onCloseRight={() => menuState.tabId && void disposeAndCloseRightTabs(menuState.tabId)}
onCloseLeft={() => menuState.tabId && void disposeAndCloseLeftTabs(menuState.tabId)}
onCloseAll={() => void disposeAndCloseAllTabs()}
onClone={() => {
if (currentTab) {
addTab({
@@ -176,6 +171,10 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
sessionId: currentTab.sessionId,
connectionConfig: currentTab.connectionConfig,
serialConfig: currentTab.serialConfig,
isLocal: currentTab.isLocal,
localShellKind: currentTab.localShellKind,
localShellPath: currentTab.localShellPath,
splitLayout: currentTab.splitLayout,
})
}
}}
+44 -5
View File
@@ -6,6 +6,9 @@ import SFTPView from '../sftp/SFTPView'
import PortForwardView from '../connection/PortForwardView'
import SettingsView from '../settings/SettingsView'
import SerialTerminalView from '../serial/SerialTerminalView'
import UpgradeView from '../pro/UpgradeView'
import BatchExecView from '../sessions/BatchExecView'
import { useEntitlementStore, type ProFeature } from '../../stores/entitlementStore'
interface TabContentProps {
activeTabId: string | null
@@ -13,6 +16,20 @@ interface TabContentProps {
onQuickConnect?: () => void
}
function ProGate({
feature,
children,
}: {
feature: ProFeature
children: React.ReactNode
}) {
const canUse = useEntitlementStore((s) => s.canUse(feature))
if (!canUse) {
return <UpgradeView feature={feature} />
}
return <>{children}</>
}
function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentProps) {
const tabs = useTabStore((state) => state.tabs)
@@ -21,7 +38,6 @@ function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentPro
{tabs.map((tab) => {
const isActive = tab.id === activeTabId
// 根据类型渲染不同内容
let content: React.ReactNode
switch (tab.type) {
@@ -31,15 +47,38 @@ function TabContent({ activeTabId, onNewSession, onQuickConnect }: TabContentPro
case 'sftp':
content = <SFTPView tabId={tab.id} isActive={isActive} sessionId={tab.sessionId} />
break
case 'serial':
content = <SerialTerminalView tabId={tab.id} isActive={isActive} />
break
case 'portforward':
content = <PortForwardView tabId={tab.id} isActive={isActive} />
content = (
<ProGate feature="portForward">
<PortForwardView tabId={tab.id} isActive={isActive} />
</ProGate>
)
break
case 'serial':
content = (
<ProGate feature="serial">
<SerialTerminalView tabId={tab.id} isActive={isActive} />
</ProGate>
)
break
case 'settings':
content = <SettingsView />
break
case 'upgrade':
content = (
<UpgradeView
feature={tab.upgradeFeature as ProFeature | undefined}
comingSoon={tab.upgradeComingSoon}
/>
)
break
case 'batch':
content = (
<ProGate feature="batchExec">
<BatchExecView tabId={tab.id} initialSessionIds={tab.batchSessionIds} />
</ProGate>
)
break
case 'welcome':
default:
content = <WelcomeView onNewSession={onNewSession} onQuickConnect={onQuickConnect} />
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'
import { Laptop, ChevronDown } from 'lucide-react'
import { useTabStore } from '../../../stores/tabStore'
import { shellDisplayFromPath } from '../../../utils/shellName'
import { fallbackLocalShells } from '../../../utils/platformShells'
export interface ShellOption {
path: string
@@ -27,10 +28,7 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const fallback = () => setShells([
{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true },
{ path: '/bin/bash', name: 'Bash', kind: 'bash', isDefault: false },
])
const fallback = () => setShells(fallbackLocalShells() as ShellOption[])
void window.opsxApi?.localListShells?.().then((res) => {
if (res?.success && res.shells?.length) {
@@ -115,7 +113,7 @@ export default function LocalShellMenu({ compact = false, className = '' }: Loca
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-content-primary">本地 Shell</div>
<div className="text-xs text-txt-muted mt-0.5">选择 Zsh / Bash 打开本机终端</div>
<div className="text-xs text-txt-muted mt-0.5">打开本机终端</div>
</div>
<ChevronDown className={`w-4 h-4 text-txt-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
+5 -1
View File
@@ -2,7 +2,7 @@
* TabItem — local shell shows zsh/bash badge like professional terminal UIs
*/
import { X, Terminal, FolderSync, Cable } from 'lucide-react'
import { X, Terminal, FolderSync, Cable, Crown, TerminalSquare } from 'lucide-react'
import type { Tab } from '../../../stores/tabStore'
interface TabItemProps {
@@ -86,6 +86,10 @@ export function TabItem({
return <FolderSync className={`w-3.5 h-3.5 ${isActive ? 'text-accent-purple' : ''}`} />
case 'serial':
return <Cable className={`w-3.5 h-3.5 ${isActive ? 'text-accent-green' : ''}`} />
case 'upgrade':
return <Crown className={`w-3.5 h-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
case 'batch':
return <TerminalSquare className={`w-3.5 h-3.5 ${isActive ? 'text-accent-blue' : ''}`} />
default:
return null
}
+237
View File
@@ -0,0 +1,237 @@
/**
* UpgradeView — in-app Pro comparison + one-time checkout / license activate
*/
import { useCallback, useEffect, useState } from 'react'
import {
Check,
Crown,
ExternalLink,
Loader2,
Minus,
Shield,
} from 'lucide-react'
import {
licenseServerBaseUrl,
PRO_FEATURE_LABELS,
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
import { createLogger } from '../../utils/logger'
const logger = createLogger('UpgradeView')
interface UpgradeViewProps {
feature?: ProFeature
comingSoon?: boolean
}
const FREE_ITEMS = ['SSH 终端', 'SFTP 文件管理', '本地终端', '会话 CRUD', '单条 / ssh_config 导入']
const PRO_ITEMS = [
'端口转发',
'会话代理 / SOCKS',
'串口终端',
'终端 AI 助手',
'批量执行 / 广播同步',
'批量导入导出主机清单',
'RDP / VNC(即将推出)',
]
export default function UpgradeView({ feature, comingSoon }: UpgradeViewProps) {
const plan = useEntitlementStore((s) => s.plan)
const deviceId = useEntitlementStore((s) => s.deviceId)
const expiresAt = useEntitlementStore((s) => s.expiresAt)
const activate = useEntitlementStore((s) => s.activate)
const clear = useEntitlementStore((s) => s.clear)
const hydrate = useEntitlementStore((s) => s.hydrate)
const [checkoutLoading, setCheckoutLoading] = useState(false)
const [checkoutError, setCheckoutError] = useState<string | null>(null)
const [licenseInput, setLicenseInput] = useState('')
const [activateError, setActivateError] = useState<string | null>(null)
const [activateOk, setActivateOk] = useState(false)
const [showActivate, setShowActivate] = useState(false)
useEffect(() => {
void hydrate()
}, [hydrate])
const highlight = feature ? PRO_FEATURE_LABELS[feature] : null
const startCheckout = useCallback(async () => {
setCheckoutLoading(true)
setCheckoutError(null)
try {
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 data = (await res.json()) as { checkoutUrl?: string; error?: string }
if (!res.ok || !data.checkoutUrl) {
throw new Error(data.error || `结账服务不可用 (${res.status})`)
}
await window.opsxApi.openExternal(data.checkoutUrl)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn('checkout failed', message)
setCheckoutError(message)
} finally {
setCheckoutLoading(false)
}
}, [deviceId])
const handleActivate = useCallback(async () => {
setActivateError(null)
setActivateOk(false)
const result = await activate(licenseInput)
if (result.success) {
setActivateOk(true)
setLicenseInput('')
} else {
setActivateError(result.error || '激活失败')
}
}, [activate, licenseInput])
return (
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
<div className="mx-auto w-full max-w-3xl px-6 py-8">
<header className="mb-8 flex items-start gap-3 border-b border-dark-700/40 pb-6">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-accent-blue/20 text-accent-blue">
<Crown className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold tracking-tight text-content-primary">
OpsXShell Pro · 解锁专业运维能力
</h1>
<p className="mt-1 text-sm text-txt-muted">
{comingSoon && highlight
? `${highlight} 即将推出 · 升级 Pro 可优先体验`
: highlight
? `正在解锁:${highlight}`
: '转发、代理、串口、AI、批量与广播'}
</p>
{plan === 'pro' && (
<p className="mt-2 text-xs text-accent-green">
已激活 Pro
{expiresAt
? ` · 有效期至 ${new Date(expiresAt * 1000).toLocaleDateString()}`
: ' · 终身授权'}
</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">Free</div>
<ul className="space-y-2">
{FREE_ITEMS.map((item) => (
<li key={item} 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" />
{item}
</li>
))}
</ul>
</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">Pro</div>
<ul className="space-y-2">
{PRO_ITEMS.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm text-content-primary">
{item.includes('即将推出') ? (
<Minus className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
) : (
<Check className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
)}
<span className={item.includes('即将推出') ? 'text-txt-muted' : ''}>{item}</span>
</li>
))}
</ul>
</div>
</div>
{plan !== 'pro' && (
<>
<p className="mb-3 text-center text-sm text-txt-muted">
一次性费用 · 本机终身授权 · 无需订阅
</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" />
)}
去付费升级
</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"
>
已有许可证?激活
</button>
{showActivate && (
<div className="mt-3 space-y-2 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4">
<label className="ui-label" htmlFor="license-token">
粘贴许可证
</label>
<textarea
id="license-token"
value={licenseInput}
onChange={(e) => setLicenseInput(e.target.value)}
rows={3}
spellCheck={false}
placeholder="opsx1...."
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
type="button"
disabled={!licenseInput.trim()}
onClick={() => void handleActivate()}
className="rounded-lg bg-dark-700 px-3 py-2 text-sm text-content-primary hover:bg-dark-600 disabled:opacity-40"
>
激活
</button>
{activateError && <p className="text-xs text-red-400">{activateError}</p>}
{activateOk && <p className="text-xs text-accent-green">激活成功</p>}
</div>
)}
</>
)}
{plan === 'pro' && (
<button
type="button"
onClick={() => void clear()}
className="rounded-lg border border-dark-600 px-3 py-2 text-sm text-txt-muted hover:bg-dark-800"
>
清除本机许可证
</button>
)}
<footer className="mt-10 space-y-2 border-t border-dark-700/40 pt-5 text-[11px] leading-relaxed text-txt-faint">
<p className="flex items-start gap-1.5">
<Shield className="mt-0.5 h-3 w-3 flex-shrink-0" />
支付由 Waffo 托管收银台处理;本应用不收集或保存银行卡信息。
</p>
<p>测试环境 · 设备 ID:{deviceId || '…'}</p>
</footer>
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
/**
* UpgradeWall — intercepts Pro features for Free users
*/
import { Crown, Sparkles, X } from 'lucide-react'
import {
PRO_FEATURE_LABELS,
useEntitlementStore,
type ProFeature,
} from '../../stores/entitlementStore'
export default function UpgradeWall() {
const wall = useEntitlementStore((s) => s.wall)
const closeWall = useEntitlementStore((s) => s.closeWall)
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
if (!wall.open) return null
const feature = wall.feature as ProFeature | undefined
const label = feature ? PRO_FEATURE_LABELS[feature] : '专业功能'
const comingSoon = wall.comingSoon
return (
<div
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="upgrade-wall-title"
onClick={closeWall}
>
<div
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-dark-600/80 bg-dark-800 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={closeWall}
className="absolute right-3 top-3 rounded-lg p-1.5 text-txt-muted hover:bg-dark-700 hover:text-content-primary"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
<div className="border-b border-dark-700/50 bg-gradient-to-br from-accent-blue/15 to-transparent px-6 pb-5 pt-6">
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl bg-accent-blue/20 text-accent-blue">
<Crown className="h-5 w-5" />
</div>
<h2 id="upgrade-wall-title" className="text-base font-semibold text-content-primary">
{comingSoon ? `${label} · 即将推出` : `解锁 ${label}`}
</h2>
<p className="mt-1.5 text-sm leading-relaxed text-txt-muted">
{wall.message ||
(comingSoon
? '该能力属于 OpsXShell Pro,协议实现尚在开发中。升级后可优先体验。'
: '此功能属于 OpsXShell Pro。升级后即可在本机使用。')}
</p>
</div>
<div className="flex gap-2 px-6 py-4">
<button
type="button"
onClick={closeWall}
className="flex-1 rounded-lg border border-dark-600 px-3 py-2.5 text-sm text-content-secondary transition-colors hover:bg-dark-700"
>
稍后再说
</button>
<button
type="button"
onClick={() => openUpgradeTab(feature, comingSoon)}
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-accent-blue px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-600"
>
<Sparkles className="h-3.5 w-3.5" />
查看 Pro
</button>
</div>
</div>
</div>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { default as UpgradeView } from './UpgradeView'
export { default as UpgradeWall } from './UpgradeWall'
+12 -4
View File
@@ -8,6 +8,7 @@ import type {
SerialParity,
SerialPortSummary,
} from '../../../shared/types/serial.types'
import { getDesktopPlatform } from '../../platform/runtime'
interface SerialConnectDialogProps {
isOpen: boolean
@@ -17,13 +18,20 @@ interface SerialConnectDialogProps {
const BAUD_RATES = [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]
function defaultSerialPath(): string {
const platform = getDesktopPlatform()
if (platform === 'windows') return 'COM1'
if (platform === 'linux') return '/dev/ttyUSB0'
return '/dev/tty.usbserial'
}
export default function SerialConnectDialog({
isOpen,
onClose,
onConnect,
}: SerialConnectDialogProps) {
const [ports, setPorts] = useState<SerialPortSummary[]>([])
const [path, setPath] = useState('/dev/tty.debug-console')
const [path, setPath] = useState(defaultSerialPath)
const [baudRate, setBaudRate] = useState(115200)
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8)
const [stopBits, setStopBits] = useState<1 | 2>(1)
@@ -98,7 +106,7 @@ export default function SerialConnectDialog({
id="serial-path"
value={path}
onChange={(event) => setPath(event.target.value)}
placeholder="/dev/tty.debug-console"
placeholder={defaultSerialPath()}
className="ui-input font-mono"
spellCheck={false}
/>
@@ -122,8 +130,8 @@ export default function SerialConnectDialog({
</div>
) : (
<p className="text-[11px] text-txt-faint">
未枚举到设备时可直接输入路径,例如 /dev/tty.debug-console 或
/dev/tty.Bluetooth-Incoming-Port。
未枚举到设备时可直接输入路径,例如 {defaultSerialPath()}
{getDesktopPlatform() === 'windows' ? '、COM3' : getDesktopPlatform() === 'linux' ? '、/dev/ttyACM0' : '、/dev/tty.usbserial-*'}。
</p>
)}
</section>
+393
View File
@@ -0,0 +1,393 @@
/**
* BatchExecView — Pro 批量命令结果矩阵(独立标签,关闭即丢结果)
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
AlertCircle,
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
Clock3,
Loader2,
Play,
Search,
Server,
TerminalSquare,
} from 'lucide-react'
import type { Session } from '../../stores/sessionStore'
import { useSessionStore } from '../../stores/sessionStore'
import { getConnectionStore, getSessionConfig } from '../../stores/connection'
import { useSettingsStore } from '../../stores/settingsStore'
import Button from '../common/Button'
type BatchCommandStatus = 'idle' | 'running' | 'success' | 'error'
interface BatchCommandResult {
sessionId: string
status: BatchCommandStatus
output?: string
error?: string
durationMs?: number
}
interface BatchExecViewProps {
tabId: string
initialSessionIds?: string[]
}
const MAX_CONCURRENCY = 5
export default function BatchExecView({ tabId, initialSessionIds = [] }: BatchExecViewProps) {
const sessions = useSessionStore((s) => s.sessions)
const [command, setCommand] = useState('')
const [query, setQuery] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(initialSessionIds))
const [running, setRunning] = useState(false)
const [results, setResults] = useState<BatchCommandResult[]>([])
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const timeout = useSettingsStore((state) => state.settings.timeout)
useEffect(() => {
const available = new Set(sessions.map((session) => session.id))
const requested = initialSessionIds.filter((id) => available.has(id))
const initial = requested.length > 0 ? requested : []
setSelectedIds(new Set(initial))
setResults(initial.map((sessionId) => ({ sessionId, status: 'idle' })))
}, [initialSessionIds, sessions])
const filteredSessions = useMemo(() => {
const normalized = query.trim().toLowerCase()
if (!normalized) return sessions
return sessions.filter((session) =>
[session.name, session.host, session.username, session.group, ...(session.tags || [])]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalized))
)
}, [query, sessions])
const selectedSessions = useMemo(
() => sessions.filter((session) => selectedIds.has(session.id)),
[selectedIds, sessions]
)
const updateResult = useCallback((sessionId: string, updates: Partial<BatchCommandResult>) => {
setResults((current) =>
current.map((result) => (result.sessionId === sessionId ? { ...result, ...updates } : result))
)
}, [])
const executeForSession = useCallback(
async (session: Session, commandText: string) => {
const startedAt = Date.now()
const poolTabId = `batch:${tabId}:${session.id}`
let connectionId: string | undefined
updateResult(session.id, { status: 'running', output: undefined, error: undefined })
try {
const config = await getSessionConfig(session.id)
if (!config) throw new Error('无法读取会话配置')
const created = await getConnectionStore().createConnection(poolTabId, {
...config,
timeoutSeconds: config.timeoutSeconds ?? timeout,
})
if (!created.success || !created.connectionId) {
throw new Error(created.error || '连接失败')
}
connectionId = created.connectionId
const executed = await window.opsxApi.sshExec(connectionId, commandText)
if (!executed.success) throw new Error(executed.error || '命令执行失败')
updateResult(session.id, {
status: 'success',
output: executed.result?.trimEnd() || '(无输出)',
durationMs: Date.now() - startedAt,
})
setExpanded((prev) => new Set(prev).add(session.id))
} catch (error) {
updateResult(session.id, {
status: 'error',
error: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - startedAt,
})
setExpanded((prev) => new Set(prev).add(session.id))
} finally {
if (connectionId) {
await getConnectionStore().releaseConnection(connectionId, poolTabId).catch(() => undefined)
}
}
},
[tabId, timeout, updateResult]
)
const execute = useCallback(async () => {
const commandText = command.trim()
if (!commandText || running || selectedSessions.length === 0) return
const targets = [...selectedSessions]
setRunning(true)
setResults(targets.map((session) => ({ sessionId: session.id, status: 'idle' })))
setExpanded(new Set())
let nextIndex = 0
const worker = async () => {
while (nextIndex < targets.length) {
const session = targets[nextIndex]
nextIndex += 1
await executeForSession(session, commandText)
}
}
try {
await Promise.all(
Array.from({ length: Math.min(MAX_CONCURRENCY, targets.length) }, () => worker())
)
} finally {
setRunning(false)
}
}, [command, executeForSession, running, selectedSessions])
const toggleSession = (sessionId: string) => {
if (running) return
setSelectedIds((current) => {
const next = new Set(current)
if (next.has(sessionId)) next.delete(sessionId)
else next.add(sessionId)
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
return next
})
}
const allFilteredSelected =
filteredSessions.length > 0 && filteredSessions.every((session) => selectedIds.has(session.id))
const toggleFiltered = () => {
if (running) return
setSelectedIds((current) => {
const next = new Set(current)
for (const session of filteredSessions) {
if (allFilteredSelected) next.delete(session.id)
else next.add(session.id)
}
setResults(Array.from(next, (id) => ({ sessionId: id, status: 'idle' })))
return next
})
}
const summary = useMemo(
() => ({
success: results.filter((result) => result.status === 'success').length,
error: results.filter((result) => result.status === 'error').length,
running: results.filter((result) => result.status === 'running').length,
}),
[results]
)
const completed = summary.success + summary.error
const progress =
selectedSessions.length > 0 ? Math.round((completed / selectedSessions.length) * 100) : 0
return (
<div className="flex h-full flex-col bg-dark-900">
<div className="flex items-center justify-between gap-3 border-b border-dark-700/40 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<TerminalSquare className="h-4 w-4 text-accent-blue" />
<div className="min-w-0">
<div className="text-sm font-medium text-content-primary">批量执行</div>
<div className="text-[11px] text-txt-muted">
已选 {selectedSessions.length} 台 · 并发 {MAX_CONCURRENCY} · 关闭标签即丢弃结果
</div>
</div>
</div>
<div className="flex items-center gap-2">
{summary.success > 0 && (
<span className="text-xs text-accent-green">成功 {summary.success}</span>
)}
{summary.error > 0 && <span className="text-xs text-red-400">失败 {summary.error}</span>}
<Button
variant="primary"
size="sm"
icon={<Play className="h-3.5 w-3.5" />}
onClick={() => void execute()}
disabled={!command.trim() || selectedSessions.length === 0}
loading={running}
>
{completed > 0 ? '再次执行' : `执行 ${selectedSessions.length} 台`}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4 lg:flex-row">
<section className="flex w-full flex-col gap-3 lg:w-[320px] lg:flex-shrink-0">
<div className="rounded-xl border border-dark-700/70 bg-dark-800/40 p-3">
<label htmlFor="batch-command-panel" className="mb-2 block text-xs font-medium text-content-secondary">
远程命令
</label>
<textarea
id="batch-command-panel"
value={command}
onChange={(event) => setCommand(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
void execute()
}
}}
disabled={running}
rows={4}
spellCheck={false}
placeholder="例如:uptime"
className="w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-sm text-content-primary outline-none focus:border-accent-blue disabled:opacity-60"
/>
{(running || completed > 0) && (
<div className="mt-2 h-1 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full rounded-full bg-accent-blue transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-800/40">
<div className="space-y-2 border-b border-dark-700/60 p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-content-secondary">
<Server className="h-3.5 w-3.5" /> 目标主机
</div>
<button
type="button"
disabled={running || filteredSessions.length === 0}
onClick={toggleFiltered}
className="text-[11px] text-accent-blue disabled:opacity-40"
>
{allFilteredSelected ? '取消当前结果' : '全选当前结果'}
</button>
</div>
<label className="relative block">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-faint" />
<input
aria-label="筛选主机"
value={query}
onChange={(event) => setQuery(event.target.value)}
disabled={running}
placeholder="名称、IP、分组或标签"
className="ui-input-sm w-full pl-8"
/>
</label>
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{filteredSessions.map((session) => {
const selected = selectedIds.has(session.id)
return (
<button
key={session.id}
type="button"
role="checkbox"
aria-checked={selected}
disabled={running}
onClick={() => toggleSession(session.id)}
className={`flex w-full items-start gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors ${
selected
? 'border-accent-blue/40 bg-accent-blue/10'
: 'border-transparent hover:border-dark-700 hover:bg-dark-800'
}`}
>
<span
className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
selected
? 'border-accent-blue bg-accent-blue text-white'
: 'border-dark-500 bg-dark-900'
}`}
>
{selected && <Check className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-content-primary">
{session.name}
</span>
<span className="block truncate font-mono text-[10px] text-txt-faint">
{session.username}@{session.host}:{session.port || 22}
</span>
</span>
</button>
)
})}
{filteredSessions.length === 0 && (
<div className="py-12 text-center text-xs text-txt-faint">没有匹配的主机</div>
)}
</div>
</div>
</section>
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-dark-700/70 bg-dark-800/40">
<div className="flex items-center justify-between border-b border-dark-700/60 px-3 py-2.5">
<span className="text-xs font-medium text-content-secondary">结果矩阵</span>
<span className="text-[10px] text-txt-faint">主机 × 状态 × 输出</span>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto p-2.5">
{selectedSessions.map((session) => {
const result = results.find((item) => item.sessionId === session.id)
const status = result?.status ?? 'idle'
const isOpen = expanded.has(session.id)
const hasBody = !!(result?.output || result?.error)
return (
<div key={session.id} className="overflow-hidden rounded-lg border border-dark-700 bg-dark-900/80">
<button
type="button"
className="flex w-full min-h-10 items-center gap-2 px-3 py-2 text-left"
onClick={() => {
if (!hasBody) return
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(session.id)) next.delete(session.id)
else next.add(session.id)
return next
})
}}
>
{hasBody ? (
isOpen ? (
<ChevronDown className="h-3.5 w-3.5 text-txt-faint" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-txt-faint" />
)
) : (
<span className="w-3.5" />
)}
{status === 'running' && <Loader2 className="h-4 w-4 animate-spin text-accent-blue" />}
{status === 'success' && <CheckCircle2 className="h-4 w-4 text-accent-green" />}
{status === 'error' && <AlertCircle className="h-4 w-4 text-red-400" />}
{status === 'idle' && <Clock3 className="h-4 w-4 text-txt-faint" />}
<span className="min-w-0 flex-1 truncate text-xs font-medium text-content-primary">
{session.name}
</span>
<span className="truncate font-mono text-[10px] text-txt-muted">{session.host}</span>
{result?.durationMs !== undefined && (
<span className="text-[10px] tabular-nums text-txt-faint">{result.durationMs} ms</span>
)}
</button>
{isOpen && hasBody && (
<pre
className={`max-h-56 overflow-auto whitespace-pre-wrap break-words border-t border-dark-700/70 px-3 py-2 text-[11px] leading-relaxed ${
result?.error ? 'text-red-300' : 'text-content-secondary'
}`}
>
{result?.error || result?.output}
</pre>
)}
</div>
)
})}
{selectedSessions.length === 0 && (
<div className="flex h-full min-h-64 items-center justify-center text-xs text-txt-faint">
从左侧选择目标主机,或在侧栏批量模式保留选中后打开
</div>
)}
</div>
</section>
</div>
</div>
)
}
@@ -112,7 +112,6 @@ export default function QuickConnectDialog({ isOpen, onClose, initialTarget = ''
isOpen={isOpen}
onClose={onClose}
title="快速连接"
description="粘贴 user@host:port 即可,不必拆成多个框"
icon={<Zap className="h-5 w-5" />}
size="md"
showFooter={false}
+2 -1
View File
@@ -7,6 +7,7 @@ import {
listLocalShells,
type AvailableLocalShell,
} from '../../platform/localTerminal'
import { fallbackLocalShells } from '../../utils/platformShells'
interface QuickLauncherProps {
isOpen: boolean
@@ -100,7 +101,7 @@ export default function QuickLauncher({
<div className="space-y-1">
{(localShells.length > 0
? localShells.slice(0, 4)
: [{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true }]
: fallbackLocalShells()
).map((shell) => (
<button
key={shell.path}
+6 -3
View File
@@ -6,7 +6,7 @@ import { useState, useEffect, type ComponentType } from 'react'
import {
X, Server, Key, Lock, Tag, Folder, ChevronDown, Check,
} from 'lucide-react'
import { useSessionStore, Session } from '../../stores'
import { useSessionStore, Session, useEntitlementStore } from '../../stores'
import type { AuthType, ProxyType } from '../../../shared/types/session.types'
import { createLogger } from '../../utils/logger'
import { parseSshTarget } from './QuickConnectDialog'
@@ -475,7 +475,10 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
<button
key={t.value}
type="button"
onClick={() =>
onClick={() => {
if (t.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
return
}
setFormData({
...formData,
proxy: {
@@ -487,7 +490,7 @@ export default function SessionDialog({ isOpen, onClose, editSession }: SessionD
},
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
})
}
}}
className={`flex-1 py-1.5 rounded-lg text-[11px] border transition-colors ${
formData.proxy.type === t.value
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
+236 -93
View File
@@ -1,19 +1,73 @@
import { Plus, Zap, Clock, Server, ChevronRight } from 'lucide-react'
import { Plus, Zap, Clock, Server, ChevronRight, Laptop, Terminal, Monitor, Cast } from 'lucide-react'
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useSessionStore } from '../../stores'
import { useTabStore } from '../../stores/tabStore'
import { useEntitlementStore } from '../../stores/entitlementStore'
import EmptyState from '../common/EmptyState'
import LocalShellMenu from '../layout/sidebar/LocalShellMenu'
import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShellMenu'
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { shellDisplayFromPath } from '../../utils/shellName'
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
interface WelcomeViewProps {
onNewSession?: () => void
onQuickConnect?: () => void
}
function Kbd({ children }: { children: string }) {
return (
<kbd className="rounded border border-dark-600/80 bg-dark-900/80 px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
{children}
</kbd>
)
}
export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeViewProps) {
const { addTab } = useTabStore()
const requirePro = useEntitlementStore((s) => s.requirePro)
const openUpgradeTab = useEntitlementStore((s) => s.openUpgradeTab)
const sessions = useSessionStore((s) => s.sessions)
const recentSessions = sessions.slice(-6).reverse()
const recentSessions = sessions.slice(-8).reverse()
const modKey = useMemo(() => modKeyLabel(), [])
const [shells, setShells] = useState<ShellOption[]>([])
const [shellMenuOpen, setShellMenuOpen] = useState(false)
const shellMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const fallback = () => setShells(fallbackLocalShells() as ShellOption[])
void window.opsxApi
?.localListShells?.()
.then((res) => {
if (res?.success && res.shells?.length) setShells(res.shells)
else fallback()
})
.catch(fallback)
}, [])
useEffect(() => {
if (!shellMenuOpen) return
const onDoc = (e: MouseEvent) => {
if (shellMenuRef.current && !shellMenuRef.current.contains(e.target as Node)) {
setShellMenuOpen(false)
}
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [shellMenuOpen])
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
const defaultShellLabel = defaultShell
? shellDisplayFromPath(defaultShell.path || defaultShell.name).title
: 'Shell'
const openDefaultLocal = useCallback(() => {
openLocalShellTab(
defaultShell
? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name }
: undefined
)
}, [defaultShell])
const handleSessionClick = (session: (typeof sessions)[number]) => {
addTab({
@@ -24,115 +78,204 @@ export default function WelcomeView({ onNewSession, onQuickConnect }: WelcomeVie
}
return (
<div className="h-full flex flex-col items-center bg-dark-900 overflow-y-auto">
<div className="w-full max-w-3xl px-6 py-10 sm:py-12">
{/* Hero — 短文案 */}
<div className="flex items-center gap-4 mb-8">
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-8 py-12 sm:py-16">
<header className="mb-10 flex items-center gap-3.5">
<img
src={appIconUrl}
alt="OpsXShell"
width={56}
height={56}
className="h-14 w-14 flex-shrink-0 rounded-2xl object-cover shadow-lg shadow-black/20"
alt=""
width={40}
height={40}
className="h-10 w-10 flex-shrink-0 rounded-xl object-cover"
draggable={false}
/>
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-content-primary tracking-tight">OpsXShell</h1>
<p className="text-sm text-txt-muted mt-0.5">SSH / SFTP 终端</p>
<h1 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h1>
<p className="text-xs text-txt-muted">远程终端 · SFTP · 端口转发</p>
</div>
</div>
</header>
{/* 主路径:快速连接 */}
<button
type="button"
onClick={() => onQuickConnect?.()}
className="
group w-full flex items-center gap-4 p-5 mb-3 rounded-2xl text-left
bg-accent-blue hover:bg-blue-500 border border-blue-400/30
shadow-lg shadow-accent-blue/10 transition-all duration-150
"
>
<div className="w-12 h-12 rounded-xl bg-white/15 flex items-center justify-center flex-shrink-0">
<Zap className="w-6 h-6 text-white" />
<section aria-label="开始" className="mb-12">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<button
type="button"
onClick={() => onQuickConnect?.()}
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Zap className="h-4 w-4" strokeWidth={2} />
</span>
<Kbd>{`${modKey}K`}</Kbd>
</div>
<div>
<div className="text-sm font-medium text-content-primary">快速连接</div>
<div className="mt-1 text-xs leading-relaxed text-txt-muted">临时 SSH,不写入会话库</div>
</div>
</button>
<button
type="button"
onClick={() => onNewSession?.()}
className="group flex flex-col gap-4 rounded-xl border border-dark-700/60 bg-dark-800/50 p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-green/15 text-accent-green">
<Plus className="h-4 w-4" strokeWidth={2} />
</span>
<Kbd>{`${modKey}N`}</Kbd>
</div>
<div>
<div className="text-sm font-medium text-content-primary">新建会话</div>
<div className="mt-1 text-xs leading-relaxed text-txt-muted">保存主机,侧栏双击连接</div>
</div>
</button>
<div ref={shellMenuRef} className="relative">
<div className="flex h-full overflow-hidden rounded-xl border border-dark-700/60 bg-dark-800/50 transition-colors hover:border-dark-600 hover:bg-dark-800">
<button
type="button"
onClick={openDefaultLocal}
className="flex min-w-0 flex-1 flex-col gap-4 p-4 text-left"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-content-secondary">
<Laptop className="h-4 w-4" strokeWidth={2} />
</span>
<Terminal className="h-3.5 w-3.5 text-txt-faint" />
</div>
<div>
<div className="text-sm font-medium text-content-primary">本地 Shell</div>
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">
{defaultShellLabel}
</div>
</div>
</button>
<button
type="button"
aria-label="选择 Shell"
aria-expanded={shellMenuOpen}
onClick={() => setShellMenuOpen((v) => !v)}
className="flex w-9 flex-shrink-0 items-center justify-center border-l border-dark-700/60 text-txt-faint transition-colors hover:bg-dark-700/40 hover:text-content-secondary"
>
<ChevronRight className={`h-4 w-4 transition-transform ${shellMenuOpen ? 'rotate-90' : ''}`} />
</button>
</div>
{shellMenuOpen && shells.length > 0 && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-dark-600/80 bg-dark-800 py-1 shadow-xl">
{shells.map((s) => (
<button
key={s.path}
type="button"
onClick={() => {
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
setShellMenuOpen(false)
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-dark-700/70"
>
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
{s.isDefault && (
<span className="text-[10px] text-txt-faint">默认</span>
)}
</button>
))}
</div>
)}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="font-semibold text-white text-base">快速连接</div>
<div className="text-sm text-blue-100/80 mt-0.5">粘贴 user@host:port,一步连上</div>
</section>
<section aria-label="Pro 即将推出" className="mb-10">
<div className="mb-3 flex items-center gap-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">Pro · 即将推出</h2>
</div>
<ChevronRight className="w-5 h-5 text-white/70 group-hover:text-white group-hover:translate-x-0.5 transition-all flex-shrink-0" />
</button>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => {
if (!requirePro('rdp', { comingSoon: true })) return
openUpgradeTab('rdp', true)
}}
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
<Monitor className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-content-secondary">RDP 远程桌面</div>
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
</div>
</button>
<button
type="button"
onClick={() => {
if (!requirePro('vnc', { comingSoon: true })) return
openUpgradeTab('vnc', true)
}}
className="flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/30 px-4 py-3 text-left transition-colors hover:border-dark-600"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-txt-muted">
<Cast className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-content-secondary">VNC 远程桌面</div>
<div className="text-[11px] text-txt-faint">Pro · 即将推出</div>
</div>
</button>
</div>
</section>
{/* 次路径:保存常用会话 */}
<div className="mb-3">
<button
type="button"
onClick={() => onNewSession?.()}
className="
group flex items-center gap-3 p-4 rounded-2xl text-left
bg-dark-800/70 border border-dark-700/50 hover:border-accent-green/40
hover:bg-dark-800 transition-all duration-150
"
>
<div className="w-10 h-10 rounded-xl bg-accent-green/15 border border-accent-green/20 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-green/25">
<Plus className="w-5 h-5 text-accent-green" />
</div>
<div className="min-w-0">
<div className="font-medium text-content-primary text-sm">新建会话</div>
<div className="text-xs text-txt-muted mt-0.5">保存后侧栏双击即开终端</div>
</div>
</button>
</div>
{/* 本地 Shell — 降级为次要入口 */}
<div className="mb-10 opacity-90">
<LocalShellMenu />
</div>
{/* 最近会话 */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-txt-faint" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-txt-faint">最近会话</h3>
</div>
<section aria-label="最近会话" className="min-h-0 flex-1">
<div className="mb-3 flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-txt-faint" />
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
最近会话
</h2>
{recentSessions.length > 0 && (
<span className="text-[11px] text-txt-faint">{recentSessions.length} 个</span>
<span className="text-[11px] text-txt-dim">{recentSessions.length}</span>
)}
</div>
{recentSessions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-dark-600/60 bg-dark-800/30 p-6">
<EmptyState icon={Server} title="暂无会话" description="创建会话或快速连接开始运维" />
<div className="rounded-xl border border-dashed border-dark-700/70 px-4 py-10">
<EmptyState
icon={Server}
title="暂无已保存会话"
description="用快速连接试连,或新建会话以便复用"
/>
</div>
) : (
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/40 overflow-hidden divide-y divide-dark-700/40">
<ul className="divide-y divide-dark-700/40 overflow-hidden rounded-xl border border-dark-700/50">
{recentSessions.map((session) => (
<button
key={session.id}
type="button"
onClick={() => handleSessionClick(session)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-700/40 transition-colors text-left group"
>
<div className="w-8 h-8 rounded-lg bg-dark-700 flex items-center justify-center flex-shrink-0 group-hover:bg-accent-blue/20">
<Server className="w-4 h-4 text-accent-blue" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-content-primary truncate">
{session.name}
</div>
<div className="text-xs text-txt-faint font-mono truncate">
{session.username}@{session.host}:{session.port || 22}
</div>
</div>
{session.group && (
<span className="ui-chip flex-shrink-0">{session.group}</span>
)}
<ChevronRight className="w-4 h-4 text-txt-faint opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
</button>
<li key={session.id}>
<button
type="button"
onClick={() => handleSessionClick(session)}
className="group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-dark-800/80"
>
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-dark-800 text-txt-muted group-hover:text-accent-blue">
<Server className="h-3.5 w-3.5" />
</span>
<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}
</span>
</span>
{session.group ? (
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">
{session.group}
</span>
) : null}
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
</button>
</li>
))}
</div>
</ul>
)}
</div>
</section>
</div>
</div>
)
+10 -17
View File
@@ -178,8 +178,6 @@ export default function SettingsView() {
? [
`OpsXShell v${runtimeInfo.appVersion}`,
`${runtimeInfo.os} ${runtimeInfo.arch}`,
runtimeInfo.nativeBackend,
runtimeInfo.sshBackend,
`Data: ${runtimeInfo.dataPath}`,
].join('\n')
: `OpsXShell v${appVersion}`
@@ -577,7 +575,9 @@ export default function SettingsView() {
</span>
</SettingRow>
<SettingRow label="本地主密钥" description="仅当前系统用户可读取">
<code className="text-xs text-txt-muted">~/.opsxshsell/master.key</code>
<code className="text-xs text-txt-muted break-all">
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}
</code>
</SettingRow>
</SettingGroup>
<KnownHostsSettingsCard />
@@ -600,9 +600,6 @@ export default function SettingsView() {
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue">
应用 v{appVersion}
</span>
<span className="border-dark-600/50 inline-flex items-center rounded-md border bg-dark-700 px-2.5 py-1 font-mono text-xs text-txt-muted">
Tauri 2 · Rust
</span>
<button
type="button"
onClick={() => copyText('version', diagnosticText)}
@@ -624,16 +621,12 @@ export default function SettingsView() {
<SettingCard>
<SettingSectionTitle>运行环境</SettingSectionTitle>
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
{[
['系统', runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'],
['原生运行时', runtimeInfo?.nativeBackend ?? '读取中...'],
['SSH 后端', runtimeInfo?.sshBackend ?? '读取中...'],
].map(([label, value]) => (
<div key={label} className="flex items-center justify-between gap-4 py-2">
<span className="text-txt-muted">{label}</span>
<code className="text-right text-xs text-content-secondary">{value}</code>
</div>
))}
<div className="flex items-center justify-between gap-4 py-2">
<span className="text-txt-muted">系统</span>
<code className="text-right text-xs text-content-secondary">
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'}
</code>
</div>
</div>
</SettingCard>
@@ -656,7 +649,7 @@ export default function SettingsView() {
</div>
</SettingCard>
<p className="px-1 text-[11px] text-txt-faint">MIT License · Tauri 2 · Rust · React · XTerm.js</p>
<p className="px-1 text-[11px] text-txt-faint">MIT License · Rust · XTerm.js</p>
</div>
)
+19 -13
View File
@@ -1,39 +1,45 @@
/**
* Breadcrumb - 路径导航组件
* Breadcrumb - 路径导航组件(本地支持 Windows / UNC,远程保持 POSIX)
*/
import { ChevronRight, Home } from 'lucide-react'
import { splitPathSegments } from '../../utils/localPath'
interface BreadcrumbProps {
path: string
isLocal?: boolean
onNavigate: (p: string) => void
onHome: () => void
}
export function Breadcrumb({ path, onNavigate, onHome }: BreadcrumbProps) {
const parts = path.split('/').filter(Boolean)
export function Breadcrumb({ path, isLocal = false, onNavigate, onHome }: BreadcrumbProps) {
const { root, parts, join } = splitPathSegments(path || (isLocal ? '' : '/'), isLocal)
const rootLabel = isLocal && root !== '/' ? root.replace(/\\$/, '') || root : '/'
return (
<div className="flex items-center gap-1 text-sm min-w-0">
<div className="flex min-w-0 items-center gap-1 text-sm">
<button
type="button"
onClick={onHome}
className="p-1.5 hover:bg-dark-700 rounded transition-colors flex-shrink-0"
className="flex-shrink-0 rounded p-1.5 transition-colors hover:bg-dark-700"
title="主目录"
>
<Home className="w-4 h-4 text-txt-muted" />
<Home className="h-4 w-4 text-txt-muted" />
</button>
<button
onClick={() => onNavigate('/')}
className="px-1.5 py-0.5 hover:bg-dark-700 rounded text-txt-muted hover:text-dark-200 transition-colors"
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"
>
/
{rootLabel}
</button>
{parts.map((part, index) => (
<span key={index} className="flex items-center min-w-0">
<ChevronRight className="w-3 h-3 text-dark-600 mx-0.5 flex-shrink-0" />
<span key={`${part}-${index}`} className="flex min-w-0 items-center">
<ChevronRight className="mx-0.5 h-3 w-3 flex-shrink-0 text-dark-600" />
<button
onClick={() => onNavigate('/' + parts.slice(0, index + 1).join('/'))}
className="px-1.5 py-0.5 hover:bg-dark-700 rounded text-content-primary hover:text-content-primary transition-colors truncate max-w-[120px]"
type="button"
onClick={() => onNavigate(join(parts.slice(0, index + 1)))}
className="max-w-[120px] truncate rounded px-1.5 py-0.5 text-content-primary transition-colors hover:bg-dark-700"
>
{part}
</button>
+15 -26
View File
@@ -1,10 +1,10 @@
/**
* ConnectionPanel - SFTP 连接状态面板
*
* 显示连接状态或连接选择界面
*
* 显示连接状态或连接选择界面(已连接时不占位,避免与双栏重复)
*/
import { Monitor, AlertCircle, Loader2 } from 'lucide-react'
import { AlertCircle, Loader2 } from 'lucide-react'
import { ConnectionSelector } from './ConnectionSelector'
import type { Session } from '../../../shared/types/session.types'
@@ -29,37 +29,30 @@ export function ConnectionPanel({
onShowSelector,
onCloseSelector,
}: ConnectionPanelProps) {
// Connected State
if (isConnected) {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-green-500/10 border-b border-green-500/20">
<Monitor className="w-4 h-4 text-green-400" />
<span className="text-sm text-green-400">已连接</span>
</div>
)
return null
}
// Connecting State
if (isConnecting) {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-accent-blue/10 border-b border-accent-blue/20">
<Loader2 className="w-4 h-4 text-accent-blue animate-spin" />
<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">连接中...</span>
</div>
)
}
// Error State
if (error) {
return (
<div className="flex items-center justify-between px-4 py-2 bg-red-500/10 border-b border-red-500/20">
<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="w-4 h-4 text-red-400" />
<AlertCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">{error}</span>
</div>
<button
type="button"
onClick={onShowSelector}
className="px-3 py-1 bg-dark-700 hover:bg-dark-600 rounded text-sm transition-colors"
className="rounded bg-dark-700 px-3 py-1 text-sm transition-colors hover:bg-dark-600"
>
重新连接
</button>
@@ -67,25 +60,21 @@ export function ConnectionPanel({
)
}
// Disconnected State
return (
<>
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
<div className="flex items-center justify-between border-b border-dark-700/30 bg-dark-800 px-4 py-2">
<span className="text-sm text-txt-muted">未连接</span>
<button
type="button"
onClick={onShowSelector}
className="px-3 py-1.5 bg-accent-blue hover:bg-blue-600 rounded text-sm font-medium transition-colors"
className="rounded bg-accent-blue px-3 py-1.5 text-sm font-medium transition-colors hover:bg-blue-600"
>
选择服务器
</button>
</div>
{showSelector && (
<ConnectionSelector
sessions={sessions}
onSelect={onConnect}
onClose={onCloseSelector}
/>
<ConnectionSelector sessions={sessions} onSelect={onConnect} onClose={onCloseSelector} />
)}
</>
)
+6 -1
View File
@@ -286,7 +286,12 @@ export function FileListPanel({
{/* 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} onNavigate={onNavigate} onHome={() => onNavigate(isLocal ? '/' : '/')} />
<Breadcrumb
path={path}
isLocal={isLocal}
onNavigate={onNavigate}
onHome={() => onNavigate(isLocal ? (history[0] || path) : '/')}
/>
</div>
<div className="relative flex-shrink-0 w-[140px] sm:w-[168px]">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-txt-muted pointer-events-none" />
+3 -4
View File
@@ -25,6 +25,7 @@ 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 type { SftpDragEntry } from './dragDrop'
interface SFTPViewProps {
@@ -278,13 +279,11 @@ export default function SFTPView({ tabId, isActive: _isActive = true, sessionId
// Navigate up
const navigateLocalUp = useCallback(() => {
const parent = localPath.split('/').slice(0, -1).join('/') || '/'
navigateLocal(parent)
navigateLocal(parentPath(localPath, true))
}, [localPath, navigateLocal])
const navigateRemoteUp = useCallback(() => {
const parent = remotePath.split('/').slice(0, -1).join('/') || '/'
navigateRemote(parent)
navigateRemote(parentPath(remotePath, false))
}, [remotePath, navigateRemote])
// Handle file open
+53 -40
View File
@@ -1,7 +1,5 @@
/**
* Toolbar - SFTP 工具栏
*
* 包含上传、下载、刷新、新建文件夹等操作按钮
* Toolbar - SFTP 工具栏(图标优先,文案放 title/aria-label)
*/
import { Upload, Download, RefreshCw, FolderPlus } from 'lucide-react'
@@ -15,44 +13,59 @@ interface ToolbarProps {
onNewFolder: () => void
}
export function Toolbar({ isConnected, canDownload, onUpload, onDownload, onRefresh, onNewFolder }: ToolbarProps) {
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) {
return (
<div className="flex items-center justify-between px-4 py-2 bg-dark-800 border-b border-dark-700/30">
<div className="flex items-center gap-2">
<button
onClick={onUpload}
disabled={!isConnected}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent-blue hover:bg-blue-600 disabled:bg-dark-700 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
>
<Upload className="w-4 h-4" />
上传
</button>
<button
onClick={onDownload}
disabled={!isConnected || !canDownload}
title={canDownload ? '下载选中的远程文件' : '请先选择远程文件'}
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
>
<Download className="w-4 h-4" />
下载
</button>
<button
onClick={onRefresh}
disabled={!isConnected}
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
刷新
</button>
<button
onClick={onNewFolder}
disabled={!isConnected}
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:text-txt-muted rounded text-sm font-medium transition-colors"
>
<FolderPlus className="w-4 h-4" />
新建文件夹
</button>
</div>
<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="上传"
aria-label="上传"
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 ? '下载选中的远程文件' : '请先选择远程文件'}
aria-label="下载"
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="刷新"
aria-label="刷新"
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="新建文件夹"
aria-label="新建文件夹"
className={`${iconBtn} text-content-secondary hover:bg-dark-700 hover:text-content-primary`}
>
<FolderPlus className="h-4 w-4" />
</button>
</div>
)
}
+7 -1
View File
@@ -9,6 +9,8 @@ import { Code2, Plus, Search, Trash2, Copy, Pencil, X } from 'lucide-react'
import { useSnippetStore, type Snippet } from '../../stores/snippetStore'
import { useTabStore } from '../../stores/tabStore'
import { expandSnippetVariables, SNIPPET_VARIABLE_HINTS } from '../../utils/snippetVariables'
import { shellWrite } from '../../utils/shellIO'
import { localShellEnterKey } from '../../utils/platformShells'
import Button from '../common/Button'
import Modal from '../common/Modal'
import EmptyState from '../common/EmptyState'
@@ -149,7 +151,11 @@ export default function SnippetPanel() {
})
try {
await window.opsxApi?.sshShellWrite(shellId, expanded + '\n')
const enter =
activeTab?.isLocal || shellId.startsWith('local-')
? localShellEnterKey(activeTab?.localShellKind)
: '\n'
await shellWrite(shellId, expanded + enter)
} catch (err) {
console.error('Failed to insert snippet:', err)
}
+83 -38
View File
@@ -26,9 +26,11 @@ import { useTabStore } from '../../stores/tabStore'
import { getConnectionStore } from '../../stores/connection'
import {
getTerminalBroadcastTargets,
terminalBroadcastTargetCount,
selectBroadcastTargetCount,
useTerminalBroadcastStore,
type BroadcastScope,
} from '../../stores/terminalBroadcastStore'
import { useEntitlementStore } from '../../stores/entitlementStore'
import { useTerminalSnapshotStore } from '../../stores/terminalSnapshotStore'
import { applyKeywordHighlights } from '../../utils/keywordHighlight'
import { shellPayloadByteLength } from '../../utils/shellData'
@@ -363,15 +365,24 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null)
const broadcastInput = useTerminalBroadcastStore((state) => state.enabled)
const setBroadcastInput = useTerminalBroadcastStore((state) => state.setEnabled)
const broadcastScope = useTerminalBroadcastStore((state) => state.scope)
const setBroadcastScope = useTerminalBroadcastStore((state) => state.setScope)
const registerBroadcastWorkspace = useTerminalBroadcastStore((state) => state.registerWorkspace)
const unregisterBroadcastWorkspace = useTerminalBroadcastStore((state) => state.unregisterWorkspace)
const broadcastTargetCount = useTerminalBroadcastStore(terminalBroadcastTargetCount)
const broadcastTargetCount = useTerminalBroadcastStore(selectBroadcastTargetCount(tabId))
const requirePro = useEntitlementStore((state) => state.requirePro)
const [aiPanelOpen, setAiPanelOpen] = useState(false)
const isLocalTab = !!currentTab?.isLocal
const [contextMenu, setContextMenu] = useState<{
paneId: string
position: { x: number; y: number }
hasSelection: boolean
} | null>(null)
// AI 助手仅适用于 SSH 远程终端;切到本地时强制关闭面板
useEffect(() => {
if (isLocalTab) setAiPanelOpen(false)
}, [isLocalTab])
const draggingRef = useRef(false)
const layoutHydrated = useRef(false)
@@ -432,11 +443,12 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
shellId: pane.shellId,
connectionId,
label: currentTab?.title || 'SSH 终端',
sessionId: currentTab?.sessionId,
}]
: [])
: []
registerBroadcastWorkspace({ tabId, targets })
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
}, [connectionIdRef, connectionState, currentTab?.isLocal, currentTab?.sessionId, currentTab?.title, panes, registerBroadcastWorkspace, tabId])
useEffect(() => () => unregisterBroadcastWorkspace(tabId), [tabId, unregisterBroadcastWorkspace])
@@ -628,7 +640,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
currentTab?.isLocal,
connectionIdRef.current
)
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets() : []
const broadcastTargets = shouldBroadcast ? getTerminalBroadcastTargets(tabId) : []
const targets = shouldBroadcast
? broadcastTargets.map((target) => target.shellId)
: resolveTerminalInputTargets(panesRef.current, sourcePaneId, false)
@@ -647,7 +659,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
}
},
[broadcastInput, connectionIdRef, currentTab?.isLocal]
[broadcastInput, connectionIdRef, currentTab?.isLocal, tabId]
)
const split = useCallback(
@@ -874,37 +886,70 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
</button>
)}
<div className="bg-dark-600/70 mx-1 h-4 w-px" />
<button
type="button"
title={broadcastInput
? `广播已开启:键盘输入将发送到全部 ${broadcastTargetCount} 个已连接 SSH 终端`
: `广播输入到所有打开的 SSH 终端(${broadcastTargetCount})`}
aria-label="广播输入"
aria-pressed={broadcastInput}
disabled={isLocal || connectionState !== 'connected' || broadcastTargetCount < 2}
onClick={() => setBroadcastInput(!broadcastInput)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
broadcastInput
? 'bg-accent-orange/15 text-accent-orange'
: 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'
}`}
>
<Radio className="h-3.5 w-3.5" />
广播
</button>
{!isLocal && (
<>
<button
type="button"
title={broadcastInput
? `广播已开启:输入将发送到范围内 ${broadcastTargetCount} 个 SSH 终端`
: `广播同步输入(Pro)· 当前范围 ${broadcastTargetCount} 个目标`}
aria-label="广播输入"
aria-pressed={broadcastInput}
disabled={connectionState !== 'connected' || (broadcastInput ? false : broadcastTargetCount < 2)}
onClick={() => {
if (broadcastInput) {
setBroadcastInput(false)
return
}
if (!requirePro('broadcast')) return
setBroadcastInput(true)
}}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] transition-colors disabled:opacity-30 ${
broadcastInput
? 'bg-accent-orange/15 text-accent-orange'
: 'text-txt-muted hover:bg-dark-700 hover:text-content-primary'
}`}
>
<Radio className="h-3.5 w-3.5" />
广播
</button>
<select
aria-label="广播目标范围"
value={broadcastScope}
onChange={(event) => {
if (!requirePro('broadcast')) return
setBroadcastScope(event.target.value as BroadcastScope)
}}
className="max-w-[7.5rem] truncate rounded-md border border-dark-600 bg-dark-800 px-1.5 py-1 text-[10px] text-txt-muted outline-none hover:text-content-secondary"
>
<option value="current-tab">当前标签</option>
<option value="selected-sessions">侧栏选中</option>
<option value="all">全部已开 SSH</option>
</select>
</>
)}
<div className="flex-1" />
<button
type="button"
title="终端 AI 助手"
aria-label={aiPanelOpen ? '关闭终端 AI 助手' : '打开终端 AI 助手'}
aria-pressed={aiPanelOpen}
data-testid="terminal-ai-button"
onClick={() => setAiPanelOpen((open) => !open)}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
>
<Sparkles className="h-3.5 w-3.5" /> AI 助手
</button>
{broadcastInput && (
{!isLocal && (
<button
type="button"
title="终端 AI 助手(Pro)"
aria-label={aiPanelOpen ? '关闭终端 AI 助手' : '打开终端 AI 助手'}
aria-pressed={aiPanelOpen}
data-testid="terminal-ai-button"
onClick={() => {
if (aiPanelOpen) {
setAiPanelOpen(false)
return
}
if (!requirePro('ai')) return
setAiPanelOpen(true)
}}
className={`flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-medium transition-colors ${aiPanelOpen ? 'border-accent-blue/40 bg-accent-blue/20 text-accent-blue' : 'border-accent-blue/20 bg-accent-blue/10 hover:border-accent-blue/40 hover:bg-accent-blue/15 text-accent-blue'}`}
>
<Sparkles className="h-3.5 w-3.5" /> AI 助手
</button>
)}
{broadcastInput && !isLocal && (
<div className="ui-chip border-accent-orange/30 text-accent-orange">
广播中 · {broadcastTargetCount}
</div>
@@ -993,13 +1038,13 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
onResume={resumeConnection}
/>
</div>
{aiPanelOpen && (
{!isLocal && aiPanelOpen && (
<AiTerminalPanel
capture={() => paneRuntimesRef.current.get(focusedPaneId)?.capture() || { selection: '', viewport: '' }}
onClose={() => setAiPanelOpen(false)}
connectionId={connectionState === 'connected' && !isLocal ? connectionIdRef.current : null}
connectionId={connectionState === 'connected' ? connectionIdRef.current : null}
targetLabel={currentTab?.title || '当前 SSH 会话'}
isLocal={isLocal}
isLocal={false}
/>
)}
</div>