✨ feat(vscode): 用自定义 SelectMenu 替换原生 select 修复暗色样式
This commit is contained in:
@@ -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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user