mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
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:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user