diff --git a/package.json b/package.json index fbbaa5502e..3a781589e7 100644 --- a/package.json +++ b/package.json @@ -699,7 +699,7 @@ "onLanguage:csharp", "onCommand:o.showOutput", "onCommand:omnisharp.registerLanguageMiddleware", - "workspaceContains:**/*.{csproj,csx,cake}" + "workspaceContains:**/*.{csproj,csx,cake,sln,slnx,slnf}" ], "contributes": { "themes": [ diff --git a/src/lsptoolshost/server/roslynLanguageServer.ts b/src/lsptoolshost/server/roslynLanguageServer.ts index c82ed302ae..c972c580f0 100644 --- a/src/lsptoolshost/server/roslynLanguageServer.ts +++ b/src/lsptoolshost/server/roslynLanguageServer.ts @@ -70,6 +70,7 @@ import { isString } from '../utils/isString'; import { getServerPath } from '../activate'; import { UriConverter } from '../utils/uriConverter'; import { ProjectContextFeature } from '../projectContext/projectContextFeature'; +import { isSolutionFileOnDisk } from '../../solutionFileWorkspaceHandler'; // Flag indicating if C# Devkit was installed the last time we activated. // Used to determine if we need to restart the server on extension changes. @@ -553,11 +554,16 @@ export class RoslynLanguageServer { } private async openDefaultSolutionOrProjects(): Promise { + const activeEditor = vscode.window.activeTextEditor; + // If Dev Kit isn't installed, then we are responsible for picking the solution to open, assuming the user hasn't explicitly // disabled it. const defaultSolution = commonOptions.defaultSolution; if (!_wasActivatedWithCSharpDevkit && defaultSolution !== 'disable' && this._solutionFile === undefined) { - if (defaultSolution !== '') { + // If we are started with an active solution file, open it. + if (isSolutionFileOnDisk(activeEditor?.document)) { + await this.openSolution(activeEditor.document.uri); + } else if (defaultSolution !== '') { await this.openSolution(vscode.Uri.file(defaultSolution)); } else { // Auto open if there is just one solution target; if there's more the one we'll just let the user pick with the picker. diff --git a/src/main.ts b/src/main.ts index 3f44af2d2b..53579c7671 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ import { checkIsSupportedPlatform } from './checkSupportedPlatform'; import { activateOmniSharp } from './activateOmniSharp'; import { activateRoslyn } from './activateRoslyn'; import { LimitedActivationStatus } from './shared/limitedActivationStatus'; +import { registerSolutionFileWorkspaceHandler } from './solutionFileWorkspaceHandler'; export async function activate( context: vscode.ExtensionContext @@ -40,6 +41,10 @@ export async function activate( const csharpChannel = vscode.window.createOutputChannel('C#', { log: true }); csharpChannel.trace('Activating C# Extension'); + // Handle the case where a user opens a solution file via `code ./solution.sln` + // This must happen early to redirect to the correct folder before other initialization. + registerSolutionFileWorkspaceHandler(context, csharpChannel); + util.setExtensionPath(context.extension.extensionPath); const aiKey = context.extension.packageJSON.contributes.debuggers[0].aiKey; diff --git a/src/solutionFileWorkspaceHandler.ts b/src/solutionFileWorkspaceHandler.ts new file mode 100644 index 0000000000..4c14b3d0e7 --- /dev/null +++ b/src/solutionFileWorkspaceHandler.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as vscode from 'vscode'; + +/** + * Checks if the given document is a solution file (.sln, .slnx, or .slnf) + */ +export function isSolutionFileOnDisk(document: vscode.TextDocument | undefined): document is vscode.TextDocument { + if (document?.uri.scheme !== 'file') { + return false; + } + + const fileName = document.fileName.toLowerCase(); + return fileName.endsWith('.sln') || fileName.endsWith('.slnx') || fileName.endsWith('.slnf'); +} + +/** + * Checks if the given URI is within any of the workspace folders + */ +function isWithinWorkspaceFolders(uri: vscode.Uri): boolean { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return false; + } + + const filePath = uri.fsPath; + return workspaceFolders.some((folder) => filePath.startsWith(folder.uri.fsPath)); +} + +/** + * Handles the scenario where a user opens a solution file via `code ./solution.sln`. + * - If workspaceFolders is empty and the active document is a solution file, opens the parent folder in the current window. + * - If workspaceFolders exist and the active document changes to a solution file outside those folders, + * launches a new window for the parent folder. + */ +export function registerSolutionFileWorkspaceHandler( + context: vscode.ExtensionContext, + csharpChannel: vscode.LogOutputChannel +): void { + // Check on activation if we should open a folder for the current solution file + void checkAndOpenSolutionFolder(csharpChannel); + + // Listen for active editor changes to handle solutions opened outside current workspace + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor) { + void handleActiveEditorChange(editor, csharpChannel); + } + }) + ); +} + +/** + * Checks on extension activation if we should open a folder for the active solution file. + * This handles the case where the user runs `code ./solution.sln` from the command line. + */ +async function checkAndOpenSolutionFolder(csharpChannel: vscode.LogOutputChannel): Promise { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return; + } + + await handleActiveEditorChange(activeEditor, csharpChannel); +} + +/** + * Handles changes to the active text editor to detect when a solution file outside + * the current workspace is opened. + */ +async function handleActiveEditorChange( + editor: vscode.TextEditor, + csharpChannel: vscode.LogOutputChannel +): Promise { + const document = editor.document; + if (!isSolutionFileOnDisk(document)) { + return; + } + + const workspaceFolders = vscode.workspace.workspaceFolders; + const solutionFolderUri = vscode.Uri.file(path.dirname(document.uri.fsPath)); + + // Case 1: No workspace folders - open the solution's parent folder in the current window + if (!workspaceFolders || workspaceFolders.length === 0) { + csharpChannel.info( + `Opening solution file detected with no workspace. Opening folder: ${solutionFolderUri.fsPath}` + ); + await vscode.commands.executeCommand('vscode.openFolder', solutionFolderUri, { + forceReuseWindow: true, + }); + } + // Case 2: Workspace folders exist but solution is outside of them - open new window + else if (!isWithinWorkspaceFolders(document.uri)) { + // Close the current editor to avoid confusion + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + csharpChannel.info( + `Solution file outside workspace detected. Opening in new window: ${solutionFolderUri.fsPath}` + ); + // open solution folder and solution file in a new window + await vscode.commands.executeCommand('vscode.openFolder', solutionFolderUri, { + forceNewWindow: true, + filesToOpen: [document.uri], + }); + } +} diff --git a/src/vscodeAdapter.ts b/src/vscodeAdapter.ts index 8768f219a2..b58ec5df07 100644 --- a/src/vscodeAdapter.ts +++ b/src/vscodeAdapter.ts @@ -983,6 +983,7 @@ export interface vscode { options: MessageOptions, ...items: T[] ): Thenable; + onDidChangeActiveTextEditor: Event; }; workspace: { getConfiguration: (section?: string, resource?: Uri) => WorkspaceConfiguration; diff --git a/test/fakes.ts b/test/fakes.ts index aaa6f9a35d..eab2e90f2c 100644 --- a/test/fakes.ts +++ b/test/fakes.ts @@ -203,6 +203,13 @@ export function getFakeVsCode(): vscode.vscode { showErrorMessage: (_message: string, ..._items: T[]) => { throw new Error('Not Implemented'); }, + onDidChangeActiveTextEditor: ( + _listener: (e: vscode.TextEditor | undefined) => any, + _thisArgs?: any, + _disposables?: vscode.Disposable[] + ): vscode.Disposable => { + return { dispose: () => {} }; + }, }, workspace: { workspaceFolders: undefined, diff --git a/test/lsptoolshost/unitTests/solutionFileWorkspaceHandler.test.ts b/test/lsptoolshost/unitTests/solutionFileWorkspaceHandler.test.ts new file mode 100644 index 0000000000..288baec8c0 --- /dev/null +++ b/test/lsptoolshost/unitTests/solutionFileWorkspaceHandler.test.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, test, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import * as vscode from 'vscode'; +import { registerSolutionFileWorkspaceHandler } from '../../../src/solutionFileWorkspaceHandler'; + +describe('SolutionFileWorkspaceHandler', () => { + let mockContext: vscode.ExtensionContext; + let mockChannel: vscode.LogOutputChannel; + let subscriptions: vscode.Disposable[]; + let executeCommandSpy: jest.SpiedFunction; + let onDidChangeActiveTextEditorHandler: ((editor: vscode.TextEditor | undefined) => void) | undefined; + + beforeEach(() => { + subscriptions = []; + mockContext = { + subscriptions, + } as unknown as vscode.ExtensionContext; + + mockChannel = { + info: jest.fn(), + trace: jest.fn(), + } as unknown as vscode.LogOutputChannel; + + // Set up spy on executeCommand + executeCommandSpy = jest + .spyOn(vscode.commands, 'executeCommand') + .mockImplementation(async () => Promise.resolve(undefined)); + + // Capture the onDidChangeActiveTextEditor handler + onDidChangeActiveTextEditorHandler = undefined; + jest.spyOn(vscode.window, 'onDidChangeActiveTextEditor').mockImplementation((handler) => { + onDidChangeActiveTextEditorHandler = handler as (editor: vscode.TextEditor | undefined) => void; + return { dispose: jest.fn() } as unknown as vscode.Disposable; + }); + + // Reset workspace state + Object.defineProperty(vscode.workspace, 'workspaceFolders', { + value: undefined, + writable: true, + configurable: true, + }); + Object.defineProperty(vscode.window, 'activeTextEditor', { + value: undefined, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when no workspace folders exist', () => { + test('should open solution folder in current window when active document is .sln', async () => { + const mockDocument = { + fileName: '/path/to/project.sln', + uri: vscode.Uri.file('/path/to/project.sln'), + } as vscode.TextDocument; + + Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: undefined }); + Object.defineProperty(vscode.window, 'activeTextEditor', { + value: { document: mockDocument }, + }); + + registerSolutionFileWorkspaceHandler(mockContext, mockChannel); + + // Give async operation time to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(executeCommandSpy).toHaveBeenCalledWith('vscode.openFolder', expect.anything(), { + forceReuseWindow: true, + }); + }); + + test('should open solution folder in current window when active document is .slnf', async () => { + const mockDocument = { + fileName: '/path/to/project.slnf', + uri: vscode.Uri.file('/path/to/project.slnf'), + } as vscode.TextDocument; + + Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: undefined }); + Object.defineProperty(vscode.window, 'activeTextEditor', { + value: { document: mockDocument }, + }); + + registerSolutionFileWorkspaceHandler(mockContext, mockChannel); + + // Give async operation time to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(executeCommandSpy).toHaveBeenCalledWith('vscode.openFolder', expect.anything(), { + forceReuseWindow: true, + }); + }); + + test('should not open folder for non-solution files', async () => { + const mockDocument = { + fileName: '/path/to/file.cs', + uri: vscode.Uri.file('/path/to/file.cs'), + } as vscode.TextDocument; + + Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: undefined }); + Object.defineProperty(vscode.window, 'activeTextEditor', { + value: { document: mockDocument }, + }); + + registerSolutionFileWorkspaceHandler(mockContext, mockChannel); + + // Give async operation time to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(executeCommandSpy).not.toHaveBeenCalled(); + }); + }); + + describe('when workspace folders exist', () => { + test('should open new window when solution is outside workspace folders', async () => { + const workspaceFolders: vscode.WorkspaceFolder[] = [ + { uri: vscode.Uri.file('/workspace/folder'), name: 'workspace', index: 0 }, + ]; + + const mockDocument = { + fileName: '/other/path/project.sln', + uri: vscode.Uri.file('/other/path/project.sln'), + } as vscode.TextDocument; + + Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: workspaceFolders }); + Object.defineProperty(vscode.window, 'activeTextEditor', { value: undefined }); + + registerSolutionFileWorkspaceHandler(mockContext, mockChannel); + + // Simulate editor change to solution file outside workspace + expect(onDidChangeActiveTextEditorHandler).toBeDefined(); + onDidChangeActiveTextEditorHandler!({ document: mockDocument } as vscode.TextEditor); + + // Give async operation time to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(executeCommandSpy).toHaveBeenCalledWith('vscode.openFolder', expect.anything(), { + forceNewWindow: true, + }); + }); + + test('should not open new window when solution is inside workspace folders', async () => { + const workspaceFolders: vscode.WorkspaceFolder[] = [ + { uri: vscode.Uri.file('/workspace/folder'), name: 'workspace', index: 0 }, + ]; + + const mockDocument = { + fileName: '/workspace/folder/project.sln', + uri: vscode.Uri.file('/workspace/folder/project.sln'), + } as vscode.TextDocument; + + Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: workspaceFolders }); + Object.defineProperty(vscode.window, 'activeTextEditor', { value: undefined }); + + registerSolutionFileWorkspaceHandler(mockContext, mockChannel); + + // Simulate editor change to solution file inside workspace + expect(onDidChangeActiveTextEditorHandler).toBeDefined(); + onDidChangeActiveTextEditorHandler!({ document: mockDocument } as vscode.TextEditor); + + // Give async operation time to complete + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(executeCommandSpy).not.toHaveBeenCalled(); + }); + }); + + test('should add disposable to context subscriptions', () => { + registerSolutionFileWorkspaceHandler(mockContext, mockChannel); + + expect(subscriptions.length).toBe(1); + }); +});