diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts index 8367517abd6..755143a4e44 100644 --- a/packages/vscode-ide-companion/src/diff-manager.ts +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -14,7 +14,7 @@ import * as vscode from 'vscode'; import { DIFF_SCHEME } from './extension.js'; import { findLeftGroupOfChatWebview, - ensureLeftGroupOfChatWebview, + findRightGroupOfChatWebview, } from './utils/editorGroupUtils.js'; export class DiffContentProvider implements vscode.TextDocumentContentProvider { @@ -222,17 +222,14 @@ export class DiffManager { true, ); - // Prefer opening the diff adjacent to the chat webview (so we don't - // replace content inside the locked webview group). We try the group to - // the left of the chat webview first; if none exists we fall back to - // ViewColumn.Beside. With the chat locked in the leftmost group, this - // fallback opens diffs to the right of the chat. - let targetViewColumn = findLeftGroupOfChatWebview(); - if (targetViewColumn === undefined) { - // If there is no left neighbor, create one to satisfy the requirement of - // opening diffs to the left of the chat webview. - targetViewColumn = await ensureLeftGroupOfChatWebview(); - } + // Prefer opening the diff in the group to the left of the chat webview. + // When that isn't available (e.g. chat is in the leftmost group), try the + // group to the right so we reuse existing layout. Only fall back to + // ViewColumn.Beside when neither neighbor exists or the webview is missing. + const targetViewColumn = + findLeftGroupOfChatWebview() ?? + findRightGroupOfChatWebview() ?? + vscode.ViewColumn.Beside; await vscode.commands.executeCommand( 'vscode.diff', @@ -240,10 +237,7 @@ export class DiffManager { rightDocUri, diffTitle, { - // If a left-of-webview group was found, target it explicitly so the - // diff opens there while keeping focus on the webview. Otherwise, use - // the default "open to side" behavior. - viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside, + viewColumn: targetViewColumn, preview: false, preserveFocus: true, }, diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.test.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.test.ts new file mode 100644 index 00000000000..a8dd4ce38f5 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.test.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const chatViewType = 'mainThreadWebview-qwenCode.chat'; + +const vscodeMock = vi.hoisted(() => ({ + ViewColumn: { One: 1, Two: 2, Three: 3, Four: 4 }, + window: { + tabGroups: { + all: [] as Array<{ tabs: Array<{ input: unknown }>; viewColumn: number }>, + }, + }, +})); + +vi.mock('vscode', () => vscodeMock); + +import { + findLeftGroupOfChatWebview, + findRightGroupOfChatWebview, +} from './editorGroupUtils.js'; + +function chatTab() { + return { input: { viewType: chatViewType } }; +} + +function regularTab() { + return { input: { viewType: 'default' } }; +} + +describe('findLeftGroupOfChatWebview', () => { + beforeEach(() => { + vi.clearAllMocks(); + vscodeMock.window.tabGroups.all = []; + }); + + it('returns the nearest left neighbor when chat webview has a group to its left', () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [regularTab()], viewColumn: 1 }, + { tabs: [chatTab()], viewColumn: 2 }, + { tabs: [regularTab()], viewColumn: 3 }, + ]; + + expect(findLeftGroupOfChatWebview()).toBe(1); + }); + + it('returns the closest left neighbor when multiple left groups exist', () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [regularTab()], viewColumn: 1 }, + { tabs: [regularTab()], viewColumn: 2 }, + { tabs: [chatTab()], viewColumn: 4 }, + { tabs: [regularTab()], viewColumn: 5 }, + ]; + + // closest left is group 2, not group 1 + expect(findLeftGroupOfChatWebview()).toBe(2); + }); + + it('returns undefined when chat webview is in the leftmost group', () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [chatTab()], viewColumn: 1 }, + { tabs: [regularTab()], viewColumn: 2 }, + ]; + + expect(findLeftGroupOfChatWebview()).toBeUndefined(); + }); + + it('returns undefined when no chat webview is found', () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [regularTab()], viewColumn: 1 }, + { tabs: [regularTab()], viewColumn: 2 }, + ]; + + expect(findLeftGroupOfChatWebview()).toBeUndefined(); + }); + + it('returns undefined when there are no tab groups', () => { + vscodeMock.window.tabGroups.all = []; + + expect(findLeftGroupOfChatWebview()).toBeUndefined(); + }); + + it('returns undefined when tabGroups access throws', () => { + // make .all throw on access + Object.defineProperty(vscodeMock.window.tabGroups, 'all', { + get: () => { + throw new Error('unexpected error'); + }, + configurable: true, + }); + + expect(findLeftGroupOfChatWebview()).toBeUndefined(); + + // restore + Object.defineProperty(vscodeMock.window.tabGroups, 'all', { + value: [], + configurable: true, + writable: true, + }); + }); +}); + +describe('findRightGroupOfChatWebview', () => { + beforeEach(() => { + vi.clearAllMocks(); + vscodeMock.window.tabGroups.all = []; + }); + + it('returns the nearest right neighbor when chat webview has a group to its right', () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [regularTab()], viewColumn: 1 }, + { tabs: [chatTab()], viewColumn: 2 }, + { tabs: [regularTab()], viewColumn: 3 }, + ]; + + expect(findRightGroupOfChatWebview()).toBe(3); + }); + + it('returns the closest right neighbor when multiple right groups exist', () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [regularTab()], viewColumn: 1 }, + { tabs: [chatTab()], viewColumn: 2 }, + { tabs: [regularTab()], viewColumn: 3 }, + { tabs: [regularTab()], viewColumn: 5 }, + ]; + + // closest right is group 3, not group 5 + expect(findRightGroupOfChatWebview()).toBe(3); + }); + + it('returns undefined when chat webview is in the rightmost group', () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [regularTab()], viewColumn: 1 }, + { tabs: [chatTab()], viewColumn: 3 }, + ]; + + expect(findRightGroupOfChatWebview()).toBeUndefined(); + }); + + it('returns undefined when no chat webview is found', () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [regularTab()], viewColumn: 1 }, + { tabs: [regularTab()], viewColumn: 2 }, + ]; + + expect(findRightGroupOfChatWebview()).toBeUndefined(); + }); + + it('returns undefined when there are no tab groups', () => { + vscodeMock.window.tabGroups.all = []; + + expect(findRightGroupOfChatWebview()).toBeUndefined(); + }); + + it('returns undefined when tabGroups access throws', () => { + Object.defineProperty(vscodeMock.window.tabGroups, 'all', { + get: () => { + throw new Error('unexpected error'); + }, + configurable: true, + }); + + expect(findRightGroupOfChatWebview()).toBeUndefined(); + + Object.defineProperty(vscodeMock.window.tabGroups, 'all', { + value: [], + configurable: true, + writable: true, + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts index e855b2dec04..3326cd33683 100644 --- a/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts +++ b/packages/vscode-ide-companion/src/utils/editorGroupUtils.ts @@ -5,159 +5,78 @@ */ import * as vscode from 'vscode'; -import { openChatCommand } from '../commands/index.js'; + +const CHAT_WEBVIEW_TYPE = 'mainThreadWebview-qwenCode.chat'; + +function isChatWebview(tab: vscode.Tab): boolean { + const input: unknown = (tab as { input?: unknown }).input; + return ( + !!input && + typeof input === 'object' && + (input as { viewType: string }).viewType === CHAT_WEBVIEW_TYPE + ); +} + +function findWebviewGroup(): vscode.TabGroup | undefined { + return vscode.window.tabGroups.all.find((group) => + group.tabs.some(isChatWebview), + ); +} + +function findNeighborGroup( + isOnSide: (v: vscode.ViewColumn) => boolean, + isCloser: (cur: vscode.ViewColumn, cand: vscode.ViewColumn) => boolean, +): vscode.ViewColumn | undefined { + let candidate: vscode.ViewColumn | undefined; + for (const g of vscode.window.tabGroups.all) { + if (!isOnSide(g.viewColumn)) continue; + if (candidate === undefined || isCloser(candidate, g.viewColumn)) { + candidate = g.viewColumn; + } + } + return candidate; +} /** * 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 { try { - const groups = vscode.window.tabGroups.all; - - // Locate the group that contains our chat webview - const webviewGroup = groups.find((group) => - group.tabs.some((tab) => { - const input: unknown = (tab as { input?: unknown }).input; - const isWebviewInput = (inp: unknown): inp is { viewType: string } => - !!inp && typeof inp === 'object' && 'viewType' in inp; - return ( - isWebviewInput(input) && - input.viewType === 'mainThreadWebview-qwenCode.chat' - ); - }), - ); - + const webviewGroup = findWebviewGroup(); if (!webviewGroup) { return undefined; } - // Among all groups to the left (smaller viewColumn), choose the one with - // the largest viewColumn value (i.e. the immediate neighbor on the left). - let candidate: - | { group: vscode.TabGroup; viewColumn: vscode.ViewColumn } - | undefined; - for (const g of groups) { - if (g.viewColumn < webviewGroup.viewColumn) { - if (!candidate || g.viewColumn > candidate.viewColumn) { - candidate = { group: g, viewColumn: g.viewColumn }; - } - } - } - - return candidate?.viewColumn; + // Among groups with smaller viewColumn, pick the largest (closest neighbor). + return findNeighborGroup( + (v) => v < webviewGroup.viewColumn, + (cur, cand) => cand > cur, + ); } catch (_err) { - // Best-effort only; fall back to default behavior if anything goes wrong return undefined; } } /** - * Wait for a condition to become true, driven by tab-group change events. - * Falls back to a timeout to avoid hanging forever. - */ -function waitForTabGroupsCondition( - condition: () => boolean, - timeout: number = 2000, -): Promise { - if (condition()) { - return Promise.resolve(true); - } - - return new Promise((resolve) => { - const subscription = vscode.window.tabGroups.onDidChangeTabGroups(() => { - if (!condition()) { - return; - } - clearTimeout(timeoutHandle); - subscription.dispose(); - resolve(true); - }); - - const timeoutHandle = setTimeout(() => { - subscription.dispose(); - resolve(false); - }, timeout); - }); -} - -/** - * Ensure there is an editor group directly to the left of the Qwen chat webview. - * - If one exists, return its ViewColumn. - * - If none exists, focus the chat panel and create a new group on its left, - * then return the new group's ViewColumn. - * - If the chat webview cannot be located, returns undefined. + * Find the editor group immediately to the right of the Qwen chat webview. + * - If the chat webview group is the rightmost group, returns undefined. + * - If no chat webview is found in any editor group, returns undefined. */ -export async function ensureLeftGroupOfChatWebview(): Promise< - vscode.ViewColumn | undefined -> { - // First try to find an existing left neighbor - const existing = findLeftGroupOfChatWebview(); - if (existing !== undefined) { - return existing; - } - - // Locate the chat webview group - const groups = vscode.window.tabGroups.all; - const webviewGroup = groups.find((group) => - group.tabs.some((tab) => { - const input: unknown = (tab as { input?: unknown }).input; - const isWebviewInput = (inp: unknown): inp is { viewType: string } => - !!inp && typeof inp === 'object' && 'viewType' in inp; - return ( - isWebviewInput(input) && - input.viewType === 'mainThreadWebview-qwenCode.chat' - ); - }), - ); - - if (!webviewGroup) { - return undefined; - } - - const initialGroupCount = vscode.window.tabGroups.all.length; - - // Make the chat group active by revealing the panel +export function findRightGroupOfChatWebview(): vscode.ViewColumn | undefined { try { - await vscode.commands.executeCommand(openChatCommand); - } catch { - // Best-effort; continue even if this fails - } + const webviewGroup = findWebviewGroup(); + if (!webviewGroup) { + return undefined; + } - // Create a new group to the left of the chat group - try { - await vscode.commands.executeCommand('workbench.action.newGroupLeft'); - } catch { - // If we fail to create a group, fall back to default behavior + // Among groups with larger viewColumn, pick the smallest (closest neighbor). + return findNeighborGroup( + (v) => v > webviewGroup.viewColumn, + (cur, cand) => cand < cur, + ); + } catch (_err) { return undefined; } - - // Wait for the new group to actually be created (check that group count increased) - const groupCreated = await waitForTabGroupsCondition( - () => vscode.window.tabGroups.all.length > initialGroupCount, - 1000, // 1 second timeout - ); - - if (!groupCreated) { - // Fallback if group creation didn't complete in time - return vscode.ViewColumn.One; - } - - // After creating a new group to the left, the new group takes ViewColumn.One - // and all existing groups shift right. So the new left group is always ViewColumn.One. - // However, to be safe, let's query for it again. - const newLeftGroup = findLeftGroupOfChatWebview(); - - // Restore focus to chat (optional), so we don't disturb user focus - try { - await vscode.commands.executeCommand(openChatCommand); - } catch { - // Ignore - } - - // If we successfully found the new left group, return it - // Otherwise, fallback to ViewColumn.One (the newly created group should be first) - return newLeftGroup ?? vscode.ViewColumn.One; } diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts index d6ff4c4a9f5..faeaa8f19f7 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts @@ -32,10 +32,12 @@ const vscodeMock = vi.hoisted(() => { return { Uri, + ViewColumn: { One: 1, Two: 2, Three: 3, Beside: -2 }, workspace: { findFiles: vi.fn(), getWorkspaceFolder: vi.fn(), asRelativePath: vi.fn(), + openTextDocument: vi.fn(), workspaceFolders: [] as vscode.WorkspaceFolder[], createFileSystemWatcher: vi.fn(() => ({ onDidCreate: vi.fn(), @@ -47,8 +49,12 @@ const vscodeMock = vi.hoisted(() => { }, window: { activeTextEditor: undefined, + showTextDocument: vi.fn(), tabGroups: { - all: [], + all: [] as Array<{ + tabs: Array<{ input: unknown }>; + viewColumn: number; + }>, }, }, }; @@ -74,6 +80,15 @@ vi.mock('@qwen-code/qwen-code-core/src/utils/filesearch/crawlCache.js', () => ({ clear: vi.fn(), })); +const readonlyProviderMock = vi.hoisted(() => ({ + createUri: vi.fn(), + setContent: vi.fn(), + getInstance: vi.fn(), +})); +vi.mock('../../services/readonlyFileSystemProvider.js', () => ({ + ReadonlyFileSystemProvider: readonlyProviderMock, +})); + describe('FileMessageHandler', () => { beforeEach(() => { vi.clearAllMocks(); @@ -183,4 +198,106 @@ describe('FileMessageHandler', () => { expect(payload.type).toBe('workspaceFiles'); expect(payload.data.requestId).toBe(7); }); + + describe('createAndOpenTempFile viewColumn selection', () => { + const chatViewType = 'mainThreadWebview-qwenCode.chat'; + + beforeEach(() => { + vi.clearAllMocks(); + readonlyProviderMock.getInstance.mockReturnValue(readonlyProviderMock); + readonlyProviderMock.createUri.mockReturnValue( + vscodeMock.Uri.file('/tmp/temp.txt'), + ); + readonlyProviderMock.setContent.mockReturnValue(undefined); + vscodeMock.workspace.openTextDocument.mockResolvedValue({ + uri: vscodeMock.Uri.file('/tmp/temp.txt'), + }); + vscodeMock.window.showTextDocument.mockResolvedValue(undefined); + // ensure the existing-tab search finds nothing + vscodeMock.window.tabGroups.all = []; + }); + + function chatTab() { + return { input: { viewType: chatViewType as unknown } }; + } + + function regularTab() { + return { input: { viewType: 'default' as unknown } }; + } + + it('opens in left group when chat webview has a left neighbor', async () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [regularTab()], viewColumn: 1 }, + { tabs: [chatTab()], viewColumn: 2 }, + ]; + + const sendToWebView = vi.fn(); + const handler = new FileMessageHandler( + {} as QwenAgentManager, + {} as ConversationStore, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'createAndOpenTempFile' as never, + data: { content: 'hello', fileName: 'test.txt' }, + }); + + expect(vscodeMock.window.showTextDocument).toHaveBeenCalledTimes(1); + const options = vscodeMock.window.showTextDocument.mock.calls[0]?.[1] as { + viewColumn: number; + }; + expect(options.viewColumn).toBe(1); + }); + + it('opens in right group when no left neighbor but right exists', async () => { + vscodeMock.window.tabGroups.all = [ + { tabs: [chatTab()], viewColumn: 1 }, + { tabs: [regularTab()], viewColumn: 2 }, + ]; + + const sendToWebView = vi.fn(); + const handler = new FileMessageHandler( + {} as QwenAgentManager, + {} as ConversationStore, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'createAndOpenTempFile' as never, + data: { content: 'hello', fileName: 'test.txt' }, + }); + + expect(vscodeMock.window.showTextDocument).toHaveBeenCalledTimes(1); + const options = vscodeMock.window.showTextDocument.mock.calls[0]?.[1] as { + viewColumn: number; + }; + expect(options.viewColumn).toBe(2); + }); + + it('falls back to ViewColumn.Beside when neither left nor right neighbor exists', async () => { + vscodeMock.window.tabGroups.all = [{ tabs: [chatTab()], viewColumn: 1 }]; + + const sendToWebView = vi.fn(); + const handler = new FileMessageHandler( + {} as QwenAgentManager, + {} as ConversationStore, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'createAndOpenTempFile' as never, + data: { content: 'hello', fileName: 'test.txt' }, + }); + + expect(vscodeMock.window.showTextDocument).toHaveBeenCalledTimes(1); + const options = vscodeMock.window.showTextDocument.mock.calls[0]?.[1] as { + viewColumn: number; + }; + expect(options.viewColumn).toBe(vscodeMock.ViewColumn.Beside); + }); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index f8708d8d4d3..547cd6108a6 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -10,7 +10,7 @@ import { getFileName } from '../utils/webviewUtils.js'; import { showDiffCommand } from '../../commands/index.js'; import { findLeftGroupOfChatWebview, - ensureLeftGroupOfChatWebview, + findRightGroupOfChatWebview, } from '../../utils/editorGroupUtils.js'; import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js'; import { FileDiscoveryService } from '@qwen-code/qwen-code-core/src/services/fileDiscoveryService.js'; @@ -673,16 +673,17 @@ export class FileMessageHandler extends BaseMessageHandler { return; } - // Find or ensure left group of chat webview - let targetViewColumn = findLeftGroupOfChatWebview(); - if (targetViewColumn === undefined) { - targetViewColumn = await ensureLeftGroupOfChatWebview(); - } + // Find the nearest editor group to the left or right of the chat webview. + // Fall back to ViewColumn.Beside when neither neighbor exists or the webview is missing. + const targetViewColumn = + findLeftGroupOfChatWebview() ?? + findRightGroupOfChatWebview() ?? + vscode.ViewColumn.Beside; - // Open as readonly document in the left group and focus it (single click should be enough) + // Open as readonly document in the selected neighboring group and focus it (single click should be enough) const document = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(document, { - viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside, + viewColumn: targetViewColumn, preview: false, preserveFocus: false, });