Skip to content

Commit 3e176aa

Browse files
committed
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
1 parent 0653d69 commit 3e176aa

11 files changed

+408
-9
lines changed

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.testExplorer !== 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
@@ -95,6 +95,9 @@ export class WorkspaceContext implements vscode.Disposable {
9595
onDocumentSymbols: (folder, document, symbols) => {
9696
folder.onDocumentSymbols(document, symbols);
9797
},
98+
onDocumentCodeLens: (folder, document, codelens) => {
99+
folder.onDocumentCodeLens(document, codelens);
100+
},
98101
});
99102
this.tasks = new TaskManager(this);
100103
this.diagnostics = new DiagnosticsManager(this);

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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 { checkExperimentalCapability } from "../sourcekit-lsp/LanguageClientManager";
15+
import { LanguageClientManager } from "../sourcekit-lsp/LanguageClientManager";
16+
import { Playground, WorkspacePlaygroundsRequest } from "../sourcekit-lsp/extensions";
17+
18+
export { Playground };
19+
20+
/**
21+
* Uses document symbol request to keep a running copy of all the test methods
22+
* in a file. When a file is saved it checks to see if any new methods have been
23+
* added, or if any methods have been removed and edits the test items based on
24+
* these results.
25+
*/
26+
export class LSPPlaygroundsDiscovery {
27+
constructor(private languageClient: LanguageClientManager) {}
28+
/**
29+
* Return list of workspace playgrounds
30+
*/
31+
async getWorkspacePlaygrounds(): Promise<Playground[]> {
32+
return await this.languageClient.useLanguageClient(async (client, token) => {
33+
// Only use the lsp for this request if it supports the
34+
// workspace/playgrounds method.
35+
if (checkExperimentalCapability(client, WorkspacePlaygroundsRequest.method, 1)) {
36+
return await client.sendRequest(WorkspacePlaygroundsRequest.type, token);
37+
} else {
38+
throw new Error(`${WorkspacePlaygroundsRequest.method} requests not supported`);
39+
}
40+
});
41+
}
42+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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 { LSPPlaygroundsDiscovery, Playground } from "./LSPPlaygroundsDiscovery";
19+
20+
export { Playground };
21+
22+
export interface PlaygroundChangeEvent {
23+
uri: string;
24+
playgrounds: Playground[];
25+
}
26+
27+
/**
28+
* Uses document symbol request to keep a running copy of all the test methods
29+
* in a file. When a file is saved it checks to see if any new methods have been
30+
* added, or if any methods have been removed and edits the test items based on
31+
* these results.
32+
*/
33+
export class PlaygroundProvider implements vscode.Disposable {
34+
private fetchPromise: Promise<Playground[]> | undefined;
35+
private documentPlaygrounds: Map<string, Playground[]> = new Map();
36+
private didChangePlaygroundsEmitter: vscode.EventEmitter<PlaygroundChangeEvent> =
37+
new vscode.EventEmitter();
38+
39+
constructor(private folderContext: FolderContext) {}
40+
41+
private get lspPlaygroundDiscovery(): LSPPlaygroundsDiscovery {
42+
return new LSPPlaygroundsDiscovery(this.folderContext.languageClientManager);
43+
}
44+
45+
/**
46+
* Create folder observer that creates a PlaygroundProvider when a folder is added and
47+
* discovers available playgrounds when the folder is in focus
48+
* @param workspaceContext Workspace context for extension
49+
* @returns Observer disposable
50+
*/
51+
public static observeFolders(workspaceContext: WorkspaceContext): vscode.Disposable {
52+
return workspaceContext.onDidChangeFolders(({ folder, operation }) => {
53+
switch (operation) {
54+
case FolderOperation.add:
55+
case FolderOperation.packageUpdated:
56+
if (folder) {
57+
void this.setupPlaygroundProviderForFolder(folder);
58+
}
59+
break;
60+
}
61+
});
62+
}
63+
64+
private static async setupPlaygroundProviderForFolder(folder: FolderContext) {
65+
if (!folder.hasPlaygroundProvider()) {
66+
folder.addPlaygroundProvider();
67+
}
68+
await folder.refreshPlaygroundProvider();
69+
}
70+
71+
/**
72+
* Fetch the full list of playgrounds
73+
*/
74+
async getWorkspacePlaygrounds(): Promise<Playground[]> {
75+
if (this.fetchPromise) {
76+
return await this.fetchPromise;
77+
}
78+
return Array.from(this.documentPlaygrounds.values()).flatMap(v => v);
79+
}
80+
81+
onDocumentCodeLens(
82+
document: vscode.TextDocument,
83+
codeLens: vscode.CodeLens[] | null | undefined
84+
) {
85+
const playgrounds: Playground[] = (
86+
codeLens?.map(c => (c.command?.arguments ?? [])[0]) ?? []
87+
)
88+
.filter(p => !!p)
89+
// Convert from LSP TextDocumentPlayground to Playground
90+
.map(p => ({
91+
...p,
92+
range: undefined,
93+
location: new vscode.Location(document.uri, p.range),
94+
}));
95+
const uri = document.uri.toString();
96+
if (playgrounds.length > 0) {
97+
this.documentPlaygrounds.set(uri, playgrounds);
98+
this.didChangePlaygroundsEmitter.fire({ uri, playgrounds });
99+
} else {
100+
if (this.documentPlaygrounds.delete(uri)) {
101+
this.didChangePlaygroundsEmitter.fire({ uri, playgrounds: [] });
102+
}
103+
}
104+
}
105+
106+
onDidChangePlaygrounds: vscode.Event<PlaygroundChangeEvent> =
107+
this.didChangePlaygroundsEmitter.event;
108+
109+
async fetch() {
110+
this.fetchPromise = this.lspPlaygroundDiscovery.getWorkspacePlaygrounds();
111+
const playgrounds = await this.fetchPromise;
112+
this.documentPlaygrounds.clear();
113+
for (const playground of playgrounds) {
114+
const uri = playground.location.uri;
115+
this.documentPlaygrounds.set(
116+
uri,
117+
(this.documentPlaygrounds.get(uri) ?? []).concat(playground)
118+
);
119+
}
120+
this.fetchPromise = undefined;
121+
}
122+
123+
dispose() {
124+
this.documentPlaygrounds.clear();
125+
}
126+
}

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":

src/sourcekit-lsp/LanguageClientManager.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,21 @@ import { activateGetReferenceDocument } from "./getReferenceDocument";
4343
import { activateLegacyInlayHints } from "./inlayHints";
4444
import { activatePeekDocuments } from "./peekDocuments";
4545

46+
/**
47+
* Options for the LanguageClientManager
48+
*/
4649
interface LanguageClientManageOptions {
47-
/**
48-
* Options for the LanguageClientManager
49-
*/
5050
onDocumentSymbols?: (
5151
folder: FolderContext,
5252
document: vscode.TextDocument,
5353
symbols: vscode.DocumentSymbol[] | null | undefined
5454
) => void;
55+
56+
onDocumentCodeLens?: (
57+
folder: FolderContext,
58+
document: vscode.TextDocument,
59+
codeLens: vscode.CodeLens[] | null | undefined
60+
) => void;
5561
}
5662

5763
/**
@@ -472,6 +478,18 @@ export class LanguageClientManager implements vscode.Disposable {
472478
return;
473479
}
474480
this.options.onDocumentSymbols?.(documentFolderContext, document, symbols);
481+
},
482+
(document, codeLens) => {
483+
const documentFolderContext = [this.folderContext, ...this.addedFolders].find(
484+
folderContext => document.uri.fsPath.startsWith(folderContext.folder.fsPath)
485+
);
486+
if (!documentFolderContext) {
487+
this.languageClientOutputChannel?.warn(
488+
"Unable to find folder for document: " + document.uri.fsPath
489+
);
490+
return;
491+
}
492+
this.options.onDocumentCodeLens?.(documentFolderContext, document, codeLens);
475493
}
476494
);
477495

src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export class LanguageClientToolchainCoordinator implements vscode.Disposable {
3939
document: vscode.TextDocument,
4040
symbols: vscode.DocumentSymbol[] | null | undefined
4141
) => void;
42+
onDocumentCodeLens?: (
43+
folder: FolderContext,
44+
document: vscode.TextDocument,
45+
symbols: vscode.CodeLens[] | null | undefined
46+
) => void;
4247
} = {},
4348
languageClientFactory: LanguageClientFactory = new LanguageClientFactory() // used for testing only
4449
) {

0 commit comments

Comments
 (0)