Files
opsxshell/src/components/layout/sidebar/LocalShellMenu.tsx
T
Logan.LiandCursor fea6d8db04 feat: Pro 买断门禁、SFTP 精简与批量多机改造
新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:38:30 +08:00

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,
})
}