feat(vscode-ide-companion): 新增自动完成功能

- 新增 CompletionMenu 组件支持 @ 和 / 触发补全
- 新增 useCompletionTrigger hook 处理补全触发逻辑
- 支持实时查询和过滤补全项

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yiliang114
2025-11-21 01:52:47 +08:00
parent b82ef5b73f
commit 088c766c22
3 changed files with 418 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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<CompletionMenuProps> = ({
items,
position,
onSelect,
onClose,
selectedIndex = 0,
}) => {
const menuRef = useRef<HTMLDivElement>(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 (
<div
ref={menuRef}
className="completion-menu"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
<div className="completion-menu-items">
{items.map((item, index) => (
<div
key={item.id}
data-index={index}
className={`completion-menu-item ${index === selected ? 'selected' : ''}`}
onClick={() => onSelect(item)}
onMouseEnter={() => setSelected(index)}
>
{item.icon && (
<div className="completion-item-icon">{item.icon}</div>
)}
<div className="completion-item-content">
<div className="completion-item-label">{item.label}</div>
{item.description && (
<div className="completion-item-description">
{item.description}
</div>
)}
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -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<HTMLDivElement>,
getCompletionItems: (
trigger: '@' | '/',
query: string,
) => Promise<CompletionItem[]>,
) {
const [state, setState] = useState<CompletionTriggerState>({
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,
};
}