Files
qwen-code/packages/vscode-ide-companion/src/WebViewProvider.ts
yiliang114 6286b8b6e8 feat(vscode-ide-companion): 增加代码编辑功能和文件操作支持
- 实现了与 Claude Code 类似的代码编辑功能
- 添加了文件打开、保存等操作的支持
- 优化了消息显示,增加了代码高亮和文件路径点击功能
- 改进了用户界面,增加了编辑模式切换和思考模式功能
2025-11-20 01:04:11 +08:00

1234 lines
38 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import {
QwenAgentManager,
type ChatMessage,
} from './agents/qwenAgentManager.js';
import { ConversationStore } from './storage/conversationStore.js';
import type { AcpPermissionRequest } from './shared/acpTypes.js';
import { CliDetector } from './utils/cliDetector.js';
import { AuthStateManager } from './auth/authStateManager.js';
export class WebViewProvider {
private panel: vscode.WebviewPanel | null = null;
// Track the Webview tab (avoid pin/lock; use for reveal/visibility bookkeeping)
private panelTab: vscode.Tab | null = null;
private agentManager: QwenAgentManager;
private conversationStore: ConversationStore;
private authStateManager: AuthStateManager;
private currentConversationId: string | null = null;
private disposables: vscode.Disposable[] = [];
private agentInitialized = false; // Track if agent has been initialized
private currentStreamContent = ''; // Track streaming content for saving
constructor(
context: vscode.ExtensionContext,
private extensionUri: vscode.Uri,
) {
this.agentManager = new QwenAgentManager();
this.conversationStore = new ConversationStore(context);
this.authStateManager = new AuthStateManager(context);
// Setup agent callbacks
this.agentManager.onStreamChunk((chunk: string) => {
this.currentStreamContent += chunk;
this.sendMessageToWebView({
type: 'streamChunk',
data: { chunk },
});
});
// Setup thought chunk handler
this.agentManager.onThoughtChunk((chunk: string) => {
this.currentStreamContent += chunk;
this.sendMessageToWebView({
type: 'thoughtChunk',
data: { chunk },
});
});
// Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager
// and sent via onStreamChunk callback
this.agentManager.onToolCall((update) => {
this.sendMessageToWebView({
type: 'toolCall',
data: {
type: 'tool_call',
...(update as unknown as Record<string, unknown>),
},
});
});
// Setup plan handler
this.agentManager.onPlan((entries) => {
this.sendMessageToWebView({
type: 'plan',
data: { entries },
});
});
this.agentManager.onPermissionRequest(
async (request: AcpPermissionRequest) => {
// Send permission request to WebView
this.sendMessageToWebView({
type: 'permissionRequest',
data: request,
});
// Wait for user response
return new Promise((resolve) => {
const handler = (message: {
type: string;
data: { optionId: string };
}) => {
if (message.type === 'permissionResponse') {
resolve(message.data.optionId);
}
};
// Store handler temporarily (in real implementation, use proper event system)
(this as { permissionHandler?: typeof handler }).permissionHandler =
handler;
});
},
);
}
async show(): Promise<void> {
// Track if we're creating a new panel in a new column
let startedInNewColumn = false;
if (this.panel) {
// Reveal the existing panel via Tab API (Claude-style), fallback to panel.reveal
this.revealPanelTab(true);
this.capturePanelTab();
return;
}
// Mark that we're creating a new panel
startedInNewColumn = true;
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'),
],
},
);
// Capture the Tab that corresponds to our WebviewPanel (Claude-style)
this.capturePanelTab();
// Auto-lock editor group when opened in new column (Claude Code style)
if (startedInNewColumn) {
console.log(
'[WebViewProvider] Auto-locking editor group for Qwen Code chat',
);
try {
// Reveal panel without preserving focus to make it the active group
// This ensures the lock command targets the correct editor group
this.revealPanelTab(false);
await vscode.commands.executeCommand(
'workbench.action.lockEditorGroup',
);
console.log('[WebViewProvider] Editor group locked successfully');
} catch (error) {
console.warn('[WebViewProvider] Failed to lock editor group:', error);
// Non-fatal error, continue anyway
}
} else {
// For existing panel, reveal with preserving focus
this.revealPanelTab(true);
}
// Set panel icon to Qwen logo
this.panel.iconPath = vscode.Uri.joinPath(
this.extensionUri,
'assets',
'icon.png',
);
this.panel.webview.html = this.getWebviewContent();
// Handle messages from WebView
this.panel.webview.onDidReceiveMessage(
async (message) => {
await this.handleWebViewMessage(message);
},
null,
this.disposables,
);
// Listen for view state changes (no pin/lock; just keep tab reference fresh)
this.panel.onDidChangeViewState(
() => {
if (this.panel && this.panel.visible) {
this.capturePanelTab();
}
},
null,
this.disposables,
);
this.panel.onDidDispose(
() => {
this.panel = null;
// Don't disconnect agent - keep it alive for next time
this.disposables.forEach((d) => d.dispose());
},
null,
this.disposables,
);
// Listen for active editor changes and notify WebView
const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(
(editor) => {
const fileName = editor?.document.uri.fsPath
? this.getFileName(editor.document.uri.fsPath)
: null;
this.sendMessageToWebView({
type: 'activeEditorChanged',
data: { fileName },
});
},
);
this.disposables.push(editorChangeDisposable);
// Initialize agent connection only once
if (!this.agentInitialized) {
await this.initializeAgentConnection();
} else {
console.log(
'[WebViewProvider] Agent already initialized, reusing existing connection',
);
// Reload current session messages
await this.loadCurrentSessionMessages();
}
}
/**
* Initialize agent connection and session
* Can be called from show() or restorePanel()
*/
private async initializeAgentConnection(): Promise<void> {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
console.log(
'[WebViewProvider] Starting initialization, workingDir:',
workingDir,
);
const config = vscode.workspace.getConfiguration('qwenCode');
const qwenEnabled = config.get<boolean>('qwen.enabled', true);
if (qwenEnabled) {
// Check if CLI is installed before attempting to connect
const cliDetection = await CliDetector.detectQwenCli();
if (!cliDetection.isInstalled) {
console.log(
'[WebViewProvider] Qwen CLI not detected, skipping agent connection',
);
console.log(
'[WebViewProvider] CLI detection error:',
cliDetection.error,
);
// Show VSCode notification with installation option
await this.promptCliInstallation();
// Initialize empty conversation (can still browse history)
await this.initializeEmptyConversation();
} else {
console.log(
'[WebViewProvider] Qwen CLI detected, attempting connection...',
);
console.log('[WebViewProvider] CLI path:', cliDetection.cliPath);
console.log('[WebViewProvider] CLI version:', cliDetection.version);
try {
console.log('[WebViewProvider] Connecting to agent...');
const authInfo = await this.authStateManager.getAuthInfo();
console.log('[WebViewProvider] Auth cache status:', authInfo);
await this.agentManager.connect(workingDir, this.authStateManager);
console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true;
// Load messages from the current Qwen session
await this.loadCurrentSessionMessages();
} catch (error) {
console.error('[WebViewProvider] Agent connection error:', error);
// Clear auth cache on error (might be auth issue)
await this.authStateManager.clearAuthState();
vscode.window.showWarningMessage(
`Failed to connect to Qwen CLI: ${error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
);
// Fallback to empty conversation
await this.initializeEmptyConversation();
}
}
} else {
console.log('[WebViewProvider] Qwen agent is disabled in settings');
// Fallback to ConversationStore
await this.initializeEmptyConversation();
}
}
private async checkCliInstallation(): Promise<void> {
try {
const result = await CliDetector.detectQwenCli();
this.sendMessageToWebView({
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('[WebViewProvider] Qwen CLI not detected:', result.error);
} else {
console.log(
'[WebViewProvider] Qwen CLI detected:',
result.cliPath,
result.version,
);
}
} catch (error) {
console.error('[WebViewProvider] CLI detection error:', error);
}
}
private async loadCurrentSessionMessages(): Promise<void> {
try {
console.log(
'[WebViewProvider] Initializing with empty conversation and creating ACP session',
);
// Create a new ACP session so user can send messages immediately
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
try {
await this.agentManager.createNewSession(workingDir);
console.log('[WebViewProvider] ACP session created successfully');
} catch (sessionError) {
console.error(
'[WebViewProvider] Failed to create ACP session:',
sessionError,
);
vscode.window.showWarningMessage(
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
);
}
await this.initializeEmptyConversation();
} catch (error) {
console.error(
'[WebViewProvider] Failed to load session messages:',
error,
);
vscode.window.showErrorMessage(
`Failed to load session messages: ${error}`,
);
await this.initializeEmptyConversation();
}
}
private async promptCliInstallation(): 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.installQwenCli();
} else if (selection === 'View Documentation') {
vscode.env.openExternal(
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
);
}
}
private async installQwenCli(): 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('[WebViewProvider] Installation output:', stdout);
if (stderr) {
console.warn('[WebViewProvider] 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',
);
}
});
// Update webview with new detection result
await this.checkCliInstallation();
} else {
throw new Error(
'Installation completed but CLI still not detected',
);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
'[WebViewProvider] 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('[WebViewProvider] Install CLI error:', error);
}
}
private async initializeEmptyConversation(): Promise<void> {
try {
console.log('[WebViewProvider] Initializing empty conversation');
const newConv = await this.conversationStore.createConversation();
this.currentConversationId = newConv.id;
this.sendMessageToWebView({
type: 'conversationLoaded',
data: newConv,
});
console.log(
'[WebViewProvider] Empty conversation initialized:',
this.currentConversationId,
);
} catch (error) {
console.error(
'[WebViewProvider] Failed to initialize conversation:',
error,
);
// Send empty state to WebView as fallback
this.sendMessageToWebView({
type: 'conversationLoaded',
data: { id: 'temp', messages: [] },
});
}
}
private async handleWebViewMessage(message: {
type: string;
data?: { text?: string; id?: string; sessionId?: string; path?: string };
}): Promise<void> {
console.log('[WebViewProvider] Received message from webview:', message);
const self = this as {
permissionHandler?: (msg: {
type: string;
data: { optionId: string };
}) => void;
};
switch (message.type) {
case 'sendMessage':
await this.handleSendMessage(message.data?.text || '');
break;
case 'permissionResponse':
// Forward to permission handler
if (self.permissionHandler) {
self.permissionHandler(
message as { type: string; data: { optionId: string } },
);
delete self.permissionHandler;
}
break;
case 'loadConversation':
await this.handleLoadConversation(message.data?.id || '');
break;
case 'newConversation':
await this.handleNewConversation();
break;
case 'newQwenSession':
await this.handleNewQwenSession();
break;
case 'deleteConversation':
await this.handleDeleteConversation(message.data?.id || '');
break;
case 'getQwenSessions':
await this.handleGetQwenSessions();
break;
case 'getActiveEditor': {
// 发送当前激活编辑器的文件名给 WebView
const editor = vscode.window.activeTextEditor;
const fileName = editor?.document.uri.fsPath
? this.getFileName(editor.document.uri.fsPath)
: null;
this.sendMessageToWebView({
type: 'activeEditorChanged',
data: { fileName },
});
break;
}
case 'switchQwenSession':
await this.handleSwitchQwenSession(message.data?.sessionId || '');
break;
case 'recheckCli':
// Clear cache and recheck CLI installation
CliDetector.clearCache();
await this.checkCliInstallation();
break;
case 'cancelPrompt':
await this.handleCancelPrompt();
break;
case 'openFile':
await this.handleOpenFile(message.data?.path);
break;
case 'openDiff':
await this.handleOpenDiff(
message.data as { path?: string; oldText?: string; newText?: string },
);
break;
default:
console.warn('[WebViewProvider] Unknown message type:', message.type);
break;
}
}
private async handleSendMessage(text: string): Promise<void> {
console.log('[WebViewProvider] handleSendMessage called with:', text);
// Ensure we have an active conversation - create one if needed
if (!this.currentConversationId) {
console.log('[WebViewProvider] No active conversation, creating one...');
try {
await this.initializeEmptyConversation();
console.log(
'[WebViewProvider] Created conversation:',
this.currentConversationId,
);
} catch (error) {
const errorMsg = `Failed to create conversation: ${error}`;
console.error('[WebViewProvider]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendMessageToWebView({
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('[WebViewProvider]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendMessageToWebView({
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('[WebViewProvider] User message saved to store');
// Send to WebView
this.sendMessageToWebView({
type: 'message',
data: userMessage,
});
console.log('[WebViewProvider] User message sent to webview');
// Check if agent is connected
if (!this.agentManager.isConnected) {
console.warn(
'[WebViewProvider] Agent is not connected, skipping AI response',
);
this.sendMessageToWebView({
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.currentStreamContent = '';
// Create placeholder for assistant message
this.sendMessageToWebView({
type: 'streamStart',
data: { timestamp: Date.now() },
});
console.log('[WebViewProvider] Stream start sent');
console.log('[WebViewProvider] Sending to agent manager...');
await this.agentManager.sendMessage(text);
console.log('[WebViewProvider] 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('[WebViewProvider] Assistant message saved to store');
}
this.sendMessageToWebView({
type: 'streamEnd',
data: { timestamp: Date.now() },
});
console.log('[WebViewProvider] Stream end sent');
} catch (error) {
console.error('[WebViewProvider] Error sending message:', error);
vscode.window.showErrorMessage(`Error sending message: ${error}`);
this.sendMessageToWebView({
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.sendMessageToWebView({
type: 'conversationLoaded',
data: conversation,
});
}
}
private async handleNewConversation(): Promise<void> {
const newConv = await this.conversationStore.createConversation();
this.currentConversationId = newConv.id;
this.sendMessageToWebView({
type: 'conversationLoaded',
data: newConv,
});
}
private async handleDeleteConversation(id: string): Promise<void> {
await this.conversationStore.deleteConversation(id);
this.sendMessageToWebView({
type: 'conversationDeleted',
data: { id },
});
}
private async handleGetQwenSessions(): Promise<void> {
try {
console.log('[WebViewProvider] Getting Qwen sessions...');
const sessions = await this.agentManager.getSessionList();
console.log('[WebViewProvider] Retrieved sessions:', sessions.length);
this.sendMessageToWebView({
type: 'qwenSessionList',
data: { sessions },
});
} catch (error) {
console.error('[WebViewProvider] Failed to get Qwen sessions:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to get sessions: ${error}` },
});
}
}
private async handleNewQwenSession(): Promise<void> {
try {
console.log('[WebViewProvider] 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.sendMessageToWebView({
type: 'conversationCleared',
data: {},
});
} catch (error) {
console.error('[WebViewProvider] Failed to create new session:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to create new session: ${error}` },
});
}
}
private async handleSwitchQwenSession(sessionId: string): Promise<void> {
try {
console.log('[WebViewProvider] Switching to Qwen session:', sessionId);
// Get session messages from local files
const messages = await this.agentManager.getSessionMessages(sessionId);
console.log(
'[WebViewProvider] 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('[WebViewProvider] Could not get session details:', err);
}
// TESTING: 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('[WebViewProvider] Testing session/load via ACP...');
const loadResponse =
await this.agentManager.loadSessionViaAcp(sessionId);
console.log('[WebViewProvider] session/load succeeded:', loadResponse);
// If load succeeded, use the loaded session
this.currentConversationId = sessionId;
console.log(
'[WebViewProvider] Set currentConversationId to loaded session:',
sessionId,
);
} catch (_loadError) {
console.log(
'[WebViewProvider] 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(
'[WebViewProvider] Created new ACP session for conversation:',
newAcpSessionId,
);
// Use the NEW ACP session ID for sending messages to CLI
this.currentConversationId = newAcpSessionId;
console.log(
'[WebViewProvider] Set currentConversationId (ACP) to:',
newAcpSessionId,
);
} catch (createError) {
console.error(
'[WebViewProvider] 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.sendMessageToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages, session: sessionDetails },
});
} catch (error) {
console.error('[WebViewProvider] Failed to switch session:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to switch session: ${error}` },
});
vscode.window.showErrorMessage(`Failed to switch session: ${error}`);
}
}
/**
* Handle cancel prompt request from WebView
* Cancels the current AI response generation
*/
private async handleCancelPrompt(): Promise<void> {
try {
console.log('[WebViewProvider] Cancel prompt requested');
if (!this.agentManager.isConnected) {
console.warn('[WebViewProvider] Agent not connected, cannot cancel');
return;
}
await this.agentManager.cancelCurrentPrompt();
this.sendMessageToWebView({
type: 'promptCancelled',
data: { timestamp: Date.now() },
});
console.log('[WebViewProvider] Prompt cancelled successfully');
} catch (error) {
console.error('[WebViewProvider] Failed to cancel prompt:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to cancel: ${error}` },
});
}
}
/**
* Handle open file request from WebView
* Opens a file in VS Code editor, optionally at a specific line
*/
private async handleOpenFile(filePath?: string): Promise<void> {
try {
if (!filePath) {
console.warn('[WebViewProvider] No file path provided');
return;
}
console.log('[WebViewProvider] Opening file:', filePath);
// Parse file path and line number (format: path/to/file.ts:123)
const match = filePath.match(/^(.+?)(?::(\d+))?$/);
if (!match) {
console.warn('[WebViewProvider] 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('[WebViewProvider] File opened successfully:', absolutePath);
} catch (error) {
console.error('[WebViewProvider] Failed to open file:', error);
vscode.window.showErrorMessage(`Failed to open file: ${error}`);
}
}
/**
* Handle open diff request from WebView
* Opens VS Code's diff viewer to compare old and new file contents
*/
private async handleOpenDiff(data?: {
path?: string;
oldText?: string;
newText?: string;
}): Promise<void> {
try {
if (!data || !data.path) {
console.warn('[WebViewProvider] No file path provided for diff');
return;
}
const { path, oldText = '', newText = '' } = data;
console.log('[WebViewProvider] 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 = this.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('[WebViewProvider] Diff opened successfully');
} catch (error) {
console.error('[WebViewProvider] Failed to open diff:', error);
vscode.window.showErrorMessage(`Failed to open diff: ${error}`);
}
}
private sendMessageToWebView(message: unknown): void {
this.panel?.webview.postMessage(message);
}
/**
* 从完整路径中提取文件名
* @param fsPath 文件的完整路径
* @returns 文件名(不含路径)
*/
private getFileName(fsPath: string): string {
// 使用 path.basename 的逻辑:找到最后一个路径分隔符后的部分
const lastSlash = Math.max(
fsPath.lastIndexOf('/'),
fsPath.lastIndexOf('\\'),
);
return lastSlash >= 0 ? fsPath.substring(lastSlash + 1) : fsPath;
}
private getWebviewContent(): string {
const scriptUri = this.panel!.webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview.js'),
);
// Convert extension URI for webview access - this allows frontend to construct resource paths
const extensionUri = this.panel!.webview.asWebviewUri(this.extensionUri);
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 ${this.panel!.webview.cspSource}; script-src ${this.panel!.webview.cspSource}; style-src ${this.panel!.webview.cspSource} 'unsafe-inline';">
<title>Qwen Code Chat</title>
</head>
<body data-extension-uri="${extensionUri}">
<div id="root"></div>
<script src="${scriptUri}"></script>
</body>
</html>`;
}
/**
* Reset agent initialization state
* Call this when auth cache is cleared to force re-authentication
*/
resetAgentState(): void {
console.log('[WebViewProvider] Resetting agent state');
this.agentInitialized = false;
// Disconnect existing connection
this.agentManager.disconnect();
}
/**
* Capture the VS Code Tab that corresponds to our WebviewPanel.
* We do not pin or lock the editor group, mirroring Claude's approach.
* Instead, we:
* - open beside the active editor
* - preserve focus to keep typing in the current file
* - keep a Tab reference for reveal/visibility bookkeeping if needed
*/
private capturePanelTab(): 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);
}
/**
* Reveal the WebView panel (optionally preserving focus)
* We track the tab for bookkeeping, but use panel.reveal for actual reveal
*/
private revealPanelTab(preserveFocus: boolean = true): void {
if (this.panel) {
this.panel.reveal(vscode.ViewColumn.Beside, preserveFocus);
}
}
/**
* Restore an existing WebView panel (called during VSCode restart)
* This sets up the panel with all event listeners
*/
restorePanel(panel: vscode.WebviewPanel): void {
console.log('[WebViewProvider] Restoring WebView panel');
this.panel = panel;
// Set panel icon to Qwen logo
this.panel.iconPath = vscode.Uri.joinPath(
this.extensionUri,
'assets',
'icon.png',
);
// Set webview HTML
this.panel.webview.html = this.getWebviewContent();
// Handle messages from WebView
this.panel.webview.onDidReceiveMessage(
async (message) => {
await this.handleWebViewMessage(message);
},
null,
this.disposables,
);
// Listen for view state changes (track the tab only)
this.panel.onDidChangeViewState(
() => {
if (this.panel && this.panel.visible) {
this.capturePanelTab();
}
},
null,
this.disposables,
);
this.panel.onDidDispose(
() => {
this.panel = null;
this.disposables.forEach((d) => d.dispose());
},
null,
this.disposables,
);
// Track the tab reference on restore
this.capturePanelTab();
console.log('[WebViewProvider] Panel restored successfully');
// Initialize agent connection if not already done
if (!this.agentInitialized) {
console.log(
'[WebViewProvider] Initializing agent connection after restore...',
);
this.initializeAgentConnection().catch((error) => {
console.error(
'[WebViewProvider] Failed to initialize agent after restore:',
error,
);
});
} else {
console.log(
'[WebViewProvider] Agent already initialized, loading current session...',
);
// Reload current session messages
this.loadCurrentSessionMessages().catch((error) => {
console.error(
'[WebViewProvider] Failed to load session messages after restore:',
error,
);
});
}
}
/**
* Get the current state for serialization
* This is used when VSCode restarts to restore the WebView
*/
getState(): {
conversationId: string | null;
agentInitialized: boolean;
} {
return {
conversationId: this.currentConversationId,
agentInitialized: this.agentInitialized,
};
}
/**
* Restore state after VSCode restart
*/
restoreState(state: {
conversationId: string | null;
agentInitialized: boolean;
}): void {
console.log('[WebViewProvider] Restoring state:', state);
this.currentConversationId = state.conversationId;
this.agentInitialized = state.agentInitialized;
// Reload content after restore
if (this.panel) {
this.panel.webview.html = this.getWebviewContent();
}
}
dispose(): void {
this.panel?.dispose();
this.agentManager.disconnect();
this.disposables.forEach((d) => d.dispose());
}
}