新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
/**
|
|
* Local Shell picker — choose Zsh / Bash (default marked)
|
|
*/
|
|
|
|
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
|
|
name: string
|
|
kind: string
|
|
isDefault: boolean
|
|
}
|
|
|
|
interface LocalShellMenuProps {
|
|
/** Compact icon-only trigger (sidebar) */
|
|
compact?: boolean
|
|
className?: string
|
|
}
|
|
|
|
export default function LocalShellMenu({ compact = false, className = '' }: LocalShellMenuProps) {
|
|
const { addTab } = useTabStore()
|
|
const [open, setOpen] = useState(false)
|
|
const [shells, setShells] = useState<ShellOption[]>([])
|
|
const rootRef = 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 (!open) return
|
|
const onDoc = (e: MouseEvent) => {
|
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', onDoc)
|
|
return () => document.removeEventListener('mousedown', onDoc)
|
|
}, [open])
|
|
|
|
const openShell = useCallback(
|
|
(opt?: ShellOption) => {
|
|
const chosen =
|
|
opt ||
|
|
shells.find((s) => s.isDefault) ||
|
|
shells[0] || {
|
|
path: '',
|
|
name: 'Local',
|
|
kind: 'unknown',
|
|
isDefault: true,
|
|
}
|
|
const display = shellDisplayFromPath(chosen.path || chosen.name)
|
|
addTab({
|
|
title: display.title,
|
|
type: 'terminal',
|
|
isLocal: true,
|
|
localShellPath: chosen.path || undefined,
|
|
localShellKind: display.kind,
|
|
})
|
|
setOpen(false)
|
|
},
|
|
[addTab, shells]
|
|
)
|
|
|
|
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'
|
|
return 'bg-dark-600 text-content-secondary'
|
|
}
|
|
|
|
return (
|
|
<div ref={rootRef} className={`relative ${className}`}>
|
|
{compact ? (
|
|
<div className="flex">
|
|
<button
|
|
type="button"
|
|
onClick={() => openShell()}
|
|
title="打开默认本地 Shell"
|
|
className="flex items-center justify-center px-2.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-l-lg text-sm font-medium transition-colors text-content-primary border-r-0"
|
|
>
|
|
<Laptop className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
title="选择 Shell"
|
|
className="flex items-center justify-center px-1.5 py-2 bg-dark-700 hover:bg-dark-600 border border-dark-600/60 rounded-r-lg text-content-secondary"
|
|
>
|
|
<ChevronDown className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="w-full flex items-center gap-3 p-4 rounded-2xl border border-dark-700/50 bg-dark-800/70 hover:bg-dark-800 hover:border-accent-blue/40 transition-all text-left"
|
|
>
|
|
<div className="w-11 h-11 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center">
|
|
<Laptop className="w-5 h-5 text-accent-blue" />
|
|
</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">打开本机终端</div>
|
|
</div>
|
|
<ChevronDown className={`w-4 h-4 text-txt-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
)}
|
|
|
|
{open && (
|
|
<div
|
|
className={`
|
|
absolute z-50 mt-1 py-1.5 rounded-xl border border-dark-600/80 bg-dark-800 shadow-xl
|
|
min-w-[200px]
|
|
${compact ? 'right-0' : 'left-0 right-0'}
|
|
`}
|
|
>
|
|
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-txt-faint">
|
|
本地 Shell
|
|
</div>
|
|
{shells.map((s) => (
|
|
<button
|
|
key={s.path}
|
|
type="button"
|
|
onClick={() => openShell(s)}
|
|
className="w-full flex items-center gap-2.5 px-3 py-2 hover:bg-dark-700/70 transition-colors text-left"
|
|
>
|
|
<span
|
|
className={`w-7 h-7 rounded-lg flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${badgeClass(s.kind)}`}
|
|
>
|
|
{s.kind === 'bash' ? '$' : s.kind === 'zsh' ? 'zsh' : s.kind.slice(0, 2)}
|
|
</span>
|
|
<span className="text-sm text-content-primary flex-1">{s.name}</span>
|
|
{s.isDefault && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700 text-txt-muted border border-dark-600">
|
|
默认
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Programmatic open helper for menu / palette */
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export function openLocalShellTab(shell?: { path?: string; kind?: string; name?: string }) {
|
|
const { addTab } = useTabStore.getState()
|
|
const display = shellDisplayFromPath(shell?.path || shell?.name)
|
|
addTab({
|
|
title: display.title,
|
|
type: 'terminal',
|
|
isLocal: true,
|
|
localShellPath: shell?.path,
|
|
localShellKind: shell?.kind || display.kind,
|
|
})
|
|
}
|