From a6c4603dcb1986a2f51f5e3c08fad30699b811de Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Thu, 27 Nov 2025 15:32:02 -0500 Subject: [PATCH 1/6] 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 --- src/FolderContext.ts | 49 ++++++- src/WorkspaceContext.ts | 3 + src/extension.ts | 2 + src/playgrounds/LSPPlaygroundsDiscovery.ts | 42 ++++++ src/playgrounds/PlaygroundProvider.ts | 126 ++++++++++++++++++ .../LanguageClientConfiguration.ts | 6 +- src/sourcekit-lsp/LanguageClientManager.ts | 24 +++- .../LanguageClientToolchainCoordinator.ts | 5 + .../extensions/WorkspacePlaygroundsRequest.ts | 57 ++++++++ src/sourcekit-lsp/extensions/index.ts | 1 + src/ui/ProjectPanelProvider.ts | 102 +++++++++++++- 11 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 src/playgrounds/LSPPlaygroundsDiscovery.ts create mode 100644 src/playgrounds/PlaygroundProvider.ts create mode 100644 src/sourcekit-lsp/extensions/WorkspacePlaygroundsRequest.ts diff --git a/src/FolderContext.ts b/src/FolderContext.ts index 391ed4b10..ad3ef5350 100644 --- a/src/FolderContext.ts +++ b/src/FolderContext.ts @@ -24,6 +24,7 @@ import { TestRunProxy } from "./TestExplorer/TestRunner"; import { FolderOperation, WorkspaceContext } from "./WorkspaceContext"; import configuration from "./configuration"; import { SwiftLogger } from "./logging/SwiftLogger"; +import { PlaygroundProvider } from "./playgrounds/PlaygroundProvider"; import { TaskQueue } from "./tasks/TaskQueue"; import { SwiftToolchain } from "./toolchain/toolchain"; import { showToolchainError } from "./ui/ToolchainSelection"; @@ -35,6 +36,7 @@ export class FolderContext implements vscode.Disposable { public taskQueue: TaskQueue; public testExplorer?: TestExplorer; public resolvedTestExplorer: Promise; + public playgroundProvider?: PlaygroundProvider; private testExplorerResolver?: (testExplorer: TestExplorer) => void; private packageWatcher: PackageWatcher; private testRunManager: TestRunManager; @@ -247,7 +249,7 @@ export class FolderContext implements vscode.Disposable { return this.testExplorer; } - /** Create Test explorer for this folder */ + /** Remove Test explorer from this folder */ removeTestExplorer() { this.testExplorer?.dispose(); this.testExplorer = undefined; @@ -260,11 +262,35 @@ export class FolderContext implements vscode.Disposable { } } - /** Return if package folder has a test explorer */ + /** Return `true` if package folder has a test explorer */ hasTestExplorer() { return this.testExplorer !== undefined; } + /** Create Playground provider for this folder */ + addPlaygroundProvider() { + if (!this.playgroundProvider) { + this.playgroundProvider = new PlaygroundProvider(this); + } + return this.playgroundProvider; + } + + /** Refresh the tests in the test explorer for this folder */ + async refreshPlaygroundProvider() { + await this.playgroundProvider?.fetch(); + } + + /** Remove playground provider from this folder */ + removePlaygroundProvider() { + this.playgroundProvider?.dispose(); + this.playgroundProvider = undefined; + } + + /** Return `true` if package folder has a playground provider */ + hasPlaygroundProvider() { + return this.testExplorer !== undefined; + } + static uriName(uri: vscode.Uri): string { return path.basename(uri.fsPath); } @@ -335,6 +361,25 @@ export class FolderContext implements vscode.Disposable { void this.testExplorer.getDocumentTests(this, uri, symbols); } } + + /** + * Called whenever we have new document CodeLens + */ + onDocumentCodeLens( + document: vscode.TextDocument, + codeLens: vscode.CodeLens[] | null | undefined + ) { + const uri = document?.uri; + if ( + this.playgroundProvider && + codeLens && + uri && + uri.scheme === "file" && + isPathInsidePath(uri.fsPath, this.folder.fsPath) + ) { + void this.playgroundProvider.onDocumentCodeLens(document, codeLens); + } + } } export interface EditedPackage { diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index 525a5c1dd..d448e31bc 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -95,6 +95,9 @@ export class WorkspaceContext implements vscode.Disposable { onDocumentSymbols: (folder, document, symbols) => { folder.onDocumentSymbols(document, symbols); }, + onDocumentCodeLens: (folder, document, codelens) => { + folder.onDocumentCodeLens(document, codelens); + }, }); this.tasks = new TaskManager(this); this.diagnostics = new DiagnosticsManager(this); diff --git a/src/extension.ts b/src/extension.ts index cd2727ca4..ab9760e81 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,7 @@ import { registerDebugger } from "./debugger/debugAdapterFactory"; import * as debug from "./debugger/launch"; import { SwiftLogger } from "./logging/SwiftLogger"; import { SwiftLoggerFactory } from "./logging/SwiftLoggerFactory"; +import { PlaygroundProvider } from "./playgrounds/PlaygroundProvider"; import { SwiftEnvironmentVariablesManager, SwiftTerminalProfileProvider } from "./terminal"; import { SelectedXcodeWatcher } from "./toolchain/SelectedXcodeWatcher"; import { checkForSwiftlyInstallation } from "./toolchain/swiftly"; @@ -159,6 +160,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { // observer that will resolve package and build launch configurations context.subscriptions.push(workspaceContext.onDidChangeFolders(handleFolderEvent(logger))); context.subscriptions.push(TestExplorer.observeFolders(workspaceContext)); + context.subscriptions.push(PlaygroundProvider.observeFolders(workspaceContext)); context.subscriptions.push(registerSourceKitSchemaWatcher(workspaceContext)); const subscriptionsElapsed = Date.now() - subscriptionsStartTime; diff --git a/src/playgrounds/LSPPlaygroundsDiscovery.ts b/src/playgrounds/LSPPlaygroundsDiscovery.ts new file mode 100644 index 000000000..b1d7710d7 --- /dev/null +++ b/src/playgrounds/LSPPlaygroundsDiscovery.ts @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import { checkExperimentalCapability } from "../sourcekit-lsp/LanguageClientManager"; +import { LanguageClientManager } from "../sourcekit-lsp/LanguageClientManager"; +import { Playground, WorkspacePlaygroundsRequest } from "../sourcekit-lsp/extensions"; + +export { Playground }; + +/** + * Uses document symbol request to keep a running copy of all the test methods + * in a file. When a file is saved it checks to see if any new methods have been + * added, or if any methods have been removed and edits the test items based on + * these results. + */ +export class LSPPlaygroundsDiscovery { + constructor(private languageClient: LanguageClientManager) {} + /** + * Return list of workspace playgrounds + */ + async getWorkspacePlaygrounds(): Promise { + return await this.languageClient.useLanguageClient(async (client, token) => { + // Only use the lsp for this request if it supports the + // workspace/playgrounds method. + if (checkExperimentalCapability(client, WorkspacePlaygroundsRequest.method, 1)) { + return await client.sendRequest(WorkspacePlaygroundsRequest.type, token); + } else { + throw new Error(`${WorkspacePlaygroundsRequest.method} requests not supported`); + } + }); + } +} diff --git a/src/playgrounds/PlaygroundProvider.ts b/src/playgrounds/PlaygroundProvider.ts new file mode 100644 index 000000000..3eaf986c7 --- /dev/null +++ b/src/playgrounds/PlaygroundProvider.ts @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import * as vscode from "vscode"; + +import { FolderContext } from "../FolderContext"; +import { FolderOperation, WorkspaceContext } from "../WorkspaceContext"; +import { LSPPlaygroundsDiscovery, Playground } from "./LSPPlaygroundsDiscovery"; + +export { Playground }; + +export interface PlaygroundChangeEvent { + uri: string; + playgrounds: Playground[]; +} + +/** + * Uses document symbol request to keep a running copy of all the test methods + * in a file. When a file is saved it checks to see if any new methods have been + * added, or if any methods have been removed and edits the test items based on + * these results. + */ +export class PlaygroundProvider implements vscode.Disposable { + private fetchPromise: Promise | undefined; + private documentPlaygrounds: Map = new Map(); + private didChangePlaygroundsEmitter: vscode.EventEmitter = + new vscode.EventEmitter(); + + constructor(private folderContext: FolderContext) {} + + private get lspPlaygroundDiscovery(): LSPPlaygroundsDiscovery { + return new LSPPlaygroundsDiscovery(this.folderContext.languageClientManager); + } + + /** + * Create folder observer that creates a PlaygroundProvider when a folder is added and + * discovers available playgrounds when the folder is in focus + * @param workspaceContext Workspace context for extension + * @returns Observer disposable + */ + public static observeFolders(workspaceContext: WorkspaceContext): vscode.Disposable { + return workspaceContext.onDidChangeFolders(({ folder, operation }) => { + switch (operation) { + case FolderOperation.add: + case FolderOperation.packageUpdated: + if (folder) { + void this.setupPlaygroundProviderForFolder(folder); + } + break; + } + }); + } + + private static async setupPlaygroundProviderForFolder(folder: FolderContext) { + if (!folder.hasPlaygroundProvider()) { + folder.addPlaygroundProvider(); + } + await folder.refreshPlaygroundProvider(); + } + + /** + * Fetch the full list of playgrounds + */ + async getWorkspacePlaygrounds(): Promise { + if (this.fetchPromise) { + return await this.fetchPromise; + } + return Array.from(this.documentPlaygrounds.values()).flatMap(v => v); + } + + onDocumentCodeLens( + document: vscode.TextDocument, + codeLens: vscode.CodeLens[] | null | undefined + ) { + const playgrounds: Playground[] = ( + codeLens?.map(c => (c.command?.arguments ?? [])[0]) ?? [] + ) + .filter(p => !!p) + // Convert from LSP TextDocumentPlayground to Playground + .map(p => ({ + ...p, + range: undefined, + location: new vscode.Location(document.uri, p.range), + })); + const uri = document.uri.toString(); + if (playgrounds.length > 0) { + this.documentPlaygrounds.set(uri, playgrounds); + this.didChangePlaygroundsEmitter.fire({ uri, playgrounds }); + } else { + if (this.documentPlaygrounds.delete(uri)) { + this.didChangePlaygroundsEmitter.fire({ uri, playgrounds: [] }); + } + } + } + + onDidChangePlaygrounds: vscode.Event = + this.didChangePlaygroundsEmitter.event; + + async fetch() { + this.fetchPromise = this.lspPlaygroundDiscovery.getWorkspacePlaygrounds(); + const playgrounds = await this.fetchPromise; + this.documentPlaygrounds.clear(); + for (const playground of playgrounds) { + const uri = playground.location.uri; + this.documentPlaygrounds.set( + uri, + (this.documentPlaygrounds.get(uri) ?? []).concat(playground) + ); + } + this.fetchPromise = undefined; + } + + dispose() { + this.documentPlaygrounds.clear(); + } +} diff --git a/src/sourcekit-lsp/LanguageClientConfiguration.ts b/src/sourcekit-lsp/LanguageClientConfiguration.ts index fcc570140..8a08a9eff 100644 --- a/src/sourcekit-lsp/LanguageClientConfiguration.ts +++ b/src/sourcekit-lsp/LanguageClientConfiguration.ts @@ -215,7 +215,8 @@ export function lspClientOptions( documentSymbolWatcher?: ( document: vscode.TextDocument, symbols: vscode.DocumentSymbol[] - ) => void + ) => void, + documentCodeLensWatcher?: (document: vscode.TextDocument, codeLens: vscode.CodeLens[]) => void ): LanguageClientOptions { return { documentSelector: LanguagerClientDocumentSelectors.sourcekitLSPDocumentTypes(), @@ -247,6 +248,9 @@ export function lspClientOptions( }, provideCodeLenses: async (document, token, next) => { const result = await next(document, token); + if (documentCodeLensWatcher && result) { + documentCodeLensWatcher(document, result); + } return result?.map(codelens => { switch (codelens.command?.command) { case "swift.run": diff --git a/src/sourcekit-lsp/LanguageClientManager.ts b/src/sourcekit-lsp/LanguageClientManager.ts index 13ec7d139..8e7f22b21 100644 --- a/src/sourcekit-lsp/LanguageClientManager.ts +++ b/src/sourcekit-lsp/LanguageClientManager.ts @@ -43,15 +43,21 @@ import { activateGetReferenceDocument } from "./getReferenceDocument"; import { activateLegacyInlayHints } from "./inlayHints"; import { activatePeekDocuments } from "./peekDocuments"; +/** + * Options for the LanguageClientManager + */ interface LanguageClientManageOptions { - /** - * Options for the LanguageClientManager - */ onDocumentSymbols?: ( folder: FolderContext, document: vscode.TextDocument, symbols: vscode.DocumentSymbol[] | null | undefined ) => void; + + onDocumentCodeLens?: ( + folder: FolderContext, + document: vscode.TextDocument, + codeLens: vscode.CodeLens[] | null | undefined + ) => void; } /** @@ -472,6 +478,18 @@ export class LanguageClientManager implements vscode.Disposable { return; } this.options.onDocumentSymbols?.(documentFolderContext, document, symbols); + }, + (document, codeLens) => { + const documentFolderContext = [this.folderContext, ...this.addedFolders].find( + folderContext => document.uri.fsPath.startsWith(folderContext.folder.fsPath) + ); + if (!documentFolderContext) { + this.languageClientOutputChannel?.warn( + "Unable to find folder for document: " + document.uri.fsPath + ); + return; + } + this.options.onDocumentCodeLens?.(documentFolderContext, document, codeLens); } ); diff --git a/src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts b/src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts index 44800f876..a9d2fc458 100644 --- a/src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts +++ b/src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts @@ -39,6 +39,11 @@ export class LanguageClientToolchainCoordinator implements vscode.Disposable { document: vscode.TextDocument, symbols: vscode.DocumentSymbol[] | null | undefined ) => void; + onDocumentCodeLens?: ( + folder: FolderContext, + document: vscode.TextDocument, + symbols: vscode.CodeLens[] | null | undefined + ) => void; } = {}, languageClientFactory: LanguageClientFactory = new LanguageClientFactory() // used for testing only ) { diff --git a/src/sourcekit-lsp/extensions/WorkspacePlaygroundsRequest.ts b/src/sourcekit-lsp/extensions/WorkspacePlaygroundsRequest.ts new file mode 100644 index 000000000..e663e6fda --- /dev/null +++ b/src/sourcekit-lsp/extensions/WorkspacePlaygroundsRequest.ts @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +// We use namespaces to store request information just like vscode-languageclient +/* eslint-disable @typescript-eslint/no-namespace */ +import { Location, MessageDirection, RequestType0 } from "vscode-languageclient"; + +/** Represents a single test returned from a {@link WorkspacePlaygroundsRequest}. */ +export interface Playground { + /** + * Unique identifier for the `Playground` with the format `/::[column]` where `target` + * corresponds to the Swift package's target where the playground is defined, `filename` is the basename of the file + * (not entire relative path), and `column` is optional only required if multiple playgrounds are defined on the same + * line. Client can run the playground by executing `swift play `. + * + * This property is always present whether the `Playground` has a `label` or not. + * + * Follows the format output by `swift play --list`. + */ + id: string; + + /** + * The label that can be used as a display name for the playground. This optional property is only available + * for named playgrounds. For example: `#Playground("hello") { print("Hello!) }` would have a `label` of `"hello"`. + */ + label?: string; + + /** + * The location of where the #Playground macro was used in the source code. + */ + location: Location; +} + +/** + * A request that returns symbols for all the playgrounds within the current workspace. + * + * ### LSP Extension + * + * This request is an extension to LSP supported by SourceKit-LSP. + * + * It requires the experimental client capability `"workspace/playgrounds"` to use. + */ +export namespace WorkspacePlaygroundsRequest { + export const method = "workspace/playgrounds" as const; + export const messageDirection: MessageDirection = MessageDirection.clientToServer; + export const type = new RequestType0(method); +} diff --git a/src/sourcekit-lsp/extensions/index.ts b/src/sourcekit-lsp/extensions/index.ts index 346eac36c..2db75614a 100644 --- a/src/sourcekit-lsp/extensions/index.ts +++ b/src/sourcekit-lsp/extensions/index.ts @@ -22,3 +22,4 @@ export * from "./PeekDocumentsRequest"; export * from "./ReIndexProjectRequest"; export * from "./SourceKitLogMessageNotification"; export * from "./SymbolInfoRequest"; +export * from "./WorkspacePlaygroundsRequest"; diff --git a/src/ui/ProjectPanelProvider.ts b/src/ui/ProjectPanelProvider.ts index a5399479f..1998901c9 100644 --- a/src/ui/ProjectPanelProvider.ts +++ b/src/ui/ProjectPanelProvider.ts @@ -22,6 +22,7 @@ import { Dependency, ResolvedDependency, Target } from "../SwiftPackage"; import { WorkspaceContext } from "../WorkspaceContext"; import { FolderOperation } from "../WorkspaceContext"; import configuration from "../configuration"; +import { Playground } from "../playgrounds/PlaygroundProvider"; import { SwiftTask, TaskPlatformSpecificConfig } from "../tasks/SwiftTaskProvider"; import { getPlatformConfig, resolveTaskCwd } from "../utilities/tasks"; import { Version } from "../utilities/version"; @@ -399,6 +400,53 @@ class TargetNode { } } +class PlaygroundNode { + constructor( + public playground: Playground, + private folder: FolderContext, + private activeTasks: Set + ) {} + + get name(): string { + return this.playground.label ?? this.playground.id; + } + + get args(): string[] { + return [this.name]; + } + + toTreeItem(): vscode.TreeItem { + const name = this.name; + const item = new vscode.TreeItem(this.name, vscode.TreeItemCollapsibleState.None); + item.id = `${this.folder.name}:${this.playground.id}`; + item.iconPath = new vscode.ThemeIcon(this.icon()); + item.contextValue = "playground"; + item.accessibilityInformation = { label: name }; + item.tooltip = `${this.name} (${this.folder.name})`; + item.command = { + title: "Open Playground", + command: "vscode.openWith", + arguments: [ + vscode.Uri.parse(this.playground.location.uri), + "default", + { selection: this.playground.location.range }, + ], + }; + return item; + } + + private icon(): string { + if (this.activeTasks.has(this.name)) { + return LOADING_ICON; + } + return "symbol-numeric"; + } + + getChildren(): TreeNode[] { + return []; + } +} + class HeaderNode { constructor( private id: string, @@ -462,7 +510,14 @@ class ErrorNode { * * Can be either a {@link PackageNode}, {@link FileNode}, {@link TargetNode}, {@link TaskNode}, {@link ErrorNode} or {@link HeaderNode}. */ -export type TreeNode = PackageNode | FileNode | HeaderNode | TaskNode | TargetNode | ErrorNode; +export type TreeNode = + | PackageNode + | FileNode + | HeaderNode + | TaskNode + | TargetNode + | PlaygroundNode + | ErrorNode; /** * A {@link vscode.TreeDataProvider TreeDataProvider} for project dependencies, tasks and commands {@link vscode.TreeView TreeView}. @@ -477,6 +532,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { private lastComputedNodes: TreeNode[] = []; private buildPluginOutputWatcher?: vscode.FileSystemWatcher; private buildPluginFolderWatcher?: vscode.Disposable; + private playgroundWatcher?: vscode.Disposable; onDidChangeTreeData = this.didChangeTreeDataEmitter.event; @@ -491,6 +547,8 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { dispose() { this.workspaceObserver?.dispose(); + this.buildPluginFolderWatcher?.dispose(); + this.playgroundWatcher?.dispose(); this.disposables.forEach(d => d.dispose()); this.disposables.length = 0; } @@ -576,7 +634,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { if (!folder) { return; } - this.watchBuildPluginOutputs(folder); + this.observeFolder(folder); treeView.title = `Swift Project (${folder.name})`; this.workspaceContext.logger.info( `Project panel updating, focused folder ${folder.name}` @@ -611,7 +669,12 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { ); } - watchBuildPluginOutputs(folderContext: FolderContext) { + observeFolder(folderContext: FolderContext) { + this.watchBuildPluginOutputs(folderContext); + this.watchPlaygrounds(folderContext); + } + + private watchBuildPluginOutputs(folderContext: FolderContext) { if (this.buildPluginOutputWatcher) { this.buildPluginOutputWatcher.dispose(); } @@ -638,6 +701,19 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { ); } + private watchPlaygrounds(folderContext: FolderContext) { + if (this.playgroundWatcher) { + this.playgroundWatcher.dispose(); + } + + const playgroundProvider = folderContext.playgroundProvider; + if (playgroundProvider) { + this.playgroundWatcher = playgroundProvider.onDidChangePlaygrounds(() => + this.didChangeTreeDataEmitter.fire() + ); + } + } + getTreeItem(element: TreeNode): vscode.TreeItem { return element.toTreeItem(); } @@ -674,6 +750,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { const commands = await this.commands(); const targets = await this.targets(); const tasks = await this.tasks(folderContext); + const playgrounds = await this.playgrounds(); // TODO: Control ordering return [ @@ -711,6 +788,13 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { ), ] : []), + ...(playgrounds.length > 0 + ? [ + new HeaderNode("playgrounds", "Playgrounds", "symbol-numeric", () => + Promise.resolve(playgrounds) + ), + ] + : []), ]; } @@ -819,6 +903,18 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { .flatMap(target => new TargetNode(target, folderContext, this.activeTasks)) .sort((a, b) => a.name.localeCompare(b.name)); } + + private async playgrounds(): Promise { + const folderContext = this.workspaceContext.currentFolder; + if (!folderContext) { + return []; + } + const playgrounds = + (await folderContext.playgroundProvider?.getWorkspacePlaygrounds()) ?? []; + return playgrounds.flatMap( + playground => new PlaygroundNode(playground, folderContext, this.activeTasks) + ); + } } /* From bef05f9fed9ff8638142810c15405278576b6376 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 28 Nov 2025 08:28:47 -0500 Subject: [PATCH 2/6] Better error handling and only fetch if workspace/playground is experimental capability --- src/playgrounds/LSPPlaygroundsDiscovery.ts | 15 ++++++++++ src/playgrounds/PlaygroundProvider.ts | 33 +++++++++++++++++----- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/playgrounds/LSPPlaygroundsDiscovery.ts b/src/playgrounds/LSPPlaygroundsDiscovery.ts index b1d7710d7..1ae33c505 100644 --- a/src/playgrounds/LSPPlaygroundsDiscovery.ts +++ b/src/playgrounds/LSPPlaygroundsDiscovery.ts @@ -14,6 +14,7 @@ import { checkExperimentalCapability } from "../sourcekit-lsp/LanguageClientManager"; import { LanguageClientManager } from "../sourcekit-lsp/LanguageClientManager"; import { Playground, WorkspacePlaygroundsRequest } from "../sourcekit-lsp/extensions"; +import { Version } from "../utilities/version"; export { Playground }; @@ -25,6 +26,11 @@ export { Playground }; */ export class LSPPlaygroundsDiscovery { constructor(private languageClient: LanguageClientManager) {} + + private get toolchainVersion(): Version { + return this.languageClient.folderContext.toolchain.swiftVersion; + } + /** * Return list of workspace playgrounds */ @@ -39,4 +45,13 @@ export class LSPPlaygroundsDiscovery { } }); } + + async supportsPlaygrounds(): Promise { + if (this.toolchainVersion.isLessThan(new Version(6, 3, 0))) { + return false; + } + return await this.languageClient.useLanguageClient(async client => { + return checkExperimentalCapability(client, WorkspacePlaygroundsRequest.method, 1); + }); + } } diff --git a/src/playgrounds/PlaygroundProvider.ts b/src/playgrounds/PlaygroundProvider.ts index 3eaf986c7..6ec7ef10e 100644 --- a/src/playgrounds/PlaygroundProvider.ts +++ b/src/playgrounds/PlaygroundProvider.ts @@ -15,6 +15,7 @@ import * as vscode from "vscode"; import { FolderContext } from "../FolderContext"; import { FolderOperation, WorkspaceContext } from "../WorkspaceContext"; +import { SwiftLogger } from "../logging/SwiftLogger"; import { LSPPlaygroundsDiscovery, Playground } from "./LSPPlaygroundsDiscovery"; export { Playground }; @@ -42,6 +43,10 @@ export class PlaygroundProvider implements vscode.Disposable { return new LSPPlaygroundsDiscovery(this.folderContext.languageClientManager); } + private get logger(): SwiftLogger { + return this.folderContext.workspaceContext.logger; + } + /** * Create folder observer that creates a PlaygroundProvider when a folder is added and * discovers available playgrounds when the folder is in focus @@ -107,14 +112,28 @@ export class PlaygroundProvider implements vscode.Disposable { this.didChangePlaygroundsEmitter.event; async fetch() { + if (!(await this.lspPlaygroundDiscovery.supportsPlaygrounds())) { + this.logger.debug( + `Fetching playgrounds not supported by the language server`, + this.folderContext.name + ); + return; + } this.fetchPromise = this.lspPlaygroundDiscovery.getWorkspacePlaygrounds(); - const playgrounds = await this.fetchPromise; - this.documentPlaygrounds.clear(); - for (const playground of playgrounds) { - const uri = playground.location.uri; - this.documentPlaygrounds.set( - uri, - (this.documentPlaygrounds.get(uri) ?? []).concat(playground) + try { + const playgrounds = await this.fetchPromise; + this.documentPlaygrounds.clear(); + for (const playground of playgrounds) { + const uri = playground.location.uri; + this.documentPlaygrounds.set( + uri, + (this.documentPlaygrounds.get(uri) ?? []).concat(playground) + ); + } + } catch (error) { + this.logger.error( + `Failed to fetch workspace playgrounds: ${error}`, + this.folderContext.name ); } this.fetchPromise = undefined; From c14261e83ab8dacf51b23c00787f304f1fc401b1 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 28 Nov 2025 12:13:44 -0500 Subject: [PATCH 3/6] Context command to run playground --- package.json | 5 +++++ src/commands.ts | 8 ++++++-- src/ui/ProjectPanelProvider.ts | 25 ++++++++++++++++++++----- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index bd05ebc80..dc92b1d6b 100644 --- a/package.json +++ b/package.json @@ -1527,6 +1527,11 @@ "command": "swift.coverAllTests", "when": "view == projectPanel && viewItem == 'test_runnable'", "group": "inline@3" + }, + { + "command": "swift.play", + "when": "view == projectPanel && viewItem == 'playground'", + "group": "inline@1" } ] }, diff --git a/src/commands.ts b/src/commands.ts index 293f152a4..707aaa2b0 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -51,7 +51,7 @@ import { switchPlatform } from "./commands/switchPlatform"; import { extractTestItemsAndCount, runTestMultipleTimes } from "./commands/testMultipleTimes"; import { SwiftLogger } from "./logging/SwiftLogger"; import { SwiftToolchain } from "./toolchain/toolchain"; -import { PackageNode } from "./ui/ProjectPanelProvider"; +import { PackageNode, PlaygroundNode } from "./ui/ProjectPanelProvider"; import { showToolchainSelectionQuickPick } from "./ui/ToolchainSelection"; /** @@ -153,7 +153,11 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { if (!folder || !target) { return false; } - return await runPlayground(folder, ctx.tasks, target); + return await runPlayground( + folder, + ctx.tasks, + PlaygroundNode.isPlaygroundNode(target) ? target.playground : target + ); }), vscode.commands.registerCommand(Commands.CLEAN_BUILD, async () => await cleanBuild(ctx)), vscode.commands.registerCommand( diff --git a/src/ui/ProjectPanelProvider.ts b/src/ui/ProjectPanelProvider.ts index 1998901c9..61c643bd2 100644 --- a/src/ui/ProjectPanelProvider.ts +++ b/src/ui/ProjectPanelProvider.ts @@ -400,21 +400,36 @@ class TargetNode { } } -class PlaygroundNode { +export class PlaygroundNode { constructor( public playground: Playground, private folder: FolderContext, private activeTasks: Set ) {} + /** + * "instanceof" has a bad effect in our nightly tests when the VSIX + * bundled source is used. For example: + * + * ``` + * vscode.commands.registerCommand(Commands.PLAY, async (item, folder) => { + * if (item instanceof PlaygroundNode) { + * return await runPlayground(item.playground); + * } + * }), + * ``` + * + * So instead we'll check for this set boolean property. Even if the implementation of the + * {@link PlaygroundNode} class changes, this property should not need to change + */ + static isPlaygroundNode = (item: { __isPlaygroundNode?: boolean }) => + item.__isPlaygroundNode ?? false; + __isPlaygroundNode = true; + get name(): string { return this.playground.label ?? this.playground.id; } - get args(): string[] { - return [this.name]; - } - toTreeItem(): vscode.TreeItem { const name = this.name; const item = new vscode.TreeItem(this.name, vscode.TreeItemCollapsibleState.None); From 185302fd22cdf7c126fe1fba5eddd02577c60913 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 28 Nov 2025 12:40:15 -0500 Subject: [PATCH 4/6] Add test --- .../commands/runPlayground.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/integration-tests/commands/runPlayground.test.ts b/test/integration-tests/commands/runPlayground.test.ts index edfce882b..b7ec9cbc0 100644 --- a/test/integration-tests/commands/runPlayground.test.ts +++ b/test/integration-tests/commands/runPlayground.test.ts @@ -19,8 +19,10 @@ import { FolderContext } from "@src/FolderContext"; import { WorkspaceContext } from "@src/WorkspaceContext"; import { Commands } from "@src/commands"; import { runPlayground } from "@src/commands/runPlayground"; +import { Playground } from "@src/sourcekit-lsp/extensions"; import { SwiftTask } from "@src/tasks/SwiftTaskProvider"; import { TaskManager } from "@src/tasks/TaskManager"; +import { PlaygroundNode } from "@src/ui/ProjectPanelProvider"; import { MockedObject, instance, mockObject } from "../../MockUtils"; import { activateExtensionForSuite, folderInRootWorkspace } from "../utilities/testutilities"; @@ -43,7 +45,7 @@ suite("Run Playground Command", function () { }); suite("Command", () => { - test("Succeeds", async () => { + test("Succeeds with PlaygroundItem", async () => { expect( await vscode.commands.executeCommand(Commands.PLAY, { id: "PackageLib/PackageLib.swift:3", @@ -51,6 +53,22 @@ suite("Run Playground Command", function () { ).to.be.true; }); + test("Succeeds with PlaygroundNode", async () => { + expect( + await vscode.commands.executeCommand( + Commands.PLAY, + new PlaygroundNode( + { + label: "foo", + id: "PackageLib/PackageLib.swift:3", + } as Playground, + folderContext, + new Set() + ) + ) + ).to.be.true; + }); + test("No playground item provided", async () => { expect(await vscode.commands.executeCommand(Commands.PLAY), undefined).to.be.false; }); From 9580a4c9cf25f2bf2310cf2b1051dd32390d2768 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Mon, 1 Dec 2025 09:29:29 -0500 Subject: [PATCH 5/6] Fix failing unit test --- .../unit-tests/sourcekit-lsp/LanguageClientManager.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts b/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts index b3b2b9753..41b3140c0 100644 --- a/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts +++ b/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts @@ -162,6 +162,7 @@ suite("LanguageClientManager Suite", () => { outputChannel: instance( mockObject({ dispose: mockFn(), + warn: mockFn(), }) ), initializeResult: { @@ -578,7 +579,11 @@ suite("LanguageClientManager Suite", () => { const middleware = languageClientFactoryMock.createLanguageClient.args[0][3].middleware!; expect(middleware).to.have.property("provideCodeLenses"); await expect( - middleware.provideCodeLenses!({} as any, {} as any, codelensesFromSourceKitLSP) + middleware.provideCodeLenses!( + { uri: vscode.Uri.file("/path/to/doc.swift") } as any, + {} as any, + codelensesFromSourceKitLSP + ) ).to.eventually.deep.equal([ { range: new vscode.Range(0, 0, 0, 0), From c9494a403720822a70afae5257b0fd5103bd3986 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Mon, 8 Dec 2025 14:27:46 -0500 Subject: [PATCH 6/6] Fix review comments --- src/FolderContext.ts | 2 +- src/playgrounds/LSPPlaygroundsDiscovery.ts | 9 ++++++--- src/playgrounds/PlaygroundProvider.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/FolderContext.ts b/src/FolderContext.ts index ad3ef5350..27607a1bd 100644 --- a/src/FolderContext.ts +++ b/src/FolderContext.ts @@ -288,7 +288,7 @@ export class FolderContext implements vscode.Disposable { /** Return `true` if package folder has a playground provider */ hasPlaygroundProvider() { - return this.testExplorer !== undefined; + return this.playgroundProvider !== undefined; } static uriName(uri: vscode.Uri): string { diff --git a/src/playgrounds/LSPPlaygroundsDiscovery.ts b/src/playgrounds/LSPPlaygroundsDiscovery.ts index 1ae33c505..eb087f820 100644 --- a/src/playgrounds/LSPPlaygroundsDiscovery.ts +++ b/src/playgrounds/LSPPlaygroundsDiscovery.ts @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// +import { FolderContext } from "../FolderContext"; import { checkExperimentalCapability } from "../sourcekit-lsp/LanguageClientManager"; import { LanguageClientManager } from "../sourcekit-lsp/LanguageClientManager"; import { Playground, WorkspacePlaygroundsRequest } from "../sourcekit-lsp/extensions"; @@ -25,10 +26,12 @@ export { Playground }; * these results. */ export class LSPPlaygroundsDiscovery { - constructor(private languageClient: LanguageClientManager) {} + private languageClient: LanguageClientManager; + private toolchainVersion: Version; - private get toolchainVersion(): Version { - return this.languageClient.folderContext.toolchain.swiftVersion; + constructor(folderContext: FolderContext) { + this.languageClient = folderContext.languageClientManager; + this.toolchainVersion = folderContext.toolchain.swiftVersion; } /** diff --git a/src/playgrounds/PlaygroundProvider.ts b/src/playgrounds/PlaygroundProvider.ts index 6ec7ef10e..554f7cfaf 100644 --- a/src/playgrounds/PlaygroundProvider.ts +++ b/src/playgrounds/PlaygroundProvider.ts @@ -32,6 +32,7 @@ export interface PlaygroundChangeEvent { * these results. */ export class PlaygroundProvider implements vscode.Disposable { + private hasFetched: boolean = false; private fetchPromise: Promise | undefined; private documentPlaygrounds: Map = new Map(); private didChangePlaygroundsEmitter: vscode.EventEmitter = @@ -40,7 +41,7 @@ export class PlaygroundProvider implements vscode.Disposable { constructor(private folderContext: FolderContext) {} private get lspPlaygroundDiscovery(): LSPPlaygroundsDiscovery { - return new LSPPlaygroundsDiscovery(this.folderContext.languageClientManager); + return new LSPPlaygroundsDiscovery(this.folderContext); } private get logger(): SwiftLogger { @@ -79,6 +80,8 @@ export class PlaygroundProvider implements vscode.Disposable { async getWorkspacePlaygrounds(): Promise { if (this.fetchPromise) { return await this.fetchPromise; + } else if (!this.hasFetched) { + await this.fetch(); } return Array.from(this.documentPlaygrounds.values()).flatMap(v => v); } @@ -112,6 +115,11 @@ export class PlaygroundProvider implements vscode.Disposable { this.didChangePlaygroundsEmitter.event; async fetch() { + this.hasFetched = true; + if (this.fetchPromise) { + await this.fetchPromise; + return; + } if (!(await this.lspPlaygroundDiscovery.supportsPlaygrounds())) { this.logger.debug( `Fetching playgrounds not supported by the language server`,