Skip to content

Commit 1c57141

Browse files
committed
Add the ability to open a solution file
1 parent f238bbe commit 1c57141

7 files changed

Lines changed: 308 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,7 @@
699699
"onLanguage:csharp",
700700
"onCommand:o.showOutput",
701701
"onCommand:omnisharp.registerLanguageMiddleware",
702-
"workspaceContains:**/*.{csproj,csx,cake}"
702+
"workspaceContains:**/*.{csproj,csx,cake,sln,slnx,slnf}"
703703
],
704704
"contributes": {
705705
"themes": [

src/lsptoolshost/server/roslynLanguageServer.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { isString } from '../utils/isString';
7070
import { getServerPath } from '../activate';
7171
import { UriConverter } from '../utils/uriConverter';
7272
import { ProjectContextFeature } from '../projectContext/projectContextFeature';
73+
import { isSolutionFileOnDisk } from '../../solutionFileWorkspaceHandler';
7374

7475
// Flag indicating if C# Devkit was installed the last time we activated.
7576
// Used to determine if we need to restart the server on extension changes.
@@ -553,11 +554,16 @@ export class RoslynLanguageServer {
553554
}
554555

555556
private async openDefaultSolutionOrProjects(): Promise<void> {
557+
const activeEditor = vscode.window.activeTextEditor;
558+
556559
// If Dev Kit isn't installed, then we are responsible for picking the solution to open, assuming the user hasn't explicitly
557560
// disabled it.
558561
const defaultSolution = commonOptions.defaultSolution;
559562
if (!_wasActivatedWithCSharpDevkit && defaultSolution !== 'disable' && this._solutionFile === undefined) {
560-
if (defaultSolution !== '') {
563+
// If we are started with an active solution file, open it.
564+
if (isSolutionFileOnDisk(activeEditor?.document)) {
565+
await this.openSolution(activeEditor.document.uri);
566+
} else if (defaultSolution !== '') {
561567
await this.openSolution(vscode.Uri.file(defaultSolution));
562568
} else {
563569
// 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.

src/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { checkIsSupportedPlatform } from './checkSupportedPlatform';
3030
import { activateOmniSharp } from './activateOmniSharp';
3131
import { activateRoslyn } from './activateRoslyn';
3232
import { LimitedActivationStatus } from './shared/limitedActivationStatus';
33+
import { registerSolutionFileWorkspaceHandler } from './solutionFileWorkspaceHandler';
3334

3435
export async function activate(
3536
context: vscode.ExtensionContext
@@ -40,6 +41,10 @@ export async function activate(
4041
const csharpChannel = vscode.window.createOutputChannel('C#', { log: true });
4142
csharpChannel.trace('Activating C# Extension');
4243

44+
// Handle the case where a user opens a solution file via `code ./solution.sln`
45+
// This must happen early to redirect to the correct folder before other initialization.
46+
registerSolutionFileWorkspaceHandler(context, csharpChannel);
47+
4348
util.setExtensionPath(context.extension.extensionPath);
4449

4550
const aiKey = context.extension.packageJSON.contributes.debuggers[0].aiKey;
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as path from 'path';
7+
import * as vscode from 'vscode';
8+
9+
/**
10+
* Checks if the given document is a solution file (.sln, .slnx, or .slnf)
11+
*/
12+
export function isSolutionFileOnDisk(document: vscode.TextDocument | undefined): document is vscode.TextDocument {
13+
if (document?.uri.scheme !== 'file') {
14+
return false;
15+
}
16+
17+
const fileName = document.fileName.toLowerCase();
18+
return fileName.endsWith('.sln') || fileName.endsWith('.slnx') || fileName.endsWith('.slnf');
19+
}
20+
21+
/**
22+
* Checks if the given URI is within any of the workspace folders
23+
*/
24+
function isWithinWorkspaceFolders(uri: vscode.Uri): boolean {
25+
const workspaceFolders = vscode.workspace.workspaceFolders;
26+
if (!workspaceFolders) {
27+
return false;
28+
}
29+
30+
const filePath = uri.fsPath;
31+
return workspaceFolders.some((folder) => filePath.startsWith(folder.uri.fsPath));
32+
}
33+
34+
/**
35+
* Handles the scenario where a user opens a solution file via `code ./solution.sln`.
36+
* - If workspaceFolders is empty and the active document is a solution file, opens the parent folder in the current window.
37+
* - If workspaceFolders exist and the active document changes to a solution file outside those folders,
38+
* launches a new window for the parent folder.
39+
*/
40+
export function registerSolutionFileWorkspaceHandler(
41+
context: vscode.ExtensionContext,
42+
csharpChannel: vscode.LogOutputChannel
43+
): void {
44+
// Check on activation if we should open a folder for the current solution file
45+
void checkAndOpenSolutionFolder(csharpChannel);
46+
47+
// Listen for active editor changes to handle solutions opened outside current workspace
48+
context.subscriptions.push(
49+
vscode.window.onDidChangeActiveTextEditor((editor) => {
50+
if (editor) {
51+
void handleActiveEditorChange(editor, csharpChannel);
52+
}
53+
})
54+
);
55+
}
56+
57+
/**
58+
* Checks on extension activation if we should open a folder for the active solution file.
59+
* This handles the case where the user runs `code ./solution.sln` from the command line.
60+
*/
61+
async function checkAndOpenSolutionFolder(csharpChannel: vscode.LogOutputChannel): Promise<void> {
62+
const activeEditor = vscode.window.activeTextEditor;
63+
if (!activeEditor) {
64+
return;
65+
}
66+
67+
await handleActiveEditorChange(activeEditor, csharpChannel);
68+
}
69+
70+
/**
71+
* Handles changes to the active text editor to detect when a solution file outside
72+
* the current workspace is opened.
73+
*/
74+
async function handleActiveEditorChange(
75+
editor: vscode.TextEditor,
76+
csharpChannel: vscode.LogOutputChannel
77+
): Promise<void> {
78+
const document = editor.document;
79+
if (!isSolutionFileOnDisk(document)) {
80+
return;
81+
}
82+
83+
const workspaceFolders = vscode.workspace.workspaceFolders;
84+
const solutionFolderUri = vscode.Uri.file(path.dirname(document.uri.fsPath));
85+
86+
// Case 1: No workspace folders - open the solution's parent folder in the current window
87+
if (!workspaceFolders || workspaceFolders.length === 0) {
88+
csharpChannel.info(
89+
`Opening solution file detected with no workspace. Opening folder: ${solutionFolderUri.fsPath}`
90+
);
91+
await vscode.commands.executeCommand('vscode.openFolder', solutionFolderUri, {
92+
forceReuseWindow: true,
93+
});
94+
}
95+
// Case 2: Workspace folders exist but solution is outside of them - open new window
96+
else if (!isWithinWorkspaceFolders(document.uri)) {
97+
// Close the current editor to avoid confusion
98+
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
99+
csharpChannel.info(
100+
`Solution file outside workspace detected. Opening in new window: ${solutionFolderUri.fsPath}`
101+
);
102+
// open solution folder and solution file in a new window
103+
await vscode.commands.executeCommand('vscode.openFolder', solutionFolderUri, {
104+
forceNewWindow: true,
105+
filesToOpen: [document.uri],
106+
});
107+
}
108+
}

src/vscodeAdapter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,7 @@ export interface vscode {
983983
options: MessageOptions,
984984
...items: T[]
985985
): Thenable<T | undefined>;
986+
onDidChangeActiveTextEditor: Event<TextEditor | undefined>;
986987
};
987988
workspace: {
988989
getConfiguration: (section?: string, resource?: Uri) => WorkspaceConfiguration;

test/fakes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ export function getFakeVsCode(): vscode.vscode {
203203
showErrorMessage: <T extends vscode.MessageItem>(_message: string, ..._items: T[]) => {
204204
throw new Error('Not Implemented');
205205
},
206+
onDidChangeActiveTextEditor: (
207+
_listener: (e: vscode.TextEditor | undefined) => any,
208+
_thisArgs?: any,
209+
_disposables?: vscode.Disposable[]
210+
): vscode.Disposable => {
211+
return { dispose: () => {} };
212+
},
206213
},
207214
workspace: {
208215
workspaceFolders: undefined,
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { describe, test, expect, jest, beforeEach, afterEach } from '@jest/globals';
7+
import * as vscode from 'vscode';
8+
import { registerSolutionFileWorkspaceHandler } from '../../../src/solutionFileWorkspaceHandler';
9+
10+
describe('SolutionFileWorkspaceHandler', () => {
11+
let mockContext: vscode.ExtensionContext;
12+
let mockChannel: vscode.LogOutputChannel;
13+
let subscriptions: vscode.Disposable[];
14+
let executeCommandSpy: jest.SpiedFunction<typeof vscode.commands.executeCommand>;
15+
let onDidChangeActiveTextEditorHandler: ((editor: vscode.TextEditor | undefined) => void) | undefined;
16+
17+
beforeEach(() => {
18+
subscriptions = [];
19+
mockContext = {
20+
subscriptions,
21+
} as unknown as vscode.ExtensionContext;
22+
23+
mockChannel = {
24+
info: jest.fn(),
25+
trace: jest.fn(),
26+
} as unknown as vscode.LogOutputChannel;
27+
28+
// Set up spy on executeCommand
29+
executeCommandSpy = jest
30+
.spyOn(vscode.commands, 'executeCommand')
31+
.mockImplementation(async () => Promise.resolve(undefined));
32+
33+
// Capture the onDidChangeActiveTextEditor handler
34+
onDidChangeActiveTextEditorHandler = undefined;
35+
jest.spyOn(vscode.window, 'onDidChangeActiveTextEditor').mockImplementation((handler) => {
36+
onDidChangeActiveTextEditorHandler = handler as (editor: vscode.TextEditor | undefined) => void;
37+
return { dispose: jest.fn() } as unknown as vscode.Disposable;
38+
});
39+
40+
// Reset workspace state
41+
Object.defineProperty(vscode.workspace, 'workspaceFolders', {
42+
value: undefined,
43+
writable: true,
44+
configurable: true,
45+
});
46+
Object.defineProperty(vscode.window, 'activeTextEditor', {
47+
value: undefined,
48+
writable: true,
49+
configurable: true,
50+
});
51+
});
52+
53+
afterEach(() => {
54+
jest.restoreAllMocks();
55+
});
56+
57+
describe('when no workspace folders exist', () => {
58+
test('should open solution folder in current window when active document is .sln', async () => {
59+
const mockDocument = {
60+
fileName: '/path/to/project.sln',
61+
uri: vscode.Uri.file('/path/to/project.sln'),
62+
} as vscode.TextDocument;
63+
64+
Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: undefined });
65+
Object.defineProperty(vscode.window, 'activeTextEditor', {
66+
value: { document: mockDocument },
67+
});
68+
69+
registerSolutionFileWorkspaceHandler(mockContext, mockChannel);
70+
71+
// Give async operation time to complete
72+
await new Promise((resolve) => setTimeout(resolve, 10));
73+
74+
expect(executeCommandSpy).toHaveBeenCalledWith('vscode.openFolder', expect.anything(), {
75+
forceReuseWindow: true,
76+
});
77+
});
78+
79+
test('should open solution folder in current window when active document is .slnf', async () => {
80+
const mockDocument = {
81+
fileName: '/path/to/project.slnf',
82+
uri: vscode.Uri.file('/path/to/project.slnf'),
83+
} as vscode.TextDocument;
84+
85+
Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: undefined });
86+
Object.defineProperty(vscode.window, 'activeTextEditor', {
87+
value: { document: mockDocument },
88+
});
89+
90+
registerSolutionFileWorkspaceHandler(mockContext, mockChannel);
91+
92+
// Give async operation time to complete
93+
await new Promise((resolve) => setTimeout(resolve, 10));
94+
95+
expect(executeCommandSpy).toHaveBeenCalledWith('vscode.openFolder', expect.anything(), {
96+
forceReuseWindow: true,
97+
});
98+
});
99+
100+
test('should not open folder for non-solution files', async () => {
101+
const mockDocument = {
102+
fileName: '/path/to/file.cs',
103+
uri: vscode.Uri.file('/path/to/file.cs'),
104+
} as vscode.TextDocument;
105+
106+
Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: undefined });
107+
Object.defineProperty(vscode.window, 'activeTextEditor', {
108+
value: { document: mockDocument },
109+
});
110+
111+
registerSolutionFileWorkspaceHandler(mockContext, mockChannel);
112+
113+
// Give async operation time to complete
114+
await new Promise((resolve) => setTimeout(resolve, 10));
115+
116+
expect(executeCommandSpy).not.toHaveBeenCalled();
117+
});
118+
});
119+
120+
describe('when workspace folders exist', () => {
121+
test('should open new window when solution is outside workspace folders', async () => {
122+
const workspaceFolders: vscode.WorkspaceFolder[] = [
123+
{ uri: vscode.Uri.file('/workspace/folder'), name: 'workspace', index: 0 },
124+
];
125+
126+
const mockDocument = {
127+
fileName: '/other/path/project.sln',
128+
uri: vscode.Uri.file('/other/path/project.sln'),
129+
} as vscode.TextDocument;
130+
131+
Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: workspaceFolders });
132+
Object.defineProperty(vscode.window, 'activeTextEditor', { value: undefined });
133+
134+
registerSolutionFileWorkspaceHandler(mockContext, mockChannel);
135+
136+
// Simulate editor change to solution file outside workspace
137+
expect(onDidChangeActiveTextEditorHandler).toBeDefined();
138+
onDidChangeActiveTextEditorHandler!({ document: mockDocument } as vscode.TextEditor);
139+
140+
// Give async operation time to complete
141+
await new Promise((resolve) => setTimeout(resolve, 10));
142+
143+
expect(executeCommandSpy).toHaveBeenCalledWith('vscode.openFolder', expect.anything(), {
144+
forceNewWindow: true,
145+
});
146+
});
147+
148+
test('should not open new window when solution is inside workspace folders', async () => {
149+
const workspaceFolders: vscode.WorkspaceFolder[] = [
150+
{ uri: vscode.Uri.file('/workspace/folder'), name: 'workspace', index: 0 },
151+
];
152+
153+
const mockDocument = {
154+
fileName: '/workspace/folder/project.sln',
155+
uri: vscode.Uri.file('/workspace/folder/project.sln'),
156+
} as vscode.TextDocument;
157+
158+
Object.defineProperty(vscode.workspace, 'workspaceFolders', { value: workspaceFolders });
159+
Object.defineProperty(vscode.window, 'activeTextEditor', { value: undefined });
160+
161+
registerSolutionFileWorkspaceHandler(mockContext, mockChannel);
162+
163+
// Simulate editor change to solution file inside workspace
164+
expect(onDidChangeActiveTextEditorHandler).toBeDefined();
165+
onDidChangeActiveTextEditorHandler!({ document: mockDocument } as vscode.TextEditor);
166+
167+
// Give async operation time to complete
168+
await new Promise((resolve) => setTimeout(resolve, 10));
169+
170+
expect(executeCommandSpy).not.toHaveBeenCalled();
171+
});
172+
});
173+
174+
test('should add disposable to context subscriptions', () => {
175+
registerSolutionFileWorkspaceHandler(mockContext, mockChannel);
176+
177+
expect(subscriptions.length).toBe(1);
178+
});
179+
});

0 commit comments

Comments
 (0)