From f1cca06304d7ada74ea5de23e3de8728e835be80 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 14 Jan 2026 13:11:33 +0800 Subject: [PATCH 001/137] feat(vscode-ide-companion): wip configurable layout settings --- packages/vscode-ide-companion/package.json | 98 +++- .../src/commands/index.ts | 92 ++++ .../vscode-ide-companion/src/extension.ts | 153 +++++- .../src/utils/editorGroupUtils.ts | 15 + .../src/webview/ChatWebviewViewProvider.ts | 41 ++ .../src/webview/WebViewContent.ts | 13 +- .../src/webview/WebViewProvider.ts | 464 +++++++++--------- 7 files changed, 645 insertions(+), 231 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index b7c50f57c..05e438c57 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -28,9 +28,66 @@ "ide companion" ], "activationEvents": [ - "onStartupFinished" + "onStartupFinished", + "onView:qwenCode.chatView.panel", + "onView:qwenCode.chatView.secondary", + "onCommand:qwen-code.setChatLocation.editor", + "onCommand:qwen-code.setChatLocation.panel", + "onCommand:qwen-code.setChatLocation.secondary", + "onCommand:qwen-code.openChat" ], "contributes": { + "configuration": { + "title": "Qwen Code Companion", + "properties": { + "qwen-code.chat.location": { + "type": "string", + "default": "editor", + "enum": [ + "editor", + "panel", + "secondary" + ], + "description": "选择聊天界面放置的位置:编辑器标签页(默认)、底部面板或 Secondary Side Bar。修改后需重新打开聊天。" + } + } + }, + "viewsContainers": { + "panel": [ + { + "id": "qwenCodePanel", + "title": "Qwen Code", + "icon": "assets/icon.png", + "when": "qwenCode.chatLocation == 'panel'" + } + ], + "secondarySidebar": [ + { + "id": "qwenCodeSecondary", + "title": "Qwen Code", + "icon": "assets/icon.png", + "when": "qwenCode.chatLocation == 'secondary'" + } + ] + }, + "views": { + "qwenCodePanel": [ + { + "id": "qwenCode.chatView.panel", + "name": "Qwen Code", + "icon": "assets/icon.png", + "when": "qwenCode.chatLocation == 'panel'" + } + ], + "qwenCodeSecondary": [ + { + "id": "qwenCode.chatView.secondary", + "name": "Qwen Code", + "icon": "assets/icon.png", + "when": "qwenCode.chatLocation == 'secondary'" + } + ] + }, "languages": [ { "id": "qwen-diff-editable" @@ -63,6 +120,24 @@ { "command": "qwen-code.login", "title": "Qwen Code: Login" + }, + { + "command": "qwen-code.setChatLocation.editor", + "title": "Qwen Code: 将聊天放在编辑器标签页" + }, + { + "command": "qwen-code.setChatLocation.panel", + "title": "Qwen Code: 将聊天放在底部面板" + }, + { + "command": "qwen-code.setChatLocation.secondary", + "title": "Qwen Code: 将聊天放在 Secondary Bar" + } + ], + "submenus": [ + { + "id": "qwenCode.chatLocationMenu", + "label": "Qwen Code: 聊天位置" } ], "menus": { @@ -94,6 +169,27 @@ { "command": "qwen-code.openChat", "group": "navigation" + }, + { + "submenu": "qwenCode.chatLocationMenu", + "group": "navigation@2" + } + ], + "qwenCode.chatLocationMenu": [ + { + "command": "qwen-code.setChatLocation.editor", + "group": "1_editor", + "when": "qwenCode.chatLocation != 'editor'" + }, + { + "command": "qwen-code.setChatLocation.panel", + "group": "2_panel", + "when": "qwenCode.chatLocation != 'panel'" + }, + { + "command": "qwen-code.setChatLocation.secondary", + "group": "3_secondary", + "when": "qwenCode.chatLocation != 'secondary'" } ] }, diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index e75e1bd10..7ede00a63 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -3,12 +3,17 @@ import type { DiffManager } from '../diff-manager.js'; import type { WebViewProvider } from '../webview/WebViewProvider.js'; type Logger = (message: string) => void; +export type ChatHostLocation = 'editor' | 'panel' | 'secondary'; export const runQwenCodeCommand = 'qwen-code.runQwenCode'; export const showDiffCommand = 'qwenCode.showDiff'; export const openChatCommand = 'qwen-code.openChat'; export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; export const loginCommand = 'qwen-code.login'; +export const setChatLocationEditorCommand = 'qwen-code.setChatLocation.editor'; +export const setChatLocationPanelCommand = 'qwen-code.setChatLocation.panel'; +export const setChatLocationSecondaryCommand = + 'qwen-code.setChatLocation.secondary'; export function registerNewCommands( context: vscode.ExtensionContext, @@ -16,11 +21,19 @@ export function registerNewCommands( diffManager: DiffManager, getWebViewProviders: () => WebViewProvider[], createWebViewProvider: () => WebViewProvider, + getChatHostLocation: () => ChatHostLocation, + focusChatView: () => Promise, ): void { const disposables: vscode.Disposable[] = []; disposables.push( vscode.commands.registerCommand(openChatCommand, async () => { + const host = getChatHostLocation(); + if (host !== 'editor') { + await focusChatView(); + return; + } + const providers = getWebViewProviders(); if (providers.length > 0) { await providers[providers.length - 1].show(); @@ -58,6 +71,15 @@ export function registerNewCommands( disposables.push( vscode.commands.registerCommand(openNewChatTabCommand, async () => { + const host = getChatHostLocation(); + if (host !== 'editor') { + vscode.window.showInformationMessage( + '当前配置使用面板/Secondary Bar 承载聊天,暂不支持多标签。将为你聚焦现有聊天视图。', + ); + await focusChatView(); + return; + } + const provider = createWebViewProvider(); // Session restoration is now disabled by default, so no need to suppress it await provider.show(); @@ -76,5 +98,75 @@ export function registerNewCommands( } }), ); + + const updateChatLocation = async (location: ChatHostLocation) => { + const current = getChatHostLocation(); + if (current === location) { + log(`[Command] Chat location unchanged (${location}), focusing view`); + await focusChatView(); + return; + } + + const target: vscode.ConfigurationTarget = + vscode.workspace.workspaceFolders?.length && vscode.workspace.name + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; + + try { + await vscode.workspace + .getConfiguration('qwen-code') + .update('chat.location', location, target); + log( + `[Command] Chat location set to ${location} (target=${target}; prev=${current})`, + ); + // Update context immediately so view container visibility switches without reload + await vscode.commands.executeCommand( + 'setContext', + 'qwenCode.chatLocation', + location, + ); + + // Dispose existing editor-hosted chat panels when moving to panel/secondary to avoid confusion. + if (location !== 'editor') { + for (const provider of getWebViewProviders()) { + try { + provider.getPanel()?.dispose(); + } catch (error) { + log( + `[Command] Failed to dispose chat panel during relocation: ${error}`, + ); + } + } + } + + // Small delay to ensure context has updated before focusing + await new Promise((resolve) => setTimeout(resolve, 100)); + await focusChatView(); + void vscode.window.showInformationMessage( + `聊天位置已切换为 ${location === 'editor' ? '编辑器标签页' : location === 'panel' ? '底部面板' : 'Secondary Bar'},已尝试为你打开对应位置。如未生效,请重载窗口。`, + ); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + log(`[Command] Failed to set chat location: ${msg}`); + void vscode.window.showErrorMessage( + `切换聊天位置失败:${msg}。可在 Settings 搜索 "Qwen Code Chat Location" 手动修改。`, + ); + } + }; + + disposables.push( + vscode.commands.registerCommand(setChatLocationEditorCommand, async () => { + await updateChatLocation('editor'); + }), + vscode.commands.registerCommand(setChatLocationPanelCommand, async () => { + await updateChatLocation('panel'); + }), + vscode.commands.registerCommand( + setChatLocationSecondaryCommand, + async () => { + await updateChatLocation('secondary'); + }, + ), + ); context.subscriptions.push(...disposables); } diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index be0f669e6..69382fb3f 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -15,13 +15,42 @@ import { type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { WebViewProvider } from './webview/WebViewProvider.js'; -import { registerNewCommands } from './commands/index.js'; +import { + ChatWebviewViewProvider, + CHAT_VIEW_ID_PANEL, + CHAT_VIEW_ID_SECONDARY, +} from './webview/ChatWebviewViewProvider.js'; +import { + registerNewCommands, + type ChatHostLocation, +} from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; export const DIFF_SCHEME = 'qwen-diff'; +const CHAT_LOCATION_SETTING_KEY = 'chat.location'; + +function getChatHostLocation(): ChatHostLocation { + const location = vscode.workspace + .getConfiguration('qwen-code') + .get(CHAT_LOCATION_SETTING_KEY, 'editor'); + + if (location === 'panel' || location === 'secondary') { + return location; + } + return 'editor'; +} + +async function setChatLocationContext(location: ChatHostLocation) { + console.log('[Extension] setChatLocationContext ->', location); + await vscode.commands.executeCommand( + 'setContext', + 'qwenCode.chatLocation', + location, + ); +} /** * IDE environments where the installation greeting is hidden. In these @@ -143,6 +172,9 @@ export async function activate(context: vscode.ExtensionContext) { }, ); + const initialChatLocation = getChatHostLocation(); + await setChatLocationContext(initialChatLocation); + // Helper function to create a new WebView provider instance const createWebViewProvider = (): WebViewProvider => { const provider = new WebViewProvider(context, context.extensionUri); @@ -150,6 +182,44 @@ export async function activate(context: vscode.ExtensionContext) { return provider; }; + // Register WebviewView hosts (panel and secondary sidebar). They are shown based on config conditions. + const chatViewProviderPanel = new ChatWebviewViewProvider( + createWebViewProvider(), + context.extensionUri, + 'panel', + ); + const chatViewProviderSecondary = new ChatWebviewViewProvider( + createWebViewProvider(), + context.extensionUri, + 'secondary', + ); + console.log( + '[Extension] Registering WebviewView providers for panel and secondary', + ); + const panelProviderDisposable = vscode.window.registerWebviewViewProvider( + CHAT_VIEW_ID_PANEL, + chatViewProviderPanel, + { + webviewOptions: { retainContextWhenHidden: true }, + }, + ); + const secondaryProviderDisposable = vscode.window.registerWebviewViewProvider( + CHAT_VIEW_ID_SECONDARY, + chatViewProviderSecondary, + { + webviewOptions: { retainContextWhenHidden: true }, + }, + ); + console.log( + '[Extension] WebviewView providers registered:', + panelProviderDisposable ? 'panel' : 'panel (missing)', + secondaryProviderDisposable ? 'secondary' : 'secondary (missing)', + ); + context.subscriptions.push( + panelProviderDisposable, + secondaryProviderDisposable, + ); + // Register WebView panel serializer for persistence across reloads context.subscriptions.push( vscode.window.registerWebviewPanelSerializer('qwenCode.chat', { @@ -187,6 +257,43 @@ export async function activate(context: vscode.ExtensionContext) { }), ); + const focusChatView = async () => { + const host = getChatHostLocation(); + if (host === 'editor') { + const providers = webViewProviders; + if (providers.length > 0) { + await providers[providers.length - 1].show(); + } else { + const provider = createWebViewProvider(); + await provider.show(); + } + return; + } + + const viewId = + host === 'secondary' ? CHAT_VIEW_ID_SECONDARY : CHAT_VIEW_ID_PANEL; + try { + // Ensure the view provider is registered and ready before focusing + await new Promise((resolve) => setTimeout(resolve, 200)); + await vscode.commands.executeCommand(`${viewId}.focus`); + + // Wait a bit more to ensure the view has initialized + await new Promise((resolve) => setTimeout(resolve, 300)); + } catch (err) { + log(`Failed to focus chat view (${viewId}): ${err}`); + // Try to ensure the view provider exists by creating new ones if needed + try { + const currentProviders = webViewProviders; + if (currentProviders.length === 0) { + createWebViewProvider(); // Create a new provider if none exist + } + await vscode.commands.executeCommand(`${viewId}.focus`); + } catch (retryErr) { + log(`Retry failed to focus chat view (${viewId}): ${retryErr}`); + } + } + }; + // Register newly added commands via commands module registerNewCommands( context, @@ -194,6 +301,8 @@ export async function activate(context: vscode.ExtensionContext) { diffManager, () => webViewProviders, createWebViewProvider, + getChatHostLocation, + focusChatView, ); context.subscriptions.push( @@ -202,6 +311,48 @@ export async function activate(context: vscode.ExtensionContext) { diffManager.cancelDiff(doc.uri); } }), + vscode.workspace.onDidChangeConfiguration(async (event) => { + if (event.affectsConfiguration('qwen-code.chat.location')) { + const newLocation = getChatHostLocation(); + log( + `[Extension] Configuration changed: chat.location -> ${newLocation}`, + ); + await setChatLocationContext(newLocation); + if (newLocation !== 'editor') { + for (const provider of webViewProviders) { + try { + provider.getPanel()?.dispose(); + } catch (err) { + log( + `[Extension] Failed to dispose chat panel after location change: ${err}`, + ); + } + } + } + + // Focus the appropriate view after config change + if (newLocation === 'panel' || newLocation === 'secondary') { + const viewId = + newLocation === 'secondary' + ? CHAT_VIEW_ID_SECONDARY + : CHAT_VIEW_ID_PANEL; + try { + // Give VS Code a moment to update the context and UI + await new Promise((resolve) => setTimeout(resolve, 300)); + await vscode.commands.executeCommand(`${viewId}.focus`); + + // Refresh the view to ensure content appears + await new Promise((resolve) => setTimeout(resolve, 200)); + // Trigger a re-render by focusing again after a delay + await vscode.commands.executeCommand(`${viewId}.focus`); + } catch (err) { + log( + `[Extension] Failed to focus chat view after location change: ${err}`, + ); + } + } + } + }), vscode.workspace.registerTextDocumentContentProvider( DIFF_SCHEME, diffContentProvider, diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts index e3b837778..13897412c 100644 --- a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts @@ -7,12 +7,23 @@ import * as vscode from 'vscode'; import { openChatCommand } from '../commands/index.js'; +function isEditorHostedChat(): boolean { + const location = vscode.workspace + .getConfiguration('qwen-code') + .get('chat.location', 'editor'); + return location === 'editor'; +} + /** * Find the editor group immediately to the left of the Qwen chat webview. * - If the chat webview group is the leftmost group, returns undefined. * - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'. */ export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined { + if (!isEditorHostedChat()) { + return undefined; + } + try { const groups = vscode.window.tabGroups.all; @@ -92,6 +103,10 @@ function waitForTabGroupsCondition( export async function ensureLeftGroupOfChatWebview(): Promise< vscode.ViewColumn | undefined > { + if (!isEditorHostedChat()) { + return undefined; + } + // First try to find an existing left neighbor const existing = findLeftGroupOfChatWebview(); if (existing !== undefined) { diff --git a/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts b/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts new file mode 100644 index 000000000..5eb906ac5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { WebViewProvider } from './WebViewProvider.js'; + +export const CHAT_VIEW_ID_PANEL = 'qwenCode.chatView.panel'; +export const CHAT_VIEW_ID_SECONDARY = 'qwenCode.chatView.secondary'; + +/** + * WebviewView host for placing the chat UI in panel/secondary sidebar. + */ +export class ChatWebviewViewProvider implements vscode.WebviewViewProvider { + constructor( + private readonly webViewProvider: WebViewProvider, + private readonly extensionUri: vscode.Uri, + private readonly hostKind: 'panel' | 'secondary', + ) {} + + async resolveWebviewView(webviewView: vscode.WebviewView): Promise { + console.log( + `[ChatWebviewViewProvider] resolveWebviewView invoked for ${webviewView.viewType} (host=${this.hostKind})`, + ); + // Determine the view ID from the webviewView + const viewId = webviewView.viewType; // This will be either 'qwenCode.chatView.panel' or 'qwenCode.chatView.secondary' + + // Ensure scripts/resources are allowed + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }; + + await this.webViewProvider.attachToView(webviewView, viewId); + } +} diff --git a/packages/vscode-ide-companion/src/webview/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/WebViewContent.ts index 8f802c84f..f3f934890 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewContent.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewContent.ts @@ -14,20 +14,17 @@ import { escapeHtml } from './utils/webviewUtils.js'; export class WebViewContent { /** * Generate HTML content for the WebView - * @param panel WebView Panel + * @param webview VS Code Webview (panel or view) * @param extensionUri Extension URI * @returns HTML string */ - static generate( - panel: vscode.WebviewPanel, - extensionUri: vscode.Uri, - ): string { - const scriptUri = panel.webview.asWebviewUri( + static generate(webview: vscode.Webview, extensionUri: vscode.Uri): string { + const scriptUri = 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); + const extensionUriForWebview = webview.asWebviewUri(extensionUri); // Escape URI for HTML to prevent potential injection attacks const safeExtensionUri = escapeHtml(extensionUriForWebview.toString()); @@ -38,7 +35,7 @@ export class WebViewContent { - + Qwen Code diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 5aa92c0fb..248d6f20c 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -21,6 +21,9 @@ export class WebViewProvider { private agentManager: QwenAgentManager; private conversationStore: ConversationStore; private disposables: vscode.Disposable[] = []; + private currentWebview: vscode.Webview | null = null; + // Track webviews by view ID for better multi-view support + private webviews: Map = new Map(); private agentInitialized = false; // Track if agent has been initialized // Track a pending permission request and its resolver so extension commands // can "simulate" user choice from the command palette (e.g. after accepting @@ -38,7 +41,7 @@ export class WebViewProvider { this.conversationStore = new ConversationStore(context); this.panelManager = new PanelManager(extensionUri, () => { // Panel dispose callback - this.disposables.forEach((d) => d.dispose()); + this.disposeWebviewDisposables(); }); this.messageHandler = new MessageHandler( this.agentManager, @@ -369,6 +372,162 @@ export class WebViewProvider { ); } + /** + * Dispose and reset all webview-scoped disposables (listeners bound per host) + */ + private disposeWebviewDisposables(): void { + for (const disposable of this.disposables) { + try { + disposable.dispose(); + } catch (_err) { + // Best-effort cleanup + } + } + this.disposables = []; + this.currentWebview = null; + } + + private setupMessageListener( + webview: vscode.Webview, + titleSetter?: (title: string) => void, + ): void { + const disposable = webview.onDidReceiveMessage( + async (message: { type: string; data?: unknown }) => { + // Suppress UI-originated diff opens in auto/yolo mode + if (message.type === 'openDiff' && this.isAutoMode()) { + return; + } + // Allow webview to request updating the VS Code tab title + if (message.type === 'updatePanelTitle') { + const title = String( + (message.data as { title?: unknown } | undefined)?.title ?? '', + ).trim(); + if (titleSetter) { + titleSetter(title || 'Qwen Code'); + } + return; + } + await this.messageHandler.route(message); + }, + ); + this.disposables.push(disposable); + } + + private setupEditorListeners(): void { + const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + // If switching to a non-text editor (like webview), keep the last state + if (!editor) { + // Don't update - keep previous state + return; + } + + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (editor && !editor.selection.isEmpty) { + const selection = editor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + }, + ); + this.disposables.push(editorChangeDisposable); + + const selectionChangeDisposable = + vscode.window.onDidChangeTextEditorSelection((event) => { + const editor = event.textEditor; + if (editor === vscode.window.activeTextEditor) { + const filePath = editor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + // Get selection info if there is any selected text + let selectionInfo = null; + if (!event.selections[0].isEmpty) { + const selection = event.selections[0]; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + }); + this.disposables.push(selectionChangeDisposable); + } + + private sendInitialActiveEditorState(): void { + const initialEditor = vscode.window.activeTextEditor; + if (initialEditor) { + const filePath = initialEditor.document.uri.fsPath || null; + const fileName = filePath ? getFileName(filePath) : null; + + let selectionInfo = null; + if (!initialEditor.selection.isEmpty) { + const selection = initialEditor.selection; + selectionInfo = { + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + }; + } + + this.sendMessageToWebView({ + type: 'activeEditorChanged', + data: { fileName, filePath, selection: selectionInfo }, + }); + } + } + + /** + * Common binding logic for both WebviewPanel and WebviewView hosts. + */ + private async bindWebview( + webview: vscode.Webview, + options?: { titleSetter?: (title: string) => void; viewId?: string }, + ): Promise { + try { + console.log('[WebViewProvider] bindWebview start for host'); + this.disposeWebviewDisposables(); + this.currentWebview = webview; + + // Store the webview in the map if viewId is provided + if (options?.viewId) { + this.webviews.set(options.viewId, webview); + } + + webview.html = WebViewContent.generate(webview, this.extensionUri); + + this.setupMessageListener(webview, options?.titleSetter); + this.setupEditorListeners(); + this.sendInitialActiveEditorState(); + + // Attempt to restore authentication state and initialize connection + // Don't await this to prevent blocking the UI + this.attemptAuthStateRestoration().catch((error) => { + console.error('[WebViewProvider] Error in auth restoration:', error); + }); + console.log('[WebViewProvider] bindWebview completed'); + } catch (error) { + console.error('[WebViewProvider] Error in bindWebview:', error); + // Fallback to basic HTML if binding fails + webview.html = `
Initialization error: ${(error as Error).message || 'Unknown error'}
`; + throw error; // Re-throw so caller can handle it + } + } + async show(): Promise { const panel = this.panelManager.getPanel(); @@ -391,142 +550,75 @@ export class WebViewProvider { return; } - // Set up state serialization - newPanel.onDidChangeViewState(() => { - console.log( - '[WebViewProvider] Panel view state changed, triggering serialization check', - ); - }); - // Capture the Tab that corresponds to our WebviewPanel this.panelManager.captureTab(); // Auto-lock editor group when opened in new column await this.panelManager.autoLockEditorGroup(); - newPanel.webview.html = WebViewContent.generate( - newPanel, - this.extensionUri, - ); - - // Handle messages from WebView - newPanel.webview.onDidReceiveMessage( - async (message: { type: string; data?: unknown }) => { - // Suppress UI-originated diff opens in auto/yolo mode - if (message.type === 'openDiff' && this.isAutoMode()) { - return; + await this.bindWebview(newPanel.webview, { + titleSetter: (title: string) => { + try { + newPanel.title = title || 'Qwen Code'; + } catch (_err) { + // Best-effort only } - // Allow webview to request updating the VS Code tab title - if (message.type === 'updatePanelTitle') { - const title = String( - (message.data as { title?: unknown } | undefined)?.title ?? '', - ).trim(); - const panelRef = this.panelManager.getPanel(); - if (panelRef) { - panelRef.title = title || 'Qwen Code'; - } - return; - } - await this.messageHandler.route(message); }, - null, - this.disposables, - ); + }); // Listen for view state changes (no pin/lock; just keep tab reference fresh) this.panelManager.registerViewStateChangeHandler(this.disposables); // Register panel dispose handler this.panelManager.registerDisposeHandler(this.disposables); + } - // Listen for active editor changes and notify WebView - const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( - (editor) => { - // If switching to a non-text editor (like webview), keep the last state - if (!editor) { - // Don't update - keep previous state - return; - } - - const filePath = editor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (editor && !editor.selection.isEmpty) { - const selection = editor.selection; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - // Update last known state - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - }, + /** + * Attach the chat experience to a WebviewView (panel/secondary sidebar host) + */ + async attachToView( + webviewView: vscode.WebviewView, + viewId?: string, + ): Promise { + console.log( + `[WebViewProvider] attachToView called for ${webviewView.viewType} (id=${viewId ?? 'unknown'})`, ); - this.disposables.push(editorChangeDisposable); + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.extensionUri, 'dist'), + vscode.Uri.joinPath(this.extensionUri, 'assets'), + ], + }; - // Listen for text selection changes - const selectionChangeDisposable = - vscode.window.onDidChangeTextEditorSelection((event) => { - const editor = event.textEditor; - if (editor === vscode.window.activeTextEditor) { - const filePath = editor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (!event.selections[0].isEmpty) { - const selection = event.selections[0]; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; + try { + await this.bindWebview(webviewView.webview, { + titleSetter: (title: string) => { + try { + webviewView.title = title || 'Qwen Code'; + } catch (_err) { + // Ignore title update errors } - - // Update last known state - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - - // Mode callbacks are registered in constructor; no-op here - } - }); - this.disposables.push(selectionChangeDisposable); - - // Send initial active editor state to WebView - const initialEditor = vscode.window.activeTextEditor; - if (initialEditor) { - const filePath = initialEditor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - let selectionInfo = null; - if (!initialEditor.selection.isEmpty) { - const selection = initialEditor.selection; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, + }, + viewId, // Pass the viewId to bindWebview }); + } catch (error) { + console.error('[WebViewProvider] Error in attachToView:', error); + // Ensure some content is displayed even if binding fails + webviewView.webview.html = `
Error loading chat: ${error instanceof Error ? error.message : String(error)}
`; } - // Attempt to restore authentication state and initialize connection - console.log( - '[WebViewProvider] Attempting to restore auth state and connection...', + webviewView.onDidDispose( + () => { + // Remove the webview from the map on dispose + if (viewId) { + this.webviews.delete(viewId); + } + this.disposeWebviewDisposables(); + }, + null, + this.disposables, ); - await this.attemptAuthStateRestoration(); } /** @@ -878,6 +970,32 @@ export class WebViewProvider { * Send message to WebView */ private sendMessageToWebView(message: unknown): void { + // Prioritize sending to current active webview + if (this.currentWebview) { + try { + void this.currentWebview.postMessage(message); + return; + } catch (_err) { + // Fallback to panel below + } + } + + // If we have webviews stored by ID, try to send to all of them + if (this.webviews.size > 0) { + for (const [id, webview] of this.webviews) { + try { + void webview.postMessage(message); + } catch (err) { + console.error( + `[WebViewProvider] Error posting message to webview ${id}:`, + err, + ); + } + } + return; + } + + // Fallback to panel const panel = this.panelManager.getPanel(); panel?.webview.postMessage(message); } @@ -1008,30 +1126,18 @@ export class WebViewProvider { ); } - panel.webview.html = WebViewContent.generate(panel, this.extensionUri); - - // Handle messages from WebView (restored panel) - panel.webview.onDidReceiveMessage( - async (message: { type: string; data?: unknown }) => { - // Suppress UI-originated diff opens in auto/yolo mode - if (message.type === 'openDiff' && this.isAutoMode()) { - return; - } - if (message.type === 'updatePanelTitle') { - const title = String( - (message.data as { title?: unknown } | undefined)?.title ?? '', - ).trim(); + await this.bindWebview(panel.webview, { + titleSetter: (title: string) => { + try { const panelRef = this.panelManager.getPanel(); if (panelRef) { panelRef.title = title || 'Qwen Code'; } - return; + } catch (_err) { + // Ignore } - await this.messageHandler.route(message); }, - null, - this.disposables, - ); + }); // Register view state change handler this.panelManager.registerViewStateChangeHandler(this.disposables); @@ -1039,97 +1145,10 @@ export class WebViewProvider { // Register dispose handler this.panelManager.registerDisposeHandler(this.disposables); - // Listen for active editor changes and notify WebView - const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor( - (editor) => { - // If switching to a non-text editor (like webview), keep the last state - if (!editor) { - // Don't update - keep previous state - return; - } - - const filePath = editor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (editor && !editor.selection.isEmpty) { - const selection = editor.selection; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - // Update last known state - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - }, - ); - this.disposables.push(editorChangeDisposable); - - // Send initial active editor state to WebView - const initialEditor = vscode.window.activeTextEditor; - if (initialEditor) { - const filePath = initialEditor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - let selectionInfo = null; - if (!initialEditor.selection.isEmpty) { - const selection = initialEditor.selection; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - } - - // Listen for text selection changes (restore path) - const selectionChangeDisposableRestore = - vscode.window.onDidChangeTextEditorSelection((event) => { - const editor = event.textEditor; - if (editor === vscode.window.activeTextEditor) { - const filePath = editor.document.uri.fsPath || null; - const fileName = filePath ? getFileName(filePath) : null; - - // Get selection info if there is any selected text - let selectionInfo = null; - if (!event.selections[0].isEmpty) { - const selection = event.selections[0]; - selectionInfo = { - startLine: selection.start.line + 1, - endLine: selection.end.line + 1, - }; - } - - // Update last known state - - this.sendMessageToWebView({ - type: 'activeEditorChanged', - data: { fileName, filePath, selection: selectionInfo }, - }); - } - }); - this.disposables.push(selectionChangeDisposableRestore); - // Capture the tab reference on restore this.panelManager.captureTab(); console.log('[WebViewProvider] Panel restored successfully'); - - // Attempt to restore authentication state and initialize connection - console.log( - '[WebViewProvider] Attempting to restore auth state and connection after restore...', - ); - await this.attemptAuthStateRestoration(); } /** @@ -1182,7 +1201,10 @@ export class WebViewProvider { // Reload content after restore const panel = this.panelManager.getPanel(); if (panel) { - panel.webview.html = WebViewContent.generate(panel, this.extensionUri); + panel.webview.html = WebViewContent.generate( + panel.webview, + this.extensionUri, + ); } } From a87e638d796ae9c7a371144eb6730974f56850b7 Mon Sep 17 00:00:00 2001 From: kiri-chenchen Date: Wed, 28 Jan 2026 14:30:20 +0800 Subject: [PATCH 002/137] docs: explain Docker sandbox runtime and Java usage --- docs-site/src/app/docker-runtime/page.mdx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs-site/src/app/docker-runtime/page.mdx diff --git a/docs-site/src/app/docker-runtime/page.mdx b/docs-site/src/app/docker-runtime/page.mdx new file mode 100644 index 000000000..cd5ea5f86 --- /dev/null +++ b/docs-site/src/app/docker-runtime/page.mdx @@ -0,0 +1,23 @@ +# Docker sandbox runtime + +## Why Java is not available by default + +The official Qwen Code Docker image is intentionally minimal to keep the image +small, secure, and fast to pull. + +Different users require different language runtimes (Java, Python, Node.js, etc.), +and bundling all environments into a single image is not practical. + +Therefore, Java is **not included by default** in the Docker sandbox. + +## How to add Java to the Docker sandbox + +If your workflow requires Java, you can extend the base image with your own +dependencies. For example: + +```dockerfile +FROM qwenlm/qwen-code:latest + +RUN apt-get update && \ + apt-get install -y openjdk-17-jre && \ + apt-get clean From 02b5ff54bd19d1cd6d3e0834f8a2fdc522e43fc1 Mon Sep 17 00:00:00 2001 From: Sakuranda Date: Mon, 23 Feb 2026 00:58:38 +0800 Subject: [PATCH 003/137] fix(core): normalize Windows PATH-like env keys for shell execution --- .../services/shellExecutionService.test.ts | 58 +++++++++++++++++++ .../src/services/shellExecutionService.ts | 38 +++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 8c8e7bd4a..004658dad 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -421,6 +421,36 @@ describe('ShellExecutionService', () => { ); }); + it('should normalize PATH-like env keys on Windows for pty execution', async () => { + mockPlatform.mockReturnValue('win32'); + const originalPath = process.env['Path']; + const originalPATH = process.env['PATH']; + // On Windows, env keys are case-insensitive. Set PATH first, then Path. + process.env['PATH'] = 'C:\\Windows\\System32'; + process.env['Path'] = 'C:\\Users\\tester\\bin'; + + try { + await simulateExecution('dir', (pty) => + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }), + ); + + const spawnOptions = mockPtySpawn.mock.calls[0][2]; + expect(spawnOptions.env.Path).toBe('C:\\Users\\tester\\bin'); + expect(spawnOptions.env.PATH).toBeUndefined(); + } finally { + if (originalPath === undefined) { + delete process.env['Path']; + } else { + process.env['Path'] = originalPath; + } + if (originalPATH === undefined) { + delete process.env['PATH']; + } else { + process.env['PATH'] = originalPATH; + } + } + }); + it('should use bash on Linux', async () => { mockPlatform.mockReturnValue('linux'); await simulateExecution('ls "foo bar"', (pty) => @@ -836,6 +866,34 @@ describe('ShellExecutionService child_process fallback', () => { ); }); + it('should normalize PATH-like env keys on Windows for child_process fallback', async () => { + mockPlatform.mockReturnValue('win32'); + const originalPath = process.env['Path']; + const originalPATH = process.env['PATH']; + // On Windows, env keys are case-insensitive. Set PATH first, then Path. + process.env['PATH'] = 'C:\\Windows\\System32'; + process.env['Path'] = 'C:\\Users\\tester\\bin'; + + try { + await simulateExecution('dir', (cp) => cp.emit('exit', 0, null)); + + const spawnOptions = mockCpSpawn.mock.calls[0][2]; + expect(spawnOptions.env.Path).toBe('C:\\Users\\tester\\bin'); + expect(spawnOptions.env.PATH).toBeUndefined(); + } finally { + if (originalPath === undefined) { + delete process.env['Path']; + } else { + process.env['Path'] = originalPath; + } + if (originalPATH === undefined) { + delete process.env['PATH']; + } else { + process.env['PATH'] = originalPATH; + } + } + }); + it('should use bash and detached process group on Linux', async () => { mockPlatform.mockReturnValue('linux'); await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null)); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 3d812d899..64df994c9 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -22,6 +22,40 @@ const { Terminal } = pkg; const SIGKILL_TIMEOUT_MS = 200; +function normalizePathEnvForWindows( + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + if (os.platform() !== 'win32') { + return env; + } + + const normalized: NodeJS.ProcessEnv = { ...env }; + const pathKeys = Object.keys(normalized).filter( + (key) => key.toLowerCase() === 'path', + ); + + if (pathKeys.length === 0) { + return normalized; + } + + // Prefer canonical "Path" value when present, otherwise use the first + // available PATH-like key and collapse duplicates to avoid ambiguity. + const canonicalValue = + normalized['Path'] ?? normalized[pathKeys[0] as keyof NodeJS.ProcessEnv]; + + for (const key of pathKeys) { + if (key !== 'Path') { + delete normalized[key]; + } + } + + if (canonicalValue !== undefined) { + normalized['Path'] = canonicalValue; + } + + return normalized; +} + /** A structured result from a shell command execution. */ export interface ShellExecutionResult { /** The raw, unprocessed output buffer. */ @@ -237,7 +271,7 @@ export class ShellExecutionService { detached: !isWindows, windowsHide: isWindows, env: { - ...process.env, + ...normalizePathEnvForWindows(process.env), QWEN_CODE: '1', TERM: 'xterm-256color', PAGER: 'cat', @@ -431,7 +465,7 @@ export class ShellExecutionService { cols, rows, env: { - ...process.env, + ...normalizePathEnvForWindows(process.env), QWEN_CODE: '1', TERM: 'xterm-256color', PAGER: shellExecutionConfig.pager ?? 'cat', From 8e72c4fb875375a0963d7dc6ae86685eae442ab1 Mon Sep 17 00:00:00 2001 From: hs-ye Date: Fri, 27 Feb 2026 10:57:24 +1100 Subject: [PATCH 004/137] Add undocumented limits to sub-agents documentation - Document the 301 character limit for description field - Document the 10,000 character limit for system prompt Co-authored-by: Qwen-Coder --- docs/users/features/sub-agents.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index 85ca4aff9..cba874960 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -502,3 +502,10 @@ Always follow these standards: - **Access Control**: Project and user-level separation provides appropriate boundaries - **Sensitive Information**: Avoid including secrets or credentials in agent configurations - **Production Environments**: Consider separate agents for production vs development environments + +## Limits + +The following limits apply to Subagent configurations: + +- **Description Field**: Limited to 301 characters +- **System Prompt**: Limited to 10,000 characters From ed598312134271f4a8db975500052cbe09b82b87 Mon Sep 17 00:00:00 2001 From: hs-ye Date: Sat, 28 Feb 2026 14:21:28 +1100 Subject: [PATCH 005/137] fix: correct sub-agent limits in documentation - Change description field limit from 301 to 300 characters - Verified limits from source code in CreationSummary.tsx Co-authored-by: Qwen-Coder --- docs/users/features/sub-agents.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index cba874960..248d41747 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -507,5 +507,5 @@ Always follow these standards: The following limits apply to Subagent configurations: -- **Description Field**: Limited to 301 characters +- **Description Field**: Limited to 300 characters - **System Prompt**: Limited to 10,000 characters From de20bb12bd6b8c345ded0ccebf6d63230a960561 Mon Sep 17 00:00:00 2001 From: Drew Duncan Date: Sat, 28 Feb 2026 10:17:46 -0800 Subject: [PATCH 006/137] fix(core): reject PDF files to prevent session corruption (fixes #2020) --- docs/developers/tools/file-system.md | 8 +++++--- packages/core/src/utils/fileUtils.test.ts | 21 +++++---------------- packages/core/src/utils/fileUtils.ts | 10 ++++++++-- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/docs/developers/tools/file-system.md b/docs/developers/tools/file-system.md index bfa6de8d0..bf449b44e 100644 --- a/docs/developers/tools/file-system.md +++ b/docs/developers/tools/file-system.md @@ -24,7 +24,7 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local ## 2. `read_file` (ReadFile) -`read_file` reads and returns the content of a specified file. This tool handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges. Other binary file types are generally skipped. +`read_file` reads and returns the content of a specified file. This tool handles text and images (PNG, JPG, GIF, WEBP, SVG, BMP). For text files, it can read specific line ranges. PDF files are not supported directly - extract text externally first. Other binary file types are generally skipped. - **Tool name:** `read_file` - **Display name:** ReadFile @@ -35,11 +35,13 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local - `limit` (number, optional): For text files, the maximum number of lines to read. If omitted, reads a default maximum (e.g., 2000 lines) or the entire file if feasible. - **Behavior:** - For text files: Returns the content. If `offset` and `limit` are used, returns only that slice of lines. Indicates if content was truncated due to line limits or line length limits. - - For image and PDF files: Returns the file content as a base64-encoded data structure suitable for model consumption. + - For image files: Returns the file content as a base64-encoded `inlineData` object suitable for model consumption. + - For PDF files: Returns an error message directing users to extract text externally. - For other binary files: Attempts to identify and skip them, returning a message indicating it's a generic binary file. - **Output:** (`llmContent`): - For text files: The file content, potentially prefixed with a truncation message (e.g., `[File content truncated: showing lines 1-100 of 500 total lines...]\nActual file content...`). - - For image/PDF files: An object containing `inlineData` with `mimeType` and base64 `data` (e.g., `{ inlineData: { mimeType: 'image/png', data: 'base64encodedstring' } }`). + - For image files: An object containing `inlineData` with `mimeType` and base64 `data` (e.g., `{ inlineData: { mimeType: 'image/png', data: 'base64encodedstring' } }`). + - For PDF files: An error message string explaining that PDFs are not supported. - For other binary files: A message like `Cannot display content of binary file: /path/to/data.bin`. - **Confirmation:** No. diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index da9f257fd..d695642b2 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -738,7 +738,7 @@ describe('fileUtils', () => { expect(result.returnDisplay).toContain('Read image file: image.png'); }); - it('should process a PDF file', async () => { + it('should reject PDF files with error message', async () => { const fakePdfData = Buffer.from('fake pdf data'); actualNodeFs.writeFileSync(testPdfFilePath, fakePdfData); mockMimeGetType.mockReturnValue('application/pdf'); @@ -746,21 +746,10 @@ describe('fileUtils', () => { testPdfFilePath, mockConfig, ); - expect( - (result.llmContent as { inlineData: unknown }).inlineData, - ).toBeDefined(); - expect( - (result.llmContent as { inlineData: { mimeType: string } }).inlineData - .mimeType, - ).toBe('application/pdf'); - expect( - (result.llmContent as { inlineData: { data: string } }).inlineData.data, - ).toBe(fakePdfData.toString('base64')); - expect( - (result.llmContent as { inlineData: { displayName?: string } }) - .inlineData.displayName, - ).toBe('document.pdf'); - expect(result.returnDisplay).toContain('Read pdf file: document.pdf'); + expect(typeof result.llmContent).toBe('string'); + expect(result.llmContent).toContain('PDF files cannot be read directly'); + expect(result.returnDisplay).toContain('Skipped PDF file'); + expect(result.error).toContain('PDF files are not supported'); }); it('should read an SVG file as text when under 1MB', async () => { diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 3e4124d18..5e42bc5f4 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -461,8 +461,7 @@ export async function processSingleFileContent( } case 'image': case 'audio': - case 'video': - case 'pdf': { + case 'video': { const contentBuffer = await fs.promises.readFile(filePath); const base64Data = contentBuffer.toString('base64'); return { @@ -476,6 +475,13 @@ export async function processSingleFileContent( returnDisplay: `Read ${fileType} file: ${relativePathForDisplay}`, }; } + case 'pdf': { + return { + llmContent: `PDF files cannot be read directly. Use an external tool to extract text from: ${relativePathForDisplay}`, + returnDisplay: `Skipped PDF file: ${relativePathForDisplay}`, + error: `PDF files are not supported. Extract text externally and paste it instead.`, + }; + } default: { // Should not happen with current detectFileType logic const exhaustiveCheck: never = fileType; From 6867b43be3335fea5bd9a6eb7537f6be29531db4 Mon Sep 17 00:00:00 2001 From: xieyonn Date: Mon, 2 Mar 2026 19:53:52 +0800 Subject: [PATCH 007/137] feat(cli): change temporary filename prefix to qwen-edit- --- packages/cli/src/ui/components/shared/text-buffer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index baed1c192..369c7fff5 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1840,7 +1840,7 @@ export function useTextBuffer({ process.env['VISUAL'] ?? process.env['EDITOR'] ?? (process.platform === 'win32' ? 'notepad' : 'vi'); - const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); + const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'qwen-edit-')); const filePath = pathMod.join(tmpDir, 'buffer.txt'); fs.writeFileSync(filePath, text, 'utf8'); From d98ffd0b00779138fe0d35397f09d9f819cdb3a4 Mon Sep 17 00:00:00 2001 From: Drew Duncan Date: Wed, 4 Mar 2026 01:00:39 -0800 Subject: [PATCH 008/137] feat: check model modalities before processing media files --- packages/core/src/utils/fileUtils.test.ts | 62 +++++++++++++++++-- packages/core/src/utils/fileUtils.ts | 74 ++++++++++++++++++++--- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index e1c328f16..cc7614f3c 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -54,6 +54,7 @@ describe('fileUtils', () => { getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputLines: () => 500, getTargetDir: () => tempRootDir, + getModel: () => 'qwen3.5-plus', // Default model with image+video support } as unknown as Config; beforeEach(() => { @@ -738,18 +739,69 @@ describe('fileUtils', () => { expect(result.returnDisplay).toContain('Read image file: image.png'); }); - it('should reject PDF files with error message', async () => { + it('should reject image files when model does not support image', async () => { + const fakePngData = Buffer.from('fake png data'); + actualNodeFs.writeFileSync(testImageFilePath, fakePngData); + mockMimeGetType.mockReturnValue('image/png'); + + // Use a model that doesn't support image (text-only model) + const mockConfigNoImage = { + ...mockConfig, + getModel: () => 'deepseek-chat', + } as unknown as Config; + + const result = await processSingleFileContent( + testImageFilePath, + mockConfigNoImage, + ); + expect(typeof result.llmContent).toBe('string'); + expect(result.llmContent).toContain('does not support image input'); + expect(result.returnDisplay).toContain('Skipped image file'); + expect(result.error).toContain('does not support image input'); + }); + + it('should reject PDF files when model does not support PDF', async () => { const fakePdfData = Buffer.from('fake pdf data'); actualNodeFs.writeFileSync(testPdfFilePath, fakePdfData); mockMimeGetType.mockReturnValue('application/pdf'); + + // Use a model that doesn't support PDF (e.g., qwen text-only model) + const mockConfigNoPdf = { + ...mockConfig, + getModel: () => 'qwen3-coder-plus', + } as unknown as Config; + const result = await processSingleFileContent( testPdfFilePath, - mockConfig, + mockConfigNoPdf, ); expect(typeof result.llmContent).toBe('string'); - expect(result.llmContent).toContain('PDF files cannot be read directly'); - expect(result.returnDisplay).toContain('Skipped PDF file'); - expect(result.error).toContain('PDF files are not supported'); + expect(result.llmContent).toContain('does not support pdf input'); + expect(result.returnDisplay).toContain('Skipped pdf file'); + expect(result.error).toContain('does not support pdf input'); + }); + + it('should accept PDF files when model supports PDF', async () => { + const fakePdfData = Buffer.from('fake pdf data'); + actualNodeFs.writeFileSync(testPdfFilePath, fakePdfData); + mockMimeGetType.mockReturnValue('application/pdf'); + + // Use a model that supports PDF (e.g., Claude) + const mockConfigWithPdf = { + ...mockConfig, + getModel: () => 'claude-3-sonnet', + } as unknown as Config; + + const result = await processSingleFileContent( + testPdfFilePath, + mockConfigWithPdf, + ); + expect(result.llmContent).toHaveProperty('inlineData'); + expect( + (result.llmContent as { inlineData: { mimeType: string } }).inlineData + .mimeType, + ).toBe('application/pdf'); + expect(result.returnDisplay).toContain('Read pdf file'); }); it('should read an SVG file as text when under 1MB', async () => { diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 04bfe5388..1846c1909 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -13,6 +13,8 @@ import { ToolErrorType } from '../tools/tool-error.js'; import { BINARY_EXTENSIONS } from './ignorePatterns.js'; import type { Config } from '../config/config.js'; import { createDebugLogger } from './debugLogger.js'; +import { defaultModalities } from '../core/modalityDefaults.js'; +import type { InputModalities } from '../core/contentGenerator.js'; const debugLogger = createDebugLogger('FILE_UTILS'); @@ -302,6 +304,49 @@ export interface ProcessedFileReadResult { linesShown?: [number, number]; // For text files [startLine, endLine] (1-based for display) } +/** + * Maps file type to the corresponding modality flag. + */ +function fileTypeToModalityKey( + fileType: 'image' | 'pdf' | 'audio' | 'video', +): keyof InputModalities { + switch (fileType) { + case 'image': + return 'image'; + case 'pdf': + return 'pdf'; + case 'audio': + return 'audio'; + case 'video': + return 'video'; + default: + // This should never happen due to the type constraint + throw new Error(`Unexpected file type: ${fileType}`); + } +} + +/** + * Checks if a file type is supported by the model's input modalities. + * @param fileType The detected file type. + * @param modalities The model's supported input modalities. + * @returns True if the file type is supported, false otherwise. + */ +function isFileTypeSupported( + fileType: 'image' | 'pdf' | 'audio' | 'video' | 'text' | 'binary' | 'svg', + modalities: InputModalities, +): boolean { + // Text, binary (rejected separately), and SVG (treated as text) are always supported + if (fileType === 'text' || fileType === 'binary' || fileType === 'svg') { + return true; + } + + // Check modalities for media types + const modalityKey = fileTypeToModalityKey( + fileType as 'image' | 'pdf' | 'audio' | 'video', + ); + return modalities[modalityKey] === true; +} + /** * Reads and processes a single file, handling text, images, and PDFs. * @param filePath Absolute path to the file. @@ -356,6 +401,25 @@ export async function processSingleFileContent( .replace(/\\/g, '/'); const displayName = path.basename(filePath); + + // Get the current model's supported modalities + const model = config.getModel(); + const modalities = defaultModalities(model); + + // Check if the file type is supported by the current model + if (!isFileTypeSupported(fileType, modalities)) { + // At this point, fileType must be a media type (image, pdf, audio, video) + // because text/binary/svg are always supported + const modalityName = fileTypeToModalityKey( + fileType as 'image' | 'pdf' | 'audio' | 'video', + ); + return { + llmContent: `The current model "${model}" does not support ${modalityName} input. ${fileType.toUpperCase()} files cannot be read directly.`, + returnDisplay: `Skipped ${fileType} file: ${relativePathForDisplay} (model doesn't support ${modalityName} input)`, + error: `Model "${model}" does not support ${modalityName} input. Please use a model that supports ${modalityName} or convert the file to text externally.`, + }; + } + switch (fileType) { case 'binary': { return { @@ -462,7 +526,8 @@ export async function processSingleFileContent( } case 'image': case 'audio': - case 'video': { + case 'video': + case 'pdf': { const contentBuffer = await fs.promises.readFile(filePath); const base64Data = contentBuffer.toString('base64'); const base64SizeInMB = base64Data.length / (1024 * 1024); @@ -486,13 +551,6 @@ export async function processSingleFileContent( returnDisplay: `Read ${fileType} file: ${relativePathForDisplay}`, }; } - case 'pdf': { - return { - llmContent: `PDF files cannot be read directly. Use an external tool to extract text from: ${relativePathForDisplay}`, - returnDisplay: `Skipped PDF file: ${relativePathForDisplay}`, - error: `PDF files are not supported. Extract text externally and paste it instead.`, - }; - } default: { // Should not happen with current detectFileType logic const exhaustiveCheck: never = fileType; From 0cede7bc5ea07fb1a37274804a4ca7263c421e09 Mon Sep 17 00:00:00 2001 From: Drew Duncan Date: Wed, 4 Mar 2026 01:08:52 -0800 Subject: [PATCH 009/137] style: format streamingToolCallParser.test.ts (prettier) --- .../openaiContentGenerator/streamingToolCallParser.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts index dc4d696d5..1735097be 100644 --- a/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts +++ b/packages/core/src/core/openaiContentGenerator/streamingToolCallParser.test.ts @@ -813,7 +813,12 @@ describe('StreamingToolCallParser', () => { it('should return true when a tool call is inside a string literal', () => { // Simulate truncation mid-string: {"file_path": "/tmp/test.txt", "content": "some text - parser.addChunk(0, '{"file_path": "/tmp/test.txt"', 'call_1', 'write_file'); + parser.addChunk( + 0, + '{"file_path": "/tmp/test.txt"', + 'call_1', + 'write_file', + ); parser.addChunk(0, ', "content": "some text'); const state = parser.getState(0); expect(state.inString).toBe(true); From 4ce6f6f597f7704397f5394a52955ff78d3f68ff Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Fri, 6 Mar 2026 19:00:49 +0800 Subject: [PATCH 010/137] Keep rejected plan content visible in plan mode When a plan is rejected, preserve and display the plan content so users can still see what was proposed. The rejection message is now shown in yellow (AccentYellow) instead of green to visually indicate the rejected state. Changes: - Add 'rejected' flag to PlanResultDisplay interface - Update PlanSummaryDisplay to conditionally color message based on rejection - Preserve plan content in coreToolScheduler when plan is cancelled - Add tests for both rejected and approved plan rendering Co-authored-by: Qwen-Coder --- .../src/ui/components/PlanSummaryDisplay.tsx | 5 +- .../components/messages/ToolMessage.test.tsx | 51 +++++++++++++++++++ packages/core/src/core/coreToolScheduler.ts | 8 +++ packages/core/src/tools/tools.ts | 1 + 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/PlanSummaryDisplay.tsx b/packages/cli/src/ui/components/PlanSummaryDisplay.tsx index c827b9d86..a856bcdc4 100644 --- a/packages/cli/src/ui/components/PlanSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/PlanSummaryDisplay.tsx @@ -21,12 +21,13 @@ export const PlanSummaryDisplay: React.FC = ({ availableHeight, childWidth, }) => { - const { message, plan } = data; + const { message, plan, rejected } = data; + const messageColor = rejected ? Colors.AccentYellow : Colors.AccentGreen; return ( - + {message} diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 0c44a8ed9..e5f846601 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -300,4 +300,55 @@ describe('', () => { ); expect(lastFrame()).toContain('MockAnsiOutput:hello'); }); + + it('renders rejected plan content with plan text still visible', () => { + const planResultDisplay = { + type: 'plan_summary' as const, + message: 'Plan was rejected. Remaining in plan mode.', + plan: '# My Plan\n- Step 1: Do something\n- Step 2: Do another thing', + rejected: true, + }; + + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + + const output = lastFrame(); + expect(output).toContain('Plan was rejected. Remaining in plan mode.'); + expect(output).toContain('MockMarkdown:# My Plan'); + expect(output).toContain('- Step 1: Do something'); + expect(output).toContain('- Step 2: Do another thing'); + }); + + it('renders approved plan content with approval message', () => { + const planResultDisplay = { + type: 'plan_summary' as const, + message: 'User approved the plan.', + plan: '# My Plan\n- Step 1\n- Step 2', + }; + + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + + const output = lastFrame(); + expect(output).toContain('User approved the plan.'); + expect(output).toContain('MockMarkdown:# My Plan'); + expect(output).toContain('- Step 1'); + expect(output).toContain('- Step 2'); + }); }); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 3cdc8232f..0beca4a0a 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -509,6 +509,7 @@ export class CoreToolScheduler { : undefined; // Preserve diff for cancelled edit operations + // Preserve plan content for cancelled plan operations let resultDisplay: ToolResultDisplay | undefined = undefined; if (currentCall.status === 'awaiting_approval') { const waitingCall = currentCall as WaitingToolCall; @@ -520,6 +521,13 @@ export class CoreToolScheduler { waitingCall.confirmationDetails.originalContent, newContent: waitingCall.confirmationDetails.newContent, }; + } else if (waitingCall.confirmationDetails.type === 'plan') { + resultDisplay = { + type: 'plan_summary', + message: 'Plan was rejected. Remaining in plan mode.', + plan: waitingCall.confirmationDetails.plan, + rejected: true, + }; } } else if (currentCall.status === 'executing') { // If the tool was streaming live output, preserve the latest diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 96ae53402..3406dff7c 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -525,6 +525,7 @@ export interface PlanResultDisplay { type: 'plan_summary'; message: string; plan: string; + rejected?: boolean; } export interface ToolEditConfirmationDetails { From c10aa7ebe5a99cf66eb6826b2cc7579825654278 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 7 Mar 2026 00:30:40 +0800 Subject: [PATCH 011/137] feat(vscode-ide-companion/layout): add sidebar view and simplify chat positioning All chat positions (sidebar, editor tab, panel, secondary sidebar) are now available simultaneously. Remove the old chat.location configuration and setChatLocation commands. Add focusChat, newConversation, and showLogs commands. Refactor ChatWebviewViewProvider to use lazy factory pattern and move webview files into providers/ subdirectory. --- .../assets/sidebar-icon.svg | 6 + packages/vscode-ide-companion/package.json | 92 ++++------ .../src/commands/index.ts | 137 +++++--------- .../src/constants/viewIds.ts | 13 ++ .../src/extension.test.ts | 3 + .../vscode-ide-companion/src/extension.ts | 169 +++--------------- .../src/utils/editorGroupUtils.ts | 16 +- .../src/webview/ChatWebviewViewProvider.ts | 41 ----- .../providers/ChatWebviewViewProvider.ts | 47 +++++ .../webview/{ => providers}/MessageHandler.ts | 8 +- .../webview/{ => providers}/PanelManager.ts | 0 .../webview/{ => providers}/WebViewContent.ts | 2 +- .../{ => providers}/WebViewProvider.ts | 22 +-- 13 files changed, 190 insertions(+), 366 deletions(-) create mode 100644 packages/vscode-ide-companion/assets/sidebar-icon.svg create mode 100644 packages/vscode-ide-companion/src/constants/viewIds.ts delete mode 100644 packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts create mode 100644 packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts rename packages/vscode-ide-companion/src/webview/{ => providers}/MessageHandler.ts (84%) rename packages/vscode-ide-companion/src/webview/{ => providers}/PanelManager.ts (100%) rename packages/vscode-ide-companion/src/webview/{ => providers}/WebViewContent.ts (96%) rename packages/vscode-ide-companion/src/webview/{ => providers}/WebViewProvider.ts (98%) diff --git a/packages/vscode-ide-companion/assets/sidebar-icon.svg b/packages/vscode-ide-companion/assets/sidebar-icon.svg new file mode 100644 index 000000000..51cdab785 --- /dev/null +++ b/packages/vscode-ide-companion/assets/sidebar-icon.svg @@ -0,0 +1,6 @@ + + + diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 37152f8b4..358aa018a 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -31,60 +31,60 @@ "onStartupFinished", "onView:qwenCode.chatView.panel", "onView:qwenCode.chatView.secondary", - "onCommand:qwen-code.setChatLocation.editor", - "onCommand:qwen-code.setChatLocation.panel", - "onCommand:qwen-code.setChatLocation.secondary", - "onCommand:qwen-code.openChat" + "onView:qwenCode.chatView.sidebar", + "onCommand:qwen-code.openChat", + "onCommand:qwen-code.focusChat", + "onCommand:qwen-code.newConversation", + "onCommand:qwen-code.showLogs" ], "contributes": { "configuration": { "title": "Qwen Code Companion", - "properties": { - "qwen-code.chat.location": { - "type": "string", - "default": "editor", - "enum": [ - "editor", - "panel", - "secondary" - ], - "description": "选择聊天界面放置的位置:编辑器标签页(默认)、底部面板或 Secondary Side Bar。修改后需重新打开聊天。" - } - } + "properties": {} }, "viewsContainers": { + "activitybar": [ + { + "id": "qwenCodeSidebar", + "title": "Qwen Code", + "icon": "assets/sidebar-icon.svg" + } + ], "panel": [ { "id": "qwenCodePanel", "title": "Qwen Code", - "icon": "assets/icon.png", - "when": "qwenCode.chatLocation == 'panel'" + "icon": "assets/icon.png" } ], "secondarySidebar": [ { "id": "qwenCodeSecondary", "title": "Qwen Code", - "icon": "assets/icon.png", - "when": "qwenCode.chatLocation == 'secondary'" + "icon": "assets/icon.png" } ] }, "views": { + "qwenCodeSidebar": [ + { + "type": "webview", + "id": "qwenCode.chatView.sidebar", + "name": "Qwen Code" + } + ], "qwenCodePanel": [ { "id": "qwenCode.chatView.panel", "name": "Qwen Code", - "icon": "assets/icon.png", - "when": "qwenCode.chatLocation == 'panel'" + "icon": "assets/icon.png" } ], "qwenCodeSecondary": [ { "id": "qwenCode.chatView.secondary", "name": "Qwen Code", - "icon": "assets/icon.png", - "when": "qwenCode.chatLocation == 'secondary'" + "icon": "assets/icon.png" } ] }, @@ -122,22 +122,16 @@ "title": "Qwen Code: Login" }, { - "command": "qwen-code.setChatLocation.editor", - "title": "Qwen Code: 将聊天放在编辑器标签页" + "command": "qwen-code.focusChat", + "title": "Qwen Code: Focus Chat Input" }, { - "command": "qwen-code.setChatLocation.panel", - "title": "Qwen Code: 将聊天放在底部面板" + "command": "qwen-code.newConversation", + "title": "Qwen Code: New Conversation" }, { - "command": "qwen-code.setChatLocation.secondary", - "title": "Qwen Code: 将聊天放在 Secondary Bar" - } - ], - "submenus": [ - { - "id": "qwenCode.chatLocationMenu", - "label": "Qwen Code: 聊天位置" + "command": "qwen-code.showLogs", + "title": "Qwen Code: Show Logs" } ], "menus": { @@ -169,27 +163,6 @@ { "command": "qwen-code.openChat", "group": "navigation" - }, - { - "submenu": "qwenCode.chatLocationMenu", - "group": "navigation@2" - } - ], - "qwenCode.chatLocationMenu": [ - { - "command": "qwen-code.setChatLocation.editor", - "group": "1_editor", - "when": "qwenCode.chatLocation != 'editor'" - }, - { - "command": "qwen-code.setChatLocation.panel", - "group": "2_panel", - "when": "qwenCode.chatLocation != 'panel'" - }, - { - "command": "qwen-code.setChatLocation.secondary", - "group": "3_secondary", - "when": "qwenCode.chatLocation != 'secondary'" } ] }, @@ -203,6 +176,11 @@ "command": "qwen.diff.accept", "key": "cmd+s", "when": "qwen.diff.isVisible" + }, + { + "command": "qwen-code.focusChat", + "key": "ctrl+shift+l", + "mac": "cmd+shift+l" } ] }, diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 0a04fafe1..3af162bd3 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -6,40 +6,47 @@ import * as vscode from 'vscode'; import type { DiffManager } from '../diff-manager.js'; -import type { WebViewProvider } from '../webview/WebViewProvider.js'; +import type { WebViewProvider } from '../webview/providers/WebViewProvider.js'; +import { CHAT_VIEW_ID_SIDEBAR } from '../constants/viewIds.js'; type Logger = (message: string) => void; -export type ChatHostLocation = 'editor' | 'panel' | 'secondary'; export const runQwenCodeCommand = 'qwen-code.runQwenCode'; export const showDiffCommand = 'qwenCode.showDiff'; export const openChatCommand = 'qwen-code.openChat'; export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; export const loginCommand = 'qwen-code.login'; -export const setChatLocationEditorCommand = 'qwen-code.setChatLocation.editor'; -export const setChatLocationPanelCommand = 'qwen-code.setChatLocation.panel'; -export const setChatLocationSecondaryCommand = - 'qwen-code.setChatLocation.secondary'; +export const focusChatCommand = 'qwen-code.focusChat'; +export const newConversationCommand = 'qwen-code.newConversation'; +export const showLogsCommand = 'qwen-code.showLogs'; +/** + * Register all Qwen Code chat-related commands. + * + * All chat positions (editor tab, sidebar, panel, secondary sidebar) are + * available simultaneously. `openChat` and `newConversation` always open an + * editor tab, while `focusChat` focuses the primary sidebar view. + * + * @param context - VS Code extension context for subscription management + * @param log - Logger function for debug output + * @param diffManager - Diff manager for showing file diffs + * @param getWebViewProviders - Returns all active editor-tab WebView providers + * @param createWebViewProvider - Factory to create a new editor-tab WebView provider + * @param outputChannel - Optional output channel for the showLogs command + */ export function registerNewCommands( context: vscode.ExtensionContext, log: Logger, diffManager: DiffManager, getWebViewProviders: () => WebViewProvider[], createWebViewProvider: () => WebViewProvider, - getChatHostLocation: () => ChatHostLocation, - focusChatView: () => Promise, + outputChannel?: vscode.OutputChannel, ): void { const disposables: vscode.Disposable[] = []; + // Open Chat: show the most recent editor tab or create a new one disposables.push( vscode.commands.registerCommand(openChatCommand, async () => { - const host = getChatHostLocation(); - if (host !== 'editor') { - await focusChatView(); - return; - } - const providers = getWebViewProviders(); if (providers.length > 0) { await providers[providers.length - 1].show(); @@ -75,19 +82,10 @@ export function registerNewCommands( ), ); + // Open New Chat Tab: always create a new editor tab disposables.push( vscode.commands.registerCommand(openNewChatTabCommand, async () => { - const host = getChatHostLocation(); - if (host !== 'editor') { - vscode.window.showInformationMessage( - '当前配置使用面板/Secondary Bar 承载聊天,暂不支持多标签。将为你聚焦现有聊天视图。', - ); - await focusChatView(); - return; - } - const provider = createWebViewProvider(); - // Session restoration is now disabled by default, so no need to suppress it await provider.show(); }), ); @@ -105,74 +103,33 @@ export function registerNewCommands( }), ); - const updateChatLocation = async (location: ChatHostLocation) => { - const current = getChatHostLocation(); - if (current === location) { - log(`[Command] Chat location unchanged (${location}), focusing view`); - await focusChatView(); - return; - } - - const target: vscode.ConfigurationTarget = - vscode.workspace.workspaceFolders?.length && vscode.workspace.name - ? vscode.ConfigurationTarget.Workspace - : vscode.ConfigurationTarget.Global; - - try { - await vscode.workspace - .getConfiguration('qwen-code') - .update('chat.location', location, target); - log( - `[Command] Chat location set to ${location} (target=${target}; prev=${current})`, - ); - // Update context immediately so view container visibility switches without reload - await vscode.commands.executeCommand( - 'setContext', - 'qwenCode.chatLocation', - location, - ); - - // Dispose existing editor-hosted chat panels when moving to panel/secondary to avoid confusion. - if (location !== 'editor') { - for (const provider of getWebViewProviders()) { - try { - provider.getPanel()?.dispose(); - } catch (error) { - log( - `[Command] Failed to dispose chat panel during relocation: ${error}`, - ); - } - } - } - - // Small delay to ensure context has updated before focusing - await new Promise((resolve) => setTimeout(resolve, 100)); - await focusChatView(); - void vscode.window.showInformationMessage( - `聊天位置已切换为 ${location === 'editor' ? '编辑器标签页' : location === 'panel' ? '底部面板' : 'Secondary Bar'},已尝试为你打开对应位置。如未生效,请重载窗口。`, - ); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - log(`[Command] Failed to set chat location: ${msg}`); - void vscode.window.showErrorMessage( - `切换聊天位置失败:${msg}。可在 Settings 搜索 "Qwen Code Chat Location" 手动修改。`, - ); - } - }; - + // Focus Chat: bring the primary sidebar chat view to front disposables.push( - vscode.commands.registerCommand(setChatLocationEditorCommand, async () => { - await updateChatLocation('editor'); + vscode.commands.registerCommand(focusChatCommand, async () => { + await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SIDEBAR}.focus`); }), - vscode.commands.registerCommand(setChatLocationPanelCommand, async () => { - await updateChatLocation('panel'); - }), - vscode.commands.registerCommand( - setChatLocationSecondaryCommand, - async () => { - await updateChatLocation('secondary'); - }, - ), ); + + // New Conversation: open a new editor tab for a fresh conversation + disposables.push( + vscode.commands.registerCommand(newConversationCommand, async () => { + const provider = createWebViewProvider(); + await provider.show(); + }), + ); + + // Show Logs: reveal the output channel + disposables.push( + vscode.commands.registerCommand(showLogsCommand, async () => { + if (outputChannel) { + outputChannel.show(true); + } else { + vscode.window.showWarningMessage( + 'Qwen Code Companion log channel is not available.', + ); + } + }), + ); + context.subscriptions.push(...disposables); } diff --git a/packages/vscode-ide-companion/src/constants/viewIds.ts b/packages/vscode-ide-companion/src/constants/viewIds.ts new file mode 100644 index 000000000..96862d058 --- /dev/null +++ b/packages/vscode-ide-companion/src/constants/viewIds.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * WebviewView IDs for the three host positions where the chat UI can appear. + * These IDs must match the `views` contributions declared in package.json. + */ +export const CHAT_VIEW_ID_PANEL = 'qwenCode.chatView.panel'; +export const CHAT_VIEW_ID_SECONDARY = 'qwenCode.chatView.secondary'; +export const CHAT_VIEW_ID_SIDEBAR = 'qwenCode.chatView.sidebar'; diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index ef0d5ad46..d6128f91e 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -43,6 +43,9 @@ vi.mock('vscode', () => ({ registerWebviewPanelSerializer: vi.fn(() => ({ dispose: vi.fn(), })), + registerWebviewViewProvider: vi.fn(() => ({ + dispose: vi.fn(), + })), }, workspace: { workspaceFolders: [], diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 0efe48c6b..55c3f5299 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -14,43 +14,20 @@ import { IDE_DEFINITIONS, type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; -import { WebViewProvider } from './webview/WebViewProvider.js'; +import { WebViewProvider } from './webview/providers/WebViewProvider.js'; +import { ChatWebviewViewProvider } from './webview/providers/ChatWebviewViewProvider.js'; import { - ChatWebviewViewProvider, CHAT_VIEW_ID_PANEL, CHAT_VIEW_ID_SECONDARY, -} from './webview/ChatWebviewViewProvider.js'; -import { - registerNewCommands, - type ChatHostLocation, -} from './commands/index.js'; + CHAT_VIEW_ID_SIDEBAR, +} from './constants/viewIds.js'; +import { registerNewCommands } from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion'; const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown'; export const DIFF_SCHEME = 'qwen-diff'; -const CHAT_LOCATION_SETTING_KEY = 'chat.location'; - -function getChatHostLocation(): ChatHostLocation { - const location = vscode.workspace - .getConfiguration('qwen-code') - .get(CHAT_LOCATION_SETTING_KEY, 'editor'); - - if (location === 'panel' || location === 'secondary') { - return location; - } - return 'editor'; -} - -async function setChatLocationContext(location: ChatHostLocation) { - console.log('[Extension] setChatLocationContext ->', location); - await vscode.commands.executeCommand( - 'setContext', - 'qwenCode.chatLocation', - location, - ); -} /** * IDE environments where the installation greeting is hidden. In these @@ -172,9 +149,6 @@ export async function activate(context: vscode.ExtensionContext) { }, ); - const initialChatLocation = getChatHostLocation(); - await setChatLocationContext(initialChatLocation); - // Helper function to create a new WebView provider instance const createWebViewProvider = (): WebViewProvider => { const provider = new WebViewProvider(context, context.extensionUri); @@ -182,43 +156,24 @@ export async function activate(context: vscode.ExtensionContext) { return provider; }; - // Register WebviewView hosts (panel and secondary sidebar). They are shown based on config conditions. - const chatViewProviderPanel = new ChatWebviewViewProvider( - createWebViewProvider(), - context.extensionUri, - 'panel', - ); - const chatViewProviderSecondary = new ChatWebviewViewProvider( - createWebViewProvider(), - context.extensionUri, - 'secondary', - ); - console.log( - '[Extension] Registering WebviewView providers for panel and secondary', - ); - const panelProviderDisposable = vscode.window.registerWebviewViewProvider( + // Register WebviewView hosts for all positions (sidebar, panel, secondary). + // Providers are lazily instantiated — the factory is only called when VS Code + // actually opens the view, keeping startup lightweight. + const chatViewIds = [ + CHAT_VIEW_ID_SIDEBAR, CHAT_VIEW_ID_PANEL, - chatViewProviderPanel, - { - webviewOptions: { retainContextWhenHidden: true }, - }, - ); - const secondaryProviderDisposable = vscode.window.registerWebviewViewProvider( CHAT_VIEW_ID_SECONDARY, - chatViewProviderSecondary, - { - webviewOptions: { retainContextWhenHidden: true }, - }, - ); - console.log( - '[Extension] WebviewView providers registered:', - panelProviderDisposable ? 'panel' : 'panel (missing)', - secondaryProviderDisposable ? 'secondary' : 'secondary (missing)', - ); - context.subscriptions.push( - panelProviderDisposable, - secondaryProviderDisposable, - ); + ] as const; + + for (const viewId of chatViewIds) { + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + viewId, + new ChatWebviewViewProvider(createWebViewProvider), + { webviewOptions: { retainContextWhenHidden: true } }, + ), + ); + } // Register WebView panel serializer for persistence across reloads context.subscriptions.push( @@ -257,43 +212,6 @@ export async function activate(context: vscode.ExtensionContext) { }), ); - const focusChatView = async () => { - const host = getChatHostLocation(); - if (host === 'editor') { - const providers = webViewProviders; - if (providers.length > 0) { - await providers[providers.length - 1].show(); - } else { - const provider = createWebViewProvider(); - await provider.show(); - } - return; - } - - const viewId = - host === 'secondary' ? CHAT_VIEW_ID_SECONDARY : CHAT_VIEW_ID_PANEL; - try { - // Ensure the view provider is registered and ready before focusing - await new Promise((resolve) => setTimeout(resolve, 200)); - await vscode.commands.executeCommand(`${viewId}.focus`); - - // Wait a bit more to ensure the view has initialized - await new Promise((resolve) => setTimeout(resolve, 300)); - } catch (err) { - log(`Failed to focus chat view (${viewId}): ${err}`); - // Try to ensure the view provider exists by creating new ones if needed - try { - const currentProviders = webViewProviders; - if (currentProviders.length === 0) { - createWebViewProvider(); // Create a new provider if none exist - } - await vscode.commands.executeCommand(`${viewId}.focus`); - } catch (retryErr) { - log(`Retry failed to focus chat view (${viewId}): ${retryErr}`); - } - } - }; - // Register newly added commands via commands module registerNewCommands( context, @@ -301,8 +219,7 @@ export async function activate(context: vscode.ExtensionContext) { diffManager, () => webViewProviders, createWebViewProvider, - getChatHostLocation, - focusChatView, + logger, ); context.subscriptions.push( @@ -311,48 +228,6 @@ export async function activate(context: vscode.ExtensionContext) { diffManager.cancelDiff(doc.uri); } }), - vscode.workspace.onDidChangeConfiguration(async (event) => { - if (event.affectsConfiguration('qwen-code.chat.location')) { - const newLocation = getChatHostLocation(); - log( - `[Extension] Configuration changed: chat.location -> ${newLocation}`, - ); - await setChatLocationContext(newLocation); - if (newLocation !== 'editor') { - for (const provider of webViewProviders) { - try { - provider.getPanel()?.dispose(); - } catch (err) { - log( - `[Extension] Failed to dispose chat panel after location change: ${err}`, - ); - } - } - } - - // Focus the appropriate view after config change - if (newLocation === 'panel' || newLocation === 'secondary') { - const viewId = - newLocation === 'secondary' - ? CHAT_VIEW_ID_SECONDARY - : CHAT_VIEW_ID_PANEL; - try { - // Give VS Code a moment to update the context and UI - await new Promise((resolve) => setTimeout(resolve, 300)); - await vscode.commands.executeCommand(`${viewId}.focus`); - - // Refresh the view to ensure content appears - await new Promise((resolve) => setTimeout(resolve, 200)); - // Trigger a re-render by focusing again after a delay - await vscode.commands.executeCommand(`${viewId}.focus`); - } catch (err) { - log( - `[Extension] Failed to focus chat view after location change: ${err}`, - ); - } - } - } - }), vscode.workspace.registerTextDocumentContentProvider( DIFF_SCHEME, diffContentProvider, diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts index 13897412c..e855b2dec 100644 --- a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts @@ -7,23 +7,13 @@ import * as vscode from 'vscode'; import { openChatCommand } from '../commands/index.js'; -function isEditorHostedChat(): boolean { - const location = vscode.workspace - .getConfiguration('qwen-code') - .get('chat.location', 'editor'); - return location === 'editor'; -} - /** * Find the editor group immediately to the left of the Qwen chat webview. * - If the chat webview group is the leftmost group, returns undefined. + * - If no chat webview is found in any editor group, returns undefined. * - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'. */ export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined { - if (!isEditorHostedChat()) { - return undefined; - } - try { const groups = vscode.window.tabGroups.all; @@ -103,10 +93,6 @@ function waitForTabGroupsCondition( export async function ensureLeftGroupOfChatWebview(): Promise< vscode.ViewColumn | undefined > { - if (!isEditorHostedChat()) { - return undefined; - } - // First try to find an existing left neighbor const existing = findLeftGroupOfChatWebview(); if (existing !== undefined) { diff --git a/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts b/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts deleted file mode 100644 index 5eb906ac5..000000000 --- a/packages/vscode-ide-companion/src/webview/ChatWebviewViewProvider.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as vscode from 'vscode'; -import type { WebViewProvider } from './WebViewProvider.js'; - -export const CHAT_VIEW_ID_PANEL = 'qwenCode.chatView.panel'; -export const CHAT_VIEW_ID_SECONDARY = 'qwenCode.chatView.secondary'; - -/** - * WebviewView host for placing the chat UI in panel/secondary sidebar. - */ -export class ChatWebviewViewProvider implements vscode.WebviewViewProvider { - constructor( - private readonly webViewProvider: WebViewProvider, - private readonly extensionUri: vscode.Uri, - private readonly hostKind: 'panel' | 'secondary', - ) {} - - async resolveWebviewView(webviewView: vscode.WebviewView): Promise { - console.log( - `[ChatWebviewViewProvider] resolveWebviewView invoked for ${webviewView.viewType} (host=${this.hostKind})`, - ); - // Determine the view ID from the webviewView - const viewId = webviewView.viewType; // This will be either 'qwenCode.chatView.panel' or 'qwenCode.chatView.secondary' - - // Ensure scripts/resources are allowed - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.extensionUri, 'dist'), - vscode.Uri.joinPath(this.extensionUri, 'assets'), - ], - }; - - await this.webViewProvider.attachToView(webviewView, viewId); - } -} diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts new file mode 100644 index 000000000..ffce1152a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type * as vscode from 'vscode'; +import type { WebViewProvider } from './WebViewProvider.js'; + +/** + * Factory function type that lazily creates a WebViewProvider instance. + * The provider is only instantiated when VS Code actually opens the view. + */ +export type WebViewProviderFactory = () => WebViewProvider; + +/** + * WebviewView host for placing the chat UI in sidebar / panel / secondary sidebar. + * + * Accepts a factory function instead of a pre-built WebViewProvider so the + * heavyweight provider (QwenAgentManager, ConversationStore, etc.) is only + * created when VS Code actually opens the view, not at extension startup. + */ +export class ChatWebviewViewProvider implements vscode.WebviewViewProvider { + private webViewProvider: WebViewProvider | null = null; + + /** + * @param createWebViewProvider - Factory that creates a WebViewProvider on demand + */ + constructor(private readonly createWebViewProvider: WebViewProviderFactory) {} + + /** + * Called by VS Code when the webview view becomes visible for the first time. + * Creates the WebViewProvider lazily and attaches the webview. + * + * @param webviewView - The webview view created by VS Code + */ + async resolveWebviewView(webviewView: vscode.WebviewView): Promise { + // Lazily create the provider on first resolve + if (!this.webViewProvider) { + this.webViewProvider = this.createWebViewProvider(); + } + + // Webview options (enableScripts, localResourceRoots) are configured + // inside WebViewProvider.attachToView — no duplication needed here. + await this.webViewProvider.attachToView(webviewView, webviewView.viewType); + } +} diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts similarity index 84% rename from packages/vscode-ide-companion/src/webview/MessageHandler.ts rename to packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index 30b9abe56..7507ad4e1 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { QwenAgentManager } from '../services/qwenAgentManager.js'; -import type { ConversationStore } from '../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; -import { MessageRouter } from './handlers/MessageRouter.js'; +import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import type { ConversationStore } from '../../services/conversationStore.js'; +import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js'; +import { MessageRouter } from '../handlers/MessageRouter.js'; /** * MessageHandler (Refactored Version) diff --git a/packages/vscode-ide-companion/src/webview/PanelManager.ts b/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts similarity index 100% rename from packages/vscode-ide-companion/src/webview/PanelManager.ts rename to packages/vscode-ide-companion/src/webview/providers/PanelManager.ts diff --git a/packages/vscode-ide-companion/src/webview/WebViewContent.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts similarity index 96% rename from packages/vscode-ide-companion/src/webview/WebViewContent.ts rename to packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts index f3f934890..bdc71e0ba 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewContent.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.ts @@ -5,7 +5,7 @@ */ import * as vscode from 'vscode'; -import { escapeHtml } from './utils/webviewUtils.js'; +import { escapeHtml } from '../utils/webviewUtils.js'; /** * WebView HTML Content Generator diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts similarity index 98% rename from packages/vscode-ide-companion/src/webview/WebViewProvider.ts rename to packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 5a9bd4346..d926b8315 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -5,17 +5,17 @@ */ import * as vscode from 'vscode'; -import { QwenAgentManager } from '../services/qwenAgentManager.js'; -import { ConversationStore } from '../services/conversationStore.js'; -import type { AcpPermissionRequest } from '../types/acpTypes.js'; -import type { ModelInfo } from '../types/acpTypes.js'; -import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; -import { PanelManager } from '../webview/PanelManager.js'; -import { MessageHandler } from '../webview/MessageHandler.js'; -import { WebViewContent } from '../webview/WebViewContent.js'; -import { getFileName } from './utils/webviewUtils.js'; -import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js'; -import { isAuthenticationRequiredError } from '../utils/authErrors.js'; +import { QwenAgentManager } from '../../services/qwenAgentManager.js'; +import { ConversationStore } from '../../services/conversationStore.js'; +import type { AcpPermissionRequest } from '../../types/acpTypes.js'; +import type { ModelInfo } from '../../types/acpTypes.js'; +import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js'; +import { PanelManager } from './PanelManager.js'; +import { MessageHandler } from './MessageHandler.js'; +import { WebViewContent } from './WebViewContent.js'; +import { getFileName } from '../utils/webviewUtils.js'; +import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; +import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; export class WebViewProvider { private panelManager: PanelManager; From 2220936eac6659f1c7b9a40cb8db960f0daf97f4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 7 Mar 2026 12:28:36 +0800 Subject: [PATCH 012/137] fix(vscode-ide-companion): fix PanelManager race condition and add session clear API - Guard captureTab() setTimeout callback against panel dispose/replace that can occur before the deferred callback runs - Add AcpConnection.resetSessionState() to clear session without disconnecting the child process - Add QwenAgentManager.clearCurrentSession() so sidebar/panel views can force creation of a fresh session Co-authored-by: ZZBuAoYe --- .../src/services/acpConnection.ts | 9 +++++++++ .../src/services/qwenAgentManager.ts | 9 +++++++++ .../src/webview/providers/PanelManager.ts | 12 +++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 0a5aec02c..f216c1409 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -429,6 +429,15 @@ export class AcpConnection { this.sessionManager.reset(); } + /** + * Reset session manager state without disconnecting the child process. + * Clears the current session ID so that the next `newSession()` call creates + * a fresh session instead of reusing the existing one. + */ + resetSessionState(): void { + this.sessionManager.reset(); + } + /** * Check if connected */ diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 0944ee5b7..5b6ef417d 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -1243,6 +1243,15 @@ export class QwenAgentManager { await this.connection.switchSession(sessionId); } + /** + * Clear the current session so the next `createNewSession()` call will + * create a fresh session instead of reusing the existing one. + * Only resets the session manager state; the ACP child process stays alive. + */ + clearCurrentSession(): void { + this.connection.resetSessionState(); + } + /** * Cancel current prompt */ diff --git a/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts b/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts index 44f1a6ecc..0c02dc3ca 100644 --- a/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts +++ b/packages/vscode-ide-companion/src/webview/providers/PanelManager.ts @@ -224,8 +224,18 @@ export class PanelManager { return; } + // Capture a reference to the current panel so the deferred callback can + // detect if the panel was disposed or replaced before it runs. + const scheduledPanel = this.panel; + // Defer slightly so the tab model is updated after create/reveal setTimeout(() => { + // The panel may have been disposed/replaced before this callback runs. + if (!this.panel || this.panel !== scheduledPanel) { + return; + } + + const panelTitle = this.panel.title; const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs); const match = allTabs.find((t) => { // Type guard for webview tab input @@ -234,7 +244,7 @@ export class PanelManager { !!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; + const sameLabel = t.label === panelTitle; return !!(sameViewType || sameLabel); }); this.panelTab = match ?? null; From 9b05ae10d7ad19c3dafe1ad7ce2f03761bea7099 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 7 Mar 2026 12:42:43 +0800 Subject: [PATCH 013/137] fix(vscode-ide-companion): fix PanelManager captureTab race condition Guard the setTimeout callback in captureTab() against panel being disposed or replaced before the deferred callback runs. Capture a reference to the current panel before the timeout and verify it is still the active panel inside the callback. Co-authored-by: ZZBuAoYe --- .../vscode-ide-companion/src/services/acpConnection.ts | 9 --------- .../src/services/qwenAgentManager.ts | 9 --------- 2 files changed, 18 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index f216c1409..0a5aec02c 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -429,15 +429,6 @@ export class AcpConnection { this.sessionManager.reset(); } - /** - * Reset session manager state without disconnecting the child process. - * Clears the current session ID so that the next `newSession()` call creates - * a fresh session instead of reusing the existing one. - */ - resetSessionState(): void { - this.sessionManager.reset(); - } - /** * Check if connected */ diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 5b6ef417d..0944ee5b7 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -1243,15 +1243,6 @@ export class QwenAgentManager { await this.connection.switchSession(sessionId); } - /** - * Clear the current session so the next `createNewSession()` call will - * create a fresh session instead of reusing the existing one. - * Only resets the session manager state; the ACP child process stays alive. - */ - clearCurrentSession(): void { - this.connection.resetSessionState(); - } - /** * Cancel current prompt */ From 135430846b08022107d949eaf6acb1a3182a6145 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 7 Mar 2026 13:18:45 +0800 Subject: [PATCH 014/137] chore(vscode-ide-companion): update lock file after merge --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index c0c2bb039..a9c699f64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14293,6 +14293,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } From b26e21f71d7f4f45cb8784b45c746f8d2f50c900 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Mar 2026 16:11:26 +0800 Subject: [PATCH 015/137] feat(vscode-ide-companion): cherry-pick ACP compatibility improvements from PR #2195 - Add extractSessionListItems() utility for robust ACP response parsing - Refactor getSessionList() and getSessionListPaged() to use the new utility - Add openNewChatTabCommand to create new session when opening chat tab - Add comprehensive test coverage for session list extraction Co-authored-by: ZZAoYe --- packages/vscode-ide-companion/package.json | 9 +- .../src/commands/index.test.ts | 66 +++++++++++++ .../src/commands/index.ts | 1 + .../src/services/qwenAgentManager.test.ts | 98 +++++++++++++++++++ .../src/services/qwenAgentManager.ts | 48 ++++++--- 5 files changed, 198 insertions(+), 24 deletions(-) create mode 100644 packages/vscode-ide-companion/src/commands/index.test.ts create mode 100644 packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 19f5074e3..8690ba360 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -28,14 +28,7 @@ "ide companion" ], "activationEvents": [ - "onStartupFinished", - "onView:qwenCode.chatView.panel", - "onView:qwenCode.chatView.secondary", - "onView:qwenCode.chatView.sidebar", - "onCommand:qwen-code.openChat", - "onCommand:qwen-code.focusChat", - "onCommand:qwen-code.newConversation", - "onCommand:qwen-code.showLogs" + "onStartupFinished" ], "contributes": { "jsonValidation": [ diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts new file mode 100644 index 000000000..5d69c4edd --- /dev/null +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { openNewChatTabCommand, registerNewCommands } from './index.js'; + +vi.mock('vscode', () => ({ + commands: { + registerCommand: vi.fn( + (_command: string, _handler: (...args: unknown[]) => unknown) => ({ + dispose: vi.fn(), + }), + ), + }, + workspace: { + workspaceFolders: [], + }, + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + }, + Uri: { + joinPath: vi.fn(), + }, +})); + +describe('registerNewCommands', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a fresh session when opening a new chat tab', async () => { + const fakeProvider = { + show: vi.fn().mockResolvedValue(undefined), + createNewSession: vi.fn().mockResolvedValue(undefined), + forceReLogin: vi.fn().mockResolvedValue(undefined), + }; + + registerNewCommands( + { subscriptions: [] } as unknown as vscode.ExtensionContext, + vi.fn(), + {} as never, + () => [], + () => fakeProvider as never, + ); + + const commandCall = vi + .mocked(vscode.commands.registerCommand) + .mock.calls.find(([command]) => command === openNewChatTabCommand); + + expect(commandCall).toBeDefined(); + + const handler = commandCall?.[1] as (() => Promise) | undefined; + expect(handler).toBeDefined(); + + await handler?.(); + + expect(fakeProvider.show).toHaveBeenCalledTimes(1); + expect(fakeProvider.createNewSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 3af162bd3..5bcf2565a 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -87,6 +87,7 @@ export function registerNewCommands( vscode.commands.registerCommand(openNewChatTabCommand, async () => { const provider = createWebViewProvider(); await provider.show(); + await provider.createNewSession(); }), ); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts new file mode 100644 index 000000000..5de2b5c9d --- /dev/null +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + extractSessionListItems, + QwenAgentManager, +} from './qwenAgentManager.js'; + +vi.mock('vscode', () => ({ + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, +})); + +describe('extractSessionListItems', () => { + it('reads ACP session arrays from the sessions field', () => { + const items = extractSessionListItems({ + sessions: [{ sessionId: 'session-1' }], + }); + + expect(items).toEqual([{ sessionId: 'session-1' }]); + }); + + it('reads ACP session arrays from the legacy items field', () => { + const items = extractSessionListItems({ + items: [{ sessionId: 'session-2' }], + }); + + expect(items).toEqual([{ sessionId: 'session-2' }]); + }); + + it('returns empty array for invalid responses', () => { + expect(extractSessionListItems(null)).toEqual([]); + expect(extractSessionListItems(undefined)).toEqual([]); + expect(extractSessionListItems('string')).toEqual([]); + expect(extractSessionListItems({})).toEqual([]); + expect(extractSessionListItems({ sessions: 'not-array' })).toEqual([]); + }); + + it('prefers sessions over items when both exist', () => { + const items = extractSessionListItems({ + sessions: [{ sessionId: 'from-sessions' }], + items: [{ sessionId: 'from-items' }], + }); + + expect(items).toEqual([{ sessionId: 'from-sessions' }]); + }); +}); + +describe('QwenAgentManager session list compatibility', () => { + it('maps paged ACP session lists returned via items', async () => { + const manager = new QwenAgentManager(); + const listSessions = vi.fn().mockResolvedValue({ + items: [ + { + sessionId: 'session-3', + prompt: 'Fix sidebar history', + mtime: 1772114825468.5825, + cwd: '/workspace/qwen-code', + }, + ], + }); + + ( + manager as unknown as { + connection: { listSessions: typeof listSessions }; + } + ).connection = { listSessions }; + + const page = await manager.getSessionListPaged({ size: 20 }); + + expect(listSessions).toHaveBeenCalledWith({ size: 20 }); + expect(page).toEqual({ + sessions: [ + { + id: 'session-3', + sessionId: 'session-3', + title: 'Fix sidebar history', + name: 'Fix sidebar history', + startTime: undefined, + lastUpdated: 1772114825468.5825, + messageCount: 0, + projectHash: undefined, + filePath: undefined, + cwd: '/workspace/qwen-code', + }, + ], + nextCursor: undefined, + hasMore: false, + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 4fb044a73..81303fe0d 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -40,6 +40,36 @@ import { handleAuthenticateUpdate } from '../utils/authNotificationHandler.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; +/** + * Extract session list items from ACP response. + * Handles both 'sessions' (new) and 'items' (legacy) response shapes. + * @param response - The ACP session/list response + * @returns Array of session items, or empty array if invalid + */ +export function extractSessionListItems( + response: unknown, +): Array> { + if (!response || typeof response !== 'object') { + return []; + } + + const payload = response as { + sessions?: unknown; + items?: unknown; + }; + + // Prefer 'sessions' field, fall back to 'items' for backwards compatibility + if (Array.isArray(payload.sessions)) { + return payload.sessions as Array>; + } + + if (Array.isArray(payload.items)) { + return payload.items as Array>; + } + + return []; +} + /** * Qwen Agent Manager * @@ -413,14 +443,7 @@ export class QwenAgentManager { console.log('[QwenAgentManager] ACP session list response:', response); const res: unknown = response; - let items: Array> = []; - - if (res && typeof res === 'object' && 'sessions' in res) { - const sessionsValue = (res as { sessions?: unknown }).sessions; - items = Array.isArray(sessionsValue) - ? (sessionsValue as Array>) - : []; - } + const items = extractSessionListItems(res); console.log( '[QwenAgentManager] Sessions retrieved via ACP:', @@ -514,14 +537,7 @@ export class QwenAgentManager { ...(cursor !== undefined ? { cursor } : {}), }); const res: unknown = response; - let items: Array> = []; - - if (res && typeof res === 'object' && 'sessions' in res) { - const sessionsValue = (res as { sessions?: unknown }).sessions; - items = Array.isArray(sessionsValue) - ? (sessionsValue as Array>) - : []; - } + const items = extractSessionListItems(res); const mapped = items.map((item) => ({ id: item.sessionId || item.id, From 3bb21c3adbdf51e6575bb76e57a539a4f2116d58 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Mar 2026 17:54:59 +0800 Subject: [PATCH 016/137] refactor(vscode-ide-companion): unify view ID naming and add multi-view tests - Use consistent kebab-case prefix 'qwen-code.' for all view IDs - Update package.json views and viewsContainers to match new naming - Add fine-grained activationEvents for views and commands - Add test verifying all three view positions are registered correctly This ensures consistent naming across commands, views, and containers. --- packages/vscode-ide-companion/package.json | 27 ++++++++++++------- .../src/constants/viewIds.ts | 8 +++--- .../src/extension.test.ts | 17 ++++++++++++ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 8690ba360..51dc6911b 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -28,7 +28,14 @@ "ide companion" ], "activationEvents": [ - "onStartupFinished" + "onStartupFinished", + "onView:qwen-code.chatView.sidebar", + "onView:qwen-code.chatView.panel", + "onView:qwen-code.chatView.secondary", + "onCommand:qwen-code.openChat", + "onCommand:qwen-code.focusChat", + "onCommand:qwen-code.newConversation", + "onCommand:qwen-code.showLogs" ], "contributes": { "jsonValidation": [ @@ -40,44 +47,44 @@ "viewsContainers": { "activitybar": [ { - "id": "qwenCodeSidebar", + "id": "qwen-code-sidebar", "title": "Qwen Code", "icon": "assets/sidebar-icon.svg" } ], "panel": [ { - "id": "qwenCodePanel", + "id": "qwen-code-panel", "title": "Qwen Code", "icon": "assets/icon.png" } ], "secondarySidebar": [ { - "id": "qwenCodeSecondary", + "id": "qwen-code-secondary", "title": "Qwen Code", "icon": "assets/icon.png" } ] }, "views": { - "qwenCodeSidebar": [ + "qwen-code-sidebar": [ { "type": "webview", - "id": "qwenCode.chatView.sidebar", + "id": "qwen-code.chatView.sidebar", "name": "Qwen Code" } ], - "qwenCodePanel": [ + "qwen-code-panel": [ { - "id": "qwenCode.chatView.panel", + "id": "qwen-code.chatView.panel", "name": "Qwen Code", "icon": "assets/icon.png" } ], - "qwenCodeSecondary": [ + "qwen-code-secondary": [ { - "id": "qwenCode.chatView.secondary", + "id": "qwen-code.chatView.secondary", "name": "Qwen Code", "icon": "assets/icon.png" } diff --git a/packages/vscode-ide-companion/src/constants/viewIds.ts b/packages/vscode-ide-companion/src/constants/viewIds.ts index 96862d058..140956b26 100644 --- a/packages/vscode-ide-companion/src/constants/viewIds.ts +++ b/packages/vscode-ide-companion/src/constants/viewIds.ts @@ -7,7 +7,9 @@ /** * WebviewView IDs for the three host positions where the chat UI can appear. * These IDs must match the `views` contributions declared in package.json. + * + * Note: We use kebab-case prefix 'qwen-code.' for consistency with command IDs. */ -export const CHAT_VIEW_ID_PANEL = 'qwenCode.chatView.panel'; -export const CHAT_VIEW_ID_SECONDARY = 'qwenCode.chatView.secondary'; -export const CHAT_VIEW_ID_SIDEBAR = 'qwenCode.chatView.sidebar'; +export const CHAT_VIEW_ID_SIDEBAR = 'qwen-code.chatView.sidebar'; +export const CHAT_VIEW_ID_PANEL = 'qwen-code.chatView.panel'; +export const CHAT_VIEW_ID_SECONDARY = 'qwen-code.chatView.secondary'; diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index d6128f91e..ea179d948 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -137,6 +137,23 @@ describe('activate', () => { expect(vscode.workspace.onDidGrantWorkspaceTrust).toHaveBeenCalled(); }); + it('should register webview view providers for all three positions (sidebar, panel, secondary)', async () => { + await activate(context); + + // Verify registerWebviewViewProvider was called 3 times for the three view positions + const registerCalls = vi.mocked(vscode.window.registerWebviewViewProvider) + .mock.calls; + expect(registerCalls).toHaveLength(3); + + // Extract view IDs from the calls + const viewIds = registerCalls.map((call) => call[0]); + + // Verify all three view IDs are registered with consistent naming + expect(viewIds).toContain('qwen-code.chatView.sidebar'); + expect(viewIds).toContain('qwen-code.chatView.panel'); + expect(viewIds).toContain('qwen-code.chatView.secondary'); + }); + it('should launch the Qwen Code when the user clicks the button', async () => { const showInformationMessageMock = vi .mocked(vscode.window.showInformationMessage) From aa20ebb70be9598f3c5f8134bc4f4edbdf23bfd3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Mar 2026 19:32:59 +0800 Subject: [PATCH 017/137] fix(ide): use callback-based dns.lookup for better compatibility --- packages/core/src/ide/ide-client.ts | 161 +++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 15 deletions(-) diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index b4835e30e..d4c8f8a2e 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -135,7 +135,19 @@ export class IdeClient { } async connect(): Promise { + debugLogger.debug('[IdeClient] connect() called'); + debugLogger.debug( + '[IdeClient] currentIde:', + this.currentIde?.name || 'null', + ); + debugLogger.debug('[IdeClient] TERM_PROGRAM:', process.env['TERM_PROGRAM']); + debugLogger.debug( + '[IdeClient] CURSOR_TRACE_ID:', + process.env['CURSOR_TRACE_ID'], + ); + if (!this.currentIde) { + debugLogger.debug('[IdeClient] No current IDE detected, disconnecting'); this.setState( IDEConnectionStatus.Disconnected, `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks`, @@ -144,9 +156,23 @@ export class IdeClient { return; } + debugLogger.debug('[IdeClient] Setting state to Connecting'); this.setState(IDEConnectionStatus.Connecting); + debugLogger.debug('[IdeClient] Getting connection config from file...'); this.connectionConfig = await this.getConnectionConfigFromFile(); + debugLogger.debug( + '[IdeClient] Connection config:', + this.connectionConfig + ? { + port: this.connectionConfig.port, + workspacePath: + this.connectionConfig.workspacePath?.substring(0, 50) + '...', + hasAuthToken: !!this.connectionConfig.authToken, + } + : 'null', + ); + if (this.connectionConfig?.authToken) { this.authToken = this.connectionConfig.authToken; } @@ -154,33 +180,51 @@ export class IdeClient { this.connectionConfig?.workspacePath ?? process.env['QWEN_CODE_IDE_WORKSPACE_PATH']; + debugLogger.debug( + '[IdeClient] Workspace path:', + workspacePath?.substring(0, 50) + '...', + ); const { isValid, error } = IdeClient.validateWorkspacePath( workspacePath, process.cwd(), ); if (!isValid) { + debugLogger.debug('[IdeClient] Workspace path validation failed:', error); this.setState(IDEConnectionStatus.Disconnected, error, true); return; } + debugLogger.debug('[IdeClient] Workspace path validation passed'); + if (this.connectionConfig) { if (this.connectionConfig.port) { + debugLogger.debug( + '[IdeClient] Trying HTTP connection on port:', + this.connectionConfig.port, + ); const connected = await this.establishHttpConnection( this.connectionConfig.port, ); if (connected) { + debugLogger.debug('[IdeClient] HTTP connection successful'); return; } + debugLogger.debug('[IdeClient] HTTP connection failed'); } if (this.connectionConfig.stdio) { + debugLogger.debug('[IdeClient] Trying stdio connection'); const connected = await this.establishStdioConnection( this.connectionConfig.stdio, ); if (connected) { + debugLogger.debug('[IdeClient] Stdio connection successful'); return; } + debugLogger.debug('[IdeClient] Stdio connection failed'); } + } else { + debugLogger.debug('[IdeClient] No connection config found'); } const portFromEnv = this.getPortFromEnv(); @@ -573,23 +617,45 @@ export class IdeClient { | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined > { + debugLogger.debug('[getConnectionConfigFromFile] Starting...'); const portFromEnv = this.getPortFromEnv(); + debugLogger.debug( + '[getConnectionConfigFromFile] portFromEnv:', + portFromEnv || 'null', + ); + if (portFromEnv) { try { const ideDir = Storage.getGlobalIdeDir(); const lockFile = path.join(ideDir, `${portFromEnv}.lock`); + debugLogger.debug( + '[getConnectionConfigFromFile] Trying to read lock file:', + lockFile, + ); const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); + debugLogger.debug( + '[getConnectionConfigFromFile] Lock file read successfully', + ); return JSON.parse(lockFileContents); - } catch (_) { + } catch (e) { + debugLogger.debug( + '[getConnectionConfigFromFile] Failed to read lock file:', + (e as Error).message, + ); // Fall through to legacy discovery. } } - // Legacy discovery for VSCode extension < v0.5.1. + // Legacy connection files were written in the global temp directory. + debugLogger.debug( + '[getConnectionConfigFromFile] Trying legacy connection config...', + ); const legacyConfig = await this.getLegacyConnectionConfig(portFromEnv); if (legacyConfig) { + debugLogger.debug('[getConnectionConfigFromFile] Legacy config found'); return legacyConfig; } + debugLogger.debug('[getConnectionConfigFromFile] No legacy config found'); // Scan lock directory as a last resort when neither env var nor legacy // file is available (e.g. code-server where the env var is not injected). @@ -597,10 +663,14 @@ export class IdeClient { // first one whose workspace matches the current working directory. if (!portFromEnv) { const ideDir = Storage.getGlobalIdeDir(); + debugLogger.debug( + '[getConnectionConfigFromFile] Scanning ideDir:', + ideDir, + ); const configs = await this.getAllConnectionConfigs(ideDir); if (configs.length > 0) { debugLogger.debug( - `Discovered ${configs.length} IDE lock file(s) via directory scan`, + `[getConnectionConfigFromFile] Discovered ${configs.length} IDE lock file(s) via directory scan`, ); const cwd = process.cwd(); const match = configs.find( @@ -608,10 +678,25 @@ export class IdeClient { c.workspacePath !== undefined && IdeClient.validateWorkspacePath(c.workspacePath, cwd).isValid, ); + if (match) { + debugLogger.debug( + '[getConnectionConfigFromFile] Found matching config via directory scan', + ); + } else { + debugLogger.debug( + '[getConnectionConfigFromFile] No matching config found via directory scan', + ); + } return match; } + debugLogger.debug( + '[getConnectionConfigFromFile] No configs found via directory scan', + ); } + debugLogger.debug( + '[getConnectionConfigFromFile] Returning undefined - no config found', + ); return undefined; } @@ -807,25 +892,60 @@ export class IdeClient { } private async establishHttpConnection(port: string): Promise { + debugLogger.debug( + '[establishHttpConnection] Starting connection to port:', + port, + ); + debugLogger.debug( + '[establishHttpConnection] Checking container environment...', + ); + + const isInContainer = + fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv'); + debugLogger.debug( + '[establishHttpConnection] isInContainer:', + isInContainer, + ); + // Always try localhost first. This covers the most common scenarios: // non-container environments, and code-server where the extension runs // inside the same container as the CLI. + debugLogger.debug( + '[establishHttpConnection] Trying localhost (127.0.0.1)...', + ); const connected = await this.tryHttpConnect(port, LOCAL_HOST); if (connected) { + debugLogger.debug( + '[establishHttpConnection] Connected to localhost successfully', + ); return true; } + debugLogger.debug( + '[establishHttpConnection] Failed to connect to localhost', + ); // If localhost failed and we are inside a container, the IDE server may // be running on the host machine (e.g. VS Code Dev Containers). Try // host.docker.internal as a fallback when it is DNS-resolvable. + debugLogger.debug( + '[establishHttpConnection] Calling getIdeServerHost()...', + ); const ideHost = await getIdeServerHost(); + debugLogger.debug( + '[establishHttpConnection] getIdeServerHost returned:', + ideHost, + ); + if (ideHost === CONTAINER_HOST) { debugLogger.debug( - `Connection to ${LOCAL_HOST}:${port} failed, retrying with ${CONTAINER_HOST}`, + `[establishHttpConnection] Connection to ${LOCAL_HOST}:${port} failed, retrying with ${CONTAINER_HOST}`, ); return this.tryHttpConnect(port, CONTAINER_HOST); } + debugLogger.debug( + '[establishHttpConnection] ideHost is not CONTAINER_HOST, giving up', + ); return false; } @@ -929,21 +1049,32 @@ export function _resetCachedIdeServerHost(): void { /** * Check if a hostname is DNS-resolvable, with a timeout guard. + * Uses callback-based dns.lookup() for better compatibility across + * different Node.js environments (e.g., VSCode, Cursor). */ async function isHostResolvable(hostname: string): Promise { - try { - const timeout = new Promise((_, reject) => { - const timer = setTimeout( - () => reject(new Error('DNS lookup timeout')), - DNS_LOOKUP_TIMEOUT_MS, + return new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + debugLogger.debug( + `DNS lookup timed out for ${hostname} after ${DNS_LOOKUP_TIMEOUT_MS}ms`, ); - timer.unref?.(); + resolve(false); + }, DNS_LOOKUP_TIMEOUT_MS); + timeout.unref?.(); + + dns.lookup(hostname, (err) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (err) { + debugLogger.debug(`DNS lookup failed for ${hostname}: ${err.message}`); + } + resolve(!err); }); - await Promise.race([dns.promises.lookup(hostname), timeout]); - return true; - } catch { - return false; - } + }); } /** From 532c416fb70152d22b472a3fb1dd88a6922b5263 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Mar 2026 21:40:41 +0800 Subject: [PATCH 018/137] fix(ide): use callback-based dns.lookup for better compatibility Replace Promise-based dns.promises.lookup with callback-based dns.lookup for better compatibility across different Node.js environments (e.g., VSCode, Cursor). The callback-based approach avoids potential issues with Promise-based DNS lookup in certain IDE embedded terminal environments. --- packages/core/src/ide/ide-client.ts | 142 +--------------------------- 1 file changed, 2 insertions(+), 140 deletions(-) diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index d4c8f8a2e..6ea3e787b 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -135,19 +135,7 @@ export class IdeClient { } async connect(): Promise { - debugLogger.debug('[IdeClient] connect() called'); - debugLogger.debug( - '[IdeClient] currentIde:', - this.currentIde?.name || 'null', - ); - debugLogger.debug('[IdeClient] TERM_PROGRAM:', process.env['TERM_PROGRAM']); - debugLogger.debug( - '[IdeClient] CURSOR_TRACE_ID:', - process.env['CURSOR_TRACE_ID'], - ); - if (!this.currentIde) { - debugLogger.debug('[IdeClient] No current IDE detected, disconnecting'); this.setState( IDEConnectionStatus.Disconnected, `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks`, @@ -159,19 +147,7 @@ export class IdeClient { debugLogger.debug('[IdeClient] Setting state to Connecting'); this.setState(IDEConnectionStatus.Connecting); - debugLogger.debug('[IdeClient] Getting connection config from file...'); this.connectionConfig = await this.getConnectionConfigFromFile(); - debugLogger.debug( - '[IdeClient] Connection config:', - this.connectionConfig - ? { - port: this.connectionConfig.port, - workspacePath: - this.connectionConfig.workspacePath?.substring(0, 50) + '...', - hasAuthToken: !!this.connectionConfig.authToken, - } - : 'null', - ); if (this.connectionConfig?.authToken) { this.authToken = this.connectionConfig.authToken; @@ -180,51 +156,33 @@ export class IdeClient { this.connectionConfig?.workspacePath ?? process.env['QWEN_CODE_IDE_WORKSPACE_PATH']; - debugLogger.debug( - '[IdeClient] Workspace path:', - workspacePath?.substring(0, 50) + '...', - ); const { isValid, error } = IdeClient.validateWorkspacePath( workspacePath, process.cwd(), ); if (!isValid) { - debugLogger.debug('[IdeClient] Workspace path validation failed:', error); this.setState(IDEConnectionStatus.Disconnected, error, true); return; } - debugLogger.debug('[IdeClient] Workspace path validation passed'); - if (this.connectionConfig) { if (this.connectionConfig.port) { - debugLogger.debug( - '[IdeClient] Trying HTTP connection on port:', - this.connectionConfig.port, - ); const connected = await this.establishHttpConnection( this.connectionConfig.port, ); if (connected) { - debugLogger.debug('[IdeClient] HTTP connection successful'); return; } - debugLogger.debug('[IdeClient] HTTP connection failed'); } if (this.connectionConfig.stdio) { - debugLogger.debug('[IdeClient] Trying stdio connection'); const connected = await this.establishStdioConnection( this.connectionConfig.stdio, ); if (connected) { - debugLogger.debug('[IdeClient] Stdio connection successful'); return; } - debugLogger.debug('[IdeClient] Stdio connection failed'); } - } else { - debugLogger.debug('[IdeClient] No connection config found'); } const portFromEnv = this.getPortFromEnv(); @@ -617,45 +575,24 @@ export class IdeClient { | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) | undefined > { - debugLogger.debug('[getConnectionConfigFromFile] Starting...'); const portFromEnv = this.getPortFromEnv(); - debugLogger.debug( - '[getConnectionConfigFromFile] portFromEnv:', - portFromEnv || 'null', - ); if (portFromEnv) { try { const ideDir = Storage.getGlobalIdeDir(); const lockFile = path.join(ideDir, `${portFromEnv}.lock`); - debugLogger.debug( - '[getConnectionConfigFromFile] Trying to read lock file:', - lockFile, - ); const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); - debugLogger.debug( - '[getConnectionConfigFromFile] Lock file read successfully', - ); return JSON.parse(lockFileContents); - } catch (e) { - debugLogger.debug( - '[getConnectionConfigFromFile] Failed to read lock file:', - (e as Error).message, - ); + } catch (_e) { // Fall through to legacy discovery. } } // Legacy connection files were written in the global temp directory. - debugLogger.debug( - '[getConnectionConfigFromFile] Trying legacy connection config...', - ); const legacyConfig = await this.getLegacyConnectionConfig(portFromEnv); if (legacyConfig) { - debugLogger.debug('[getConnectionConfigFromFile] Legacy config found'); return legacyConfig; } - debugLogger.debug('[getConnectionConfigFromFile] No legacy config found'); // Scan lock directory as a last resort when neither env var nor legacy // file is available (e.g. code-server where the env var is not injected). @@ -663,40 +600,18 @@ export class IdeClient { // first one whose workspace matches the current working directory. if (!portFromEnv) { const ideDir = Storage.getGlobalIdeDir(); - debugLogger.debug( - '[getConnectionConfigFromFile] Scanning ideDir:', - ideDir, - ); const configs = await this.getAllConnectionConfigs(ideDir); if (configs.length > 0) { - debugLogger.debug( - `[getConnectionConfigFromFile] Discovered ${configs.length} IDE lock file(s) via directory scan`, - ); const cwd = process.cwd(); const match = configs.find( (c) => c.workspacePath !== undefined && IdeClient.validateWorkspacePath(c.workspacePath, cwd).isValid, ); - if (match) { - debugLogger.debug( - '[getConnectionConfigFromFile] Found matching config via directory scan', - ); - } else { - debugLogger.debug( - '[getConnectionConfigFromFile] No matching config found via directory scan', - ); - } return match; } - debugLogger.debug( - '[getConnectionConfigFromFile] No configs found via directory scan', - ); } - debugLogger.debug( - '[getConnectionConfigFromFile] Returning undefined - no config found', - ); return undefined; } @@ -892,69 +807,28 @@ export class IdeClient { } private async establishHttpConnection(port: string): Promise { - debugLogger.debug( - '[establishHttpConnection] Starting connection to port:', - port, - ); - debugLogger.debug( - '[establishHttpConnection] Checking container environment...', - ); - - const isInContainer = - fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv'); - debugLogger.debug( - '[establishHttpConnection] isInContainer:', - isInContainer, - ); - // Always try localhost first. This covers the most common scenarios: // non-container environments, and code-server where the extension runs // inside the same container as the CLI. - debugLogger.debug( - '[establishHttpConnection] Trying localhost (127.0.0.1)...', - ); const connected = await this.tryHttpConnect(port, LOCAL_HOST); if (connected) { - debugLogger.debug( - '[establishHttpConnection] Connected to localhost successfully', - ); return true; } - debugLogger.debug( - '[establishHttpConnection] Failed to connect to localhost', - ); // If localhost failed and we are inside a container, the IDE server may // be running on the host machine (e.g. VS Code Dev Containers). Try // host.docker.internal as a fallback when it is DNS-resolvable. - debugLogger.debug( - '[establishHttpConnection] Calling getIdeServerHost()...', - ); const ideHost = await getIdeServerHost(); - debugLogger.debug( - '[establishHttpConnection] getIdeServerHost returned:', - ideHost, - ); - if (ideHost === CONTAINER_HOST) { - debugLogger.debug( - `[establishHttpConnection] Connection to ${LOCAL_HOST}:${port} failed, retrying with ${CONTAINER_HOST}`, - ); return this.tryHttpConnect(port, CONTAINER_HOST); } - debugLogger.debug( - '[establishHttpConnection] ideHost is not CONTAINER_HOST, giving up', - ); return false; } private async tryHttpConnect(port: string, host: string): Promise { let transport: StreamableHTTPClientTransport | undefined; try { - debugLogger.debug( - `Attempting to connect to IDE via HTTP at ${host}:${port}`, - ); this.client = new Client({ name: 'streamable-http-client', // TODO(#3487): use the CLI version here. @@ -980,8 +854,7 @@ export class IdeClient { await this.discoverTools(); this.setState(IDEConnectionStatus.Connected); return true; - } catch (error) { - debugLogger.debug(`HTTP connection to ${host}:${port} failed:`, error); + } catch (_error) { if (transport) { try { await transport.close(); @@ -999,7 +872,6 @@ export class IdeClient { }: StdioConfig): Promise { let transport: StdioClientTransport | undefined; try { - debugLogger.debug('Attempting to connect to IDE via stdio'); this.client = new Client({ name: 'stdio-client', // TODO(#3487): use the CLI version here. @@ -1058,9 +930,6 @@ async function isHostResolvable(hostname: string): Promise { const timeout = setTimeout(() => { if (settled) return; settled = true; - debugLogger.debug( - `DNS lookup timed out for ${hostname} after ${DNS_LOOKUP_TIMEOUT_MS}ms`, - ); resolve(false); }, DNS_LOOKUP_TIMEOUT_MS); timeout.unref?.(); @@ -1069,9 +938,6 @@ async function isHostResolvable(hostname: string): Promise { if (settled) return; settled = true; clearTimeout(timeout); - if (err) { - debugLogger.debug(`DNS lookup failed for ${hostname}: ${err.message}`); - } resolve(!err); }); }); @@ -1096,13 +962,9 @@ async function resolveIdeServerHost(): Promise { const reachable = await isHostResolvable(CONTAINER_HOST); if (reachable) { - debugLogger.debug('Container detected, host.docker.internal is reachable'); return CONTAINER_HOST; } - debugLogger.debug( - 'Container detected, but host.docker.internal is NOT reachable, falling back to 127.0.0.1', - ); return LOCAL_HOST; } From 6bf5a06bbd4aa15f10ea92e5939d2c479daa6268 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 9 Mar 2026 21:50:00 +0800 Subject: [PATCH 019/137] fix(ide): add lock file cleanup logic and tests --- packages/core/src/ide/ide-client.test.ts | 370 +++++++++++++++++++++++ packages/core/src/ide/ide-client.ts | 188 ++++++++++-- 2 files changed, 538 insertions(+), 20 deletions(-) diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 88788fc57..c640f8238 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -40,6 +40,7 @@ vi.mock('node:fs', async (importOriginal) => { readFile: vi.fn(), readdir: vi.fn(), stat: vi.fn(), + unlink: vi.fn(), }, realpathSync: (p: string) => p, existsSync: vi.fn().mockReturnValue(false), @@ -762,3 +763,372 @@ describe('getIdeServerHost', () => { expect(dnsLookupMock).toHaveBeenCalledTimes(1); }); }); + +describe('Lock File Management', () => { + const mockFs = vi.mocked(fs.promises); + const mockExistsSync = vi.mocked(fs.existsSync); + + beforeEach(async () => { + // Reset singleton instance for test isolation + ( + IdeClient as unknown as { + instancePromise: Promise | null; + } + ).instancePromise = null; + _resetCachedIdeServerHost(); + + // Mock environment + process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = '/test/workspace'; + vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode); + vi.mocked(getIdeProcessInfo).mockResolvedValue({ + pid: 12345, + command: 'test-ide', + }); + vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + vi.mocked(os.homedir).mockReturnValue('/home/test'); + vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace'); + + // Reset mocks + mockFs.readFile.mockReset(); + mockFs.readdir.mockReset(); + mockFs.stat.mockReset(); + mockFs.unlink.mockReset(); + mockExistsSync.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env['QWEN_CODE_IDE_WORKSPACE_PATH']; + }); + + describe('getAllConnectionConfigs', () => { + it('should return empty array when ide directory does not exist', async () => { + mockFs.readdir.mockRejectedValue(new Error('ENOENT: no such directory')); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getAllConnectionConfigs: (ideDir: string) => Promise; + } + ).getAllConnectionConfigs('/home/test/.qwen/ide'); + + expect(result).toEqual([]); + expect(mockFs.unlink).not.toHaveBeenCalled(); + }); + + it('should return all valid lock files sorted by mtime (newest first)', async () => { + const lockFiles = ['1000.lock', '2000.lock', '3000.lock']; + mockFs.readdir.mockResolvedValue(lockFiles as unknown as fs.Dirent[]); + + const configs = [ + { port: '1000', workspacePath: '/workspace/old', ppid: 100 }, + { port: '2000', workspacePath: '/workspace/medium', ppid: 200 }, + { port: '3000', workspacePath: '/workspace/new', ppid: 300 }, + ]; + + mockFs.readFile.mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file.endsWith('1000.lock')) return JSON.stringify(configs[0]); + if (file.endsWith('2000.lock')) return JSON.stringify(configs[1]); + if (file.endsWith('3000.lock')) return JSON.stringify(configs[2]); + throw new Error(`unexpected path: ${file}`); + }, + ); + + mockFs.stat.mockImplementation(async (filePath: fs.PathLike) => { + const file = String(filePath); + return { + mtimeMs: file.endsWith('1000.lock') + ? 1000 + : file.endsWith('2000.lock') + ? 2000 + : 3000, + } as fs.Stats; + }); + + // All processes exist + vi.spyOn(process, 'kill').mockImplementation(() => undefined); + mockExistsSync.mockReturnValue(true); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getAllConnectionConfigs: (ideDir: string) => Promise; + } + ).getAllConnectionConfigs('/home/test/.qwen/ide'); + + expect(result).toHaveLength(3); + expect((result[0] as { port: string }).port).toBe('3000'); // Newest first + expect((result[1] as { port: string }).port).toBe('2000'); + expect((result[2] as { port: string }).port).toBe('1000'); + }); + + it('should skip lock files with invalid JSON', async () => { + mockFs.readdir.mockResolvedValue([ + '1000.lock', + '2000.lock', + ] as unknown as fs.Dirent[]); + + mockFs.readFile.mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file.endsWith('1000.lock')) return 'invalid json'; + if (file.endsWith('2000.lock')) + return JSON.stringify({ port: '2000' }); + throw new Error(`unexpected path: ${file}`); + }, + ); + + mockFs.stat.mockImplementation( + async () => ({ mtimeMs: 1000 }) as fs.Stats, + ); + vi.spyOn(process, 'kill').mockImplementation(() => undefined); + mockExistsSync.mockReturnValue(true); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getAllConnectionConfigs: (ideDir: string) => Promise; + } + ).getAllConnectionConfigs('/home/test/.qwen/ide'); + + expect(result).toHaveLength(1); + expect((result[0] as { port: string }).port).toBe('2000'); + }); + }); + + describe('cleanupStaleLockFiles', () => { + it('should remove lock files older than 7 days', async () => { + const now = Date.now(); + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000 - 1000; // Slightly older than 7 days + + const configs = [ + { + file: 'old.lock', + parsed: { port: '1000', ppid: 100, mtimeMs: sevenDaysAgo }, + fullPath: '/home/test/.qwen/ide/old.lock', + }, + ]; + + // Mock process.kill to throw error for ppid 100 (process doesn't exist) + vi.spyOn(process, 'kill').mockImplementationOnce((_pid: number) => { + throw new Error('ESRCH: no such process'); + }); + + const ideClient = await IdeClient.getInstance(); + await ( + ideClient as unknown as { + cleanupStaleLockFiles: ( + configs: Array<{ + file: string; + parsed: Record; + fullPath: string; + }>, + ideDir: string, + ) => Promise; + } + ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); + + expect(mockFs.unlink).toHaveBeenCalledWith( + '/home/test/.qwen/ide/old.lock', + ); + }); + + it('should remove lock files when ppid does not exist', async () => { + const now = Date.now(); + const recentTime = now - 1000; // 1 second ago + + const configs = [ + { + file: 'orphan.lock', + parsed: { port: '1000', ppid: 99999, mtimeMs: recentTime }, + fullPath: '/home/test/.qwen/ide/orphan.lock', + }, + ]; + + // Process 99999 does not exist + vi.spyOn(process, 'kill').mockImplementationOnce((_pid: number) => { + throw new Error('ESRCH: no such process'); + }); + + const ideClient = await IdeClient.getInstance(); + await ( + ideClient as unknown as { + cleanupStaleLockFiles: ( + configs: Array<{ + file: string; + parsed: Record; + fullPath: string; + }>, + ideDir: string, + ) => Promise; + } + ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); + + expect(mockFs.unlink).toHaveBeenCalledWith( + '/home/test/.qwen/ide/orphan.lock', + ); + }); + + it('should keep lock files when ppid exists', async () => { + const now = Date.now(); + const recentTime = now - 1000; + + const configs = [ + { + file: 'active.lock', + parsed: { port: '1000', ppid: 12345, mtimeMs: recentTime }, + fullPath: '/home/test/.qwen/ide/active.lock', + }, + ]; + + // Process 12345 exists + vi.spyOn(process, 'kill').mockImplementation(() => undefined); + + const ideClient = await IdeClient.getInstance(); + await ( + ideClient as unknown as { + cleanupStaleLockFiles: ( + configs: Array<{ + file: string; + parsed: Record; + fullPath: string; + }>, + ideDir: string, + ) => Promise; + } + ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); + + expect(mockFs.unlink).not.toHaveBeenCalled(); + }); + + it('should remove lock files when workspace does not exist and ppid is not set', async () => { + const now = Date.now(); + const recentTime = now - 1000; + + const configs = [ + { + file: 'no-workspace.lock', + parsed: { + port: '1000', + workspacePath: '/deleted/workspace', + mtimeMs: recentTime, + }, + fullPath: '/home/test/.qwen/ide/no-workspace.lock', + }, + ]; + + // No ppid, workspace does not exist + mockExistsSync.mockReturnValue(false); + + const ideClient = await IdeClient.getInstance(); + await ( + ideClient as unknown as { + cleanupStaleLockFiles: ( + configs: Array<{ + file: string; + parsed: Record; + fullPath: string; + }>, + ideDir: string, + ) => Promise; + } + ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); + + expect(mockFs.unlink).toHaveBeenCalledWith( + '/home/test/.qwen/ide/no-workspace.lock', + ); + }); + + it('should keep lock files when workspace exists and ppid is not set', async () => { + const now = Date.now(); + const recentTime = now - 1000; + + const configs = [ + { + file: 'valid-workspace.lock', + parsed: { + port: '1000', + workspacePath: '/test/workspace', + mtimeMs: recentTime, + }, + fullPath: '/home/test/.qwen/ide/valid-workspace.lock', + }, + ]; + + // No ppid, workspace exists + mockExistsSync.mockReturnValue(true); + + const ideClient = await IdeClient.getInstance(); + await ( + ideClient as unknown as { + cleanupStaleLockFiles: ( + configs: Array<{ + file: string; + parsed: Record; + fullPath: string; + }>, + ideDir: string, + ) => Promise; + } + ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); + + expect(mockFs.unlink).not.toHaveBeenCalled(); + }); + + it('should handle mixed scenarios - remove stale and keep active locks', async () => { + const now = Date.now(); + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000 - 1000; + const recentTime = now - 1000; + + const configs = [ + { + file: 'old.lock', + parsed: { port: '1000', ppid: 100, mtimeMs: sevenDaysAgo }, + fullPath: '/home/test/.qwen/ide/old.lock', + }, + { + file: 'orphan.lock', + parsed: { port: '2000', ppid: 99999, mtimeMs: recentTime }, + fullPath: '/home/test/.qwen/ide/orphan.lock', + }, + { + file: 'active.lock', + parsed: { port: '3000', ppid: 12345, mtimeMs: recentTime }, + fullPath: '/home/test/.qwen/ide/active.lock', + }, + ]; + + // Process 100 and 99999 do not exist, process 12345 exists + vi.spyOn(process, 'kill').mockImplementation((pid: number) => { + if (pid === 100 || pid === 99999) { + throw new Error('ESRCH: no such process'); + } + return undefined; + }); + + const ideClient = await IdeClient.getInstance(); + await ( + ideClient as unknown as { + cleanupStaleLockFiles: ( + configs: Array<{ + file: string; + parsed: Record; + fullPath: string; + }>, + ideDir: string, + ) => Promise; + } + ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); + + expect(mockFs.unlink).toHaveBeenCalledTimes(2); + expect(mockFs.unlink).toHaveBeenCalledWith( + '/home/test/.qwen/ide/old.lock', + ); + expect(mockFs.unlink).toHaveBeenCalledWith( + '/home/test/.qwen/ide/orphan.lock', + ); + }); + }); +}); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 6ea3e787b..5b33aabf3 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -174,6 +174,12 @@ export class IdeClient { if (connected) { return; } + // The connection failed, possibly because the IDE server restarted on a different port. + // Try to connect using other lock files as fallback. + const fallbackConnected = await this.tryFallbackPorts(); + if (fallbackConnected) { + return; + } } if (this.connectionConfig.stdio) { const connected = await this.establishStdioConnection( @@ -577,14 +583,18 @@ export class IdeClient { > { const portFromEnv = this.getPortFromEnv(); + // Always scan all lock files to clean up stale ones, even when portFromEnv is set + // This ensures orphaned lock files are cleaned up on every CLI invocation + const ideDir = Storage.getGlobalIdeDir(); + const allConfigs = await this.getAllConnectionConfigs(ideDir); + if (portFromEnv) { try { - const ideDir = Storage.getGlobalIdeDir(); const lockFile = path.join(ideDir, `${portFromEnv}.lock`); const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); return JSON.parse(lockFileContents); } catch (_e) { - // Fall through to legacy discovery. + // Fall through to use allConfigs which was already loaded } } @@ -598,18 +608,14 @@ export class IdeClient { // file is available (e.g. code-server where the env var is not injected). // Configs are sorted by modification time (most recent first). Pick the // first one whose workspace matches the current working directory. - if (!portFromEnv) { - const ideDir = Storage.getGlobalIdeDir(); - const configs = await this.getAllConnectionConfigs(ideDir); - if (configs.length > 0) { - const cwd = process.cwd(); - const match = configs.find( - (c) => - c.workspacePath !== undefined && - IdeClient.validateWorkspacePath(c.workspacePath, cwd).isValid, - ); - return match; - } + if (allConfigs.length > 0) { + const cwd = process.cwd(); + const match = allConfigs.find( + (c) => + c.workspacePath !== undefined && + IdeClient.validateWorkspacePath(c.workspacePath, cwd).isValid, + ); + return match; } return undefined; @@ -657,7 +663,7 @@ export class IdeClient { protected async getAllConnectionConfigs( ideDir: string, ): Promise< - ConnectionConfig & Array<{ workspacePath?: string; ideInfo?: IdeInfo }> + Array > { const fileRegex = new RegExp('^\\d+\\.lock$'); let lockFiles: string[]; @@ -678,26 +684,168 @@ export class IdeClient { const content = await fs.promises.readFile(fullPath, 'utf8'); try { const parsed = JSON.parse(content); - return { file, mtimeMs: stat.mtimeMs, parsed }; + return { file, mtimeMs: stat.mtimeMs, parsed, fullPath }; } catch (e) { debugLogger.debug('Failed to parse JSON from lock file: ', e); - return { file, mtimeMs: stat.mtimeMs, parsed: undefined }; + return { file, mtimeMs: stat.mtimeMs, parsed: undefined, fullPath }; } } catch (e) { // If we can't stat/read the file, treat it as very old so it doesn't // win ties, and skip parsing by returning undefined content. debugLogger.debug('Failed to read/stat IDE lock file:', e); - return { file, mtimeMs: -Infinity, parsed: undefined }; + return { file, mtimeMs: -Infinity, parsed: undefined, fullPath }; } }), ); - return fileContents - .filter(({ parsed }) => parsed !== undefined) + const validConfigs = fileContents.filter( + ({ parsed }) => parsed !== undefined, + ); + + // Clean up stale lock files: remove locks whose ppid no longer exists + // or whose workspace path no longer exists (similar to Claude Code's cleanup strategy) + await this.cleanupStaleLockFiles(validConfigs, ideDir); + + return validConfigs .sort((a, b) => b.mtimeMs - a.mtimeMs) .map(({ parsed }) => parsed); } + /** + * Try to connect using fallback ports from lock files when the primary + * connection fails. This handles the case where the IDE server restarted + * on a different port but the old lock file wasn't cleaned up. + * + * @returns true if connected successfully, false otherwise + */ + private async tryFallbackPorts(): Promise { + const ideDir = Storage.getGlobalIdeDir(); + const configs = await this.getAllConnectionConfigs(ideDir); + + if (configs.length === 0) { + return false; + } + + const cwd = process.cwd(); + const currentPort = this.connectionConfig?.port; + + // First try: find a config with matching workspace path + for (const config of configs) { + if ( + config.workspacePath !== undefined && + IdeClient.validateWorkspacePath(config.workspacePath, cwd).isValid && + config.port !== currentPort + ) { + if (config.authToken) { + this.authToken = config.authToken; + } + const connected = await this.establishHttpConnection(config.port!); + if (connected) { + this.connectionConfig = config; + return true; + } + } + } + + // Second try: try the most recent config regardless of workspace match + for (const config of configs) { + if (config.port !== currentPort) { + if (config.authToken) { + this.authToken = config.authToken; + } + const connected = await this.establishHttpConnection(config.port!); + if (connected) { + this.connectionConfig = config; + return true; + } + } + } + + return false; + } + + /** + * Clean up stale lock files to prevent accumulation of orphaned locks. + * This is similar to Claude Code's lock file cleanup strategy. + * + * A lock file is considered stale if: + * 1. The ppid (parent process ID) no longer exists + * 2. The lock file is older than 7 days + */ + private async cleanupStaleLockFiles( + configs: Array<{ + file: string; + parsed: Record; + fullPath: string; + }>, + _ideDir: string, + ): Promise { + const now = Date.now(); + const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds + const cleanedCount = await Promise.all( + configs.map(async ({ file, parsed, fullPath }) => { + try { + // Check if the lock file is too old + const mtimeMs = parsed['mtimeMs'] as number; + const fileAge = now - mtimeMs; + if (fileAge > maxAge) { + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - older than 7 days`, + ); + await fs.promises.unlink(fullPath); + return true; + } + + // Check if the ppid still exists (process is still running) + const ppid = parsed['ppid'] as number | undefined; + if (ppid) { + try { + // On Unix, we can check if a process exists by sending signal 0 + // which doesn't actually send a signal but checks if the process exists + process.kill(ppid, 0); + // Process exists, lock is valid + return false; + } catch (_e) { + // Process doesn't exist, lock is stale - remove it + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - ppid ${ppid} no longer exists`, + ); + await fs.promises.unlink(fullPath); + return true; + } + } + + // If ppid is not set, check if workspace path exists + const workspacePath = parsed['workspacePath'] as string | undefined; + if (workspacePath && fs.existsSync(workspacePath)) { + // Workspace exists, keep the lock file (be conservative when ppid is missing) + return false; + } + + // Workspace doesn't exist, remove the lock file + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - workspace doesn't exist`, + ); + await fs.promises.unlink(fullPath); + return true; + } catch (_e) { + debugLogger.debug( + `[cleanupStaleLockFiles] Error checking lock file "${file}":`, + _e, + ); + return false; + } + }), + ); + + const totalCleaned = cleanedCount.filter(Boolean).length; + if (totalCleaned > 0) { + debugLogger.debug( + `[cleanupStaleLockFiles] Cleaned up ${totalCleaned} stale lock file(s)`, + ); + } + } + private createProxyAwareFetch(ideHost: string) { // Ignore proxy for IDE server host to allow connecting to the ide mcp // server even when HTTP_PROXY is set From 842e2e0eee233394e84acfd517645caf9e878a00 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 10 Mar 2026 00:43:43 +0800 Subject: [PATCH 020/137] refactor: extract connection config logic into separate module --- packages/core/src/ide/ide-client.test.ts | 536 +++++------------- packages/core/src/ide/ide-client.ts | 265 ++------- .../src/ide/ide-connection-config.test.ts | 184 ++++++ .../core/src/ide/ide-connection-config.ts | 217 +++++++ .../src/ide-server.test.ts | 41 ++ .../vscode-ide-companion/src/ide-server.ts | 68 ++- 6 files changed, 676 insertions(+), 635 deletions(-) create mode 100644 packages/core/src/ide/ide-connection-config.test.ts create mode 100644 packages/core/src/ide/ide-connection-config.ts diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index c640f8238..86f6c81fa 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -50,10 +50,7 @@ vi.mock('node:dns', async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as object), - promises: { - ...actual.promises, - lookup: vi.fn(), - }, + lookup: vi.fn(), }; }); vi.mock('./process-utils.js'); @@ -85,6 +82,7 @@ describe('IdeClient', () => { // Mock dependencies vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir'); + vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode); vi.mocked(getIdeProcessInfo).mockResolvedValue({ pid: 12345, @@ -219,10 +217,18 @@ describe('IdeClient', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); - (dns.promises.lookup as unknown as Mock).mockResolvedValue({ - address: '192.168.65.254', - family: 4, - }); + (dns.lookup as unknown as Mock).mockImplementation( + ( + _hostname: string, + callback: ( + err: Error | null, + address?: string, + family?: number, + ) => void, + ) => { + callback(null, '192.168.65.254', 4); + }, + ); mockClient.connect .mockRejectedValueOnce(new Error('localhost unreachable')) .mockResolvedValueOnce(undefined); @@ -249,6 +255,85 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should try a newer lock-file port when the configured port is stale', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1111'; + const primaryConfig = { + port: '1111', + authToken: 'stale-token', + workspacePath: '/test/workspace', + }; + const fallbackConfig = { + port: '2222', + authToken: 'fresh-token', + workspacePath: '/test/workspace', + }; + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/home/test', '.qwen', 'ide', '1111.lock')) { + return JSON.stringify(primaryConfig); + } + if (file === path.join('/home/test', '.qwen', 'ide', '2222.lock')) { + return JSON.stringify(fallbackConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1111.lock', '2222.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); + const file = String(filePath); + return { + mtimeMs: file.endsWith('2222.lock') ? now : now - 1000, + } as fs.Stats; + }); + vi.mocked(fs.existsSync).mockImplementation( + (filePath: fs.PathLike) => String(filePath) === '/test/workspace', + ); + mockClient.request.mockResolvedValue({ tools: [] }); + mockClient.connect + .mockRejectedValueOnce(new Error('stale port')) + .mockResolvedValueOnce(undefined); + + const ideClient = await IdeClient.getInstance(); + await ideClient.connect(); + + expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith( + 1, + new URL('http://127.0.0.1:1111/mcp'), + expect.objectContaining({ + requestInit: { + headers: { + Authorization: 'Bearer stale-token', + }, + }, + }), + ); + expect(StreamableHTTPClientTransport).toHaveBeenNthCalledWith( + 2, + new URL('http://127.0.0.1:2222/mcp'), + expect.objectContaining({ + requestInit: { + headers: { + Authorization: 'Bearer fresh-token', + }, + }, + }), + ); + expect(ideClient.getConnectionStatus().status).toBe( + IDEConnectionStatus.Connected, + ); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; + }); + it('should connect using stdio when stdio config is in environment variables', async () => { vi.mocked(fs.promises.readFile).mockRejectedValue( new Error('File not found'), @@ -459,9 +544,10 @@ describe('IdeClient', () => { (path: fs.PathLike) => Promise > ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); const file = String(filePath); return { - mtimeMs: file.endsWith('2000.lock') ? 2000 : 1000, + mtimeMs: file.endsWith('2000.lock') ? now : now - 1000, } as fs.Stats; }); @@ -510,9 +596,10 @@ describe('IdeClient', () => { (path: fs.PathLike) => Promise > ).mockImplementation(async (filePath: fs.PathLike) => { + const now = Date.now(); const file = String(filePath); return { - mtimeMs: file.endsWith('2000.lock') ? 2000 : 1000, + mtimeMs: file.endsWith('2000.lock') ? now : now - 1000, } as fs.Stats; }); @@ -648,14 +735,25 @@ describe('IdeClient', () => { }); describe('getIdeServerHost', () => { - const dnsLookupMock = dns.promises.lookup as unknown as Mock; + const dnsLookupMock = dns.lookup as unknown as Mock; function mockDnsResolvable(reachable: boolean): void { - if (reachable) { - dnsLookupMock.mockResolvedValue({ address: '192.168.65.254', family: 4 }); - } else { - dnsLookupMock.mockRejectedValue(new Error('ENOTFOUND')); - } + dnsLookupMock.mockImplementation( + ( + _hostname: string, + callback: ( + err: Error | null, + address?: string, + family?: number, + ) => void, + ) => { + if (reachable) { + callback(null, '192.168.65.254', 4); + } else { + callback(new Error('ENOTFOUND')); + } + }, + ); } beforeEach(() => { @@ -683,7 +781,10 @@ describe('getIdeServerHost', () => { const host = await getIdeServerHost(); expect(host).toBe('host.docker.internal'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should fall back to 127.0.0.1 when in a container but host.docker.internal is not reachable', async () => { @@ -695,7 +796,10 @@ describe('getIdeServerHost', () => { const host = await getIdeServerHost(); expect(host).toBe('127.0.0.1'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should detect container via /run/.containerenv', async () => { @@ -728,15 +832,17 @@ describe('getIdeServerHost', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); - // Simulate dns.promises.lookup that never resolves - dnsLookupMock.mockReturnValue(new Promise(() => {})); + dnsLookupMock.mockImplementation(() => undefined); const hostPromise = getIdeServerHost(); await vi.advanceTimersByTimeAsync(3000); const host = await hostPromise; expect(host).toBe('127.0.0.1'); - expect(dnsLookupMock).toHaveBeenCalledWith('host.docker.internal'); + expect(dnsLookupMock).toHaveBeenCalledWith( + 'host.docker.internal', + expect.any(Function), + ); }); it('should perform only one DNS lookup when called concurrently', async () => { @@ -744,16 +850,17 @@ describe('getIdeServerHost', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); - - // Simulate a slow DNS lookup dnsLookupMock.mockImplementation( - () => - new Promise((resolve) => - setTimeout( - () => resolve({ address: '192.168.65.254', family: 4 }), - 50, - ), - ), + ( + _hostname: string, + callback: ( + err: Error | null, + address?: string, + family?: number, + ) => void, + ) => { + setTimeout(() => callback(null, '192.168.65.254', 4), 50); + }, ); const promises = Array.from({ length: 5 }, () => getIdeServerHost()); @@ -763,372 +870,3 @@ describe('getIdeServerHost', () => { expect(dnsLookupMock).toHaveBeenCalledTimes(1); }); }); - -describe('Lock File Management', () => { - const mockFs = vi.mocked(fs.promises); - const mockExistsSync = vi.mocked(fs.existsSync); - - beforeEach(async () => { - // Reset singleton instance for test isolation - ( - IdeClient as unknown as { - instancePromise: Promise | null; - } - ).instancePromise = null; - _resetCachedIdeServerHost(); - - // Mock environment - process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = '/test/workspace'; - vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode); - vi.mocked(getIdeProcessInfo).mockResolvedValue({ - pid: 12345, - command: 'test-ide', - }); - vi.mocked(os.tmpdir).mockReturnValue('/tmp'); - vi.mocked(os.homedir).mockReturnValue('/home/test'); - vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace'); - - // Reset mocks - mockFs.readFile.mockReset(); - mockFs.readdir.mockReset(); - mockFs.stat.mockReset(); - mockFs.unlink.mockReset(); - mockExistsSync.mockReset(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - delete process.env['QWEN_CODE_IDE_WORKSPACE_PATH']; - }); - - describe('getAllConnectionConfigs', () => { - it('should return empty array when ide directory does not exist', async () => { - mockFs.readdir.mockRejectedValue(new Error('ENOENT: no such directory')); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getAllConnectionConfigs: (ideDir: string) => Promise; - } - ).getAllConnectionConfigs('/home/test/.qwen/ide'); - - expect(result).toEqual([]); - expect(mockFs.unlink).not.toHaveBeenCalled(); - }); - - it('should return all valid lock files sorted by mtime (newest first)', async () => { - const lockFiles = ['1000.lock', '2000.lock', '3000.lock']; - mockFs.readdir.mockResolvedValue(lockFiles as unknown as fs.Dirent[]); - - const configs = [ - { port: '1000', workspacePath: '/workspace/old', ppid: 100 }, - { port: '2000', workspacePath: '/workspace/medium', ppid: 200 }, - { port: '3000', workspacePath: '/workspace/new', ppid: 300 }, - ]; - - mockFs.readFile.mockImplementation( - async (filePath: fs.PathLike | FileHandle) => { - const file = String(filePath); - if (file.endsWith('1000.lock')) return JSON.stringify(configs[0]); - if (file.endsWith('2000.lock')) return JSON.stringify(configs[1]); - if (file.endsWith('3000.lock')) return JSON.stringify(configs[2]); - throw new Error(`unexpected path: ${file}`); - }, - ); - - mockFs.stat.mockImplementation(async (filePath: fs.PathLike) => { - const file = String(filePath); - return { - mtimeMs: file.endsWith('1000.lock') - ? 1000 - : file.endsWith('2000.lock') - ? 2000 - : 3000, - } as fs.Stats; - }); - - // All processes exist - vi.spyOn(process, 'kill').mockImplementation(() => undefined); - mockExistsSync.mockReturnValue(true); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getAllConnectionConfigs: (ideDir: string) => Promise; - } - ).getAllConnectionConfigs('/home/test/.qwen/ide'); - - expect(result).toHaveLength(3); - expect((result[0] as { port: string }).port).toBe('3000'); // Newest first - expect((result[1] as { port: string }).port).toBe('2000'); - expect((result[2] as { port: string }).port).toBe('1000'); - }); - - it('should skip lock files with invalid JSON', async () => { - mockFs.readdir.mockResolvedValue([ - '1000.lock', - '2000.lock', - ] as unknown as fs.Dirent[]); - - mockFs.readFile.mockImplementation( - async (filePath: fs.PathLike | FileHandle) => { - const file = String(filePath); - if (file.endsWith('1000.lock')) return 'invalid json'; - if (file.endsWith('2000.lock')) - return JSON.stringify({ port: '2000' }); - throw new Error(`unexpected path: ${file}`); - }, - ); - - mockFs.stat.mockImplementation( - async () => ({ mtimeMs: 1000 }) as fs.Stats, - ); - vi.spyOn(process, 'kill').mockImplementation(() => undefined); - mockExistsSync.mockReturnValue(true); - - const ideClient = await IdeClient.getInstance(); - const result = await ( - ideClient as unknown as { - getAllConnectionConfigs: (ideDir: string) => Promise; - } - ).getAllConnectionConfigs('/home/test/.qwen/ide'); - - expect(result).toHaveLength(1); - expect((result[0] as { port: string }).port).toBe('2000'); - }); - }); - - describe('cleanupStaleLockFiles', () => { - it('should remove lock files older than 7 days', async () => { - const now = Date.now(); - const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000 - 1000; // Slightly older than 7 days - - const configs = [ - { - file: 'old.lock', - parsed: { port: '1000', ppid: 100, mtimeMs: sevenDaysAgo }, - fullPath: '/home/test/.qwen/ide/old.lock', - }, - ]; - - // Mock process.kill to throw error for ppid 100 (process doesn't exist) - vi.spyOn(process, 'kill').mockImplementationOnce((_pid: number) => { - throw new Error('ESRCH: no such process'); - }); - - const ideClient = await IdeClient.getInstance(); - await ( - ideClient as unknown as { - cleanupStaleLockFiles: ( - configs: Array<{ - file: string; - parsed: Record; - fullPath: string; - }>, - ideDir: string, - ) => Promise; - } - ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); - - expect(mockFs.unlink).toHaveBeenCalledWith( - '/home/test/.qwen/ide/old.lock', - ); - }); - - it('should remove lock files when ppid does not exist', async () => { - const now = Date.now(); - const recentTime = now - 1000; // 1 second ago - - const configs = [ - { - file: 'orphan.lock', - parsed: { port: '1000', ppid: 99999, mtimeMs: recentTime }, - fullPath: '/home/test/.qwen/ide/orphan.lock', - }, - ]; - - // Process 99999 does not exist - vi.spyOn(process, 'kill').mockImplementationOnce((_pid: number) => { - throw new Error('ESRCH: no such process'); - }); - - const ideClient = await IdeClient.getInstance(); - await ( - ideClient as unknown as { - cleanupStaleLockFiles: ( - configs: Array<{ - file: string; - parsed: Record; - fullPath: string; - }>, - ideDir: string, - ) => Promise; - } - ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); - - expect(mockFs.unlink).toHaveBeenCalledWith( - '/home/test/.qwen/ide/orphan.lock', - ); - }); - - it('should keep lock files when ppid exists', async () => { - const now = Date.now(); - const recentTime = now - 1000; - - const configs = [ - { - file: 'active.lock', - parsed: { port: '1000', ppid: 12345, mtimeMs: recentTime }, - fullPath: '/home/test/.qwen/ide/active.lock', - }, - ]; - - // Process 12345 exists - vi.spyOn(process, 'kill').mockImplementation(() => undefined); - - const ideClient = await IdeClient.getInstance(); - await ( - ideClient as unknown as { - cleanupStaleLockFiles: ( - configs: Array<{ - file: string; - parsed: Record; - fullPath: string; - }>, - ideDir: string, - ) => Promise; - } - ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); - - expect(mockFs.unlink).not.toHaveBeenCalled(); - }); - - it('should remove lock files when workspace does not exist and ppid is not set', async () => { - const now = Date.now(); - const recentTime = now - 1000; - - const configs = [ - { - file: 'no-workspace.lock', - parsed: { - port: '1000', - workspacePath: '/deleted/workspace', - mtimeMs: recentTime, - }, - fullPath: '/home/test/.qwen/ide/no-workspace.lock', - }, - ]; - - // No ppid, workspace does not exist - mockExistsSync.mockReturnValue(false); - - const ideClient = await IdeClient.getInstance(); - await ( - ideClient as unknown as { - cleanupStaleLockFiles: ( - configs: Array<{ - file: string; - parsed: Record; - fullPath: string; - }>, - ideDir: string, - ) => Promise; - } - ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); - - expect(mockFs.unlink).toHaveBeenCalledWith( - '/home/test/.qwen/ide/no-workspace.lock', - ); - }); - - it('should keep lock files when workspace exists and ppid is not set', async () => { - const now = Date.now(); - const recentTime = now - 1000; - - const configs = [ - { - file: 'valid-workspace.lock', - parsed: { - port: '1000', - workspacePath: '/test/workspace', - mtimeMs: recentTime, - }, - fullPath: '/home/test/.qwen/ide/valid-workspace.lock', - }, - ]; - - // No ppid, workspace exists - mockExistsSync.mockReturnValue(true); - - const ideClient = await IdeClient.getInstance(); - await ( - ideClient as unknown as { - cleanupStaleLockFiles: ( - configs: Array<{ - file: string; - parsed: Record; - fullPath: string; - }>, - ideDir: string, - ) => Promise; - } - ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); - - expect(mockFs.unlink).not.toHaveBeenCalled(); - }); - - it('should handle mixed scenarios - remove stale and keep active locks', async () => { - const now = Date.now(); - const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000 - 1000; - const recentTime = now - 1000; - - const configs = [ - { - file: 'old.lock', - parsed: { port: '1000', ppid: 100, mtimeMs: sevenDaysAgo }, - fullPath: '/home/test/.qwen/ide/old.lock', - }, - { - file: 'orphan.lock', - parsed: { port: '2000', ppid: 99999, mtimeMs: recentTime }, - fullPath: '/home/test/.qwen/ide/orphan.lock', - }, - { - file: 'active.lock', - parsed: { port: '3000', ppid: 12345, mtimeMs: recentTime }, - fullPath: '/home/test/.qwen/ide/active.lock', - }, - ]; - - // Process 100 and 99999 do not exist, process 12345 exists - vi.spyOn(process, 'kill').mockImplementation((pid: number) => { - if (pid === 100 || pid === 99999) { - throw new Error('ESRCH: no such process'); - } - return undefined; - }); - - const ideClient = await IdeClient.getInstance(); - await ( - ideClient as unknown as { - cleanupStaleLockFiles: ( - configs: Array<{ - file: string; - parsed: Record; - fullPath: string; - }>, - ideDir: string, - ) => Promise; - } - ).cleanupStaleLockFiles(configs, '/home/test/.qwen/ide'); - - expect(mockFs.unlink).toHaveBeenCalledTimes(2); - expect(mockFs.unlink).toHaveBeenCalledWith( - '/home/test/.qwen/ide/old.lock', - ); - expect(mockFs.unlink).toHaveBeenCalledWith( - '/home/test/.qwen/ide/orphan.lock', - ); - }); - }); -}); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 5b33aabf3..9587c0119 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -27,6 +27,14 @@ import { EnvHttpProxyAgent } from 'undici'; import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { IDE_REQUEST_TIMEOUT_MS } from './constants.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { + getAllConnectionConfigs, + getFallbackConnectionConfigs, + getWorkspaceMatchingConnectionConfig, + readConnectionConfigFromLockFile, + type IdeConnectionConfig, + type StdioConfig, +} from './ide-connection-config.js'; const debugLogger = createDebugLogger('IDE'); @@ -51,17 +59,6 @@ export enum IDEConnectionStatus { Connecting = 'connecting', } -type StdioConfig = { - command: string; - args: string[]; -}; - -type ConnectionConfig = { - port?: string; - authToken?: string; - stdio?: StdioConfig; -}; - function getRealPath(path: string): string { try { return fs.realpathSync(path); @@ -85,9 +82,7 @@ export class IdeClient { }; private currentIde: IdeInfo | undefined; private ideProcessInfo: { pid: number; command: string } | undefined; - private connectionConfig: - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined; + private connectionConfig: IdeConnectionConfig | undefined; private authToken: string | undefined; private diffResponses = new Map void>(); private statusListeners = new Set<(state: IDEConnectionState) => void>(); @@ -144,7 +139,6 @@ export class IdeClient { return; } - debugLogger.debug('[IdeClient] Setting state to Connecting'); this.setState(IDEConnectionStatus.Connecting); this.connectionConfig = await this.getConnectionConfigFromFile(); @@ -578,56 +572,39 @@ export class IdeClient { } private async getConnectionConfigFromFile(): Promise< - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined + IdeConnectionConfig | undefined > { const portFromEnv = this.getPortFromEnv(); - - // Always scan all lock files to clean up stale ones, even when portFromEnv is set - // This ensures orphaned lock files are cleaned up on every CLI invocation const ideDir = Storage.getGlobalIdeDir(); - const allConfigs = await this.getAllConnectionConfigs(ideDir); + const allConfigs = await getAllConnectionConfigs(ideDir); if (portFromEnv) { - try { - const lockFile = path.join(ideDir, `${portFromEnv}.lock`); - const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); - return JSON.parse(lockFileContents); - } catch (_e) { - // Fall through to use allConfigs which was already loaded + const lockFileConfig = await readConnectionConfigFromLockFile( + ideDir, + portFromEnv, + ); + if (lockFileConfig) { + return lockFileConfig; } } - // Legacy connection files were written in the global temp directory. const legacyConfig = await this.getLegacyConnectionConfig(portFromEnv); if (legacyConfig) { return legacyConfig; } - // Scan lock directory as a last resort when neither env var nor legacy - // file is available (e.g. code-server where the env var is not injected). - // Configs are sorted by modification time (most recent first). Pick the - // first one whose workspace matches the current working directory. - if (allConfigs.length > 0) { - const cwd = process.cwd(); - const match = allConfigs.find( - (c) => - c.workspacePath !== undefined && - IdeClient.validateWorkspacePath(c.workspacePath, cwd).isValid, - ); - return match; - } - - return undefined; + return getWorkspaceMatchingConnectionConfig( + allConfigs, + process.cwd(), + (workspacePath, cwd) => + IdeClient.validateWorkspacePath(workspacePath, cwd).isValid, + ); } // Legacy connection files were written in the global temp directory. private async getLegacyConnectionConfig( portFromEnv?: string, - ): Promise< - | (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo }) - | undefined - > { + ): Promise { if (this.ideProcessInfo) { try { const portFile = path.join( @@ -660,192 +637,29 @@ export class IdeClient { return undefined; } - protected async getAllConnectionConfigs( - ideDir: string, - ): Promise< - Array - > { - const fileRegex = new RegExp('^\\d+\\.lock$'); - let lockFiles: string[]; - try { - lockFiles = (await fs.promises.readdir(ideDir)).filter((file) => - fileRegex.test(file), - ); - } catch (e) { - debugLogger.debug('Failed to read IDE connection directory:', e); - return []; - } - - const fileContents = await Promise.all( - lockFiles.map(async (file) => { - const fullPath = path.join(ideDir, file); - try { - const stat = await fs.promises.stat(fullPath); - const content = await fs.promises.readFile(fullPath, 'utf8'); - try { - const parsed = JSON.parse(content); - return { file, mtimeMs: stat.mtimeMs, parsed, fullPath }; - } catch (e) { - debugLogger.debug('Failed to parse JSON from lock file: ', e); - return { file, mtimeMs: stat.mtimeMs, parsed: undefined, fullPath }; - } - } catch (e) { - // If we can't stat/read the file, treat it as very old so it doesn't - // win ties, and skip parsing by returning undefined content. - debugLogger.debug('Failed to read/stat IDE lock file:', e); - return { file, mtimeMs: -Infinity, parsed: undefined, fullPath }; - } - }), - ); - - const validConfigs = fileContents.filter( - ({ parsed }) => parsed !== undefined, - ); - - // Clean up stale lock files: remove locks whose ppid no longer exists - // or whose workspace path no longer exists (similar to Claude Code's cleanup strategy) - await this.cleanupStaleLockFiles(validConfigs, ideDir); - - return validConfigs - .sort((a, b) => b.mtimeMs - a.mtimeMs) - .map(({ parsed }) => parsed); - } - - /** - * Try to connect using fallback ports from lock files when the primary - * connection fails. This handles the case where the IDE server restarted - * on a different port but the old lock file wasn't cleaned up. - * - * @returns true if connected successfully, false otherwise - */ private async tryFallbackPorts(): Promise { - const ideDir = Storage.getGlobalIdeDir(); - const configs = await this.getAllConnectionConfigs(ideDir); + const configs = await getAllConnectionConfigs(Storage.getGlobalIdeDir()); + const fallbackConfigs = getFallbackConnectionConfigs(configs, { + cwd: process.cwd(), + currentPort: this.connectionConfig?.port, + matchesWorkspace: (workspacePath, cwd) => + IdeClient.validateWorkspacePath(workspacePath, cwd).isValid, + }); - if (configs.length === 0) { - return false; - } - - const cwd = process.cwd(); - const currentPort = this.connectionConfig?.port; - - // First try: find a config with matching workspace path - for (const config of configs) { - if ( - config.workspacePath !== undefined && - IdeClient.validateWorkspacePath(config.workspacePath, cwd).isValid && - config.port !== currentPort - ) { - if (config.authToken) { - this.authToken = config.authToken; - } - const connected = await this.establishHttpConnection(config.port!); - if (connected) { - this.connectionConfig = config; - return true; - } + for (const config of fallbackConfigs) { + if (config.authToken) { + this.authToken = config.authToken; } - } - - // Second try: try the most recent config regardless of workspace match - for (const config of configs) { - if (config.port !== currentPort) { - if (config.authToken) { - this.authToken = config.authToken; - } - const connected = await this.establishHttpConnection(config.port!); - if (connected) { - this.connectionConfig = config; - return true; - } + const connected = await this.establishHttpConnection(config.port!); + if (connected) { + this.connectionConfig = config; + return true; } } return false; } - /** - * Clean up stale lock files to prevent accumulation of orphaned locks. - * This is similar to Claude Code's lock file cleanup strategy. - * - * A lock file is considered stale if: - * 1. The ppid (parent process ID) no longer exists - * 2. The lock file is older than 7 days - */ - private async cleanupStaleLockFiles( - configs: Array<{ - file: string; - parsed: Record; - fullPath: string; - }>, - _ideDir: string, - ): Promise { - const now = Date.now(); - const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds - const cleanedCount = await Promise.all( - configs.map(async ({ file, parsed, fullPath }) => { - try { - // Check if the lock file is too old - const mtimeMs = parsed['mtimeMs'] as number; - const fileAge = now - mtimeMs; - if (fileAge > maxAge) { - debugLogger.debug( - `[cleanupStaleLockFiles] Removing lock file "${file}" - older than 7 days`, - ); - await fs.promises.unlink(fullPath); - return true; - } - - // Check if the ppid still exists (process is still running) - const ppid = parsed['ppid'] as number | undefined; - if (ppid) { - try { - // On Unix, we can check if a process exists by sending signal 0 - // which doesn't actually send a signal but checks if the process exists - process.kill(ppid, 0); - // Process exists, lock is valid - return false; - } catch (_e) { - // Process doesn't exist, lock is stale - remove it - debugLogger.debug( - `[cleanupStaleLockFiles] Removing lock file "${file}" - ppid ${ppid} no longer exists`, - ); - await fs.promises.unlink(fullPath); - return true; - } - } - - // If ppid is not set, check if workspace path exists - const workspacePath = parsed['workspacePath'] as string | undefined; - if (workspacePath && fs.existsSync(workspacePath)) { - // Workspace exists, keep the lock file (be conservative when ppid is missing) - return false; - } - - // Workspace doesn't exist, remove the lock file - debugLogger.debug( - `[cleanupStaleLockFiles] Removing lock file "${file}" - workspace doesn't exist`, - ); - await fs.promises.unlink(fullPath); - return true; - } catch (_e) { - debugLogger.debug( - `[cleanupStaleLockFiles] Error checking lock file "${file}":`, - _e, - ); - return false; - } - }), - ); - - const totalCleaned = cleanedCount.filter(Boolean).length; - if (totalCleaned > 0) { - debugLogger.debug( - `[cleanupStaleLockFiles] Cleaned up ${totalCleaned} stale lock file(s)`, - ); - } - } - private createProxyAwareFetch(ideHost: string) { // Ignore proxy for IDE server host to allow connecting to the ide mcp // server even when HTTP_PROXY is set @@ -977,6 +791,9 @@ export class IdeClient { private async tryHttpConnect(port: string, host: string): Promise { let transport: StreamableHTTPClientTransport | undefined; try { + debugLogger.debug( + `Attempting to connect to IDE via HTTP at ${host}:${port}`, + ); this.client = new Client({ name: 'streamable-http-client', // TODO(#3487): use the CLI version here. diff --git a/packages/core/src/ide/ide-connection-config.test.ts b/packages/core/src/ide/ide-connection-config.test.ts new file mode 100644 index 000000000..ba004d469 --- /dev/null +++ b/packages/core/src/ide/ide-connection-config.test.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; +import { + getAllConnectionConfigs, + getFallbackConnectionConfigs, + type IdeConnectionConfig, +} from './ide-connection-config.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + promises: { + ...actual.promises, + readFile: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + unlink: vi.fn(), + }, + existsSync: vi.fn().mockReturnValue(false), + }; +}); + +describe('ide-connection-config', () => { + const mockFs = vi.mocked(fs.promises); + const mockExistsSync = vi.mocked(fs.existsSync); + + beforeEach(() => { + mockFs.readFile.mockReset(); + mockFs.readdir.mockReset(); + mockFs.stat.mockReset(); + mockFs.unlink.mockReset(); + mockExistsSync.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getAllConnectionConfigs', () => { + it('returns an empty array when the IDE lock directory does not exist', async () => { + mockFs.readdir.mockRejectedValue(new Error('ENOENT: no such directory')); + + const result = await getAllConnectionConfigs('/home/test/.qwen/ide'); + + expect(result).toEqual([]); + expect(mockFs.unlink).not.toHaveBeenCalled(); + }); + + it('returns active lock files sorted by mtime and skips invalid JSON', async () => { + mockFs.readdir.mockResolvedValue([ + '1000.lock', + '2000.lock', + '3000.lock', + ] as never); + mockFs.readFile.mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file.endsWith('1000.lock')) { + return JSON.stringify({ + port: '1000', + workspacePath: '/workspace/1', + }); + } + if (file.endsWith('2000.lock')) { + return JSON.stringify({ + port: '2000', + workspacePath: '/workspace/2', + }); + } + if (file.endsWith('3000.lock')) { + return 'not-json'; + } + throw new Error(`unexpected path: ${file}`); + }, + ); + const now = Date.now(); + mockFs.stat.mockImplementation(async (filePath: fs.PathLike) => { + const file = String(filePath); + return { + mtimeMs: file.endsWith('2000.lock') ? now : now - 1000, + } as fs.Stats; + }); + mockExistsSync.mockReturnValue(true); + + const result = await getAllConnectionConfigs('/home/test/.qwen/ide'); + + expect(result).toEqual([ + { port: '2000', workspacePath: '/workspace/2' }, + { port: '1000', workspacePath: '/workspace/1' }, + ]); + }); + + it('keeps an old lock file when its IDE process is still running', async () => { + const oldTime = Date.now() - 8 * 24 * 60 * 60 * 1000; + + mockFs.readdir.mockResolvedValue(['1000.lock'] as never); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + port: '1000', + workspacePath: '/workspace/live', + ppid: 4242, + }), + ); + mockFs.stat.mockResolvedValue({ mtimeMs: oldTime } as never); + vi.spyOn(process, 'kill').mockImplementation(() => true); + + const result = await getAllConnectionConfigs('/home/test/.qwen/ide'); + + expect(mockFs.unlink).not.toHaveBeenCalled(); + expect(result).toEqual([ + { port: '1000', workspacePath: '/workspace/live', ppid: 4242 }, + ]); + }); + + it('removes incomplete lock files older than 7 days and excludes them from results', async () => { + const now = Date.now(); + const staleTime = now - 7 * 24 * 60 * 60 * 1000 - 1000; + const recentTime = now - 1000; + + mockFs.readdir.mockResolvedValue(['1000.lock', '2000.lock'] as never); + mockFs.readFile.mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file.endsWith('1000.lock')) { + return JSON.stringify({ port: '1000' }); + } + if (file.endsWith('2000.lock')) { + return JSON.stringify({ + port: '2000', + workspacePath: '/workspace/new', + }); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + mockFs.stat.mockImplementation(async (filePath: fs.PathLike) => { + const file = String(filePath); + return { + mtimeMs: file.endsWith('1000.lock') ? staleTime : recentTime, + } as fs.Stats; + }); + mockExistsSync.mockReturnValue(true); + + const result = await getAllConnectionConfigs('/home/test/.qwen/ide'); + + expect(mockFs.unlink).toHaveBeenCalledWith( + '/home/test/.qwen/ide/1000.lock', + ); + expect(result).toEqual([ + { port: '2000', workspacePath: '/workspace/new' }, + ]); + }); + }); + + describe('getFallbackConnectionConfigs', () => { + it('prioritizes workspace matches and excludes the current port', () => { + const configs: IdeConnectionConfig[] = [ + { port: '1111', workspacePath: '/workspace/other' }, + { port: '2222', workspacePath: '/test/workspace' }, + { port: '3333', workspacePath: '/workspace/another' }, + { workspacePath: '/test/workspace' }, + ]; + + const result = getFallbackConnectionConfigs(configs, { + cwd: '/test/workspace/subdir', + currentPort: '1111', + matchesWorkspace: (workspacePath, cwd) => cwd.startsWith(workspacePath), + }); + + expect(result).toEqual([ + { port: '2222', workspacePath: '/test/workspace' }, + { port: '3333', workspacePath: '/workspace/another' }, + ]); + }); + }); +}); diff --git a/packages/core/src/ide/ide-connection-config.ts b/packages/core/src/ide/ide-connection-config.ts new file mode 100644 index 000000000..890c3cdad --- /dev/null +++ b/packages/core/src/ide/ide-connection-config.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { IdeInfo } from './detect-ide.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('IDE'); +const STALE_LOCK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export type StdioConfig = { + command: string; + args: string[]; +}; + +export type ConnectionConfig = { + port?: string; + authToken?: string; + stdio?: StdioConfig; +}; + +export type IdeConnectionConfig = ConnectionConfig & { + workspacePath?: string; + ideInfo?: IdeInfo; + ppid?: number; +}; + +type ParsedConnectionLockFile = { + file: string; + fullPath: string; + mtimeMs: number; + parsed: IdeConnectionConfig; +}; + +export async function readConnectionConfigFromLockFile( + ideDir: string, + port: string, +): Promise { + try { + const lockFile = path.join(ideDir, `${port}.lock`); + const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); + return JSON.parse(lockFileContents) as IdeConnectionConfig; + } catch { + return undefined; + } +} + +export async function getAllConnectionConfigs( + ideDir: string, +): Promise { + const parsedLockFiles = await getParsedConnectionLockFiles(ideDir); + const activeLockFiles = await filterActiveLockFiles(parsedLockFiles); + + return activeLockFiles + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ parsed }) => parsed); +} + +export function getWorkspaceMatchingConnectionConfig( + configs: IdeConnectionConfig[], + cwd: string, + matchesWorkspace: (workspacePath: string, cwd: string) => boolean, +): IdeConnectionConfig | undefined { + return configs.find( + (config) => + config.workspacePath !== undefined && + matchesWorkspace(config.workspacePath, cwd), + ); +} + +export function getFallbackConnectionConfigs( + configs: IdeConnectionConfig[], + { + cwd, + currentPort, + matchesWorkspace, + }: { + cwd: string; + currentPort?: string; + matchesWorkspace: (workspacePath: string, cwd: string) => boolean; + }, +): IdeConnectionConfig[] { + const candidates = configs.filter( + (config) => config.port !== undefined && config.port !== currentPort, + ); + + const workspaceMatches = candidates.filter( + (config) => + config.workspacePath !== undefined && + matchesWorkspace(config.workspacePath, cwd), + ); + const matchedPorts = new Set(workspaceMatches.map((config) => config.port)); + + return [ + ...workspaceMatches, + ...candidates.filter((config) => !matchedPorts.has(config.port)), + ]; +} + +async function getParsedConnectionLockFiles( + ideDir: string, +): Promise { + const fileRegex = /^\d+\.lock$/; + let lockFiles: string[]; + try { + lockFiles = (await fs.promises.readdir(ideDir)) + .map((file) => file.toString()) + .filter((file) => fileRegex.test(file)); + } catch (error) { + debugLogger.debug('Failed to read IDE connection directory:', error); + return []; + } + + const parsedLockFiles = await Promise.all( + lockFiles.map(async (file) => { + const fullPath = path.join(ideDir, file); + try { + const stat = await fs.promises.stat(fullPath); + const content = await fs.promises.readFile(fullPath, 'utf8'); + try { + return { + file, + fullPath, + mtimeMs: stat.mtimeMs, + parsed: JSON.parse(content) as IdeConnectionConfig, + }; + } catch (error) { + debugLogger.debug('Failed to parse JSON from lock file: ', error); + return undefined; + } + } catch (error) { + debugLogger.debug('Failed to read/stat IDE lock file:', error); + return undefined; + } + }), + ); + + return parsedLockFiles.filter( + (lockFile): lockFile is ParsedConnectionLockFile => lockFile !== undefined, + ); +} + +async function filterActiveLockFiles( + lockFiles: ParsedConnectionLockFile[], +): Promise { + const activeResults = await Promise.all( + lockFiles.map(async (lockFile) => ({ + lockFile, + isStale: await cleanupStaleLockFile(lockFile), + })), + ); + + const staleCount = activeResults.filter(({ isStale }) => isStale).length; + if (staleCount > 0) { + debugLogger.debug( + `[cleanupStaleLockFiles] Cleaned up ${staleCount} stale lock file(s)`, + ); + } + + return activeResults + .filter(({ isStale }) => !isStale) + .map(({ lockFile }) => lockFile); +} + +async function cleanupStaleLockFile({ + file, + fullPath, + mtimeMs, + parsed, +}: ParsedConnectionLockFile): Promise { + try { + if (parsed.ppid) { + try { + process.kill(parsed.ppid, 0); + return false; + } catch { + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - ppid ${parsed.ppid} no longer exists`, + ); + await fs.promises.unlink(fullPath); + return true; + } + } + + if (parsed.workspacePath) { + if (fs.existsSync(parsed.workspacePath)) { + return false; + } + + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - workspace doesn't exist`, + ); + await fs.promises.unlink(fullPath); + return true; + } + + if (Date.now() - mtimeMs > STALE_LOCK_MAX_AGE_MS) { + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - older than 7 days`, + ); + await fs.promises.unlink(fullPath); + return true; + } + + return false; + } catch (error) { + debugLogger.debug( + `[cleanupStaleLockFiles] Error checking lock file "${file}":`, + error, + ); + return false; + } +} diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index 9c51d5021..16a5fd4ef 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -28,6 +28,9 @@ vi.mock('node:fs/promises', () => ({ unlink: vi.fn(() => Promise.resolve(undefined)), chmod: vi.fn(() => Promise.resolve(undefined)), mkdir: vi.fn(() => Promise.resolve(undefined)), + readdir: vi.fn(() => Promise.resolve([])), + readFile: vi.fn(() => Promise.resolve('')), + stat: vi.fn(() => Promise.resolve({ mtimeMs: Date.now() })), })); vi.mock('node:os', async (importOriginal) => { @@ -116,6 +119,44 @@ describe('IDEServer', () => { ]; }); + it('should remove stale lock files before starting the server', async () => { + vi.mocked(fs.readdir).mockResolvedValue(['3000.lock'] as never); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + port: 3000, + workspacePath: '/stale/workspace', + ppid: 99999, + authToken: 'stale-auth-token', + ideName: 'VS Code', + }), + ); + vi.mocked(fs.stat).mockResolvedValue({ + mtimeMs: Date.now() - 1000, + } as never); + vi.spyOn(process, 'kill').mockImplementation((pid: number) => { + if (pid === 99999) { + throw new Error('ESRCH'); + } + return true; + }); + + await ideServer.start(mockContext); + + expect(fs.unlink).toHaveBeenCalledWith( + path.join('/home/test', '.qwen', 'ide', '3000.lock'), + ); + }); + + it('should fail start when the lock file cannot be written', async () => { + vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('disk full')); + + await expect(ideServer.start(mockContext)).rejects.toThrow( + 'Failed to write IDE lock file: disk full', + ); + + expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); + }); + it('should set environment variables and workspace path on start with multiple folders', async () => { await ideServer.start(mockContext); diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 6a8ff82a1..df4b18957 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -40,6 +40,7 @@ class CORSError extends Error { const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT'; const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH'; +const LOCK_FILE_REGEX = /^\d+\.lock$/; const QWEN_DIR = '.qwen'; const IDE_DIR = 'ide'; @@ -101,10 +102,45 @@ async function writePortAndWorkspace({ await fs.chmod(lockFile, 0o600); } catch (err) { const message = err instanceof Error ? err.message : String(err); - log(`Failed to write IDE lock file: ${message}`); + throw new Error(`Failed to write IDE lock file: ${message}`); } } +async function cleanupStaleLockFiles( + ideDir: string, + log: (message: string) => void, +): Promise { + let lockFiles: string[]; + try { + lockFiles = (await fs.readdir(ideDir)).filter((file) => + LOCK_FILE_REGEX.test(file), + ); + } catch { + return; + } + + await Promise.all( + lockFiles.map(async (file) => { + const lockFile = path.join(ideDir, file); + try { + const content = await fs.readFile(lockFile, 'utf8'); + const parsed = JSON.parse(content) as { ppid?: number }; + if (!parsed.ppid) { + return; + } + try { + process.kill(parsed.ppid, 0); + } catch { + log(`Removing stale IDE lock file: ${lockFile}`); + await fs.unlink(lockFile); + } + } catch { + return; + } + }), + ); +} + function sendIdeContextUpdateNotification( transport: StreamableHTTPServerTransport, log: (message: string) => void, @@ -146,7 +182,7 @@ export class IDEServer { } start(context: vscode.ExtensionContext): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { this.context = context; this.authToken = randomUUID(); const sessionsWithInitialNotification = new Set(); @@ -340,16 +376,17 @@ export class IDEServer { this.server = app.listen(0, '127.0.0.1', async () => { const address = (this.server as HTTPServer).address(); - if (address && typeof address !== 'string') { + if (!address || typeof address === 'string') { + resolve(); + return; + } + + try { this.port = address.port; - try { - const ideDir = await getGlobalIdeDir(); - // Name the lock file by port to support multiple server instances. - this.lockFile = path.join(ideDir, `${this.port}.lock`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.log(`Failed to determine IDE lock directory: ${message}`); - } + const ideDir = await getGlobalIdeDir(); + await cleanupStaleLockFiles(ideDir, this.log); + // Name the lock file by port to support multiple server instances. + this.lockFile = path.join(ideDir, `${this.port}.lock`); this.log(`IDE server listening on http://127.0.0.1:${this.port}`); if (this.authToken && this.lockFile) { @@ -361,8 +398,15 @@ export class IDEServer { log: this.log, }); } + resolve(); + } catch (err) { + try { + await this.stop(); + } catch { + // Ignore stop errors; the original startup error is more useful. + } + reject(err); } - resolve(); }); }); } From 1ee022272762d2cd83818bd7807f271f119b1e90 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 10 Mar 2026 01:15:23 +0800 Subject: [PATCH 021/137] refactor: simplify mock implementations for testing environments --- packages/core/src/ide/ide-client.test.ts | 131 ++++++++-- packages/core/src/ide/ide-client.ts | 225 +++++++++++++++--- .../src/ide/ide-connection-config.test.ts | 184 -------------- .../core/src/ide/ide-connection-config.ts | 217 ----------------- .../src/ide-server.test.ts | 41 ---- .../vscode-ide-companion/src/ide-server.ts | 68 +----- 6 files changed, 314 insertions(+), 552 deletions(-) delete mode 100644 packages/core/src/ide/ide-connection-config.test.ts delete mode 100644 packages/core/src/ide/ide-connection-config.ts diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 86f6c81fa..34bfcac6b 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -82,7 +82,10 @@ describe('IdeClient', () => { // Mock dependencies vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir'); - vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.existsSync).mockImplementation((filePath: fs.PathLike) => { + const file = String(filePath); + return file !== '/.dockerenv' && file !== '/run/.containerenv'; + }); vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode); vi.mocked(getIdeProcessInfo).mockResolvedValue({ pid: 12345, @@ -510,6 +513,104 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should keep a live lock file even when it is older than 7 days', async () => { + const liveConfig = { + port: '1000', + workspacePath: '/test/workspace', + ppid: 4242, + }; + const oldTime = Date.now() - 8 * 24 * 60 * 60 * 1000; + + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) { + throw new Error('not found'); + } + if (file === path.join('/home/test', '.qwen', 'ide', '1000.lock')) { + return JSON.stringify(liveConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1000.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue({ mtimeMs: oldTime } as fs.Stats); + vi.spyOn(process, 'kill').mockImplementation(() => true); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(liveConfig); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + }); + + it('should remove stale incomplete lock files while scanning the IDE lock directory', async () => { + const latestConfig = { + port: '2000', + workspacePath: '/test/workspace', + }; + const now = Date.now(); + const staleTime = now - 7 * 24 * 60 * 60 * 1000 - 1000; + + vi.mocked(fs.promises.readFile).mockImplementation( + async (filePath: fs.PathLike | FileHandle) => { + const file = String(filePath); + if (file === path.join('/tmp', 'qwen-code-ide-server-12345.json')) { + throw new Error('not found'); + } + if (file === path.join('/home/test', '.qwen', 'ide', '1000.lock')) { + return JSON.stringify({ port: '1000' }); + } + if (file === path.join('/home/test', '.qwen', 'ide', '2000.lock')) { + return JSON.stringify(latestConfig); + } + throw new Error(`unexpected path: ${file}`); + }, + ); + ( + vi.mocked(fs.promises.readdir) as Mock< + (path: fs.PathLike) => Promise + > + ).mockResolvedValue(['1000.lock', '2000.lock']); + ( + vi.mocked(fs.promises.stat) as Mock< + (path: fs.PathLike) => Promise + > + ).mockImplementation(async (filePath: fs.PathLike) => { + const file = String(filePath); + return { + mtimeMs: file.endsWith('1000.lock') ? staleTime : now, + } as fs.Stats; + }); + vi.mocked(fs.existsSync).mockImplementation( + (filePath: fs.PathLike) => String(filePath) === '/test/workspace', + ); + + const ideClient = await IdeClient.getInstance(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(fs.promises.unlink).toHaveBeenCalledWith( + path.join('/home/test', '.qwen', 'ide', '1000.lock'), + ); + expect(result).toEqual(latestConfig); + }); + it('should scan IDE lock directory when env and legacy config are unavailable', async () => { const latestConfig = { port: '2000', @@ -739,16 +840,9 @@ describe('getIdeServerHost', () => { function mockDnsResolvable(reachable: boolean): void { dnsLookupMock.mockImplementation( - ( - _hostname: string, - callback: ( - err: Error | null, - address?: string, - family?: number, - ) => void, - ) => { + (_hostname: string, callback: (err: Error | null) => void) => { if (reachable) { - callback(null, '192.168.65.254', 4); + callback(null); } else { callback(new Error('ENOTFOUND')); } @@ -832,7 +926,9 @@ describe('getIdeServerHost', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); - dnsLookupMock.mockImplementation(() => undefined); + dnsLookupMock.mockImplementation(() => { + // Never call the callback to simulate a hung lookup. + }); const hostPromise = getIdeServerHost(); await vi.advanceTimersByTimeAsync(3000); @@ -850,16 +946,11 @@ describe('getIdeServerHost', () => { vi.mocked(fs.existsSync).mockImplementation( (filePath: fs.PathLike) => filePath === '/.dockerenv', ); + + // Simulate a slow DNS lookup dnsLookupMock.mockImplementation( - ( - _hostname: string, - callback: ( - err: Error | null, - address?: string, - family?: number, - ) => void, - ) => { - setTimeout(() => callback(null, '192.168.65.254', 4), 50); + (_hostname: string, callback: (err: Error | null) => void) => { + setTimeout(() => callback(null), 50); }, ); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 9587c0119..813b84842 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -27,14 +27,6 @@ import { EnvHttpProxyAgent } from 'undici'; import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { IDE_REQUEST_TIMEOUT_MS } from './constants.js'; import { createDebugLogger } from '../utils/debugLogger.js'; -import { - getAllConnectionConfigs, - getFallbackConnectionConfigs, - getWorkspaceMatchingConnectionConfig, - readConnectionConfigFromLockFile, - type IdeConnectionConfig, - type StdioConfig, -} from './ide-connection-config.js'; const debugLogger = createDebugLogger('IDE'); @@ -59,6 +51,32 @@ export enum IDEConnectionStatus { Connecting = 'connecting', } +type StdioConfig = { + command: string; + args: string[]; +}; + +type ConnectionConfig = { + port?: string; + authToken?: string; + stdio?: StdioConfig; +}; + +type IdeConnectionConfig = ConnectionConfig & { + workspacePath?: string; + ideInfo?: IdeInfo; + ppid?: number; +}; + +type ParsedConnectionLockFile = { + file: string; + fullPath: string; + mtimeMs: number; + parsed: IdeConnectionConfig; +}; + +const STALE_LOCK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + function getRealPath(path: string): string { try { return fs.realpathSync(path); @@ -142,7 +160,6 @@ export class IdeClient { this.setState(IDEConnectionStatus.Connecting); this.connectionConfig = await this.getConnectionConfigFromFile(); - if (this.connectionConfig?.authToken) { this.authToken = this.connectionConfig.authToken; } @@ -168,8 +185,6 @@ export class IdeClient { if (connected) { return; } - // The connection failed, possibly because the IDE server restarted on a different port. - // Try to connect using other lock files as fallback. const fallbackConnected = await this.tryFallbackPorts(); if (fallbackConnected) { return; @@ -576,28 +591,29 @@ export class IdeClient { > { const portFromEnv = this.getPortFromEnv(); const ideDir = Storage.getGlobalIdeDir(); - const allConfigs = await getAllConnectionConfigs(ideDir); + const configs = await this.getAllConnectionConfigs(ideDir); if (portFromEnv) { - const lockFileConfig = await readConnectionConfigFromLockFile( - ideDir, - portFromEnv, - ); - if (lockFileConfig) { - return lockFileConfig; + try { + const lockFile = path.join(ideDir, `${portFromEnv}.lock`); + const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); + return JSON.parse(lockFileContents); + } catch (_) { + // Fall through to legacy discovery. } } + // Legacy discovery for VSCode extension < v0.5.1. const legacyConfig = await this.getLegacyConnectionConfig(portFromEnv); if (legacyConfig) { return legacyConfig; } - return getWorkspaceMatchingConnectionConfig( - allConfigs, - process.cwd(), - (workspacePath, cwd) => - IdeClient.validateWorkspacePath(workspacePath, cwd).isValid, + const cwd = process.cwd(); + return configs.find( + (config) => + config.workspacePath !== undefined && + IdeClient.validateWorkspacePath(config.workspacePath, cwd).isValid, ); } @@ -637,20 +653,152 @@ export class IdeClient { return undefined; } - private async tryFallbackPorts(): Promise { - const configs = await getAllConnectionConfigs(Storage.getGlobalIdeDir()); - const fallbackConfigs = getFallbackConnectionConfigs(configs, { - cwd: process.cwd(), - currentPort: this.connectionConfig?.port, - matchesWorkspace: (workspacePath, cwd) => - IdeClient.validateWorkspacePath(workspacePath, cwd).isValid, - }); + protected async getAllConnectionConfigs( + ideDir: string, + ): Promise { + const fileRegex = /^\d+\.lock$/; + let lockFiles: string[]; + try { + lockFiles = (await fs.promises.readdir(ideDir)) + .map((file) => file.toString()) + .filter((file) => fileRegex.test(file)); + } catch (e) { + debugLogger.debug('Failed to read IDE connection directory:', e); + return []; + } - for (const config of fallbackConfigs) { + const fileContents = await Promise.all( + lockFiles.map(async (file) => { + const fullPath = path.join(ideDir, file); + try { + const stat = await fs.promises.stat(fullPath); + const content = await fs.promises.readFile(fullPath, 'utf8'); + try { + return { + file, + fullPath, + mtimeMs: stat.mtimeMs, + parsed: JSON.parse(content) as IdeConnectionConfig, + }; + } catch (error) { + debugLogger.debug('Failed to parse JSON from lock file: ', error); + return undefined; + } + } catch (error) { + debugLogger.debug('Failed to read/stat IDE lock file:', error); + return undefined; + } + }), + ); + + const parsedLockFiles = fileContents.filter( + (lockFile): lockFile is ParsedConnectionLockFile => + lockFile !== undefined, + ); + const activeLockFiles = await Promise.all( + parsedLockFiles.map(async (lockFile) => ({ + lockFile, + isStale: await this.cleanupStaleLockFile(lockFile), + })), + ); + + const staleCount = activeLockFiles.filter(({ isStale }) => isStale).length; + if (staleCount > 0) { + debugLogger.debug( + `[cleanupStaleLockFiles] Cleaned up ${staleCount} stale lock file(s)`, + ); + } + + return activeLockFiles + .filter(({ isStale }) => !isStale) + .map(({ lockFile }) => lockFile) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ parsed }) => parsed); + } + + private async cleanupStaleLockFile({ + file, + fullPath, + mtimeMs, + parsed, + }: ParsedConnectionLockFile): Promise { + try { + if (parsed.ppid) { + try { + process.kill(parsed.ppid, 0); + return false; + } catch { + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - ppid ${parsed.ppid} no longer exists`, + ); + await fs.promises.unlink(fullPath); + return true; + } + } + + if (parsed.workspacePath) { + if (fs.existsSync(parsed.workspacePath)) { + return false; + } + + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - workspace doesn't exist`, + ); + await fs.promises.unlink(fullPath); + return true; + } + + if (Date.now() - mtimeMs > STALE_LOCK_MAX_AGE_MS) { + debugLogger.debug( + `[cleanupStaleLockFiles] Removing lock file "${file}" - older than 7 days`, + ); + await fs.promises.unlink(fullPath); + return true; + } + + return false; + } catch (error) { + debugLogger.debug( + `[cleanupStaleLockFiles] Error checking lock file "${file}":`, + error, + ); + return false; + } + } + + private async tryFallbackPorts(): Promise { + const cwd = process.cwd(); + const currentPort = this.connectionConfig?.port; + const configs = await this.getAllConnectionConfigs( + Storage.getGlobalIdeDir(), + ); + const workspaceMatches: IdeConnectionConfig[] = []; + const otherConfigs: IdeConnectionConfig[] = []; + + for (const config of configs) { + if (!config.port || config.port === currentPort) { + continue; + } + + if ( + config.workspacePath !== undefined && + IdeClient.validateWorkspacePath(config.workspacePath, cwd).isValid + ) { + workspaceMatches.push(config); + } else { + otherConfigs.push(config); + } + } + + for (const config of [...workspaceMatches, ...otherConfigs]) { + const port = config.port; + if (!port) { + continue; + } if (config.authToken) { this.authToken = config.authToken; } - const connected = await this.establishHttpConnection(config.port!); + const connected = await this.establishHttpConnection(port); if (connected) { this.connectionConfig = config; return true; @@ -782,6 +930,9 @@ export class IdeClient { // host.docker.internal as a fallback when it is DNS-resolvable. const ideHost = await getIdeServerHost(); if (ideHost === CONTAINER_HOST) { + debugLogger.debug( + `Connection to ${LOCAL_HOST}:${port} failed, retrying with ${CONTAINER_HOST}`, + ); return this.tryHttpConnect(port, CONTAINER_HOST); } @@ -819,7 +970,8 @@ export class IdeClient { await this.discoverTools(); this.setState(IDEConnectionStatus.Connected); return true; - } catch (_error) { + } catch (error) { + debugLogger.debug(`HTTP connection to ${host}:${port} failed:`, error); if (transport) { try { await transport.close(); @@ -837,6 +989,7 @@ export class IdeClient { }: StdioConfig): Promise { let transport: StdioClientTransport | undefined; try { + debugLogger.debug('Attempting to connect to IDE via stdio'); this.client = new Client({ name: 'stdio-client', // TODO(#3487): use the CLI version here. @@ -927,9 +1080,13 @@ async function resolveIdeServerHost(): Promise { const reachable = await isHostResolvable(CONTAINER_HOST); if (reachable) { + debugLogger.debug('Container detected, host.docker.internal is reachable'); return CONTAINER_HOST; } + debugLogger.debug( + 'Container detected, but host.docker.internal is NOT reachable, falling back to 127.0.0.1', + ); return LOCAL_HOST; } diff --git a/packages/core/src/ide/ide-connection-config.test.ts b/packages/core/src/ide/ide-connection-config.test.ts deleted file mode 100644 index ba004d469..000000000 --- a/packages/core/src/ide/ide-connection-config.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; -import type { FileHandle } from 'node:fs/promises'; -import { - getAllConnectionConfigs, - getFallbackConnectionConfigs, - type IdeConnectionConfig, -} from './ide-connection-config.js'; - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...(actual as object), - promises: { - ...actual.promises, - readFile: vi.fn(), - readdir: vi.fn(), - stat: vi.fn(), - unlink: vi.fn(), - }, - existsSync: vi.fn().mockReturnValue(false), - }; -}); - -describe('ide-connection-config', () => { - const mockFs = vi.mocked(fs.promises); - const mockExistsSync = vi.mocked(fs.existsSync); - - beforeEach(() => { - mockFs.readFile.mockReset(); - mockFs.readdir.mockReset(); - mockFs.stat.mockReset(); - mockFs.unlink.mockReset(); - mockExistsSync.mockReset(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('getAllConnectionConfigs', () => { - it('returns an empty array when the IDE lock directory does not exist', async () => { - mockFs.readdir.mockRejectedValue(new Error('ENOENT: no such directory')); - - const result = await getAllConnectionConfigs('/home/test/.qwen/ide'); - - expect(result).toEqual([]); - expect(mockFs.unlink).not.toHaveBeenCalled(); - }); - - it('returns active lock files sorted by mtime and skips invalid JSON', async () => { - mockFs.readdir.mockResolvedValue([ - '1000.lock', - '2000.lock', - '3000.lock', - ] as never); - mockFs.readFile.mockImplementation( - async (filePath: fs.PathLike | FileHandle) => { - const file = String(filePath); - if (file.endsWith('1000.lock')) { - return JSON.stringify({ - port: '1000', - workspacePath: '/workspace/1', - }); - } - if (file.endsWith('2000.lock')) { - return JSON.stringify({ - port: '2000', - workspacePath: '/workspace/2', - }); - } - if (file.endsWith('3000.lock')) { - return 'not-json'; - } - throw new Error(`unexpected path: ${file}`); - }, - ); - const now = Date.now(); - mockFs.stat.mockImplementation(async (filePath: fs.PathLike) => { - const file = String(filePath); - return { - mtimeMs: file.endsWith('2000.lock') ? now : now - 1000, - } as fs.Stats; - }); - mockExistsSync.mockReturnValue(true); - - const result = await getAllConnectionConfigs('/home/test/.qwen/ide'); - - expect(result).toEqual([ - { port: '2000', workspacePath: '/workspace/2' }, - { port: '1000', workspacePath: '/workspace/1' }, - ]); - }); - - it('keeps an old lock file when its IDE process is still running', async () => { - const oldTime = Date.now() - 8 * 24 * 60 * 60 * 1000; - - mockFs.readdir.mockResolvedValue(['1000.lock'] as never); - mockFs.readFile.mockResolvedValue( - JSON.stringify({ - port: '1000', - workspacePath: '/workspace/live', - ppid: 4242, - }), - ); - mockFs.stat.mockResolvedValue({ mtimeMs: oldTime } as never); - vi.spyOn(process, 'kill').mockImplementation(() => true); - - const result = await getAllConnectionConfigs('/home/test/.qwen/ide'); - - expect(mockFs.unlink).not.toHaveBeenCalled(); - expect(result).toEqual([ - { port: '1000', workspacePath: '/workspace/live', ppid: 4242 }, - ]); - }); - - it('removes incomplete lock files older than 7 days and excludes them from results', async () => { - const now = Date.now(); - const staleTime = now - 7 * 24 * 60 * 60 * 1000 - 1000; - const recentTime = now - 1000; - - mockFs.readdir.mockResolvedValue(['1000.lock', '2000.lock'] as never); - mockFs.readFile.mockImplementation( - async (filePath: fs.PathLike | FileHandle) => { - const file = String(filePath); - if (file.endsWith('1000.lock')) { - return JSON.stringify({ port: '1000' }); - } - if (file.endsWith('2000.lock')) { - return JSON.stringify({ - port: '2000', - workspacePath: '/workspace/new', - }); - } - throw new Error(`unexpected path: ${file}`); - }, - ); - mockFs.stat.mockImplementation(async (filePath: fs.PathLike) => { - const file = String(filePath); - return { - mtimeMs: file.endsWith('1000.lock') ? staleTime : recentTime, - } as fs.Stats; - }); - mockExistsSync.mockReturnValue(true); - - const result = await getAllConnectionConfigs('/home/test/.qwen/ide'); - - expect(mockFs.unlink).toHaveBeenCalledWith( - '/home/test/.qwen/ide/1000.lock', - ); - expect(result).toEqual([ - { port: '2000', workspacePath: '/workspace/new' }, - ]); - }); - }); - - describe('getFallbackConnectionConfigs', () => { - it('prioritizes workspace matches and excludes the current port', () => { - const configs: IdeConnectionConfig[] = [ - { port: '1111', workspacePath: '/workspace/other' }, - { port: '2222', workspacePath: '/test/workspace' }, - { port: '3333', workspacePath: '/workspace/another' }, - { workspacePath: '/test/workspace' }, - ]; - - const result = getFallbackConnectionConfigs(configs, { - cwd: '/test/workspace/subdir', - currentPort: '1111', - matchesWorkspace: (workspacePath, cwd) => cwd.startsWith(workspacePath), - }); - - expect(result).toEqual([ - { port: '2222', workspacePath: '/test/workspace' }, - { port: '3333', workspacePath: '/workspace/another' }, - ]); - }); - }); -}); diff --git a/packages/core/src/ide/ide-connection-config.ts b/packages/core/src/ide/ide-connection-config.ts deleted file mode 100644 index 890c3cdad..000000000 --- a/packages/core/src/ide/ide-connection-config.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import type { IdeInfo } from './detect-ide.js'; -import { createDebugLogger } from '../utils/debugLogger.js'; - -const debugLogger = createDebugLogger('IDE'); -const STALE_LOCK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; - -export type StdioConfig = { - command: string; - args: string[]; -}; - -export type ConnectionConfig = { - port?: string; - authToken?: string; - stdio?: StdioConfig; -}; - -export type IdeConnectionConfig = ConnectionConfig & { - workspacePath?: string; - ideInfo?: IdeInfo; - ppid?: number; -}; - -type ParsedConnectionLockFile = { - file: string; - fullPath: string; - mtimeMs: number; - parsed: IdeConnectionConfig; -}; - -export async function readConnectionConfigFromLockFile( - ideDir: string, - port: string, -): Promise { - try { - const lockFile = path.join(ideDir, `${port}.lock`); - const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); - return JSON.parse(lockFileContents) as IdeConnectionConfig; - } catch { - return undefined; - } -} - -export async function getAllConnectionConfigs( - ideDir: string, -): Promise { - const parsedLockFiles = await getParsedConnectionLockFiles(ideDir); - const activeLockFiles = await filterActiveLockFiles(parsedLockFiles); - - return activeLockFiles - .sort((a, b) => b.mtimeMs - a.mtimeMs) - .map(({ parsed }) => parsed); -} - -export function getWorkspaceMatchingConnectionConfig( - configs: IdeConnectionConfig[], - cwd: string, - matchesWorkspace: (workspacePath: string, cwd: string) => boolean, -): IdeConnectionConfig | undefined { - return configs.find( - (config) => - config.workspacePath !== undefined && - matchesWorkspace(config.workspacePath, cwd), - ); -} - -export function getFallbackConnectionConfigs( - configs: IdeConnectionConfig[], - { - cwd, - currentPort, - matchesWorkspace, - }: { - cwd: string; - currentPort?: string; - matchesWorkspace: (workspacePath: string, cwd: string) => boolean; - }, -): IdeConnectionConfig[] { - const candidates = configs.filter( - (config) => config.port !== undefined && config.port !== currentPort, - ); - - const workspaceMatches = candidates.filter( - (config) => - config.workspacePath !== undefined && - matchesWorkspace(config.workspacePath, cwd), - ); - const matchedPorts = new Set(workspaceMatches.map((config) => config.port)); - - return [ - ...workspaceMatches, - ...candidates.filter((config) => !matchedPorts.has(config.port)), - ]; -} - -async function getParsedConnectionLockFiles( - ideDir: string, -): Promise { - const fileRegex = /^\d+\.lock$/; - let lockFiles: string[]; - try { - lockFiles = (await fs.promises.readdir(ideDir)) - .map((file) => file.toString()) - .filter((file) => fileRegex.test(file)); - } catch (error) { - debugLogger.debug('Failed to read IDE connection directory:', error); - return []; - } - - const parsedLockFiles = await Promise.all( - lockFiles.map(async (file) => { - const fullPath = path.join(ideDir, file); - try { - const stat = await fs.promises.stat(fullPath); - const content = await fs.promises.readFile(fullPath, 'utf8'); - try { - return { - file, - fullPath, - mtimeMs: stat.mtimeMs, - parsed: JSON.parse(content) as IdeConnectionConfig, - }; - } catch (error) { - debugLogger.debug('Failed to parse JSON from lock file: ', error); - return undefined; - } - } catch (error) { - debugLogger.debug('Failed to read/stat IDE lock file:', error); - return undefined; - } - }), - ); - - return parsedLockFiles.filter( - (lockFile): lockFile is ParsedConnectionLockFile => lockFile !== undefined, - ); -} - -async function filterActiveLockFiles( - lockFiles: ParsedConnectionLockFile[], -): Promise { - const activeResults = await Promise.all( - lockFiles.map(async (lockFile) => ({ - lockFile, - isStale: await cleanupStaleLockFile(lockFile), - })), - ); - - const staleCount = activeResults.filter(({ isStale }) => isStale).length; - if (staleCount > 0) { - debugLogger.debug( - `[cleanupStaleLockFiles] Cleaned up ${staleCount} stale lock file(s)`, - ); - } - - return activeResults - .filter(({ isStale }) => !isStale) - .map(({ lockFile }) => lockFile); -} - -async function cleanupStaleLockFile({ - file, - fullPath, - mtimeMs, - parsed, -}: ParsedConnectionLockFile): Promise { - try { - if (parsed.ppid) { - try { - process.kill(parsed.ppid, 0); - return false; - } catch { - debugLogger.debug( - `[cleanupStaleLockFiles] Removing lock file "${file}" - ppid ${parsed.ppid} no longer exists`, - ); - await fs.promises.unlink(fullPath); - return true; - } - } - - if (parsed.workspacePath) { - if (fs.existsSync(parsed.workspacePath)) { - return false; - } - - debugLogger.debug( - `[cleanupStaleLockFiles] Removing lock file "${file}" - workspace doesn't exist`, - ); - await fs.promises.unlink(fullPath); - return true; - } - - if (Date.now() - mtimeMs > STALE_LOCK_MAX_AGE_MS) { - debugLogger.debug( - `[cleanupStaleLockFiles] Removing lock file "${file}" - older than 7 days`, - ); - await fs.promises.unlink(fullPath); - return true; - } - - return false; - } catch (error) { - debugLogger.debug( - `[cleanupStaleLockFiles] Error checking lock file "${file}":`, - error, - ); - return false; - } -} diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts index 16a5fd4ef..9c51d5021 100644 --- a/packages/vscode-ide-companion/src/ide-server.test.ts +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -28,9 +28,6 @@ vi.mock('node:fs/promises', () => ({ unlink: vi.fn(() => Promise.resolve(undefined)), chmod: vi.fn(() => Promise.resolve(undefined)), mkdir: vi.fn(() => Promise.resolve(undefined)), - readdir: vi.fn(() => Promise.resolve([])), - readFile: vi.fn(() => Promise.resolve('')), - stat: vi.fn(() => Promise.resolve({ mtimeMs: Date.now() })), })); vi.mock('node:os', async (importOriginal) => { @@ -119,44 +116,6 @@ describe('IDEServer', () => { ]; }); - it('should remove stale lock files before starting the server', async () => { - vi.mocked(fs.readdir).mockResolvedValue(['3000.lock'] as never); - vi.mocked(fs.readFile).mockResolvedValue( - JSON.stringify({ - port: 3000, - workspacePath: '/stale/workspace', - ppid: 99999, - authToken: 'stale-auth-token', - ideName: 'VS Code', - }), - ); - vi.mocked(fs.stat).mockResolvedValue({ - mtimeMs: Date.now() - 1000, - } as never); - vi.spyOn(process, 'kill').mockImplementation((pid: number) => { - if (pid === 99999) { - throw new Error('ESRCH'); - } - return true; - }); - - await ideServer.start(mockContext); - - expect(fs.unlink).toHaveBeenCalledWith( - path.join('/home/test', '.qwen', 'ide', '3000.lock'), - ); - }); - - it('should fail start when the lock file cannot be written', async () => { - vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('disk full')); - - await expect(ideServer.start(mockContext)).rejects.toThrow( - 'Failed to write IDE lock file: disk full', - ); - - expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); - }); - it('should set environment variables and workspace path on start with multiple folders', async () => { await ideServer.start(mockContext); diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index df4b18957..6a8ff82a1 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -40,7 +40,6 @@ class CORSError extends Error { const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'QWEN_CODE_IDE_SERVER_PORT'; const IDE_WORKSPACE_PATH_ENV_VAR = 'QWEN_CODE_IDE_WORKSPACE_PATH'; -const LOCK_FILE_REGEX = /^\d+\.lock$/; const QWEN_DIR = '.qwen'; const IDE_DIR = 'ide'; @@ -102,45 +101,10 @@ async function writePortAndWorkspace({ await fs.chmod(lockFile, 0o600); } catch (err) { const message = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to write IDE lock file: ${message}`); + log(`Failed to write IDE lock file: ${message}`); } } -async function cleanupStaleLockFiles( - ideDir: string, - log: (message: string) => void, -): Promise { - let lockFiles: string[]; - try { - lockFiles = (await fs.readdir(ideDir)).filter((file) => - LOCK_FILE_REGEX.test(file), - ); - } catch { - return; - } - - await Promise.all( - lockFiles.map(async (file) => { - const lockFile = path.join(ideDir, file); - try { - const content = await fs.readFile(lockFile, 'utf8'); - const parsed = JSON.parse(content) as { ppid?: number }; - if (!parsed.ppid) { - return; - } - try { - process.kill(parsed.ppid, 0); - } catch { - log(`Removing stale IDE lock file: ${lockFile}`); - await fs.unlink(lockFile); - } - } catch { - return; - } - }), - ); -} - function sendIdeContextUpdateNotification( transport: StreamableHTTPServerTransport, log: (message: string) => void, @@ -182,7 +146,7 @@ export class IDEServer { } start(context: vscode.ExtensionContext): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { this.context = context; this.authToken = randomUUID(); const sessionsWithInitialNotification = new Set(); @@ -376,17 +340,16 @@ export class IDEServer { this.server = app.listen(0, '127.0.0.1', async () => { const address = (this.server as HTTPServer).address(); - if (!address || typeof address === 'string') { - resolve(); - return; - } - - try { + if (address && typeof address !== 'string') { this.port = address.port; - const ideDir = await getGlobalIdeDir(); - await cleanupStaleLockFiles(ideDir, this.log); - // Name the lock file by port to support multiple server instances. - this.lockFile = path.join(ideDir, `${this.port}.lock`); + try { + const ideDir = await getGlobalIdeDir(); + // Name the lock file by port to support multiple server instances. + this.lockFile = path.join(ideDir, `${this.port}.lock`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log(`Failed to determine IDE lock directory: ${message}`); + } this.log(`IDE server listening on http://127.0.0.1:${this.port}`); if (this.authToken && this.lockFile) { @@ -398,15 +361,8 @@ export class IDEServer { log: this.log, }); } - resolve(); - } catch (err) { - try { - await this.stop(); - } catch { - // Ignore stop errors; the original startup error is more useful. - } - reject(err); } + resolve(); }); }); } From c3391fe0db59c1c08e447f5e3ab22a516c78a702 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 10 Mar 2026 14:54:37 +0800 Subject: [PATCH 022/137] refactor(ide): optimize connection config lookup and remove time-based lock cleanup Co-authored-by: Qwen-Coder --- packages/core/src/ide/ide-client.test.ts | 24 ++++++++++++++++++++---- packages/core/src/ide/ide-client.ts | 16 +++------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 34bfcac6b..a483ccb38 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -431,6 +431,24 @@ describe('IdeClient', () => { delete process.env['QWEN_CODE_IDE_SERVER_PORT']; }); + it('should not scan the lock directory when the env port lock file exists', async () => { + process.env['QWEN_CODE_IDE_SERVER_PORT'] = '1234'; + const config = { port: '1234', workspacePath: '/test/workspace' }; + vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config)); + + const ideClient = await IdeClient.getInstance(); + vi.mocked(fs.promises.readdir).mockClear(); + const result = await ( + ideClient as unknown as { + getConnectionConfigFromFile: () => Promise; + } + ).getConnectionConfigFromFile(); + + expect(result).toEqual(config); + expect(fs.promises.readdir).not.toHaveBeenCalled(); + delete process.env['QWEN_CODE_IDE_SERVER_PORT']; + }); + it('should return undefined if no config files are found', async () => { vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found')); @@ -556,7 +574,7 @@ describe('IdeClient', () => { expect(fs.promises.unlink).not.toHaveBeenCalled(); }); - it('should remove stale incomplete lock files while scanning the IDE lock directory', async () => { + it('should keep incomplete old lock files when there is no stronger stale signal', async () => { const latestConfig = { port: '2000', workspacePath: '/test/workspace', @@ -605,9 +623,7 @@ describe('IdeClient', () => { } ).getConnectionConfigFromFile(); - expect(fs.promises.unlink).toHaveBeenCalledWith( - path.join('/home/test', '.qwen', 'ide', '1000.lock'), - ); + expect(fs.promises.unlink).not.toHaveBeenCalled(); expect(result).toEqual(latestConfig); }); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 813b84842..d51607eef 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -75,8 +75,6 @@ type ParsedConnectionLockFile = { parsed: IdeConnectionConfig; }; -const STALE_LOCK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; - function getRealPath(path: string): string { try { return fs.realpathSync(path); @@ -590,11 +588,10 @@ export class IdeClient { IdeConnectionConfig | undefined > { const portFromEnv = this.getPortFromEnv(); - const ideDir = Storage.getGlobalIdeDir(); - const configs = await this.getAllConnectionConfigs(ideDir); if (portFromEnv) { try { + const ideDir = Storage.getGlobalIdeDir(); const lockFile = path.join(ideDir, `${portFromEnv}.lock`); const lockFileContents = await fs.promises.readFile(lockFile, 'utf8'); return JSON.parse(lockFileContents); @@ -609,6 +606,8 @@ export class IdeClient { return legacyConfig; } + const ideDir = Storage.getGlobalIdeDir(); + const configs = await this.getAllConnectionConfigs(ideDir); const cwd = process.cwd(); return configs.find( (config) => @@ -719,7 +718,6 @@ export class IdeClient { private async cleanupStaleLockFile({ file, fullPath, - mtimeMs, parsed, }: ParsedConnectionLockFile): Promise { try { @@ -748,14 +746,6 @@ export class IdeClient { return true; } - if (Date.now() - mtimeMs > STALE_LOCK_MAX_AGE_MS) { - debugLogger.debug( - `[cleanupStaleLockFiles] Removing lock file "${file}" - older than 7 days`, - ); - await fs.promises.unlink(fullPath); - return true; - } - return false; } catch (error) { debugLogger.debug( From 66c625a55567a9e4590f657d900e709ba3bf28cd Mon Sep 17 00:00:00 2001 From: Yusheng Date: Tue, 10 Mar 2026 17:37:51 +0800 Subject: [PATCH 023/137] fix: improve qwen mcp add option handling for arrays --- packages/cli/src/commands/mcp/add.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts index 29fe25b88..57c5b3ce2 100644 --- a/packages/cli/src/commands/mcp/add.ts +++ b/packages/cli/src/commands/mcp/add.ts @@ -174,6 +174,7 @@ export const addCommand: CommandModule = { describe: 'Set environment variables (e.g. -e KEY=value)', type: 'array', string: true, + nargs: 1, }) .option('header', { alias: 'H', @@ -181,6 +182,7 @@ export const addCommand: CommandModule = { 'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")', type: 'array', string: true, + nargs: 1, }) .option('timeout', { describe: 'Set connection timeout in milliseconds', From 01be4f6cf80b7db42b3bb56e7f4861b2dd44b941 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 11 Mar 2026 10:59:10 +0800 Subject: [PATCH 024/137] fix(core): remove duplicate exports in packages/core/src/index.ts The index.ts barrel file had 30+ modules exported multiple times across scattered sections. This consolidates all exports into a single well-organized structure with clear section headers, removing 90 lines of redundant export statements. No functional changes. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/index.ts | 116 +++++++++---------------------------- 1 file changed, 26 insertions(+), 90 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0dcce945..e207b946e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,7 +11,6 @@ // Core configuration export * from './config/config.js'; export { Storage } from './config/storage.js'; -export * from './utils/configResolver.js'; // Model configuration export { @@ -60,103 +59,28 @@ export * from './core/nonInteractiveToolExecutor.js'; export * from './core/prompts.js'; export * from './core/tokenLimits.js'; export * from './core/turn.js'; -export * from './core/geminiRequest.js'; -export * from './core/coreToolScheduler.js'; -export * from './core/nonInteractiveToolExecutor.js'; -export * from './tools/tool-names.js'; // ============================================================================ // Tools // ============================================================================ -// Export utilities -export * from './utils/paths.js'; -export * from './utils/schemaValidator.js'; -export * from './utils/errors.js'; -export * from './utils/debugLogger.js'; -export * from './utils/symlink.js'; -export * from './utils/getFolderStructure.js'; -export * from './utils/memoryDiscovery.js'; -export * from './utils/gitIgnoreParser.js'; -export * from './utils/gitUtils.js'; -export * from './utils/editor.js'; -export * from './utils/quotaErrorDetection.js'; -export * from './utils/fileUtils.js'; -export * from './utils/retry.js'; -export * from './utils/shell-utils.js'; -export * from './utils/tool-utils.js'; -export * from './utils/terminalSerializer.js'; -export * from './utils/systemEncoding.js'; -export * from './utils/textUtils.js'; -export * from './utils/formatters.js'; -export * from './utils/generateContentResponseUtilities.js'; -export * from './utils/ripgrepUtils.js'; -export * from './utils/filesearch/fileSearch.js'; -export * from './utils/errorParsing.js'; -export * from './utils/workspaceContext.js'; -export * from './utils/ignorePatterns.js'; -export * from './utils/partUtils.js'; -export * from './utils/subagentGenerator.js'; -export * from './utils/projectSummary.js'; -export * from './utils/promptIdContext.js'; -export * from './utils/thoughtUtils.js'; -export * from './utils/toml-to-markdown-converter.js'; -export * from './utils/yaml-parser.js'; - -// Config resolution utilities -export * from './utils/configResolver.js'; - -// Export services -export * from './services/fileDiscoveryService.js'; -export * from './services/gitService.js'; -export * from './services/chatRecordingService.js'; -export * from './services/sessionService.js'; -export * from './services/fileSystemService.js'; - -// Export IDE specific logic -export * from './ide/ide-client.js'; -export * from './ide/ideContext.js'; -export * from './ide/ide-installer.js'; -export { IDE_DEFINITIONS, type IdeInfo } from './ide/detect-ide.js'; -export * from './ide/constants.js'; -export * from './ide/types.js'; - -// Export Shell Execution Service -export * from './services/shellExecutionService.js'; - -// Export base tool definitions -export * from './tools/tools.js'; +// Tool names and registry +export * from './tools/tool-names.js'; export * from './tools/tool-error.js'; export * from './tools/tool-registry.js'; +export * from './tools/tools.js'; -// Export subagents (Phase 1) -export * from './subagents/index.js'; - -// Export skills -export * from './skills/index.js'; - -// Export extension -export * from './extension/index.js'; - -// Export prompt logic -export * from './prompts/mcp-prompts.js'; - -// Export specific tool logic -export * from './tools/read-file.js'; -export * from './tools/ls.js'; -export * from './tools/grep.js'; -export * from './tools/ripGrep.js'; -export * from './tools/glob.js'; +// Individual tools export * from './tools/edit.js'; export * from './tools/exitPlanMode.js'; export * from './tools/glob.js'; export * from './tools/grep.js'; export * from './tools/ls.js'; export * from './tools/lsp.js'; -export * from './tools/memoryTool.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; +export * from './tools/memoryTool.js'; export * from './tools/read-file.js'; export * from './tools/ripGrep.js'; export * from './tools/sdk-control-client-transport.js'; @@ -164,9 +88,6 @@ export * from './tools/shell.js'; export * from './tools/skill.js'; export * from './tools/task.js'; export * from './tools/todoWrite.js'; -export * from './tools/tool-error.js'; -export * from './tools/tool-registry.js'; -export * from './tools/tools.js'; export * from './tools/web-fetch.js'; export * from './tools/web-search/index.js'; export * from './tools/write-file.js'; @@ -182,11 +103,21 @@ export * from './services/gitService.js'; export * from './services/sessionService.js'; export * from './services/shellExecutionService.js'; +// ============================================================================ +// IDE Support +// ============================================================================ + +export * from './ide/ide-client.js'; +export * from './ide/ideContext.js'; +export * from './ide/ide-installer.js'; +export { IDE_DEFINITIONS, type IdeInfo } from './ide/detect-ide.js'; +export * from './ide/constants.js'; +export * from './ide/types.js'; + // ============================================================================ // LSP Support // ============================================================================ -// LSP support export * from './lsp/constants.js'; export * from './lsp/LspConfigLoader.js'; export * from './lsp/LspConnectionFactory.js'; @@ -240,7 +171,7 @@ export { } from './telemetry/types.js'; // ============================================================================ -// Extensions & Subagents +// Extensions, Skills & Subagents // ============================================================================ export * from './extension/index.js'; @@ -253,6 +184,8 @@ export * from './subagents/index.js'; // ============================================================================ export * from './utils/browser.js'; +export * from './utils/configResolver.js'; +export * from './utils/debugLogger.js'; export * from './utils/editor.js'; export * from './utils/errorParsing.js'; export * from './utils/errors.js'; @@ -264,13 +197,14 @@ export * from './utils/getFolderStructure.js'; export * from './utils/gitIgnoreParser.js'; export * from './utils/gitUtils.js'; export * from './utils/ignorePatterns.js'; +export * from './utils/jsonl-utils.js'; export * from './utils/memoryDiscovery.js'; export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; export * from './utils/partUtils.js'; export * from './utils/pathReader.js'; export * from './utils/paths.js'; -export * from './utils/promptIdContext.js'; export * from './utils/projectSummary.js'; +export * from './utils/promptIdContext.js'; export * from './utils/quotaErrorDetection.js'; export * from './utils/readManyFiles.js'; export * from './utils/request-tokenizer/supportedImageFormats.js'; @@ -279,6 +213,7 @@ export * from './utils/ripgrepUtils.js'; export * from './utils/schemaValidator.js'; export * from './utils/shell-utils.js'; export * from './utils/subagentGenerator.js'; +export * from './utils/symlink.js'; export * from './utils/systemEncoding.js'; export * from './utils/terminalSerializer.js'; export * from './utils/textUtils.js'; @@ -287,8 +222,6 @@ export * from './utils/toml-to-markdown-converter.js'; export * from './utils/tool-utils.js'; export * from './utils/workspaceContext.js'; export * from './utils/yaml-parser.js'; -export * from './utils/jsonl-utils.js'; -export * from './utils/symlink.js'; // ============================================================================ // OAuth & Authentication @@ -303,7 +236,10 @@ export * from './qwen/qwenOAuth2.js'; export { makeFakeConfig } from './test-utils/config.js'; export * from './test-utils/index.js'; -// Export hook types and components +// ============================================================================ +// Hooks +// ============================================================================ + export * from './hooks/types.js'; export { HookSystem, HookRegistry } from './hooks/index.js'; export type { HookRegistryEntry } from './hooks/index.js'; From f13a2fdf17368bd908d245c5151df3b70d3be9e8 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 11 Mar 2026 11:16:29 +0800 Subject: [PATCH 025/137] fix: export command should use current session ID instead of loadLastSession The /export commands (html, md, json, jsonl) were incorrectly using loadLastSession() which loads the last modified session from disk, rather than the currently active session. This fix uses config.getSessionId() to get the current session ID and loads the correct session with loadSession(sessionId). Fixes #2267 Co-authored-by: Qwen-Coder --- .../cli/src/ui/commands/exportCommand.test.ts | 17 ++++++++-------- packages/cli/src/ui/commands/exportCommand.ts | 20 +++++++++++-------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/ui/commands/exportCommand.test.ts b/packages/cli/src/ui/commands/exportCommand.test.ts index 9d931b328..6550c225f 100644 --- a/packages/cli/src/ui/commands/exportCommand.test.ts +++ b/packages/cli/src/ui/commands/exportCommand.test.ts @@ -19,14 +19,14 @@ import { } from '../utils/export/index.js'; const mockSessionServiceMocks = vi.hoisted(() => ({ - loadLastSession: vi.fn(), + loadSession: vi.fn(), })); vi.mock('@qwen-code/qwen-code-core', () => { class SessionService { constructor(_cwd: string) {} - async loadLastSession() { - return mockSessionServiceMocks.loadLastSession(); + async loadSession(_sessionId: string) { + return mockSessionServiceMocks.loadSession(); } } @@ -68,13 +68,14 @@ describe('exportCommand', () => { beforeEach(() => { vi.clearAllMocks(); - mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData); + mockSessionServiceMocks.loadSession.mockResolvedValue(mockSessionData); mockContext = createMockCommandContext({ services: { config: { getWorkingDir: vi.fn().mockReturnValue('/test/dir'), getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), }, }, }); @@ -132,7 +133,7 @@ describe('exportCommand', () => { content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'), }); - expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled(); expect(collectSessionData).toHaveBeenCalledWith( mockSessionData.conversation, expect.anything(), @@ -191,7 +192,7 @@ describe('exportCommand', () => { }); it('should return error when no session is found', async () => { - mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + mockSessionServiceMocks.loadSession.mockResolvedValue(undefined); const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md'); if (!mdCommand?.action) { @@ -260,7 +261,7 @@ describe('exportCommand', () => { ), }); - expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled(); + expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled(); expect(collectSessionData).toHaveBeenCalledWith( mockSessionData.conversation, expect.anything(), @@ -323,7 +324,7 @@ describe('exportCommand', () => { }); it('should return error when no session is found', async () => { - mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined); + mockSessionServiceMocks.loadSession.mockResolvedValue(undefined); const htmlCommand = exportCommand.subCommands?.find( (c) => c.name === 'html', diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index 42af225ac..8edec9f4d 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -50,9 +50,10 @@ async function exportMarkdownAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -122,9 +123,10 @@ async function exportHtmlAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -194,9 +196,10 @@ async function exportJsonAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { @@ -266,9 +269,10 @@ async function exportJsonlAction( } try { - // Load the current session + // Load the current session using the current session ID const sessionService = new SessionService(cwd); - const sessionData = await sessionService.loadLastSession(); + const sessionId = config.getSessionId(); + const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { return { From 40afc13e142accda7197402419f476ab070c6401 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 11 Mar 2026 11:20:25 +0800 Subject: [PATCH 026/137] feat(vscode-ide-companion): refactor webview layout to mutual-exclusive sidebar pattern Adopt Claude Code's approach for webview view registration: - Use mutual-exclusive sidebar/secondary sidebar with `when` conditions - Detect secondary sidebar support via VS Code version >= 1.106 - Share single ChatWebviewViewProvider instance across both view IDs - Only set context key when secondary sidebar is NOT supported - Pass supportsSecondarySidebar as closure variable to commands Additional fixes: - Fix WebViewContent.generate() to accept both Webview and WebviewPanel - Fix permission handler leak (cancel old promise before new) - Fix double diff command execution in resolve callback - Add initializationPromise dedup for auth-restore races - Add attachToView() for sidebar/secondary sidebar hosting - Add missing AskUserQuestionResponseMessage import Co-authored-by: buaoyezz --- packages/vscode-ide-companion/package.json | 29 +- .../src/commands/index.test.ts | 116 +++++-- .../src/commands/index.ts | 22 +- .../src/constants/viewIds.ts | 8 +- .../src/extension.test.ts | 10 +- .../vscode-ide-companion/src/extension.ts | 80 ++--- .../vscode-ide-companion/src/package.test.ts | 27 ++ .../src/services/qwenAgentManager.test.ts | 74 +---- .../providers/ChatProviderRegistry.test.ts | 48 +++ .../webview/providers/ChatProviderRegistry.ts | 46 +++ .../providers/ChatWebviewViewProvider.test.ts | 54 ++++ .../src/webview/providers/MessageHandler.ts | 5 +- .../webview/providers/WebViewContent.test.ts | 77 +++++ .../src/webview/providers/WebViewContent.ts | 19 +- .../src/webview/providers/WebViewProvider.ts | 299 ++++++++++++------ .../providers/chatViewRegistration.test.ts | 90 ++++++ .../webview/providers/chatViewRegistration.ts | 62 ++++ 17 files changed, 809 insertions(+), 257 deletions(-) create mode 100644 packages/vscode-ide-companion/src/package.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts create mode 100644 packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/providers/chatViewRegistration.ts diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 044d80339..d35dae11d 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -30,7 +30,6 @@ "activationEvents": [ "onStartupFinished", "onView:qwen-code.chatView.sidebar", - "onView:qwen-code.chatView.panel", "onView:qwen-code.chatView.secondary", "onCommand:qwen-code.openChat", "onCommand:qwen-code.focusChat", @@ -49,21 +48,16 @@ { "id": "qwen-code-sidebar", "title": "Qwen Code", - "icon": "assets/sidebar-icon.svg" - } - ], - "panel": [ - { - "id": "qwen-code-panel", - "title": "Qwen Code", - "icon": "assets/icon.png" + "icon": "assets/sidebar-icon.svg", + "when": "qwen-code:doesNotSupportSecondarySidebar" } ], "secondarySidebar": [ { "id": "qwen-code-secondary", "title": "Qwen Code", - "icon": "assets/icon.png" + "icon": "assets/sidebar-icon.svg", + "when": "!qwen-code:doesNotSupportSecondarySidebar" } ] }, @@ -72,21 +66,18 @@ { "type": "webview", "id": "qwen-code.chatView.sidebar", - "name": "Qwen Code" - } - ], - "qwen-code-panel": [ - { - "id": "qwen-code.chatView.panel", "name": "Qwen Code", - "icon": "assets/icon.png" + "icon": "assets/sidebar-icon.svg", + "when": "qwen-code:doesNotSupportSecondarySidebar" } ], "qwen-code-secondary": [ { + "type": "webview", "id": "qwen-code.chatView.secondary", "name": "Qwen Code", - "icon": "assets/icon.png" + "icon": "assets/sidebar-icon.svg", + "when": "!qwen-code:doesNotSupportSecondarySidebar" } ] }, @@ -125,7 +116,7 @@ }, { "command": "qwen-code.focusChat", - "title": "Qwen Code: Focus Chat Input" + "title": "Qwen Code: Focus Chat View" }, { "command": "qwen-code.newConversation", diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts index 5d69c4edd..e05f14272 100644 --- a/packages/vscode-ide-companion/src/commands/index.test.ts +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -5,62 +5,120 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as vscode from 'vscode'; -import { openNewChatTabCommand, registerNewCommands } from './index.js'; +import { + focusChatCommand, + openNewChatTabCommand, + registerNewCommands, +} from './index.js'; + +const { + registerCommand, + executeCommand, + showWarningMessage, + showInformationMessage, +} = vi.hoisted(() => ({ + registerCommand: vi.fn( + (_id: string, handler: (...args: unknown[]) => unknown) => ({ + dispose: vi.fn(), + handler, + }), + ), + executeCommand: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), +})); vi.mock('vscode', () => ({ commands: { - registerCommand: vi.fn( - (_command: string, _handler: (...args: unknown[]) => unknown) => ({ - dispose: vi.fn(), - }), - ), + registerCommand, + executeCommand, + }, + window: { + showWarningMessage, + showInformationMessage, }, workspace: { workspaceFolders: [], }, - window: { - showErrorMessage: vi.fn(), - showInformationMessage: vi.fn(), - showWarningMessage: vi.fn(), - }, Uri: { joinPath: vi.fn(), }, })); +function getRegisteredHandler(commandId: string) { + const call = registerCommand.mock.calls.find(([id]) => id === commandId); + if (!call) { + throw new Error(`Command ${commandId} was not registered`); + } + return call[1] as (...args: unknown[]) => Promise; +} + describe('registerNewCommands', () => { + const context = { subscriptions: [] as Array<{ dispose: () => void }> }; + const diffManager = { showDiff: vi.fn() }; + const log = vi.fn(); + beforeEach(() => { - vi.clearAllMocks(); + context.subscriptions = []; + registerCommand.mockClear(); + executeCommand.mockClear(); + showWarningMessage.mockClear(); + showInformationMessage.mockClear(); }); - it('creates a fresh session when opening a new chat tab', async () => { - const fakeProvider = { + it('openNewChatTab opens a new provider without creating a second session explicitly', async () => { + const provider = { show: vi.fn().mockResolvedValue(undefined), createNewSession: vi.fn().mockResolvedValue(undefined), - forceReLogin: vi.fn().mockResolvedValue(undefined), }; registerNewCommands( - { subscriptions: [] } as unknown as vscode.ExtensionContext, - vi.fn(), - {} as never, + context as never, + log, + diffManager as never, () => [], - () => fakeProvider as never, + () => provider as never, ); - const commandCall = vi - .mocked(vscode.commands.registerCommand) - .mock.calls.find(([command]) => command === openNewChatTabCommand); + await getRegisteredHandler(openNewChatTabCommand)(); - expect(commandCall).toBeDefined(); + expect(provider.show).toHaveBeenCalledTimes(1); + expect(provider.createNewSession).not.toHaveBeenCalled(); + }); - const handler = commandCall?.[1] as (() => Promise) | undefined; - expect(handler).toBeDefined(); + it('focusChat focuses the secondary sidebar when it is supported', async () => { + registerNewCommands( + context as never, + log, + diffManager as never, + () => [], + vi.fn() as never, + undefined, + true, + ); - await handler?.(); + await getRegisteredHandler(focusChatCommand)(); - expect(fakeProvider.show).toHaveBeenCalledTimes(1); - expect(fakeProvider.createNewSession).toHaveBeenCalledTimes(1); + expect(executeCommand).toHaveBeenCalledWith( + 'qwen-code.chatView.secondary.focus', + ); + }); + + it('focusChat falls back to the primary sidebar when secondary sidebar is unavailable', async () => { + registerNewCommands( + context as never, + log, + diffManager as never, + () => [], + vi.fn() as never, + undefined, + false, + ); + + await getRegisteredHandler(focusChatCommand)(); + + expect(executeCommand).toHaveBeenCalledWith( + 'qwen-code.chatView.sidebar.focus', + ); }); }); diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 5bcf2565a..c50724090 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -7,7 +7,10 @@ import * as vscode from 'vscode'; import type { DiffManager } from '../diff-manager.js'; import type { WebViewProvider } from '../webview/providers/WebViewProvider.js'; -import { CHAT_VIEW_ID_SIDEBAR } from '../constants/viewIds.js'; +import { + CHAT_VIEW_ID_SIDEBAR, + CHAT_VIEW_ID_SECONDARY, +} from '../constants/viewIds.js'; type Logger = (message: string) => void; @@ -23,9 +26,8 @@ export const showLogsCommand = 'qwen-code.showLogs'; /** * Register all Qwen Code chat-related commands. * - * All chat positions (editor tab, sidebar, panel, secondary sidebar) are - * available simultaneously. `openChat` and `newConversation` always open an - * editor tab, while `focusChat` focuses the primary sidebar view. + * `openChat` and `newConversation` always open an editor tab, while + * `focusChat` focuses the secondary sidebar (preferred) or primary sidebar. * * @param context - VS Code extension context for subscription management * @param log - Logger function for debug output @@ -33,6 +35,7 @@ export const showLogsCommand = 'qwen-code.showLogs'; * @param getWebViewProviders - Returns all active editor-tab WebView providers * @param createWebViewProvider - Factory to create a new editor-tab WebView provider * @param outputChannel - Optional output channel for the showLogs command + * @param supportsSecondarySidebar - Whether the running VS Code supports secondary sidebar */ export function registerNewCommands( context: vscode.ExtensionContext, @@ -41,6 +44,7 @@ export function registerNewCommands( getWebViewProviders: () => WebViewProvider[], createWebViewProvider: () => WebViewProvider, outputChannel?: vscode.OutputChannel, + supportsSecondarySidebar = true, ): void { const disposables: vscode.Disposable[] = []; @@ -87,7 +91,6 @@ export function registerNewCommands( vscode.commands.registerCommand(openNewChatTabCommand, async () => { const provider = createWebViewProvider(); await provider.show(); - await provider.createNewSession(); }), ); @@ -104,10 +107,15 @@ export function registerNewCommands( }), ); - // Focus Chat: bring the primary sidebar chat view to front + // Focus Chat: bring the active chat view to front. + // Use secondary sidebar when supported; fall back to primary sidebar. disposables.push( vscode.commands.registerCommand(focusChatCommand, async () => { - await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SIDEBAR}.focus`); + if (supportsSecondarySidebar) { + await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SECONDARY}.focus`); + } else { + await vscode.commands.executeCommand(`${CHAT_VIEW_ID_SIDEBAR}.focus`); + } }), ); diff --git a/packages/vscode-ide-companion/src/constants/viewIds.ts b/packages/vscode-ide-companion/src/constants/viewIds.ts index 140956b26..b54c6eaa1 100644 --- a/packages/vscode-ide-companion/src/constants/viewIds.ts +++ b/packages/vscode-ide-companion/src/constants/viewIds.ts @@ -5,11 +5,13 @@ */ /** - * WebviewView IDs for the three host positions where the chat UI can appear. + * WebviewView IDs for the chat UI host positions. * These IDs must match the `views` contributions declared in package.json. * - * Note: We use kebab-case prefix 'qwen-code.' for consistency with command IDs. + * Only one of sidebar / secondary is visible at runtime — controlled by the + * `qwen-code:doesNotSupportSecondarySidebar` context key in package.json. + * The secondary sidebar is preferred; the primary sidebar is a fallback for + * VS Code versions that lack secondary sidebar support. */ export const CHAT_VIEW_ID_SIDEBAR = 'qwen-code.chatView.sidebar'; -export const CHAT_VIEW_ID_PANEL = 'qwen-code.chatView.panel'; export const CHAT_VIEW_ID_SECONDARY = 'qwen-code.chatView.secondary'; diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts index ea179d948..72c3d476e 100644 --- a/packages/vscode-ide-companion/src/extension.test.ts +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -23,6 +23,7 @@ vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', async () => { }); vi.mock('vscode', () => ({ + version: '1.94.0', window: { createOutputChannel: vi.fn(() => ({ appendLine: vi.fn(), @@ -137,20 +138,19 @@ describe('activate', () => { expect(vscode.workspace.onDidGrantWorkspaceTrust).toHaveBeenCalled(); }); - it('should register webview view providers for all three positions (sidebar, panel, secondary)', async () => { + it('should register webview view providers for sidebar and secondary positions', async () => { await activate(context); - // Verify registerWebviewViewProvider was called 3 times for the three view positions + // Verify registerWebviewViewProvider was called 2 times (sidebar + secondary) const registerCalls = vi.mocked(vscode.window.registerWebviewViewProvider) .mock.calls; - expect(registerCalls).toHaveLength(3); + expect(registerCalls).toHaveLength(2); // Extract view IDs from the calls const viewIds = registerCalls.map((call) => call[0]); - // Verify all three view IDs are registered with consistent naming + // Only sidebar and secondary are registered; panel view was removed expect(viewIds).toContain('qwen-code.chatView.sidebar'); - expect(viewIds).toContain('qwen-code.chatView.panel'); expect(viewIds).toContain('qwen-code.chatView.secondary'); }); diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 55c3f5299..54b494024 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -15,12 +15,8 @@ import { type IdeInfo, } from '@qwen-code/qwen-code-core/src/ide/detect-ide.js'; import { WebViewProvider } from './webview/providers/WebViewProvider.js'; -import { ChatWebviewViewProvider } from './webview/providers/ChatWebviewViewProvider.js'; -import { - CHAT_VIEW_ID_PANEL, - CHAT_VIEW_ID_SECONDARY, - CHAT_VIEW_ID_SIDEBAR, -} from './constants/viewIds.js'; +import { ChatProviderRegistry } from './webview/providers/ChatProviderRegistry.js'; +import { registerChatViewProviders } from './webview/providers/chatViewRegistration.js'; import { registerNewCommands } from './commands/index.js'; import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js'; import { isWindows } from './utils/platform.js'; @@ -41,7 +37,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet = new Set([ let ideServer: IDEServer; let logger: vscode.OutputChannel; -let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs +let chatProviderRegistry: ChatProviderRegistry | null = null; let log: (message: string) => void = () => {}; @@ -131,17 +127,25 @@ export async function activate(context: vscode.ExtensionContext) { ); log('Readonly file system provider registered'); + chatProviderRegistry = new ChatProviderRegistry( + () => new WebViewProvider(context, context.extensionUri), + ); + const diffContentProvider = new DiffContentProvider(); const diffManager = new DiffManager( log, diffContentProvider, - // Delay when any chat tab has a pending permission drawer - () => webViewProviders.some((p) => p.hasPendingPermission()), - // Suppress diffs when active mode is auto or yolo in any chat tab + // Delay when any chat surface has a pending permission drawer + () => + chatProviderRegistry + ?.getPermissionAwareProviders() + .some((p) => p.hasPendingPermission()) ?? false, + // Suppress diffs when active mode is auto or yolo in any chat surface () => { - const providers = webViewProviders.filter( - (p) => typeof p.shouldSuppressDiff === 'function', - ); + const providers = + chatProviderRegistry + ?.getPermissionAwareProviders() + .filter((p) => typeof p.shouldSuppressDiff === 'function') ?? []; if (providers.length === 0) { return false; } @@ -150,30 +154,16 @@ export async function activate(context: vscode.ExtensionContext) { ); // Helper function to create a new WebView provider instance - const createWebViewProvider = (): WebViewProvider => { - const provider = new WebViewProvider(context, context.extensionUri); - webViewProviders.push(provider); - return provider; - }; + const createWebViewProvider = (): WebViewProvider => + chatProviderRegistry!.createEditorProvider(); - // Register WebviewView hosts for all positions (sidebar, panel, secondary). - // Providers are lazily instantiated — the factory is only called when VS Code - // actually opens the view, keeping startup lightweight. - const chatViewIds = [ - CHAT_VIEW_ID_SIDEBAR, - CHAT_VIEW_ID_PANEL, - CHAT_VIEW_ID_SECONDARY, - ] as const; + const createViewProvider = (): WebViewProvider => + chatProviderRegistry!.createViewProvider(); - for (const viewId of chatViewIds) { - context.subscriptions.push( - vscode.window.registerWebviewViewProvider( - viewId, - new ChatWebviewViewProvider(createWebViewProvider), - { webviewOptions: { retainContextWhenHidden: true } }, - ), - ); - } + const supportsSecondarySidebar = registerChatViewProviders({ + context, + createViewProvider, + }); // Register WebView panel serializer for persistence across reloads context.subscriptions.push( @@ -217,9 +207,10 @@ export async function activate(context: vscode.ExtensionContext) { context, log, diffManager, - () => webViewProviders, + () => chatProviderRegistry?.getEditorProviders() ?? [], createWebViewProvider, logger, + supportsSecondarySidebar, ); context.subscriptions.push( @@ -237,9 +228,10 @@ export async function activate(context: vscode.ExtensionContext) { if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.acceptDiff(docUri); } - // If WebView is requesting permission, actively select an allow option (prefer once) + // If any chat surface is requesting permission, actively select allow (prefer once) try { - for (const provider of webViewProviders) { + for (const provider of chatProviderRegistry?.getPermissionAwareProviders() ?? + []) { if (provider?.hasPendingPermission()) { provider.respondToPendingPermission('allow'); } @@ -254,9 +246,10 @@ export async function activate(context: vscode.ExtensionContext) { if (docUri && docUri.scheme === DIFF_SCHEME) { diffManager.cancelDiff(docUri); } - // If WebView is requesting permission, actively select reject/cancel + // If any chat surface is requesting permission, actively select reject/cancel try { - for (const provider of webViewProviders) { + for (const provider of chatProviderRegistry?.getPermissionAwareProviders() ?? + []) { if (provider?.hasPendingPermission()) { provider.respondToPendingPermission('cancel'); } @@ -390,11 +383,8 @@ export async function deactivate(): Promise { if (ideServer) { await ideServer.stop(); } - // Dispose all WebView providers - webViewProviders.forEach((provider) => { - provider.dispose(); - }); - webViewProviders = []; + chatProviderRegistry?.disposeAll(); + chatProviderRegistry = null; } catch (err) { const message = err instanceof Error ? err.message : String(err); log(`Failed to stop IDE server during deactivation: ${message}`); diff --git a/packages/vscode-ide-companion/src/package.test.ts b/packages/vscode-ide-companion/src/package.test.ts new file mode 100644 index 000000000..9d7cdaef5 --- /dev/null +++ b/packages/vscode-ide-companion/src/package.test.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('package.json command metadata', () => { + it('describes focusChat as focusing the chat view', () => { + const manifest = JSON.parse( + readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf8'), + ) as { + contributes: { + commands: Array<{ command: string; title: string }>; + }; + }; + + const command = manifest.contributes.commands.find( + (item) => item.command === 'qwen-code.focusChat', + ); + + expect(command?.title).toBe('Qwen Code: Focus Chat View'); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts index 5de2b5c9d..440dc2b18 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.test.ts @@ -5,10 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest'; -import { - extractSessionListItems, - QwenAgentManager, -} from './qwenAgentManager.js'; +import { extractSessionListItems } from './qwenAgentManager.js'; vi.mock('vscode', () => ({ window: { @@ -19,80 +16,41 @@ vi.mock('vscode', () => ({ })); describe('extractSessionListItems', () => { - it('reads ACP session arrays from the sessions field', () => { + it('returns sessions array from the "sessions" field', () => { const items = extractSessionListItems({ sessions: [{ sessionId: 'session-1' }], }); - expect(items).toEqual([{ sessionId: 'session-1' }]); }); - it('reads ACP session arrays from the legacy items field', () => { + it('returns items array from the legacy "items" field', () => { const items = extractSessionListItems({ items: [{ sessionId: 'session-2' }], }); - expect(items).toEqual([{ sessionId: 'session-2' }]); }); - it('returns empty array for invalid responses', () => { - expect(extractSessionListItems(null)).toEqual([]); - expect(extractSessionListItems(undefined)).toEqual([]); - expect(extractSessionListItems('string')).toEqual([]); - expect(extractSessionListItems({})).toEqual([]); - expect(extractSessionListItems({ sessions: 'not-array' })).toEqual([]); - }); - - it('prefers sessions over items when both exist', () => { + it('prefers "sessions" over "items" when both are present', () => { const items = extractSessionListItems({ sessions: [{ sessionId: 'from-sessions' }], items: [{ sessionId: 'from-items' }], }); - expect(items).toEqual([{ sessionId: 'from-sessions' }]); }); -}); -describe('QwenAgentManager session list compatibility', () => { - it('maps paged ACP session lists returned via items', async () => { - const manager = new QwenAgentManager(); - const listSessions = vi.fn().mockResolvedValue({ - items: [ - { - sessionId: 'session-3', - prompt: 'Fix sidebar history', - mtime: 1772114825468.5825, - cwd: '/workspace/qwen-code', - }, - ], - }); + it('returns empty array for null/undefined input', () => { + expect(extractSessionListItems(null)).toEqual([]); + expect(extractSessionListItems(undefined)).toEqual([]); + }); - ( - manager as unknown as { - connection: { listSessions: typeof listSessions }; - } - ).connection = { listSessions }; + it('returns empty array for non-object input', () => { + expect(extractSessionListItems('string')).toEqual([]); + expect(extractSessionListItems(42)).toEqual([]); + }); - const page = await manager.getSessionListPaged({ size: 20 }); - - expect(listSessions).toHaveBeenCalledWith({ size: 20 }); - expect(page).toEqual({ - sessions: [ - { - id: 'session-3', - sessionId: 'session-3', - title: 'Fix sidebar history', - name: 'Fix sidebar history', - startTime: undefined, - lastUpdated: 1772114825468.5825, - messageCount: 0, - projectHash: undefined, - filePath: undefined, - cwd: '/workspace/qwen-code', - }, - ], - nextCursor: undefined, - hasMore: false, - }); + it('returns empty array when neither field is an array', () => { + expect(extractSessionListItems({ sessions: 'not-array' })).toEqual([]); + expect(extractSessionListItems({ items: 123 })).toEqual([]); + expect(extractSessionListItems({})).toEqual([]); }); }); diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts new file mode 100644 index 000000000..3820538c8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ChatProviderRegistry } from './ChatProviderRegistry.js'; + +describe('ChatProviderRegistry', () => { + it('tracks editor and view providers separately while exposing a combined list', () => { + const factory = vi + .fn() + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'editor-1' }) + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'view-1' }) + .mockReturnValueOnce({ dispose: vi.fn(), kind: 'editor-2' }); + + const registry = new ChatProviderRegistry(factory); + + const editor1 = registry.createEditorProvider(); + const view1 = registry.createViewProvider(); + const editor2 = registry.createEditorProvider(); + + expect(factory).toHaveBeenCalledTimes(3); + expect(registry.getEditorProviders()).toEqual([editor1, editor2]); + expect(registry.getPermissionAwareProviders()).toEqual([ + editor1, + editor2, + view1, + ]); + }); + + it('disposes all tracked providers and resets internal collections', () => { + const editorDispose = vi.fn(); + const viewDispose = vi.fn(); + const registry = new ChatProviderRegistry(() => ({ dispose: vi.fn() })); + + registry.createEditorProvider({ dispose: editorDispose }); + registry.createViewProvider({ dispose: viewDispose }); + + registry.disposeAll(); + + expect(editorDispose).toHaveBeenCalledTimes(1); + expect(viewDispose).toHaveBeenCalledTimes(1); + expect(registry.getEditorProviders()).toEqual([]); + expect(registry.getPermissionAwareProviders()).toEqual([]); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts new file mode 100644 index 000000000..94cacf47d --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatProviderRegistry.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +type DisposableProvider = { + dispose(): void; +}; + +/** + * Tracks chat providers by host type while exposing a combined list for flows + * like permission handling and diff suppression. + */ +export class ChatProviderRegistry { + private editorProviders: T[] = []; + private viewProviders: T[] = []; + + constructor(private readonly createProvider: () => T) {} + + createEditorProvider(provider: T = this.createProvider()): T { + this.editorProviders.push(provider); + return provider; + } + + createViewProvider(provider: T = this.createProvider()): T { + this.viewProviders.push(provider); + return provider; + } + + getEditorProviders(): T[] { + return [...this.editorProviders]; + } + + getPermissionAwareProviders(): T[] { + return [...this.editorProviders, ...this.viewProviders]; + } + + disposeAll(): void { + for (const provider of this.getPermissionAwareProviders()) { + provider.dispose(); + } + this.editorProviders = []; + this.viewProviders = []; + } +} diff --git a/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts new file mode 100644 index 000000000..a25860eb8 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/ChatWebviewViewProvider.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ChatWebviewViewProvider } from './ChatWebviewViewProvider.js'; + +vi.mock('vscode', () => ({})); + +describe('ChatWebviewViewProvider', () => { + it('lazily creates the WebViewProvider on first resolveWebviewView call', async () => { + const mockProvider = { + attachToView: vi.fn().mockResolvedValue(undefined), + }; + const factory = vi.fn(() => mockProvider); + + const viewProvider = new ChatWebviewViewProvider(factory as never); + + const mockWebviewView = { + webview: {}, + viewType: 'qwen-code.chatView.sidebar', + }; + + await viewProvider.resolveWebviewView(mockWebviewView as never); + + expect(factory).toHaveBeenCalledTimes(1); + expect(mockProvider.attachToView).toHaveBeenCalledWith( + mockWebviewView, + 'qwen-code.chatView.sidebar', + ); + }); + + it('reuses the same WebViewProvider on subsequent calls', async () => { + const mockProvider = { + attachToView: vi.fn().mockResolvedValue(undefined), + }; + const factory = vi.fn(() => mockProvider); + + const viewProvider = new ChatWebviewViewProvider(factory as never); + + const mockView1 = { webview: {}, viewType: 'sidebar' }; + const mockView2 = { webview: {}, viewType: 'sidebar' }; + + await viewProvider.resolveWebviewView(mockView1 as never); + await viewProvider.resolveWebviewView(mockView2 as never); + + // Factory should only be called once (lazy creation) + expect(factory).toHaveBeenCalledTimes(1); + // But attachToView should be called for each resolve + expect(mockProvider.attachToView).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index 86cfe2fae..a06fd1a3b 100644 --- a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -6,7 +6,10 @@ import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; import type { ConversationStore } from '../../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../../types/webviewMessageTypes.js'; import { MessageRouter } from '../handlers/MessageRouter.js'; /** diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts new file mode 100644 index 000000000..3c2029fe5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewContent.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { WebViewContent } from './WebViewContent.js'; + +vi.mock('vscode', () => ({ + Uri: { + joinPath: vi.fn((_base: unknown, ...parts: string[]) => ({ + fsPath: `/ext/${parts.join('/')}`, + })), + }, +})); + +/** + * Helper: create a minimal mock vscode.Webview + */ +function createMockWebview() { + return { + asWebviewUri: vi.fn((uri: { fsPath: string }) => ({ + toString: () => `https://webview/${uri.fsPath}`, + })), + cspSource: 'https://csp.source', + }; +} + +describe('WebViewContent', () => { + const fakeExtensionUri = { fsPath: '/ext' } as never; + + it('generates HTML when given a raw Webview', () => { + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(html).toContain('Qwen Code'); + expect(html).toContain(webview.cspSource); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('generates HTML when given a WebviewPanel (has .webview property)', () => { + const webview = createMockWebview(); + const panel = { webview }; + + const html = WebViewContent.generate(panel as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('generates HTML when given a WebviewView (has .webview property)', () => { + const webview = createMockWebview(); + const view = { webview, viewType: 'sidebar' }; + + const html = WebViewContent.generate(view as never, fakeExtensionUri); + + expect(html).toContain(''); + expect(webview.asWebviewUri).toHaveBeenCalled(); + }); + + it('includes the script tag with the correct URI', () => { + const webview = createMockWebview(); + const html = WebViewContent.generate(webview as never, fakeExtensionUri); + + expect(html).toContain('