refactor(vscode-ide-companion): 重构 WebViewProvider 组件

This commit is contained in:
yiliang114
2025-11-20 11:37:28 +08:00
parent dcc10eb0a9
commit 06a8580361
8 changed files with 1209 additions and 883 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* 从完整路径中提取文件名
* @param fsPath 文件的完整路径
* @returns 文件名(不含路径)
*/
export function getFileName(fsPath: string): string {
// 使用 path.basename 的逻辑:找到最后一个路径分隔符后的部分
const lastSlash = Math.max(fsPath.lastIndexOf('/'), fsPath.lastIndexOf('\\'));
return lastSlash >= 0 ? fsPath.substring(lastSlash + 1) : fsPath;
}
/**
* HTML 转义函数,防止 XSS 攻击
* 将特殊字符转换为 HTML 实体
* @param text 需要转义的文本
* @returns 转义后的文本
*/
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { CliDetector } from '../utils/cliDetector.js';
/**
* CLI 检测和安装处理器
* 负责 Qwen CLI 的检测、安装和提示功能
*/
export class CliInstaller {
/**
* 检查 CLI 安装状态并发送结果到 WebView
* @param sendToWebView 发送消息到 WebView 的回调函数
*/
static async checkInstallation(
sendToWebView: (message: unknown) => void,
): Promise<void> {
try {
const result = await CliDetector.detectQwenCli();
sendToWebView({
type: 'cliDetectionResult',
data: {
isInstalled: result.isInstalled,
cliPath: result.cliPath,
version: result.version,
error: result.error,
installInstructions: result.isInstalled
? undefined
: CliDetector.getInstallationInstructions(),
},
});
if (!result.isInstalled) {
console.log('[CliInstaller] Qwen CLI not detected:', result.error);
} else {
console.log(
'[CliInstaller] Qwen CLI detected:',
result.cliPath,
result.version,
);
}
} catch (error) {
console.error('[CliInstaller] CLI detection error:', error);
}
}
/**
* 提示用户安装 CLI
* 显示警告消息,提供安装选项
*/
static async promptInstallation(): Promise<void> {
const selection = await vscode.window.showWarningMessage(
'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.',
'Install Now',
'View Documentation',
'Remind Me Later',
);
if (selection === 'Install Now') {
await this.install();
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
);
}
}
/**
* 安装 Qwen CLI
* 通过 npm 安装全局 CLI 包
*/
static async install(): Promise<void> {
try {
// Show progress notification
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Installing Qwen Code CLI',
cancellable: false,
},
async (progress) => {
progress.report({
message: 'Running: npm install -g @qwen-code/qwen-code@latest',
});
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
try {
const { stdout, stderr } = await execAsync(
'npm install -g @qwen-code/qwen-code@latest',
{ timeout: 120000 }, // 2 minutes timeout
);
console.log('[CliInstaller] Installation output:', stdout);
if (stderr) {
console.warn('[CliInstaller] Installation stderr:', stderr);
}
// Clear cache and recheck
CliDetector.clearCache();
const detection = await CliDetector.detectQwenCli();
if (detection.isInstalled) {
vscode.window
.showInformationMessage(
`✅ Qwen Code CLI installed successfully! Version: ${detection.version}`,
'Reload Window',
)
.then((selection) => {
if (selection === 'Reload Window') {
vscode.commands.executeCommand(
'workbench.action.reloadWindow',
);
}
});
} else {
throw new Error(
'Installation completed but CLI still not detected',
);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error('[CliInstaller] Installation failed:', errorMessage);
vscode.window
.showErrorMessage(
`Failed to install Qwen Code CLI: ${errorMessage}`,
'Try Manual Installation',
'View Documentation',
)
.then((selection) => {
if (selection === 'Try Manual Installation') {
const terminal = vscode.window.createTerminal(
'Qwen Code Installation',
);
terminal.show();
terminal.sendText(
'npm install -g @qwen-code/qwen-code@latest',
);
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse(
'https://github.com/QwenLM/qwen-code#installation',
),
);
}
});
}
},
);
} catch (error) {
console.error('[CliInstaller] Install CLI error:', error);
}
}
}

View File

@@ -0,0 +1,153 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { getFileName } from '../utils/webviewUtils.js';
/**
* 文件操作处理器
* 负责处理文件打开和 diff 查看功能
*/
export class FileOperations {
/**
* 打开文件并可选跳转到指定行
* @param filePath 文件路径可以包含行号格式path/to/file.ts:123
*/
static async openFile(filePath?: string): Promise<void> {
try {
if (!filePath) {
console.warn('[FileOperations] No file path provided');
return;
}
console.log('[FileOperations] Opening file:', filePath);
// Parse file path and line number (format: path/to/file.ts:123)
const match = filePath.match(/^(.+?)(?::(\d+))?$/);
if (!match) {
console.warn('[FileOperations] Invalid file path format:', filePath);
return;
}
const [, path, lineStr] = match;
const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers
// Convert to absolute path if relative
let absolutePath = path;
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
// Relative path - resolve against workspace
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (workspaceFolder) {
absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath;
}
}
// Open the document
const uri = vscode.Uri.file(absolutePath);
const document = await vscode.workspace.openTextDocument(uri);
const editor = await vscode.window.showTextDocument(document, {
preview: false,
preserveFocus: false,
});
// Navigate to line if specified
if (lineStr) {
const position = new vscode.Position(lineNumber, 0);
editor.selection = new vscode.Selection(position, position);
editor.revealRange(
new vscode.Range(position, position),
vscode.TextEditorRevealType.InCenter,
);
}
console.log('[FileOperations] File opened successfully:', absolutePath);
} catch (error) {
console.error('[FileOperations] Failed to open file:', error);
vscode.window.showErrorMessage(`Failed to open file: ${error}`);
}
}
/**
* 打开 diff 视图比较文件变更
* @param data Diff 数据,包含文件路径、旧内容和新内容
*/
static async openDiff(data?: {
path?: string;
oldText?: string;
newText?: string;
}): Promise<void> {
try {
if (!data || !data.path) {
console.warn('[FileOperations] No file path provided for diff');
return;
}
const { path, oldText = '', newText = '' } = data;
console.log('[FileOperations] Opening diff for:', path);
// Convert to absolute path if relative
let absolutePath = path;
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (workspaceFolder) {
absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath;
}
}
// Get the file name for display
const fileName = getFileName(absolutePath);
// Create URIs for old and new content
// Use untitled scheme for old content (before changes)
const oldUri = vscode.Uri.parse(`untitled:${absolutePath}.old`).with({
scheme: 'untitled',
});
// Use the actual file URI for new content
const newUri = vscode.Uri.file(absolutePath);
// Create a TextDocument for the old content using an in-memory document
const _oldDocument = await vscode.workspace.openTextDocument(
oldUri.with({ scheme: 'untitled' }),
);
// Write old content to the document
const edit = new vscode.WorkspaceEdit();
edit.insert(
oldUri.with({ scheme: 'untitled' }),
new vscode.Position(0, 0),
oldText,
);
await vscode.workspace.applyEdit(edit);
// Check if new file exists, if not create it with new content
try {
await vscode.workspace.fs.stat(newUri);
} catch {
// File doesn't exist, create it
const encoder = new TextEncoder();
await vscode.workspace.fs.writeFile(newUri, encoder.encode(newText));
}
// Open diff view
await vscode.commands.executeCommand(
'vscode.diff',
oldUri.with({ scheme: 'untitled' }),
newUri,
`${fileName} (Before ↔ After)`,
{
preview: false,
preserveFocus: false,
},
);
console.log('[FileOperations] Diff opened successfully');
} catch (error) {
console.error('[FileOperations] Failed to open diff:', error);
vscode.window.showErrorMessage(`Failed to open diff: ${error}`);
}
}
}

View File

@@ -0,0 +1,507 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import type { QwenAgentManager } from '../agents/qwenAgentManager.js';
import { type ChatMessage } from '../agents/qwenAgentManager.js';
import type { ConversationStore } from '../storage/conversationStore.js';
import { FileOperations } from './FileOperations.js';
import { CliInstaller } from './CliInstaller.js';
import { CliDetector } from '../utils/cliDetector.js';
import { getFileName } from '../utils/webviewUtils.js';
/**
* WebView 消息处理器
* 负责处理所有来自 WebView 的消息请求
*/
export class MessageHandler {
// 跟踪当前流式内容,用于保存
private currentStreamContent = '';
// 权限请求处理器(临时存储)
private permissionHandler?: (msg: {
type: string;
data: { optionId: string };
}) => void;
constructor(
private agentManager: QwenAgentManager,
private conversationStore: ConversationStore,
private currentConversationId: string | null,
private sendToWebView: (message: unknown) => void,
) {}
/**
* 获取当前对话 ID
*/
getCurrentConversationId(): string | null {
return this.currentConversationId;
}
/**
* 设置当前对话 ID
*/
setCurrentConversationId(id: string | null): void {
this.currentConversationId = id;
}
/**
* 获取当前流式内容
*/
getCurrentStreamContent(): string {
return this.currentStreamContent;
}
/**
* 追加流式内容
*/
appendStreamContent(chunk: string): void {
this.currentStreamContent += chunk;
}
/**
* 重置流式内容
*/
resetStreamContent(): void {
this.currentStreamContent = '';
}
/**
* 设置权限处理器
*/
setPermissionHandler(
handler: (msg: { type: string; data: { optionId: string } }) => void,
): void {
this.permissionHandler = handler;
}
/**
* 清除权限处理器
*/
clearPermissionHandler(): void {
this.permissionHandler = undefined;
}
/**
* 路由 WebView 消息到对应的处理函数
*/
async route(message: { type: string; data?: unknown }): Promise<void> {
console.log('[MessageHandler] Received message from webview:', message);
// Type guard for safe access to data properties
const data = message.data as Record<string, unknown> | undefined;
switch (message.type) {
case 'sendMessage':
await this.handleSendMessage((data?.text as string) || '');
break;
case 'permissionResponse':
// Forward to permission handler
if (this.permissionHandler) {
this.permissionHandler(
message as { type: string; data: { optionId: string } },
);
this.clearPermissionHandler();
}
break;
case 'loadConversation':
await this.handleLoadConversation((data?.id as string) || '');
break;
case 'newConversation':
await this.handleNewConversation();
break;
case 'newQwenSession':
await this.handleNewQwenSession();
break;
case 'deleteConversation':
await this.handleDeleteConversation((data?.id as string) || '');
break;
case 'getQwenSessions':
await this.handleGetQwenSessions();
break;
case 'getActiveEditor': {
// 发送当前激活编辑器的文件名给 WebView
const editor = vscode.window.activeTextEditor;
const fileName = editor?.document.uri.fsPath
? getFileName(editor.document.uri.fsPath)
: null;
this.sendToWebView({
type: 'activeEditorChanged',
data: { fileName },
});
break;
}
case 'switchQwenSession':
await this.handleSwitchQwenSession((data?.sessionId as string) || '');
break;
case 'recheckCli':
// Clear cache and recheck CLI installation
CliDetector.clearCache();
await CliInstaller.checkInstallation(this.sendToWebView);
break;
case 'cancelPrompt':
await this.handleCancelPrompt();
break;
case 'openFile':
await FileOperations.openFile(data?.path as string | undefined);
break;
case 'openDiff':
await FileOperations.openDiff(
data as {
path?: string;
oldText?: string;
newText?: string;
},
);
break;
default:
console.warn('[MessageHandler] Unknown message type:', message.type);
break;
}
}
/**
* 处理发送消息请求
*/
private async handleSendMessage(text: string): Promise<void> {
console.log('[MessageHandler] handleSendMessage called with:', text);
// Ensure we have an active conversation - create one if needed
if (!this.currentConversationId) {
console.log('[MessageHandler] No active conversation, creating one...');
try {
const newConv = await this.conversationStore.createConversation();
this.currentConversationId = newConv.id;
this.sendToWebView({
type: 'conversationLoaded',
data: newConv,
});
console.log(
'[MessageHandler] Created conversation:',
this.currentConversationId,
);
} catch (error) {
const errorMsg = `Failed to create conversation: ${error}`;
console.error('[MessageHandler]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
return;
}
}
// Double check after creation attempt
if (!this.currentConversationId) {
const errorMsg =
'Failed to create conversation. Please restart the extension.';
console.error('[MessageHandler]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendToWebView({
type: 'error',
data: { message: errorMsg },
});
return;
}
// Save user message
const userMessage: ChatMessage = {
role: 'user',
content: text,
timestamp: Date.now(),
};
await this.conversationStore.addMessage(
this.currentConversationId,
userMessage,
);
console.log('[MessageHandler] User message saved to store');
// Send to WebView
this.sendToWebView({
type: 'message',
data: userMessage,
});
console.log('[MessageHandler] User message sent to webview');
// Check if agent is connected
if (!this.agentManager.isConnected) {
console.warn(
'[MessageHandler] Agent is not connected, skipping AI response',
);
this.sendToWebView({
type: 'error',
data: {
message:
'Agent is not connected. Enable Qwen in settings or configure API key.',
},
});
return;
}
// Send to agent
try {
// Reset stream content
this.resetStreamContent();
// Create placeholder for assistant message
this.sendToWebView({
type: 'streamStart',
data: { timestamp: Date.now() },
});
console.log('[MessageHandler] Stream start sent');
console.log('[MessageHandler] Sending to agent manager...');
await this.agentManager.sendMessage(text);
console.log('[MessageHandler] Agent manager send complete');
// Stream is complete - save assistant message
if (this.currentStreamContent && this.currentConversationId) {
const assistantMessage: ChatMessage = {
role: 'assistant',
content: this.currentStreamContent,
timestamp: Date.now(),
};
await this.conversationStore.addMessage(
this.currentConversationId,
assistantMessage,
);
console.log('[MessageHandler] Assistant message saved to store');
}
this.sendToWebView({
type: 'streamEnd',
data: { timestamp: Date.now() },
});
console.log('[MessageHandler] Stream end sent');
} catch (error) {
console.error('[MessageHandler] Error sending message:', error);
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendToWebView({
type: 'error',
data: { message: String(error) },
});
}
}
/**
* 处理加载对话请求
*/
private async handleLoadConversation(id: string): Promise<void> {
const conversation = await this.conversationStore.getConversation(id);
if (conversation) {
this.currentConversationId = id;
this.sendToWebView({
type: 'conversationLoaded',
data: conversation,
});
}
}
/**
* 处理新建对话请求
*/
private async handleNewConversation(): Promise<void> {
const newConv = await this.conversationStore.createConversation();
this.currentConversationId = newConv.id;
this.sendToWebView({
type: 'conversationLoaded',
data: newConv,
});
}
/**
* 处理删除对话请求
*/
private async handleDeleteConversation(id: string): Promise<void> {
await this.conversationStore.deleteConversation(id);
this.sendToWebView({
type: 'conversationDeleted',
data: { id },
});
}
/**
* 处理获取 Qwen 会话列表请求
*/
private async handleGetQwenSessions(): Promise<void> {
try {
console.log('[MessageHandler] Getting Qwen sessions...');
const sessions = await this.agentManager.getSessionList();
console.log('[MessageHandler] Retrieved sessions:', sessions.length);
this.sendToWebView({
type: 'qwenSessionList',
data: { sessions },
});
} catch (error) {
console.error('[MessageHandler] Failed to get Qwen sessions:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to get sessions: ${error}` },
});
}
}
/**
* 处理新建 Qwen 会话请求
*/
private async handleNewQwenSession(): Promise<void> {
try {
console.log('[MessageHandler] Creating new Qwen session...');
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
await this.agentManager.createNewSession(workingDir);
// Clear current conversation UI
this.sendToWebView({
type: 'conversationCleared',
data: {},
});
} catch (error) {
console.error('[MessageHandler] Failed to create new session:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to create new session: ${error}` },
});
}
}
/**
* 处理切换 Qwen 会话请求
*/
private async handleSwitchQwenSession(sessionId: string): Promise<void> {
try {
console.log('[MessageHandler] Switching to Qwen session:', sessionId);
// Get session messages from local files
const messages = await this.agentManager.getSessionMessages(sessionId);
console.log(
'[MessageHandler] Loaded messages from session:',
messages.length,
);
// Get session details for the header
let sessionDetails = null;
try {
const allSessions = await this.agentManager.getSessionList();
sessionDetails = allSessions.find(
(s: { id?: string; sessionId?: string }) =>
s.id === sessionId || s.sessionId === sessionId,
);
} catch (err) {
console.log('[MessageHandler] Could not get session details:', err);
}
// Try to load session via ACP first, fallback to creating new session
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
try {
console.log('[MessageHandler] Testing session/load via ACP...');
const loadResponse =
await this.agentManager.loadSessionViaAcp(sessionId);
console.log('[MessageHandler] session/load succeeded:', loadResponse);
// If load succeeded, use the loaded session
this.currentConversationId = sessionId;
console.log(
'[MessageHandler] Set currentConversationId to loaded session:',
sessionId,
);
} catch (_loadError) {
console.log(
'[MessageHandler] session/load not supported, creating new session',
);
// Fallback: CLI doesn't support loading old sessions
// So we create a NEW ACP session for continuation
try {
const newAcpSessionId =
await this.agentManager.createNewSession(workingDir);
console.log(
'[MessageHandler] Created new ACP session for conversation:',
newAcpSessionId,
);
// Use the NEW ACP session ID for sending messages to CLI
this.currentConversationId = newAcpSessionId;
console.log(
'[MessageHandler] Set currentConversationId (ACP) to:',
newAcpSessionId,
);
} catch (createError) {
console.error(
'[MessageHandler] Failed to create new ACP session:',
createError,
);
vscode.window.showWarningMessage(
'Could not switch to session. Created new session instead.',
);
throw createError;
}
}
// Send messages and session details to WebView
// The historical messages are display-only, not sent to CLI
this.sendToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages, session: sessionDetails },
});
} catch (error) {
console.error('[MessageHandler] Failed to switch session:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to switch session: ${error}` },
});
vscode.window.showErrorMessage(`Failed to switch session: ${error}`);
}
}
/**
* 处理取消提示请求
* 取消当前 AI 响应生成
*/
private async handleCancelPrompt(): Promise<void> {
try {
console.log('[MessageHandler] Cancel prompt requested');
if (!this.agentManager.isConnected) {
console.warn('[MessageHandler] Agent not connected, cannot cancel');
return;
}
await this.agentManager.cancelCurrentPrompt();
this.sendToWebView({
type: 'promptCancelled',
data: { timestamp: Date.now() },
});
console.log('[MessageHandler] Prompt cancelled successfully');
} catch (error) {
console.error('[MessageHandler] Failed to cancel prompt:', error);
this.sendToWebView({
type: 'error',
data: { message: `Failed to cancel: ${error}` },
});
}
}
}

View File

@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
/**
* Panel 和 Tab 管理器
* 负责管理 WebView Panel 的创建、显示和 Tab 跟踪
*/
export class PanelManager {
private panel: vscode.WebviewPanel | null = null;
private panelTab: vscode.Tab | null = null;
constructor(
private extensionUri: vscode.Uri,
private onPanelDispose: () => void,
) {}
/**
* 获取当前的 Panel
*/
getPanel(): vscode.WebviewPanel | null {
return this.panel;
}
/**
* 设置 Panel用于恢复
*/
setPanel(panel: vscode.WebviewPanel): void {
this.panel = panel;
}
/**
* 创建新的 WebView Panel
* @returns 是否是新创建的 Panel
*/
createPanel(): boolean {
if (this.panel) {
return false; // Panel already exists
}
this.panel = vscode.window.createWebviewPanel(
'qwenCode.chat',
'Qwen Code Chat',
{
viewColumn: vscode.ViewColumn.Beside, // Open on right side of active editor
preserveFocus: true, // Don't steal focus from editor
},
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
vscode.Uri.joinPath(this.extensionUri, 'dist'),
vscode.Uri.joinPath(this.extensionUri, 'assets'),
],
},
);
// Set panel icon to Qwen logo
this.panel.iconPath = vscode.Uri.joinPath(
this.extensionUri,
'assets',
'icon.png',
);
return true; // New panel created
}
/**
* 自动锁定编辑器组(仅在新创建 Panel 时调用)
*/
async autoLockEditorGroup(): Promise<void> {
if (!this.panel) {
return;
}
console.log('[PanelManager] Auto-locking editor group for Qwen Code chat');
try {
// Reveal panel without preserving focus to make it the active group
this.revealPanel(false);
await vscode.commands.executeCommand('workbench.action.lockEditorGroup');
console.log('[PanelManager] Editor group locked successfully');
} catch (error) {
console.warn('[PanelManager] Failed to lock editor group:', error);
// Non-fatal error, continue anyway
}
}
/**
* 显示 Panel如果存在则 reveal否则什么都不做
* @param preserveFocus 是否保持焦点
*/
revealPanel(preserveFocus: boolean = true): void {
if (this.panel) {
this.panel.reveal(vscode.ViewColumn.Beside, preserveFocus);
}
}
/**
* 捕获与 WebView Panel 对应的 Tab
* 用于跟踪和管理 Tab 状态
*/
captureTab(): void {
if (!this.panel) {
return;
}
// Defer slightly so the tab model is updated after create/reveal
setTimeout(() => {
const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs);
const match = allTabs.find((t) => {
// Type guard for webview tab input
const input: unknown = (t as { input?: unknown }).input;
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
!!inp && typeof inp === 'object' && 'viewType' in inp;
const isWebview = isWebviewInput(input);
const sameViewType = isWebview && input.viewType === 'qwenCode.chat';
const sameLabel = t.label === this.panel!.title;
return !!(sameViewType || sameLabel);
});
this.panelTab = match ?? null;
}, 50);
}
/**
* 注册 Panel 的 dispose 事件处理器
* @param disposables 用于存储 Disposable 的数组
*/
registerDisposeHandler(disposables: vscode.Disposable[]): void {
if (!this.panel) {
return;
}
this.panel.onDidDispose(
() => {
this.panel = null;
this.panelTab = null;
this.onPanelDispose();
},
null,
disposables,
);
}
/**
* 注册视图状态变化事件处理器
* @param disposables 用于存储 Disposable 的数组
*/
registerViewStateChangeHandler(disposables: vscode.Disposable[]): void {
if (!this.panel) {
return;
}
this.panel.onDidChangeViewState(
() => {
if (this.panel && this.panel.visible) {
this.captureTab();
}
},
null,
disposables,
);
}
/**
* 销毁 Panel
*/
dispose(): void {
this.panel?.dispose();
this.panel = null;
this.panelTab = null;
}
}

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { escapeHtml } from '../utils/webviewUtils.js';
/**
* WebView HTML 内容生成器
* 负责生成 WebView 的 HTML 内容
*/
export class WebViewContent {
/**
* 生成 WebView 的 HTML 内容
* @param panel WebView Panel
* @param extensionUri 扩展的 URI
* @returns HTML 字符串
*/
static generate(
panel: vscode.WebviewPanel,
extensionUri: vscode.Uri,
): string {
const scriptUri = panel.webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'),
);
// Convert extension URI for webview access - this allows frontend to construct resource paths
const extensionUriForWebview = panel.webview.asWebviewUri(extensionUri);
// 对 URI 进行 HTML 转义以防止潜在的注入攻击
const safeExtensionUri = escapeHtml(extensionUriForWebview.toString());
const safeScriptUri = escapeHtml(scriptUri.toString());
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${panel.webview.cspSource}; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
<title>Qwen Code Chat</title>
</head>
<body data-extension-uri="${safeExtensionUri}">
<div id="root"></div>
<script src="${safeScriptUri}"></script>
</body>
</html>`;
}
}

View File

@@ -32,12 +32,36 @@ function getExtensionUri(): string | undefined {
return undefined;
}
/**
* 验证 URL 是否为安全的 VS Code webview 资源 URL
* 防止 XSS 攻击
*
* @param url - 待验证的 URL
* @returns 是否为安全的 URL
*/
function isValidWebviewUrl(url: string): boolean {
try {
// VS Code webview 资源 URL 的合法协议
const allowedProtocols = [
'vscode-webview-resource:',
'https-vscode-webview-resource:',
'vscode-file:',
'https:',
];
// 检查是否以合法协议开头
return allowedProtocols.some((protocol) => url.startsWith(protocol));
} catch {
return false;
}
}
/**
* Generate a resource URL for webview access
* Similar to the pattern used in other VSCode extensions
*
* @param relativePath - Relative path from extension root (e.g., 'assets/icon.png')
* @returns Full webview-accessible URL
* @returns Full webview-accessible URL (empty string if validation fails)
*
* @example
* ```tsx
@@ -52,6 +76,15 @@ export function generateResourceUrl(relativePath: string): string {
return '';
}
// 验证 extensionUri 是否为安全的 URL
if (!isValidWebviewUrl(extensionUri)) {
console.error(
'[resourceUrl] Invalid extension URI - possible security risk:',
extensionUri,
);
return '';
}
// Remove leading slash if present
const cleanPath = relativePath.startsWith('/')
? relativePath.slice(1)
@@ -62,7 +95,15 @@ export function generateResourceUrl(relativePath: string): string {
? extensionUri
: `${extensionUri}/`;
return `${baseUri}${cleanPath}`;
const fullUrl = `${baseUri}${cleanPath}`;
// 验证最终生成的 URL 是否安全
if (!isValidWebviewUrl(fullUrl)) {
console.error('[resourceUrl] Generated URL failed validation:', fullUrl);
return '';
}
return fullUrl;
}
/**