From 088c766c221aaa515efa405a7a827388386b376d Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 21 Nov 2025 01:52:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(vscode-ide-companion):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=87=AA=E5=8A=A8=E5=AE=8C=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CompletionMenu 组件支持 @ 和 / 触发补全 - 新增 useCompletionTrigger hook 处理补全触发逻辑 - 支持实时查询和过滤补全项 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/webview/components/CompletionMenu.css | 107 +++++++++++ .../src/webview/components/CompletionMenu.tsx | 135 ++++++++++++++ .../src/webview/hooks/useCompletionTrigger.ts | 176 ++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 packages/vscode-ide-companion/src/webview/components/CompletionMenu.css create mode 100644 packages/vscode-ide-companion/src/webview/components/CompletionMenu.tsx create mode 100644 packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts diff --git a/packages/vscode-ide-companion/src/webview/components/CompletionMenu.css b/packages/vscode-ide-companion/src/webview/components/CompletionMenu.css new file mode 100644 index 00000000..e51dc441 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/CompletionMenu.css @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +.completion-menu { + position: fixed; + background: var(--vscode-quickInput-background, #252526); + border: 1px solid var(--vscode-quickInput-border, #454545); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + min-width: 300px; + max-width: 500px; + max-height: 400px; + overflow: hidden; + display: flex; + flex-direction: column; + transform: translateY(-100%); + margin-top: -8px; +} + +.completion-menu-items { + overflow-y: auto; + overflow-x: hidden; +} + +.completion-menu-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.1s ease; + border-bottom: 1px solid var(--vscode-quickInput-separator, transparent); +} + +.completion-menu-item:last-child { + border-bottom: none; +} + +.completion-menu-item:hover, +.completion-menu-item.selected { + background-color: var(--vscode-list-hoverBackground, #2a2d2e); +} + +.completion-item-icon { + flex-shrink: 0; + margin-right: 10px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: var(--vscode-symbolIcon-fileForeground, #cccccc); +} + +.completion-item-icon svg { + width: 16px; + height: 16px; +} + +.completion-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: row; + align-items: baseline; + gap: 8px; +} + +.completion-item-label { + font-size: 13px; + color: var(--vscode-quickInput-foreground, #cccccc); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; +} + +.completion-item-description { + font-size: 11px; + color: var(--vscode-descriptionForeground, #8c8c8c); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +/* Scrollbar styling */ +.completion-menu-items::-webkit-scrollbar { + width: 10px; +} + +.completion-menu-items::-webkit-scrollbar-track { + background: transparent; +} + +.completion-menu-items::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background, #424242); + border-radius: 5px; +} + +.completion-menu-items::-webkit-scrollbar-thumb:hover { + background: var(--vscode-scrollbarSlider-hoverBackground, #4f4f4f); +} diff --git a/packages/vscode-ide-companion/src/webview/components/CompletionMenu.tsx b/packages/vscode-ide-companion/src/webview/components/CompletionMenu.tsx new file mode 100644 index 00000000..d509ad80 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/CompletionMenu.tsx @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import './CompletionMenu.css'; + +export interface CompletionItem { + id: string; + label: string; + description?: string; + icon?: React.ReactNode; + type: 'file' | 'symbol' | 'command' | 'variable'; + value?: unknown; +} + +interface CompletionMenuProps { + items: CompletionItem[]; + position: { top: number; left: number }; + onSelect: (item: CompletionItem) => void; + onClose: () => void; + selectedIndex?: number; +} + +/** + * Completion menu for @ and / triggers + * Based on vscode-copilot-chat's AttachContextAction + */ +export const CompletionMenu: React.FC = ({ + items, + position, + onSelect, + onClose, + selectedIndex = 0, +}) => { + const menuRef = useRef(null); + const [selected, setSelected] = useState(selectedIndex); + + useEffect(() => { + setSelected(selectedIndex); + }, [selectedIndex]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setSelected((prev) => Math.min(prev + 1, items.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setSelected((prev) => Math.max(prev - 1, 0)); + break; + case 'Enter': + event.preventDefault(); + if (items[selected]) { + onSelect(items[selected]); + } + break; + case 'Escape': + event.preventDefault(); + onClose(); + break; + default: + break; + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [items, selected, onSelect, onClose]); + + // Scroll selected item into view + useEffect(() => { + const selectedElement = menuRef.current?.querySelector( + `[data-index="${selected}"]`, + ); + if (selectedElement) { + selectedElement.scrollIntoView({ block: 'nearest' }); + } + }, [selected]); + + if (items.length === 0) { + return null; + } + + return ( +
+
+ {items.map((item, index) => ( +
onSelect(item)} + onMouseEnter={() => setSelected(index)} + > + {item.icon && ( +
{item.icon}
+ )} +
+
{item.label}
+ {item.description && ( +
+ {item.description} +
+ )} +
+
+ ))} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts new file mode 100644 index 00000000..0b9cabb0 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { RefObject } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import type { CompletionItem } from '../components/CompletionMenu.js'; + +interface CompletionTriggerState { + isOpen: boolean; + triggerChar: '@' | '/' | null; + query: string; + position: { top: number; left: number }; + items: CompletionItem[]; +} + +/** + * Hook to handle @ and / completion triggers in contentEditable + * Based on vscode-copilot-chat's AttachContextAction + */ +export function useCompletionTrigger( + inputRef: RefObject, + getCompletionItems: ( + trigger: '@' | '/', + query: string, + ) => Promise, +) { + const [state, setState] = useState({ + isOpen: false, + triggerChar: null, + query: '', + position: { top: 0, left: 0 }, + items: [], + }); + + const closeCompletion = useCallback(() => { + setState({ + isOpen: false, + triggerChar: null, + query: '', + position: { top: 0, left: 0 }, + items: [], + }); + }, []); + + const openCompletion = useCallback( + async ( + trigger: '@' | '/', + query: string, + position: { top: number; left: number }, + ) => { + const items = await getCompletionItems(trigger, query); + setState({ + isOpen: true, + triggerChar: trigger, + query, + position, + items, + }); + }, + [getCompletionItems], + ); + + useEffect(() => { + const inputElement = inputRef.current; + if (!inputElement) { + return; + } + + const getCursorPosition = (): { top: number; left: number } | null => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + + try { + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // If the range has a valid position, use it + if (rect.top > 0 && rect.left > 0) { + return { + top: rect.top, + left: rect.left, + }; + } + + // Fallback: use input element's position + const inputRect = inputElement.getBoundingClientRect(); + return { + top: inputRect.top, + left: inputRect.left, + }; + } catch (error) { + console.error( + '[useCompletionTrigger] Error getting cursor position:', + error, + ); + const inputRect = inputElement.getBoundingClientRect(); + return { + top: inputRect.top, + left: inputRect.left, + }; + } + }; + + const handleInput = async () => { + const text = inputElement.textContent || ''; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const range = selection.getRangeAt(0); + const cursorPosition = range.startOffset; + + // Find trigger character before cursor + const textBeforeCursor = text.substring(0, cursorPosition); + const lastAtMatch = textBeforeCursor.lastIndexOf('@'); + const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); + + // Check if we're in a trigger context + let triggerPos = -1; + let triggerChar: '@' | '/' | null = null; + + if (lastAtMatch > lastSlashMatch) { + triggerPos = lastAtMatch; + triggerChar = '@'; + } else if (lastSlashMatch > lastAtMatch) { + triggerPos = lastSlashMatch; + triggerChar = '/'; + } + + // Check if trigger is at word boundary (start of line or after space) + if (triggerPos >= 0 && triggerChar) { + const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; + const isValidTrigger = + charBefore === ' ' || charBefore === '\n' || triggerPos === 0; + + if (isValidTrigger) { + const query = text.substring(triggerPos + 1, cursorPosition); + + // Only show if query doesn't contain spaces (still typing the reference) + if (!query.includes(' ') && !query.includes('\n')) { + // Get precise cursor position for menu + const cursorPos = getCursorPosition(); + if (cursorPos) { + await openCompletion(triggerChar, query, cursorPos); + return; + } + } + } + } + + // Close if no valid trigger + if (state.isOpen) { + closeCompletion(); + } + }; + + inputElement.addEventListener('input', handleInput); + return () => inputElement.removeEventListener('input', handleInput); + }, [inputRef, state.isOpen, openCompletion, closeCompletion]); + + return { + isOpen: state.isOpen, + triggerChar: state.triggerChar, + query: state.query, + position: state.position, + items: state.items, + closeCompletion, + openCompletion, + }; +}