Skip to content

Commit 1b136a0

Browse files
authored
Add Playgrounds to the project panel (#1967)
* Add Playgrounds to the project panel - Support `workspace/playgrounds` request, persisting the playgrounds in memory and listening for new "swift.play" CodeLens to keep up to date - Display the list of playgrounds in the project panel - Clicking on playground in the project panel will open its location - Provide a play option Issue: #1782 * Better error handling and only fetch if workspace/playground is experimental capability * Context command to run playground * Add test * Fix failing unit test * Fix review comments
1 parent 20eb673 commit 1b136a0

15 files changed

+504
-13
lines changed

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,11 @@
15271527
"command": "swift.coverAllTests",
15281528
"when": "view == projectPanel && viewItem == 'test_runnable'",
15291529
"group": "inline@3"
1530+
},
1531+
{
1532+
"command": "swift.play",
1533+
"when": "view == projectPanel && viewItem == 'playground'",
1534+
"group": "inline@1"
15301535
}
15311536
]
15321537
},

src/FolderContext.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { TestRunProxy } from "./TestExplorer/TestRunner";
2424
import { FolderOperation, WorkspaceContext } from "./WorkspaceContext";
2525
import configuration from "./configuration";
2626
import { SwiftLogger } from "./logging/SwiftLogger";
27+
import { PlaygroundProvider } from "./playgrounds/PlaygroundProvider";
2728
import { TaskQueue } from "./tasks/TaskQueue";
2829
import { SwiftToolchain } from "./toolchain/toolchain";
2930
import { showToolchainError } from "./ui/ToolchainSelection";
@@ -35,6 +36,7 @@ export class FolderContext implements vscode.Disposable {
3536
public taskQueue: TaskQueue;
3637
public testExplorer?: TestExplorer;
3738
public resolvedTestExplorer: Promise<TestExplorer>;
39+
public playgroundProvider?: PlaygroundProvider;
3840
private testExplorerResolver?: (testExplorer: TestExplorer) => void;
3941
private packageWatcher: PackageWatcher;
4042
private testRunManager: TestRunManager;
@@ -247,7 +249,7 @@ export class FolderContext implements vscode.Disposable {
247249
return this.testExplorer;
248250
}
249251

250-
/** Create Test explorer for this folder */
252+
/** Remove Test explorer from this folder */
251253
removeTestExplorer() {
252254
this.testExplorer?.dispose();
253255
this.testExplorer = undefined;
@@ -260,11 +262,35 @@ export class FolderContext implements vscode.Disposable {
260262
}
261263
}
262264

263-
/** Return if package folder has a test explorer */
265+
/** Return `true` if package folder has a test explorer */
264266
hasTestExplorer() {
265267
return this.testExplorer !== undefined;
266268
}
267269

270+
/** Create Playground provider for this folder */
271+
addPlaygroundProvider() {
272+
if (!this.playgroundProvider) {
273+
this.playgroundProvider = new PlaygroundProvider(this);
274+
}
275+
return this.playgroundProvider;
276+
}
277+
278+
/** Refresh the tests in the test explorer for this folder */
279+
async refreshPlaygroundProvider() {
280+
await this.playgroundProvider?.fetch();
281+
}
282+
283+
/** Remove playground provider from this folder */
284+
removePlaygroundProvider() {
285+
this.playgroundProvider?.dispose();
286+
this.playgroundProvider = undefined;
287+
}
288+
289+
/** Return `true` if package folder has a playground provider */
290+
hasPlaygroundProvider() {
291+
return this.playgroundProvider !== undefined;
292+
}
293+
268294
static uriName(uri: vscode.Uri): string {
269295
return path.basename(uri.fsPath);
270296
}
@@ -335,6 +361,25 @@ export class FolderContext implements vscode.Disposable {
335361
void this.testExplorer.getDocumentTests(this, uri, symbols);
336362
}
337363
}
364+
365+
/**
366+
* Called whenever we have new document CodeLens
367+
*/
368+
onDocumentCodeLens(
369+
document: vscode.TextDocument,
370+
codeLens: vscode.CodeLens[] | null | undefined
371+
) {
372+
const uri = document?.uri;
373+
if (
374+
this.playgroundProvider &&
375+
codeLens &&
376+
uri &&
377+
uri.scheme === "file" &&
378+
isPathInsidePath(uri.fsPath, this.folder.fsPath)
379+
) {
380+
void this.playgroundProvider.onDocumentCodeLens(document, codeLens);
381+
}
382+
}
338383
}
339384

340385
export interface EditedPackage {

src/WorkspaceContext.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ export class WorkspaceContext implements vscode.Disposable {
9797
onDocumentSymbols: (folder, document, symbols) => {
9898
folder.onDocumentSymbols(document, symbols);
9999
},
100+
onDocumentCodeLens: (folder, document, codelens) => {
101+
folder.onDocumentCodeLens(document, codelens);
102+
},
100103
});
101104
this.tasks = new TaskManager(this);
102105
this.diagnostics = new DiagnosticsManager(this);

src/commands.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { switchPlatform } from "./commands/switchPlatform";
5151
import { extractTestItemsAndCount, runTestMultipleTimes } from "./commands/testMultipleTimes";
5252
import { SwiftLogger } from "./logging/SwiftLogger";
5353
import { SwiftToolchain } from "./toolchain/toolchain";
54-
import { PackageNode } from "./ui/ProjectPanelProvider";
54+
import { PackageNode, PlaygroundNode } from "./ui/ProjectPanelProvider";
5555
import { showToolchainSelectionQuickPick } from "./ui/ToolchainSelection";
5656

5757
/**
@@ -153,7 +153,11 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] {
153153
if (!folder || !target) {
154154
return false;
155155
}
156-
return await runPlayground(folder, ctx.tasks, target);
156+
return await runPlayground(
157+
folder,
158+
ctx.tasks,
159+
PlaygroundNode.isPlaygroundNode(target) ? target.playground : target
160+
);
157161
}),
158162
vscode.commands.registerCommand(Commands.CLEAN_BUILD, async () => await cleanBuild(ctx)),
159163
vscode.commands.registerCommand(

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { registerDebugger } from "./debugger/debugAdapterFactory";
2828
import * as debug from "./debugger/launch";
2929
import { SwiftLogger } from "./logging/SwiftLogger";
3030
import { SwiftLoggerFactory } from "./logging/SwiftLoggerFactory";
31+
import { PlaygroundProvider } from "./playgrounds/PlaygroundProvider";
3132
import { SwiftEnvironmentVariablesManager, SwiftTerminalProfileProvider } from "./terminal";
3233
import { SelectedXcodeWatcher } from "./toolchain/SelectedXcodeWatcher";
3334
import { checkForSwiftlyInstallation } from "./toolchain/swiftly";
@@ -159,6 +160,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<Api> {
159160
// observer that will resolve package and build launch configurations
160161
context.subscriptions.push(workspaceContext.onDidChangeFolders(handleFolderEvent(logger)));
161162
context.subscriptions.push(TestExplorer.observeFolders(workspaceContext));
163+
context.subscriptions.push(PlaygroundProvider.observeFolders(workspaceContext));
162164

163165
context.subscriptions.push(registerSourceKitSchemaWatcher(workspaceContext));
164166
const subscriptionsElapsed = Date.now() - subscriptionsStartTime;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import { FolderContext } from "../FolderContext";
15+
import { checkExperimentalCapability } from "../sourcekit-lsp/LanguageClientManager";
16+
import { LanguageClientManager } from "../sourcekit-lsp/LanguageClientManager";
17+
import { Playground, WorkspacePlaygroundsRequest } from "../sourcekit-lsp/extensions";
18+
import { Version } from "../utilities/version";
19+
20+
export { Playground };
21+
22+
/**
23+
* Uses document symbol request to keep a running copy of all the test methods
24+
* in a file. When a file is saved it checks to see if any new methods have been
25+
* added, or if any methods have been removed and edits the test items based on
26+
* these results.
27+
*/
28+
export class LSPPlaygroundsDiscovery {
29+
private languageClient: LanguageClientManager;
30+
private toolchainVersion: Version;
31+
32+
constructor(folderContext: FolderContext) {
33+
this.languageClient = folderContext.languageClientManager;
34+
this.toolchainVersion = folderContext.toolchain.swiftVersion;
35+
}
36+
37+
/**
38+
* Return list of workspace playgrounds
39+
*/
40+
async getWorkspacePlaygrounds(): Promise<Playground[]> {
41+
return await this.languageClient.useLanguageClient(async (client, token) => {
42+
// Only use the lsp for this request if it supports the
43+
// workspace/playgrounds method.
44+
if (checkExperimentalCapability(client, WorkspacePlaygroundsRequest.method, 1)) {
45+
return await client.sendRequest(WorkspacePlaygroundsRequest.type, token);
46+
} else {
47+
throw new Error(`${WorkspacePlaygroundsRequest.method} requests not supported`);
48+
}
49+
});
50+
}
51+
52+
async supportsPlaygrounds(): Promise<boolean> {
53+
if (this.toolchainVersion.isLessThan(new Version(6, 3, 0))) {
54+
return false;
55+
}
56+
return await this.languageClient.useLanguageClient(async client => {
57+
return checkExperimentalCapability(client, WorkspacePlaygroundsRequest.method, 1);
58+
});
59+
}
60+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import * as vscode from "vscode";
15+
16+
import { FolderContext } from "../FolderContext";
17+
import { FolderOperation, WorkspaceContext } from "../WorkspaceContext";
18+
import { SwiftLogger } from "../logging/SwiftLogger";
19+
import { LSPPlaygroundsDiscovery, Playground } from "./LSPPlaygroundsDiscovery";
20+
21+
export { Playground };
22+
23+
export interface PlaygroundChangeEvent {
24+
uri: string;
25+
playgrounds: Playground[];
26+
}
27+
28+
/**
29+
* Uses document symbol request to keep a running copy of all the test methods
30+
* in a file. When a file is saved it checks to see if any new methods have been
31+
* added, or if any methods have been removed and edits the test items based on
32+
* these results.
33+
*/
34+
export class PlaygroundProvider implements vscode.Disposable {
35+
private hasFetched: boolean = false;
36+
private fetchPromise: Promise<Playground[]> | undefined;
37+
private documentPlaygrounds: Map<string, Playground[]> = new Map();
38+
private didChangePlaygroundsEmitter: vscode.EventEmitter<PlaygroundChangeEvent> =
39+
new vscode.EventEmitter();
40+
41+
constructor(private folderContext: FolderContext) {}
42+
43+
private get lspPlaygroundDiscovery(): LSPPlaygroundsDiscovery {
44+
return new LSPPlaygroundsDiscovery(this.folderContext);
45+
}
46+
47+
private get logger(): SwiftLogger {
48+
return this.folderContext.workspaceContext.logger;
49+
}
50+
51+
/**
52+
* Create folder observer that creates a PlaygroundProvider when a folder is added and
53+
* discovers available playgrounds when the folder is in focus
54+
* @param workspaceContext Workspace context for extension
55+
* @returns Observer disposable
56+
*/
57+
public static observeFolders(workspaceContext: WorkspaceContext): vscode.Disposable {
58+
return workspaceContext.onDidChangeFolders(({ folder, operation }) => {
59+
switch (operation) {
60+
case FolderOperation.add:
61+
case FolderOperation.packageUpdated:
62+
if (folder) {
63+
void this.setupPlaygroundProviderForFolder(folder);
64+
}
65+
break;
66+
}
67+
});
68+
}
69+
70+
private static async setupPlaygroundProviderForFolder(folder: FolderContext) {
71+
if (!folder.hasPlaygroundProvider()) {
72+
folder.addPlaygroundProvider();
73+
}
74+
await folder.refreshPlaygroundProvider();
75+
}
76+
77+
/**
78+
* Fetch the full list of playgrounds
79+
*/
80+
async getWorkspacePlaygrounds(): Promise<Playground[]> {
81+
if (this.fetchPromise) {
82+
return await this.fetchPromise;
83+
} else if (!this.hasFetched) {
84+
await this.fetch();
85+
}
86+
return Array.from(this.documentPlaygrounds.values()).flatMap(v => v);
87+
}
88+
89+
onDocumentCodeLens(
90+
document: vscode.TextDocument,
91+
codeLens: vscode.CodeLens[] | null | undefined
92+
) {
93+
const playgrounds: Playground[] = (
94+
codeLens?.map(c => (c.command?.arguments ?? [])[0]) ?? []
95+
)
96+
.filter(p => !!p)
97+
// Convert from LSP TextDocumentPlayground to Playground
98+
.map(p => ({
99+
...p,
100+
range: undefined,
101+
location: new vscode.Location(document.uri, p.range),
102+
}));
103+
const uri = document.uri.toString();
104+
if (playgrounds.length > 0) {
105+
this.documentPlaygrounds.set(uri, playgrounds);
106+
this.didChangePlaygroundsEmitter.fire({ uri, playgrounds });
107+
} else {
108+
if (this.documentPlaygrounds.delete(uri)) {
109+
this.didChangePlaygroundsEmitter.fire({ uri, playgrounds: [] });
110+
}
111+
}
112+
}
113+
114+
onDidChangePlaygrounds: vscode.Event<PlaygroundChangeEvent> =
115+
this.didChangePlaygroundsEmitter.event;
116+
117+
async fetch() {
118+
this.hasFetched = true;
119+
if (this.fetchPromise) {
120+
await this.fetchPromise;
121+
return;
122+
}
123+
if (!(await this.lspPlaygroundDiscovery.supportsPlaygrounds())) {
124+
this.logger.debug(
125+
`Fetching playgrounds not supported by the language server`,
126+
this.folderContext.name
127+
);
128+
return;
129+
}
130+
this.fetchPromise = this.lspPlaygroundDiscovery.getWorkspacePlaygrounds();
131+
try {
132+
const playgrounds = await this.fetchPromise;
133+
this.documentPlaygrounds.clear();
134+
for (const playground of playgrounds) {
135+
const uri = playground.location.uri;
136+
this.documentPlaygrounds.set(
137+
uri,
138+
(this.documentPlaygrounds.get(uri) ?? []).concat(playground)
139+
);
140+
}
141+
} catch (error) {
142+
this.logger.error(
143+
`Failed to fetch workspace playgrounds: ${error}`,
144+
this.folderContext.name
145+
);
146+
}
147+
this.fetchPromise = undefined;
148+
}
149+
150+
dispose() {
151+
this.documentPlaygrounds.clear();
152+
}
153+
}

src/sourcekit-lsp/LanguageClientConfiguration.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,8 @@ export function lspClientOptions(
215215
documentSymbolWatcher?: (
216216
document: vscode.TextDocument,
217217
symbols: vscode.DocumentSymbol[]
218-
) => void
218+
) => void,
219+
documentCodeLensWatcher?: (document: vscode.TextDocument, codeLens: vscode.CodeLens[]) => void
219220
): LanguageClientOptions {
220221
return {
221222
documentSelector: LanguagerClientDocumentSelectors.sourcekitLSPDocumentTypes(),
@@ -247,6 +248,9 @@ export function lspClientOptions(
247248
},
248249
provideCodeLenses: async (document, token, next) => {
249250
const result = await next(document, token);
251+
if (documentCodeLensWatcher && result) {
252+
documentCodeLensWatcher(document, result);
253+
}
250254
return result?.map(codelens => {
251255
switch (codelens.command?.command) {
252256
case "swift.run":

0 commit comments

Comments
 (0)