feat(vscode): 用自定义 SelectMenu 替换原生 select 修复暗色样式

This commit is contained in:
2026-08-25 20:12:51 +08:00
parent 4de284578e
commit 8d86d3ef95
4 changed files with 331 additions and 89 deletions
@@ -12,6 +12,8 @@ import type { ManagedSession, ManagedSessionsData } from '../types'
import { Download, Plus, Terminal, Trash2, X } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { SelectMenu } from './ui/select-menu'
interface ManagedSessionsPanelProps {
data: ManagedSessionsData
profiles: ClaudeProfile[]
@@ -143,18 +145,15 @@ export function ManagedSessionsPanel({
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">Profile</span>
<select
<SelectMenu
value={selectedProfile}
onChange={e => setSelectedProfile(e.target.value)}
className="min-w-0 flex-1 rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
>
{profiles.length === 0 && (
<option value=""> profile</option>
)}
{profiles.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
</select>
onChange={setSelectedProfile}
options={profiles.length === 0
? [{ value: '', label: '(无可用 profile' }]
: profiles.map(p => ({ value: p.path, label: p.name }))}
variant="input"
className="min-w-0 flex-1"
/>
</div>
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]"></span>
@@ -385,24 +384,18 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
<span className="truncate">{basename(session.profilePath)}</span>
)
: (
<select
<SelectMenu
value={pickedProfile}
onChange={setUserPicked}
title="换 profile 后点行打开;已打开的终端下次才生效"
onClick={e => e.stopPropagation()}
onMouseDown={e => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation()
setUserPicked(e.target.value)
}}
className="min-w-0 bg-transparent py-0 pr-1 text-[10px] text-[var(--vscode-descriptionForeground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
>
{storedMissing && (
<option value={pickedProfile}>{basename(pickedProfile) || '(未设置)'}</option>
)}
{profiles.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
</select>
variant="ghost"
options={[
...(storedMissing
? [{ value: pickedProfile, label: basename(pickedProfile) || '(未设置)' }]
: []),
...profiles.map(p => ({ value: p.path, label: p.name })),
]}
/>
)}
{created ? <span className="shrink-0">{(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}{created}</span> : null}
</span>
@@ -11,6 +11,7 @@ import type { ReactElement } from 'react'
import type { ClaudeProfile } from '../hooks/useIssues'
import { X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { SelectMenu } from './ui/select-menu'
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
@@ -487,22 +488,20 @@ export function SettingsModal({
</>
)}
>
<select
<SelectMenu
value={conflictResolutionProfilePath}
onChange={e => setConflictResolutionProfilePath(e.target.value)}
onChange={setConflictResolutionProfilePath}
variant="input"
className={inputClass}
>
<option value=""></option>
{profiles.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
{conflictResolutionProfilePath
&& !profiles.some(p => p.path === conflictResolutionProfilePath) && (
<option value={conflictResolutionProfilePath}>
{`自定义:${conflictResolutionProfilePath}`}
</option>
)}
</select>
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(conflictResolutionProfilePath
&& !profiles.some(p => p.path === conflictResolutionProfilePath)
? [{ value: conflictResolutionProfilePath, label: `自定义:${conflictResolutionProfilePath}` }]
: []),
]}
/>
</Field>
<Field
@@ -513,22 +512,20 @@ export function SettingsModal({
</>
)}
>
<select
<SelectMenu
value={commitProfilePath}
onChange={e => setCommitProfilePath(e.target.value)}
onChange={setCommitProfilePath}
variant="input"
className={inputClass}
>
<option value=""></option>
{profiles.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
{commitProfilePath
&& !profiles.some(p => p.path === commitProfilePath) && (
<option value={commitProfilePath}>
{`自定义:${commitProfilePath}`}
</option>
)}
</select>
options={[
{ value: '', label: '(未配置)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(commitProfilePath
&& !profiles.some(p => p.path === commitProfilePath)
? [{ value: commitProfilePath, label: `自定义:${commitProfilePath}` }]
: []),
]}
/>
</Field>
<Field
@@ -728,20 +725,19 @@ export function SettingsModal({
</button>
</div>
{youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && (
<select
<SelectMenu
value={youtrackProjectShortName}
onChange={e => setYoutrackProjectShortName(e.target.value)}
onChange={setYoutrackProjectShortName}
className={`${inputClass} mt-2`}
>
<option value=""> </option>
{youtrackProjects.projects.map(p => (
<option key={p.id} value={p.shortName}>
{p.shortName}
{' · '}
{p.name}
</option>
))}
</select>
variant="input"
options={[
{ value: '', label: '— 选择项目 —' },
...youtrackProjects.projects.map(p => ({
value: p.shortName,
label: `${p.shortName} · ${p.name}`,
})),
]}
/>
)}
{youtrackProjects.status === 'error' && (
<span className="mt-1 block text-[10px] text-state-red">
@@ -891,14 +887,16 @@ export function SettingsModal({
</>
)}
>
<select
<SelectMenu
value={worktreeOpenWith}
onChange={e => setWorktreeOpenWith(e.target.value as 'vscode' | 'pycharm')}
onChange={v => setWorktreeOpenWith(v as 'vscode' | 'pycharm')}
className={inputClass}
>
<option value="vscode">VS Code</option>
<option value="pycharm">PyCharm</option>
</select>
variant="input"
options={[
{ value: 'vscode', label: 'VS Code' },
{ value: 'pycharm', label: 'PyCharm' },
]}
/>
</Field>
{worktreeOpenWith === 'pycharm' && (
@@ -9,7 +9,6 @@
* 背景色,依赖 VS Code 主题 CSS 变量。
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import {
ChevronDown,
@@ -24,6 +23,8 @@ import {
Search,
Settings,
} from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { SelectMenu } from './ui/select-menu'
// --- 类型定义 ---
@@ -193,22 +194,16 @@ function PropertyRow({
)
}
return (
<select
<SelectMenu
value={String(value ?? '')}
onChange={e => onChange(propDef.key, e.target.value)}
className={`${inputClass} appearance-none`}
style={{ backgroundColor: 'var(--vscode-dropdown-background)', color: 'var(--vscode-dropdown-foreground)' }}
>
{(propDef.options ?? []).map(o => (
<option
key={String(o.value)}
value={String(o.value)}
style={{ backgroundColor: 'var(--vscode-dropdown-background)', color: 'var(--vscode-dropdown-foreground)' }}
>
{o.label}
</option>
))}
</select>
onChange={v => onChange(propDef.key, v)}
className={inputClass}
variant="input"
options={(propDef.options ?? []).map(o => ({
value: String(o.value),
label: o.label,
}))}
/>
)
case 'multiline':
@@ -0,0 +1,256 @@
import type { KeyboardEvent, MouseEvent, ReactElement } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
export interface SelectMenuOption {
value: string
label: string
hint?: string
}
export interface SelectMenuProps {
value: string
options: SelectMenuOption[]
onChange: (value: string) => void
placeholder?: string
title?: string
disabled?: boolean
variant?: 'input' | 'ghost'
className?: string
}
const MAX_MENU_HEIGHT = 240
/**
* 自定义下拉组件,替代原生 select。
*
* 原生 select 的展开弹层是 Chromium 系统层,VS Code 暗色主题下样式脱节,
* 这里用手写弹层 + createPortal 渲染到 body,避免被 overflow 容器裁掉。
*/
export function SelectMenu({
value,
options,
onChange,
placeholder = '请选择',
title,
disabled = false,
variant = 'input',
className,
}: SelectMenuProps): ReactElement {
const [open, setOpen] = useState(false)
const [highlight, setHighlight] = useState(-1)
const [pos, setPos] = useState<{ top: number, left: number, width: number } | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null)
const optionRefs = useRef<Array<HTMLButtonElement | null>>([])
const selected = options.find(o => o.value === value) ?? null
const computePos = useCallback((): void => {
const el = triggerRef.current
if (!el)
return
const rect = el.getBoundingClientRect()
const menuHeight = Math.min(options.length * 32, MAX_MENU_HEIGHT)
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
const openUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
setPos({
top: openUp ? Math.max(8, rect.top - menuHeight - 4) : rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 120),
})
}, [options.length])
const openMenu = useCallback((): void => {
computePos()
setOpen(true)
setHighlight(options.findIndex(o => o.value === value))
}, [computePos, options, value])
const closeMenu = useCallback((): void => {
setOpen(false)
setHighlight(-1)
}, [])
const selectOption = useCallback((index: number): void => {
const opt = options[index]
if (opt)
onChange(opt.value)
closeMenu()
}, [options, onChange, closeMenu])
const moveHighlight = useCallback((dir: 1 | -1): void => {
if (options.length === 0)
return
setHighlight((prev) => {
if (prev < 0)
return dir === 1 ? 0 : options.length - 1
return (prev + dir + options.length) % options.length
})
}, [options.length])
// 弹层打开时把高亮项滚进视野(初始定位 + 键盘移动都靠它)。
useLayoutEffect(() => {
if (!open)
return
const el = optionRefs.current[highlight]
el?.scrollIntoView({ block: 'nearest' })
}, [open, highlight])
// 外点击关闭:mousedown 捕获阶段,落在触发器/弹层内不关。
useEffect(() => {
if (!open)
return
function onPointerDown(e: globalThis.MouseEvent): void {
const t = e.target
if (t instanceof Node && !triggerRef.current?.contains(t) && !menuRef.current?.contains(t))
setOpen(false)
}
document.addEventListener('mousedown', onPointerDown, true)
return () => document.removeEventListener('mousedown', onPointerDown, true)
}, [open])
// 窗口 resize / 容器滚动时重算位置,让弹层始终贴住触发器。
useEffect(() => {
if (!open)
return
function onScroll(): void {
computePos()
}
window.addEventListener('resize', onScroll)
document.addEventListener('scroll', onScroll, true)
return () => {
window.removeEventListener('resize', onScroll)
document.removeEventListener('scroll', onScroll, true)
}
}, [open, computePos])
function onTriggerClick(e: MouseEvent<HTMLButtonElement>): void {
e.stopPropagation()
if (disabled)
return
if (open)
closeMenu()
else
openMenu()
}
function onTriggerMouseDown(e: MouseEvent): void {
e.stopPropagation()
}
function onTriggerKeyDown(e: KeyboardEvent<HTMLButtonElement>): void {
if (disabled)
return
const key = e.key
if (!open) {
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ') {
e.preventDefault()
openMenu()
}
return
}
switch (key) {
case 'ArrowDown':
e.preventDefault()
e.stopPropagation()
moveHighlight(1)
break
case 'ArrowUp':
e.preventDefault()
e.stopPropagation()
moveHighlight(-1)
break
case 'Enter':
case ' ':
e.preventDefault()
e.stopPropagation()
if (highlight >= 0)
selectOption(highlight)
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
closeMenu()
break
}
}
const triggerClasses
= variant === 'input'
? 'rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]'
: 'rounded bg-transparent px-1 py-0 text-[10px] text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground)]'
return (
<>
<button
ref={triggerRef}
type="button"
disabled={disabled}
title={title}
aria-haspopup="listbox"
aria-expanded={open}
onClick={onTriggerClick}
onMouseDown={onTriggerMouseDown}
onKeyDown={onTriggerKeyDown}
className={cn(
'flex min-w-0 items-center justify-between gap-1',
triggerClasses,
className,
)}
>
<span className={cn('min-w-0 truncate', selected ? '' : 'opacity-50')}>
{selected?.label ?? placeholder}
</span>
<ChevronDown className={cn('size-3.5 shrink-0', variant === 'ghost' && 'size-3')} />
</button>
{open && pos && createPortal(
<div
ref={menuRef}
role="listbox"
tabIndex={-1}
className="fixed z-50 overflow-auto rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editorWidget-background,var(--vscode-editor-background))] shadow-md"
style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: MAX_MENU_HEIGHT }}
>
{options.length === 0
? (
<div className="px-2 py-3 text-center text-xs text-[var(--vscode-descriptionForeground)]">
</div>
)
: options.map((opt, i) => (
<button
key={opt.value}
ref={(el) => { optionRefs.current[i] = el }}
type="button"
role="option"
aria-selected={opt.value === value}
onClick={(e) => {
e.stopPropagation()
selectOption(i)
}}
className={cn(
'flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs',
i === highlight
? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]'
: 'text-[var(--vscode-foreground)] hover:bg-[var(--vscode-list-hoverBackground)]',
)}
>
<span className={cn('grid size-3.5 shrink-0 place-items-center', opt.value === value ? 'opacity-100' : 'opacity-0')}>
<Check className="size-3.5" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate">{opt.label}</span>
{opt.hint && <span className="truncate text-[10px] opacity-70">{opt.hint}</span>}
</span>
</button>
))}
</div>,
document.body,
)}
</>
)
}