From 8d86d3ef95a58f9f2247a866b520c96d0f101290 Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 25 Aug 2026 20:12:51 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E7=94=A8=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20SelectMenu=20=E6=9B=BF=E6=8D=A2=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20select=20=E4=BF=AE=E5=A4=8D=E6=9A=97=E8=89=B2?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ManagedSessionsPanel.tsx | 47 ++-- .../src/components/SettingsModal.tsx | 90 +++--- .../src/components/property-grid.tsx | 27 +- .../src/components/ui/select-menu.tsx | 256 ++++++++++++++++++ 4 files changed, 331 insertions(+), 89 deletions(-) create mode 100644 vscode/webview-ui/src/components/ui/select-menu.tsx diff --git a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx index aead87c..a246a7d 100644 --- a/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx +++ b/vscode/webview-ui/src/components/ManagedSessionsPanel.tsx @@ -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({
Profile - + 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" + />
名字 @@ -385,24 +384,18 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa {basename(session.profilePath)} ) : ( - + variant="ghost" + options={[ + ...(storedMissing + ? [{ value: pickedProfile, label: basename(pickedProfile) || '(未设置)' }] + : []), + ...profiles.map(p => ({ value: p.path, label: p.name })), + ]} + /> )} {created ? {(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}{created} : null} diff --git a/vscode/webview-ui/src/components/SettingsModal.tsx b/vscode/webview-ui/src/components/SettingsModal.tsx index 728394b..fe22601 100644 --- a/vscode/webview-ui/src/components/SettingsModal.tsx +++ b/vscode/webview-ui/src/components/SettingsModal.tsx @@ -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({ )} > - + options={[ + { value: '', label: '(默认)' }, + ...profiles.map(p => ({ value: p.path, label: p.name })), + ...(conflictResolutionProfilePath + && !profiles.some(p => p.path === conflictResolutionProfilePath) + ? [{ value: conflictResolutionProfilePath, label: `自定义:${conflictResolutionProfilePath}` }] + : []), + ]} + /> )} > - + options={[ + { value: '', label: '(未配置)' }, + ...profiles.map(p => ({ value: p.path, label: p.name })), + ...(commitProfilePath + && !profiles.some(p => p.path === commitProfilePath) + ? [{ value: commitProfilePath, label: `自定义:${commitProfilePath}` }] + : []), + ]} + />
{youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && ( - + variant="input" + options={[ + { value: '', label: '— 选择项目 —' }, + ...youtrackProjects.projects.map(p => ({ + value: p.shortName, + label: `${p.shortName} · ${p.name}`, + })), + ]} + /> )} {youtrackProjects.status === 'error' && ( @@ -891,14 +887,16 @@ export function SettingsModal({ )} > - + variant="input" + options={[ + { value: 'vscode', label: 'VS Code' }, + { value: 'pycharm', label: 'PyCharm' }, + ]} + /> {worktreeOpenWith === 'pycharm' && ( diff --git a/vscode/webview-ui/src/components/property-grid.tsx b/vscode/webview-ui/src/components/property-grid.tsx index 2c2679e..3245996 100644 --- a/vscode/webview-ui/src/components/property-grid.tsx +++ b/vscode/webview-ui/src/components/property-grid.tsx @@ -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 ( - + onChange={v => onChange(propDef.key, v)} + className={inputClass} + variant="input" + options={(propDef.options ?? []).map(o => ({ + value: String(o.value), + label: o.label, + }))} + /> ) case 'multiline': diff --git a/vscode/webview-ui/src/components/ui/select-menu.tsx b/vscode/webview-ui/src/components/ui/select-menu.tsx new file mode 100644 index 0000000..ccb022c --- /dev/null +++ b/vscode/webview-ui/src/components/ui/select-menu.tsx @@ -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(null) + const menuRef = useRef(null) + const optionRefs = useRef>([]) + + 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): void { + e.stopPropagation() + if (disabled) + return + if (open) + closeMenu() + else + openMenu() + } + + function onTriggerMouseDown(e: MouseEvent): void { + e.stopPropagation() + } + + function onTriggerKeyDown(e: KeyboardEvent): 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 ( + <> + + {open && pos && createPortal( +
+ {options.length === 0 + ? ( +
+ 无选项 +
+ ) + : options.map((opt, i) => ( + + ))} +
, + document.body, + )} + + ) +}