diff --git a/assets/test/targets/Package.swift b/assets/test/targets/Package.swift new file mode 100644 index 000000000..35cda10ab --- /dev/null +++ b/assets/test/targets/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version: 5.6 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "targets", + products: [ + .library( + name: "LibraryTarget", + targets: ["LibraryTarget"] + ), + .executable( + name: "ExecutableTarget", + targets: ["ExecutableTarget"] + ), + .plugin( + name: "PluginTarget", + targets: ["PluginTarget"] + ), + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-markdown.git", branch: "main"), + .package(path: "../defaultPackage"), + ], + targets: [ + .target( + name: "LibraryTarget" + ), + .executableTarget( + name: "ExecutableTarget" + ), + .plugin( + name: "PluginTarget", + capability: .command( + intent: .custom(verb: "testing", description: "A plugin for testing plugins") + ) + ), + .testTarget( + name: "TargetsTests", + dependencies: ["LibraryTarget"] + ), + .testTarget( + name: "AnotherTests", + dependencies: ["LibraryTarget"] + ), + ] +) diff --git a/assets/test/targets/Plugins/PluginTarget/main.swift b/assets/test/targets/Plugins/PluginTarget/main.swift new file mode 100644 index 000000000..8a2a8680f --- /dev/null +++ b/assets/test/targets/Plugins/PluginTarget/main.swift @@ -0,0 +1,9 @@ +import PackagePlugin +import Foundation + +@main +struct MyCommandPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) throws { + print("Plugin Target Hello World") + } +} \ No newline at end of file diff --git a/assets/test/targets/Snippets/AnotherSnippet.swift b/assets/test/targets/Snippets/AnotherSnippet.swift new file mode 100644 index 000000000..25f53dfa6 --- /dev/null +++ b/assets/test/targets/Snippets/AnotherSnippet.swift @@ -0,0 +1 @@ +print("Another Snippet Hello World") \ No newline at end of file diff --git a/assets/test/targets/Snippets/Snippet.swift b/assets/test/targets/Snippets/Snippet.swift new file mode 100644 index 000000000..cdd7d267c --- /dev/null +++ b/assets/test/targets/Snippets/Snippet.swift @@ -0,0 +1 @@ +print("Snippet Hello World") \ No newline at end of file diff --git a/assets/test/targets/Sources/CommandPluginTarget/CommandPluginTarget.swift b/assets/test/targets/Sources/CommandPluginTarget/CommandPluginTarget.swift new file mode 100644 index 000000000..e69de29bb diff --git a/assets/test/targets/Sources/ExecutableTarget/main.swift b/assets/test/targets/Sources/ExecutableTarget/main.swift new file mode 100644 index 000000000..2fcea7ab3 --- /dev/null +++ b/assets/test/targets/Sources/ExecutableTarget/main.swift @@ -0,0 +1 @@ +print("Executable Target Hello World!") \ No newline at end of file diff --git a/assets/test/targets/Sources/LibraryTarget/Targets.swift b/assets/test/targets/Sources/LibraryTarget/Targets.swift new file mode 100644 index 000000000..37c4f8832 --- /dev/null +++ b/assets/test/targets/Sources/LibraryTarget/Targets.swift @@ -0,0 +1,9 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +public func foo() { + print("foo") +} +public func bar() { + print("bar") +} \ No newline at end of file diff --git a/assets/test/targets/Tests/AnotherTests/AnotherTests.swift b/assets/test/targets/Tests/AnotherTests/AnotherTests.swift new file mode 100644 index 000000000..8aa96db8b --- /dev/null +++ b/assets/test/targets/Tests/AnotherTests/AnotherTests.swift @@ -0,0 +1,8 @@ +import LibraryTarget +import XCTest + +class AnotherTests: XCTestCase { + func testExample() { + bar() + } +} \ No newline at end of file diff --git a/assets/test/targets/Tests/TargetsTests/TargetsTests.swift b/assets/test/targets/Tests/TargetsTests/TargetsTests.swift new file mode 100644 index 000000000..089304193 --- /dev/null +++ b/assets/test/targets/Tests/TargetsTests/TargetsTests.swift @@ -0,0 +1,8 @@ +import LibraryTarget +import XCTest + +class TargetsTests: XCTestCase { + func testExample() { + foo() + } +} \ No newline at end of file diff --git a/package.json b/package.json index af7d020b5..97989137b 100644 --- a/package.json +++ b/package.json @@ -222,12 +222,14 @@ { "command": "swift.runSnippet", "title": "Run Swift Snippet", - "category": "Swift" + "category": "Swift", + "icon": "$(play)" }, { "command": "swift.debugSnippet", "title": "Debug Swift Snippet", - "category": "Swift" + "category": "Swift", + "icon": "$(debug)" }, { "command": "swift.runPluginTask", @@ -266,8 +268,27 @@ }, { "command": "swift.runAllTestsParallel", - "title": "Run All Tests in Parallel", - "category": "Test" + "title": "Run Tests in Parallel", + "category": "Test", + "icon": "$(testing-run-all-icon)" + }, + { + "command": "swift.runAllTests", + "title": "Run Tests", + "category": "Test", + "icon": "$(testing-run-icon)" + }, + { + "command": "swift.debugAllTests", + "title": "Debug Tests", + "category": "Test", + "icon": "$(testing-debug-icon)" + }, + { + "command": "swift.coverAllTests", + "title": "Run Tests with Coverage", + "category": "Test", + "icon": "$(debug-coverage)" } ], "configuration": [ @@ -910,6 +931,18 @@ { "command": "swift.runAllTestsParallel", "when": "swift.isActivated" + }, + { + "command": "swift.runAllTests", + "when": "swift.isActivated" + }, + { + "command": "swift.debugAllTests", + "when": "swift.isActivated" + }, + { + "command": "swift.coverAllTests", + "when": "swift.isActivated" } ], "editor/context": [ @@ -971,50 +1004,90 @@ "view/title": [ { "command": "swift.updateDependencies", - "when": "view == packageDependencies", + "when": "view == projectPanel", "group": "navigation@1" }, { "command": "swift.resolveDependencies", - "when": "view == packageDependencies", + "when": "view == projectPanel", "group": "navigation@2" }, { "command": "swift.resetPackage", - "when": "view == packageDependencies", + "when": "view == projectPanel", "group": "navigation@3" }, { "command": "swift.flatDependenciesList", - "when": "view == packageDependencies && !swift.flatDependenciesList", + "when": "view == projectPanel && !swift.flatDependenciesList", "group": "navigation@4" }, { "command": "swift.nestedDependenciesList", - "when": "view == packageDependencies && swift.flatDependenciesList", + "when": "view == projectPanel && swift.flatDependenciesList", "group": "navigation@5" } ], "view/item/context": [ { "command": "swift.useLocalDependency", - "when": "view == packageDependencies && viewItem == remote" + "when": "view == projectPanel && viewItem == remote" }, { "command": "swift.uneditDependency", - "when": "view == packageDependencies && viewItem == editing" + "when": "view == projectPanel && viewItem == editing" }, { "command": "swift.openInWorkspace", - "when": "view == packageDependencies && viewItem == editing" + "when": "view == projectPanel && viewItem == editing" }, { "command": "swift.openInWorkspace", - "when": "view == packageDependencies && viewItem == local" + "when": "view == projectPanel && viewItem == local" }, { "command": "swift.openExternal", - "when": "view == packageDependencies && viewItem != local" + "when": "view == projectPanel && (viewItem == 'editing' || viewItem == 'remote')" + }, + { + "command": "swift.run", + "when": "view == projectPanel && viewItem == 'runnable'", + "group": "inline@0" + }, + { + "command": "swift.debug", + "when": "view == projectPanel && viewItem == 'runnable'", + "group": "inline@1" + }, + { + "command": "swift.runSnippet", + "when": "view == projectPanel && viewItem == 'snippet_runnable'", + "group": "inline@0" + }, + { + "command": "swift.debugSnippet", + "when": "view == projectPanel && viewItem == 'snippet_runnable'", + "group": "inline@1" + }, + { + "command": "swift.runAllTests", + "when": "view == projectPanel && viewItem == 'test_runnable'", + "group": "inline@0" + }, + { + "command": "swift.debugAllTests", + "when": "view == projectPanel && viewItem == 'test_runnable'", + "group": "inline@1" + }, + { + "command": "swift.runAllTestsParallel", + "when": "view == projectPanel && viewItem == 'test_runnable'", + "group": "inline@2" + }, + { + "command": "swift.coverAllTests", + "when": "view == projectPanel && viewItem == 'test_runnable'", + "group": "inline@3" } ] }, @@ -1211,8 +1284,8 @@ "views": { "explorer": [ { - "id": "packageDependencies", - "name": "Package Dependencies", + "id": "projectPanel", + "name": "Swift Project", "icon": "$(archive)", "when": "swift.hasPackage" } diff --git a/src/PackageWatcher.ts b/src/PackageWatcher.ts index 946fe3b5e..86b4840e2 100644 --- a/src/PackageWatcher.ts +++ b/src/PackageWatcher.ts @@ -27,6 +27,7 @@ export class PackageWatcher { private packageFileWatcher?: vscode.FileSystemWatcher; private resolvedFileWatcher?: vscode.FileSystemWatcher; private workspaceStateFileWatcher?: vscode.FileSystemWatcher; + private snippetWatcher?: vscode.FileSystemWatcher; constructor( private folderContext: FolderContext, @@ -41,6 +42,7 @@ export class PackageWatcher { this.packageFileWatcher = this.createPackageFileWatcher(); this.resolvedFileWatcher = this.createResolvedFileWatcher(); this.workspaceStateFileWatcher = this.createWorkspaceStateFileWatcher(); + this.snippetWatcher = this.createSnippetFileWatcher(); } /** @@ -51,6 +53,7 @@ export class PackageWatcher { this.packageFileWatcher?.dispose(); this.resolvedFileWatcher?.dispose(); this.workspaceStateFileWatcher?.dispose(); + this.snippetWatcher?.dispose(); } private createPackageFileWatcher(): vscode.FileSystemWatcher { @@ -87,6 +90,15 @@ export class PackageWatcher { return watcher; } + private createSnippetFileWatcher(): vscode.FileSystemWatcher { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(this.folderContext.folder, "Snippets/*.swift") + ); + watcher.onDidCreate(async () => await this.handlePackageSwiftChange()); + watcher.onDidDelete(async () => await this.handlePackageSwiftChange()); + return watcher; + } + /** * Handles a create or change event for **Package.swift**. * diff --git a/src/SwiftPackage.ts b/src/SwiftPackage.ts index ed8b23c53..8df08f560 100644 --- a/src/SwiftPackage.ts +++ b/src/SwiftPackage.ts @@ -41,7 +41,7 @@ export interface Target { c99name: string; path: string; sources: string[]; - type: "executable" | "test" | "library" | "snippet"; + type: "executable" | "test" | "library" | "snippet" | "plugin"; } /** Swift Package Manager dependency */ diff --git a/src/SwiftSnippets.ts b/src/SwiftSnippets.ts index f7fa4cccb..22898b94a 100644 --- a/src/SwiftSnippets.ts +++ b/src/SwiftSnippets.ts @@ -48,29 +48,44 @@ export function setSnippetContextKey(ctx: WorkspaceContext) { * If current file is a Swift Snippet run it * @param ctx Workspace Context */ -export async function runSnippet(ctx: WorkspaceContext): Promise { - return await debugSnippetWithOptions(ctx, { noDebug: true }); +export async function runSnippet( + ctx: WorkspaceContext, + snippet?: string +): Promise { + return await debugSnippetWithOptions(ctx, { noDebug: true }, snippet); } /** * If current file is a Swift Snippet run it in the debugger * @param ctx Workspace Context */ -export async function debugSnippet(ctx: WorkspaceContext): Promise { - return await debugSnippetWithOptions(ctx, {}); +export async function debugSnippet( + ctx: WorkspaceContext, + snippet?: string +): Promise { + return await debugSnippetWithOptions(ctx, {}, snippet); } export async function debugSnippetWithOptions( ctx: WorkspaceContext, - options: vscode.DebugSessionOptions + options: vscode.DebugSessionOptions, + snippet?: string ): Promise { + // create build task + let snippetName: string; + if (snippet) { + snippetName = snippet; + } else if (ctx.currentDocument) { + snippetName = path.basename(ctx.currentDocument.fsPath, ".swift"); + } else { + return false; + } + const folderContext = ctx.currentFolder; - if (!ctx.currentDocument || !folderContext) { - return; + if (!folderContext) { + return false; } - // create build task - const snippetName = path.basename(ctx.currentDocument.fsPath, ".swift"); const snippetBuildTask = createSwiftTask( ["build", "--product", snippetName], `Build ${snippetName}`, @@ -84,26 +99,29 @@ export async function debugSnippetWithOptions( }, ctx.toolchain ); - + const snippetDebugConfig = createSnippetConfiguration(snippetName, folderContext); try { + ctx.buildStarted(snippetName, snippetDebugConfig, options); + // queue build task and when it is complete run executable in the debugger return await folderContext.taskQueue .queueOperation(new TaskOperation(snippetBuildTask)) .then(result => { if (result === 0) { - const snippetDebugConfig = createSnippetConfiguration( - snippetName, - folderContext - ); return debugLaunchConfig( folderContext.workspaceFolder, snippetDebugConfig, options ); } + }) + .then(result => { + ctx.buildFinished(snippetName, snippetDebugConfig, options); + return result; }); - } catch { + } catch (error) { + ctx.outputChannel.appendLine(`Failed to debug snippet: ${error}`); // ignore error if task failed to run - return; + return false; } } diff --git a/src/TestExplorer/TestRunner.ts b/src/TestExplorer/TestRunner.ts index d9fae8a14..fd9346210 100644 --- a/src/TestExplorer/TestRunner.ts +++ b/src/TestExplorer/TestRunner.ts @@ -520,6 +520,18 @@ export class TestRunner { ]; } + /** + * Extracts a list of unique test Targets from the list of test items. + */ + private testTargets(items: vscode.TestItem[]): string[] { + const targets = new Set(); + for (const item of items) { + const target = item.id.split(".")[0]; + targets.add(target); + } + return Array.from(targets); + } + /** * Test run handler. Run a series of tests and extracts the results from the output * @param shouldDebug Should we run the debugger @@ -527,6 +539,9 @@ export class TestRunner { * @returns When complete */ async runHandler() { + const testTargets = this.testTargets(this.testArgs.testItems); + this.workspaceContext.testsStarted(this.folderContext, this.testKind, testTargets); + const runState = new TestRunnerTestRunState(this.testRun); const cancellationDisposable = this.testRun.token.onCancellationRequested(() => { @@ -551,6 +566,8 @@ export class TestRunner { cancellationDisposable.dispose(); await this.testRun.end(); + + this.workspaceContext.testsFinished(this.folderContext, this.testKind, testTargets); } /** Run test session without attaching to a debugger */ @@ -978,11 +995,6 @@ export class TestRunner { ); } - // show test results pane - vscode.commands.executeCommand( - "testing.showMostRecentOutput" - ); - const terminateSession = vscode.debug.onDidTerminateDebugSession(() => { this.workspaceContext.outputChannel.logDiagnostic( diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index b3a635815..648b6776d 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -33,6 +33,7 @@ import { SwiftToolchain } from "./toolchain/toolchain"; import { DiagnosticsManager } from "./DiagnosticsManager"; import { DocumentationManager } from "./documentation/DocumentationManager"; import { DocCDocumentationRequest, ReIndexProjectRequest } from "./sourcekit-lsp/extensions"; +import { TestKind } from "./TestExplorer/TestKind"; /** * Context for whole workspace. Holds array of contexts for each workspace folder @@ -53,6 +54,17 @@ export class WorkspaceContext implements vscode.Disposable { private lastFocusUri: vscode.Uri | undefined; private initialisationFinished = false; + private readonly testStartEmitter = new vscode.EventEmitter(); + private readonly testFinishEmitter = new vscode.EventEmitter(); + + public onDidStartTests = this.testStartEmitter.event; + public onDidFinishTests = this.testFinishEmitter.event; + + private readonly buildStartEmitter = new vscode.EventEmitter(); + private readonly buildFinishEmitter = new vscode.EventEmitter(); + public onDidStartBuild = this.buildStartEmitter.event; + public onDidFinishBuild = this.buildFinishEmitter.event; + private constructor( extensionContext: vscode.ExtensionContext, public tempFolder: TemporaryFolder, @@ -336,6 +348,30 @@ export class WorkspaceContext implements vscode.Disposable { await this.fireEvent(folderContext, FolderOperation.focus); } + public testsFinished(folder: FolderContext, kind: TestKind, targets: string[]) { + this.testFinishEmitter.fire({ kind, folder, targets }); + } + + public testsStarted(folder: FolderContext, kind: TestKind, targets: string[]) { + this.testStartEmitter.fire({ kind, folder, targets }); + } + + public buildStarted( + targetName: string, + launchConfig: vscode.DebugConfiguration, + options: vscode.DebugSessionOptions + ) { + this.buildStartEmitter.fire({ targetName, launchConfig, options }); + } + + public buildFinished( + targetName: string, + launchConfig: vscode.DebugConfiguration, + options: vscode.DebugSessionOptions + ) { + this.buildFinishEmitter.fire({ targetName, launchConfig, options }); + } + /** * catch workspace folder changes and add or remove folders based on those changes * @param event workspace folder event @@ -594,6 +630,20 @@ export class WorkspaceContext implements vscode.Disposable { private swiftFileObservers = new Set<(listener: SwiftFileEvent) => unknown>(); } +/** Test events for test run begin/end */ +interface TestEvent { + kind: TestKind; + folder: FolderContext; + targets: string[]; +} + +/** Build events for build + run start/stop */ +interface BuildEvent { + targetName: string; + launchConfig: vscode.DebugConfiguration; + options: vscode.DebugSessionOptions; +} + /** Workspace Folder Operation types */ export enum FolderOperation { // Package folder has been added @@ -612,6 +662,8 @@ export enum FolderOperation { workspaceStateUpdated = "workspaceStateUpdated", // .build/workspace-state.json has been updated packageViewUpdated = "packageViewUpdated", + // Package plugins list has been updated + pluginsUpdated = "pluginsUpdated", } /** Workspace Folder Event */ diff --git a/src/commands.ts b/src/commands.ts index b88e6cecb..a28979167 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -14,7 +14,7 @@ import * as vscode from "vscode"; import { WorkspaceContext } from "./WorkspaceContext"; -import { PackageNode } from "./ui/PackageDependencyProvider"; +import { PackageNode } from "./ui/ProjectPanelProvider"; import { SwiftToolchain } from "./toolchain/toolchain"; import { debugSnippet, runSnippet } from "./SwiftSnippets"; import { showToolchainSelectionQuickPick } from "./ui/ToolchainSelection"; @@ -38,8 +38,10 @@ import { updateDependencies } from "./commands/dependencies/update"; import { runPluginTask } from "./commands/runPluginTask"; import { runTestMultipleTimes } from "./commands/testMultipleTimes"; import { newSwiftFile } from "./commands/newFile"; -import { runAllTestsParallel } from "./commands/runParallelTests"; +import { runAllTests } from "./commands/runAllTests"; import { updateDependenciesViewList } from "./commands/dependencies/updateDepViewList"; +import { runTask } from "./commands/runTask"; +import { TestKind } from "./TestExplorer/TestKind"; /** * References: @@ -77,8 +79,15 @@ export enum Commands { RESET_PACKAGE = "swift.resetPackage", USE_LOCAL_DEPENDENCY = "swift.useLocalDependency", UNEDIT_DEPENDENCY = "swift.uneditDependency", + RUN_TASK = "swift.runTask", RUN_PLUGIN_TASK = "swift.runPluginTask", + RUN_SNIPPET = "swift.runSnippet", + DEBUG_SNIPPET = "swift.debugSnippet", PREVIEW_DOCUMENTATION = "swift.previewDocumentation", + RUN_ALL_TESTS = "swift.runAllTests", + RUN_ALL_TESTS_PARALLEL = "swift.runAllTestsParallel", + DEBUG_ALL_TESTS = "swift.debugAllTests", + COVER_ALL_TESTS = "swift.coverAllTests", } /** @@ -93,8 +102,12 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { vscode.commands.registerCommand(Commands.UPDATE_DEPENDENCIES, () => updateDependencies(ctx) ), - vscode.commands.registerCommand(Commands.RUN, () => runBuild(ctx)), - vscode.commands.registerCommand(Commands.DEBUG, () => debugBuild(ctx)), + vscode.commands.registerCommand(Commands.RUN, target => + runBuild(ctx, ...unwrapTreeItem(target)) + ), + vscode.commands.registerCommand(Commands.DEBUG, target => + debugBuild(ctx, ...unwrapTreeItem(target)) + ), vscode.commands.registerCommand(Commands.CLEAN_BUILD, () => cleanBuild(ctx)), vscode.commands.registerCommand(Commands.RUN_TESTS_MULTIPLE_TIMES, item => { if (ctx.currentFolder) { @@ -115,9 +128,14 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { return openPackage(ctx.toolchain.swiftVersion, ctx.currentFolder.folder); } }), - vscode.commands.registerCommand("swift.runSnippet", () => runSnippet(ctx)), - vscode.commands.registerCommand("swift.debugSnippet", () => debugSnippet(ctx)), + vscode.commands.registerCommand(Commands.RUN_SNIPPET, target => + runSnippet(ctx, ...unwrapTreeItem(target)) + ), + vscode.commands.registerCommand(Commands.DEBUG_SNIPPET, target => + debugSnippet(ctx, ...unwrapTreeItem(target)) + ), vscode.commands.registerCommand(Commands.RUN_PLUGIN_TASK, () => runPluginTask()), + vscode.commands.registerCommand(Commands.RUN_TASK, name => runTask(ctx, name)), vscode.commands.registerCommand("swift.restartLSPServer", () => ctx.languageClientManager.restart() ), @@ -156,8 +174,20 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { ), vscode.commands.registerCommand("swift.captureDiagnostics", () => captureDiagnostics(ctx)), vscode.commands.registerCommand( - "swift.runAllTestsParallel", - async () => await runAllTestsParallel(ctx) + Commands.RUN_ALL_TESTS_PARALLEL, + async item => await runAllTests(ctx, TestKind.parallel, ...unwrapTreeItem(item)) + ), + vscode.commands.registerCommand( + Commands.RUN_ALL_TESTS, + async item => await runAllTests(ctx, TestKind.standard, ...unwrapTreeItem(item)) + ), + vscode.commands.registerCommand( + Commands.DEBUG_ALL_TESTS, + async item => await runAllTests(ctx, TestKind.debug, ...unwrapTreeItem(item)) + ), + vscode.commands.registerCommand( + Commands.COVER_ALL_TESTS, + async item => await runAllTests(ctx, TestKind.coverage, ...unwrapTreeItem(item)) ), vscode.commands.registerCommand( Commands.PREVIEW_DOCUMENTATION, @@ -171,3 +201,16 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { ), ]; } + +/** + * Certain commands can be called via a vscode TreeView, which will pass a {@link CommandNode} object. + * If the command is called via a command palette or other means, the target will be a string. + */ +function unwrapTreeItem(target?: string | { args: string[] }): string[] { + if (typeof target === "object" && target !== null && "args" in target) { + return target.args ?? []; + } else if (typeof target === "string") { + return [target]; + } + return []; +} diff --git a/src/commands/build.ts b/src/commands/build.ts index 03e66489a..1ce2744d3 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -18,19 +18,20 @@ import { createSwiftTask, SwiftTaskProvider } from "../tasks/SwiftTaskProvider"; import { debugLaunchConfig, getLaunchConfiguration } from "../debugger/launch"; import { executeTaskWithUI } from "./utilities"; import { FolderContext } from "../FolderContext"; +import { Target } from "../SwiftPackage"; /** * Executes a {@link vscode.Task task} to run swift target. */ -export async function runBuild(ctx: WorkspaceContext) { - return await debugBuildWithOptions(ctx, { noDebug: true }); +export async function runBuild(ctx: WorkspaceContext, target?: string) { + return await debugBuildWithOptions(ctx, { noDebug: true }, target); } /** * Executes a {@link vscode.Task task} to debug swift target. */ -export async function debugBuild(ctx: WorkspaceContext) { - return await debugBuildWithOptions(ctx, {}); +export async function debugBuild(ctx: WorkspaceContext, target?: string) { + return await debugBuildWithOptions(ctx, {}, target); } /** @@ -70,7 +71,8 @@ export async function folderCleanBuild(folderContext: FolderContext) { */ export async function debugBuildWithOptions( ctx: WorkspaceContext, - options: vscode.DebugSessionOptions + options: vscode.DebugSessionOptions, + targetName?: string ) { const current = ctx.currentFolder; if (!current) { @@ -80,13 +82,19 @@ export async function debugBuildWithOptions( return; } - const file = vscode.window.activeTextEditor?.document.fileName; - if (!file) { - ctx.outputChannel.appendLine("debugBuildWithOptions: No active text editor"); - return; + let target: Target | undefined; + if (targetName) { + target = current.swiftPackage.targets.find(target => target.name === targetName); + } else { + const file = vscode.window.activeTextEditor?.document.fileName; + if (!file) { + ctx.outputChannel.appendLine("debugBuildWithOptions: No active text editor"); + return; + } + + target = current.swiftPackage.getTarget(file); } - const target = current.swiftPackage.getTarget(file); if (!target) { ctx.outputChannel.appendLine("debugBuildWithOptions: No active target"); return; @@ -101,6 +109,9 @@ export async function debugBuildWithOptions( const launchConfig = getLaunchConfiguration(target.name, current); if (launchConfig) { - return debugLaunchConfig(current.workspaceFolder, launchConfig, options); + ctx.buildStarted(target.name, launchConfig, options); + const result = await debugLaunchConfig(current.workspaceFolder, launchConfig, options); + ctx.buildFinished(target.name, launchConfig, options); + return result; } } diff --git a/src/commands/openInExternalEditor.ts b/src/commands/openInExternalEditor.ts index 29f4114ab..6dc621765 100644 --- a/src/commands/openInExternalEditor.ts +++ b/src/commands/openInExternalEditor.ts @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import * as vscode from "vscode"; -import { PackageNode } from "../ui/PackageDependencyProvider"; +import { PackageNode } from "../ui/ProjectPanelProvider"; /** * Opens the supplied `PackageNode` externally using the default application. diff --git a/src/commands/openInWorkspace.ts b/src/commands/openInWorkspace.ts index 7b4b9601d..dda3903c0 100644 --- a/src/commands/openInWorkspace.ts +++ b/src/commands/openInWorkspace.ts @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import * as vscode from "vscode"; -import { PackageNode } from "../ui/PackageDependencyProvider"; +import { PackageNode } from "../ui/ProjectPanelProvider"; /** * Open a local package in workspace diff --git a/src/commands/runParallelTests.ts b/src/commands/runAllTests.ts similarity index 64% rename from src/commands/runParallelTests.ts rename to src/commands/runAllTests.ts index 11970b803..f629417ad 100644 --- a/src/commands/runParallelTests.ts +++ b/src/commands/runAllTests.ts @@ -17,23 +17,29 @@ import { TestKind } from "../TestExplorer/TestKind"; import { WorkspaceContext } from "../WorkspaceContext"; import { flattenTestItemCollection } from "../TestExplorer/TestUtils"; -export async function runAllTestsParallel(ctx: WorkspaceContext) { +export async function runAllTests(ctx: WorkspaceContext, testKind: TestKind, target?: string) { const testExplorer = ctx.currentFolder?.testExplorer; if (testExplorer === undefined) { return; } - const profile = testExplorer.testRunProfiles.find( - profile => profile.label === TestKind.parallel - ); + const profile = testExplorer.testRunProfiles.find(profile => profile.label === testKind); if (profile === undefined) { return; } - const tests = flattenTestItemCollection(testExplorer.controller.items); + let tests = flattenTestItemCollection(testExplorer.controller.items); + + // If a target is specified, filter the tests to only run those that match the target. + if (target) { + const targetRegex = new RegExp(`^${target}(\\.|$)`); + tests = tests.filter(test => targetRegex.test(test.id)); + } const tokenSource = new vscode.CancellationTokenSource(); await profile.runHandler( new vscode.TestRunRequest(tests, undefined, profile), tokenSource.token ); + + await vscode.commands.executeCommand("testing.showMostRecentOutput"); } diff --git a/src/commands/runTask.ts b/src/commands/runTask.ts new file mode 100644 index 000000000..3bca73535 --- /dev/null +++ b/src/commands/runTask.ts @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2021-2024 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 { WorkspaceContext } from "../WorkspaceContext"; +import { TaskOperation } from "../tasks/TaskQueue"; +import { SwiftPluginTaskProvider } from "../tasks/SwiftPluginTaskProvider"; + +export const runTask = async (ctx: WorkspaceContext, name: string) => { + if (!ctx.currentFolder) { + return; + } + + const tasks = await vscode.tasks.fetchTasks(); + let task = tasks.find(task => task.name === name); + if (!task) { + const pluginTaskProvider = new SwiftPluginTaskProvider(ctx); + const pluginTasks = await pluginTaskProvider.provideTasks( + new vscode.CancellationTokenSource().token + ); + task = pluginTasks.find(task => task.name === name); + } + + if (!task) { + vscode.window.showErrorMessage(`Task "${name}" not found`); + return; + } + + return ctx.currentFolder.taskQueue + .queueOperation(new TaskOperation(task)) + .then(result => result === 0); +}; diff --git a/src/debugger/launch.ts b/src/debugger/launch.ts index 2b0d2a08b..b91836828 100644 --- a/src/debugger/launch.ts +++ b/src/debugger/launch.ts @@ -181,6 +181,7 @@ export function createSnippetConfiguration( args: [], cwd: folder, env: swiftRuntimeEnv(true), + runType: "snippet", ...CI_DISABLE_ASLR, }; } diff --git a/src/extension.ts b/src/extension.ts index 661696230..15e45be46 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,7 +18,7 @@ import "source-map-support/register"; import * as vscode from "vscode"; import * as commands from "./commands"; import * as debug from "./debugger/launch"; -import { PackageDependenciesProvider } from "./ui/PackageDependencyProvider"; +import { ProjectPanelProvider } from "./ui/ProjectPanelProvider"; import { SwiftTaskProvider } from "./tasks/SwiftTaskProvider"; import { FolderOperation, WorkspaceContext } from "./WorkspaceContext"; import { FolderContext } from "./FolderContext"; @@ -160,13 +160,13 @@ export async function activate(context: vscode.ExtensionContext): Promise { ); }); - // dependency view - const dependenciesProvider = new PackageDependenciesProvider(workspaceContext); - const dependenciesView = vscode.window.createTreeView("packageDependencies", { - treeDataProvider: dependenciesProvider, + // project panel provider + const projectPanelProvider = new ProjectPanelProvider(workspaceContext); + const dependenciesView = vscode.window.createTreeView("projectPanel", { + treeDataProvider: projectPanelProvider, showCollapseAll: true, }); - dependenciesProvider.observeFolders(dependenciesView); + projectPanelProvider.observeFolders(dependenciesView); // observer that will resolve package and build launch configurations const resolvePackageObserver = workspaceContext.onDidChangeFolders( @@ -189,6 +189,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { } else { await resolveFolderDependencies(folder, true); } + if ( workspace.toolchain.swiftVersion.isGreaterThanOrEqual( new Version(5, 6, 0) @@ -201,6 +202,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { async () => { await folder.loadSwiftPlugins(); workspace.updatePluginContextKey(); + folder.fireEvent(FolderOperation.pluginsUpdated); } ); } @@ -252,7 +254,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { testExplorerObserver, swiftModuleDocumentProvider, dependenciesView, - dependenciesProvider, + projectPanelProvider, logObserver, languageStatusItem, pluginTaskProvider, diff --git a/src/ui/PackageDependencyProvider.ts b/src/ui/PackageDependencyProvider.ts deleted file mode 100644 index 6cd038935..000000000 --- a/src/ui/PackageDependencyProvider.ts +++ /dev/null @@ -1,269 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the VS Code Swift open source project -// -// Copyright (c) 2021 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 * as fs from "fs/promises"; -import * as path from "path"; -import configuration from "../configuration"; -import { WorkspaceContext } from "../WorkspaceContext"; -import { FolderOperation } from "../WorkspaceContext"; -import contextKeys from "../contextKeys"; -import { Dependency, ResolvedDependency } from "../SwiftPackage"; - -/** - * References: - * - * - Contributing views: - * https://code.visualstudio.com/api/references/contribution-points#contributes.views - * - Contributing welcome views: - * https://code.visualstudio.com/api/references/contribution-points#contributes.viewsWelcome - * - Implementing a TreeView: - * https://code.visualstudio.com/api/extension-guides/tree-view - */ - -/** - * Returns a {@link FileNode} for every file or subdirectory - * in the given directory. - */ -async function getChildren(directoryPath: string, parentId?: string): Promise { - const contents = await fs.readdir(directoryPath); - const results: FileNode[] = []; - const excludes = configuration.excludePathsFromPackageDependencies; - for (const fileName of contents) { - if (excludes.includes(fileName)) { - continue; - } - const filePath = path.join(directoryPath, fileName); - const stats = await fs.stat(filePath); - results.push(new FileNode(fileName, filePath, stats.isDirectory(), parentId)); - } - return results.sort((first, second) => { - if (first.isDirectory === second.isDirectory) { - // If both nodes are of the same type, sort them by name. - return first.name.localeCompare(second.name); - } else { - // Otherwise, sort directories first. - return first.isDirectory ? -1 : 1; - } - }); -} - -/** - * A package in the Package Dependencies {@link vscode.TreeView TreeView}. - */ -export class PackageNode { - private id: string; - - constructor( - private dependency: ResolvedDependency, - private childDependencies: (dependency: Dependency) => ResolvedDependency[], - private parentId?: string - ) { - this.id = - (this.parentId ? `${this.parentId}->` : "") + - `${this.name}-${this.dependency.version ?? ""}`; - } - - get name(): string { - return this.dependency.identity; - } - - get location(): string { - return this.dependency.location; - } - - get type(): string { - return this.dependency.type; - } - - get path(): string { - return this.dependency.path ?? ""; - } - - toTreeItem(): vscode.TreeItem { - const item = new vscode.TreeItem(this.name, vscode.TreeItemCollapsibleState.Collapsed); - item.id = this.id; - item.description = this.dependency.version; - item.iconPath = - this.dependency.type === "editing" - ? new vscode.ThemeIcon("edit") - : new vscode.ThemeIcon("package"); - item.contextValue = this.dependency.type; - item.accessibilityInformation = { label: `Package ${this.name}` }; - item.tooltip = this.path; - return item; - } - - async getChildren(): Promise { - const [childDeps, files] = await Promise.all([ - this.childDependencies(this.dependency), - getChildren(this.dependency.path, this.id), - ]); - const childNodes = childDeps.map( - dep => new PackageNode(dep, this.childDependencies, this.id) - ); - - // Show dependencies first, then files. - return [...childNodes, ...files]; - } -} - -/** - * A file or directory in the Package Dependencies {@link vscode.TreeView TreeView}. - */ -export class FileNode { - private id: string; - - constructor( - public name: string, - public path: string, - public isDirectory: boolean, - private parentId?: string - ) { - this.id = (this.parentId ? `${this.parentId}->` : "") + `${this.path}`; - } - - toTreeItem(): vscode.TreeItem { - const item = new vscode.TreeItem( - this.name, - this.isDirectory - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None - ); - item.id = this.id; - item.resourceUri = vscode.Uri.file(this.path); - item.tooltip = this.path; - if (!this.isDirectory) { - item.command = { - command: "vscode.open", - arguments: [item.resourceUri], - title: "Open File", - }; - item.accessibilityInformation = { label: `File ${this.name}` }; - } else { - item.accessibilityInformation = { label: `Folder ${this.name}` }; - } - return item; - } - - async getChildren(): Promise { - return await getChildren(this.path, this.id); - } -} - -/** - * A node in the Package Dependencies {@link vscode.TreeView TreeView}. - * - * Can be either a {@link PackageNode} or a {@link FileNode}. - */ -type TreeNode = PackageNode | FileNode; - -/** - * A {@link vscode.TreeDataProvider TreeDataProvider} for the Package Dependencies {@link vscode.TreeView TreeView}. - */ -export class PackageDependenciesProvider implements vscode.TreeDataProvider { - private didChangeTreeDataEmitter = new vscode.EventEmitter< - TreeNode | undefined | null | void - >(); - private workspaceObserver?: vscode.Disposable; - - onDidChangeTreeData = this.didChangeTreeDataEmitter.event; - - constructor(private workspaceContext: WorkspaceContext) { - // default context key to false. These will be updated as folders are given focus - contextKeys.hasPackage = false; - contextKeys.packageHasDependencies = false; - } - - dispose() { - this.workspaceObserver?.dispose(); - } - - observeFolders(treeView: vscode.TreeView) { - this.workspaceObserver = this.workspaceContext.onDidChangeFolders( - ({ folder, operation }) => { - switch (operation) { - case FolderOperation.focus: - if (!folder) { - return; - } - treeView.title = `Package Dependencies (${folder.name})`; - this.didChangeTreeDataEmitter.fire(); - break; - case FolderOperation.unfocus: - treeView.title = `Package Dependencies`; - this.didChangeTreeDataEmitter.fire(); - break; - case FolderOperation.workspaceStateUpdated: - case FolderOperation.resolvedUpdated: - case FolderOperation.packageViewUpdated: - if (!folder) { - return; - } - if (folder === this.workspaceContext.currentFolder) { - this.didChangeTreeDataEmitter.fire(); - } - } - } - ); - } - - getTreeItem(element: TreeNode): vscode.TreeItem { - return element.toTreeItem(); - } - - async getChildren(element?: TreeNode): Promise { - const folderContext = this.workspaceContext.currentFolder; - if (!folderContext) { - return []; - } - if (!element) { - if (contextKeys.flatDependenciesList) { - const existenceMap = new Map(); - const gatherChildren = ( - dependencies: ResolvedDependency[] - ): ResolvedDependency[] => { - const result: ResolvedDependency[] = []; - for (const dep of dependencies) { - if (!existenceMap.has(dep.identity)) { - result.push(dep); - existenceMap.set(dep.identity, true); - } - const childDeps = folderContext.swiftPackage.childDependencies(dep); - result.push(...gatherChildren(childDeps)); - } - return result; - }; - - const rootDeps = folderContext.swiftPackage.rootDependencies(); - const allDeps = gatherChildren(rootDeps); - return allDeps.map(dependency => new PackageNode(dependency, () => [])); - } else { - return folderContext.swiftPackage - .rootDependencies() - .map( - dependency => - new PackageNode( - dependency, - folderContext.swiftPackage.childDependencies.bind( - folderContext.swiftPackage - ) - ) - ); - } - } else { - return await element.getChildren(); - } - } -} diff --git a/src/ui/ProjectPanelProvider.ts b/src/ui/ProjectPanelProvider.ts new file mode 100644 index 000000000..793bebe1f --- /dev/null +++ b/src/ui/ProjectPanelProvider.ts @@ -0,0 +1,558 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2021 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 * as fs from "fs/promises"; +import * as path from "path"; +import configuration from "../configuration"; +import { WorkspaceContext } from "../WorkspaceContext"; +import { FolderOperation } from "../WorkspaceContext"; +import contextKeys from "../contextKeys"; +import { Dependency, ResolvedDependency, Target } from "../SwiftPackage"; +import { SwiftPluginTaskProvider } from "../tasks/SwiftPluginTaskProvider"; + +const LOADING_ICON = "loading~spin"; +/** + * References: + * + * - Contributing views: + * https://code.visualstudio.com/api/references/contribution-points#contributes.views + * - Contributing welcome views: + * https://code.visualstudio.com/api/references/contribution-points#contributes.viewsWelcome + * - Implementing a TreeView: + * https://code.visualstudio.com/api/extension-guides/tree-view + */ + +/** + * Returns a {@link FileNode} for every file or subdirectory + * in the given directory. + */ +async function getChildren(directoryPath: string, parentId?: string): Promise { + const contents = await fs.readdir(directoryPath); + const results: FileNode[] = []; + const excludes = configuration.excludePathsFromPackageDependencies; + for (const fileName of contents) { + if (excludes.includes(fileName)) { + continue; + } + const filePath = path.join(directoryPath, fileName); + const stats = await fs.stat(filePath); + results.push(new FileNode(fileName, filePath, stats.isDirectory(), parentId)); + } + return results.sort((first, second) => { + if (first.isDirectory === second.isDirectory) { + // If both nodes are of the same type, sort them by name. + return first.name.localeCompare(second.name); + } else { + // Otherwise, sort directories first. + return first.isDirectory ? -1 : 1; + } + }); +} + +/** + * A package in the Package Dependencies {@link vscode.TreeView TreeView}. + */ +export class PackageNode { + private id: string; + + constructor( + private dependency: ResolvedDependency, + private childDependencies: (dependency: Dependency) => ResolvedDependency[], + private parentId?: string + ) { + this.id = + (this.parentId ? `${this.parentId}->` : "") + + `${this.name}-${this.dependency.version ?? ""}`; + } + + get name(): string { + return this.dependency.identity; + } + + get location(): string { + return this.dependency.location; + } + + get type(): string { + return this.dependency.type; + } + + get path(): string { + return this.dependency.path ?? ""; + } + + toTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem(this.name, vscode.TreeItemCollapsibleState.Collapsed); + item.id = this.id; + item.description = this.dependency.version; + item.iconPath = new vscode.ThemeIcon(this.icon()); + item.contextValue = this.dependency.type; + item.accessibilityInformation = { label: `Package ${this.name}` }; + item.tooltip = this.path; + return item; + } + + icon() { + if (this.dependency.type === "editing") { + return "edit"; + } + if (this.dependency.type === "local") { + return "notebook-render-output"; + } + return "package"; + } + + async getChildren(): Promise { + const [childDeps, files] = await Promise.all([ + this.childDependencies(this.dependency), + getChildren(this.dependency.path, this.id), + ]); + const childNodes = childDeps.map( + dep => new PackageNode(dep, this.childDependencies, this.id) + ); + + // Show dependencies first, then files. + return [...childNodes, ...files]; + } +} + +/** + * A file or directory in the Package Dependencies {@link vscode.TreeView TreeView}. + */ +export class FileNode { + private id: string; + + constructor( + public name: string, + public path: string, + public isDirectory: boolean, + private parentId?: string + ) { + this.id = (this.parentId ? `${this.parentId}->` : "") + `${this.path}`; + } + + toTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem( + this.name, + this.isDirectory + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None + ); + item.id = this.id; + item.resourceUri = vscode.Uri.file(this.path); + item.tooltip = this.path; + if (!this.isDirectory) { + item.command = { + command: "vscode.open", + arguments: [item.resourceUri], + title: "Open File", + }; + item.accessibilityInformation = { label: `File ${this.name}` }; + } else { + item.accessibilityInformation = { label: `Folder ${this.name}` }; + } + return item; + } + + async getChildren(): Promise { + return await getChildren(this.path, this.id); + } +} + +class TaskNode { + constructor( + public type: string, + public name: string, + private active: boolean + ) {} + + toTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem(this.name, vscode.TreeItemCollapsibleState.None); + item.id = `${this.type}-${this.name}`; + item.iconPath = new vscode.ThemeIcon(this.active ? LOADING_ICON : "play"); + item.contextValue = "task"; + item.accessibilityInformation = { label: this.name }; + item.command = { + command: "swift.runTask", + arguments: [this.name], + title: "Run Task", + }; + return item; + } + + getChildren(): TreeNode[] { + return []; + } +} + +/* + * Prefix a unique string on the test target name to avoid confusing it + * with another target that may share the same name. Targets can't start with % + * so this is guarenteed to be unique. + */ +function testTaskName(name: string): string { + return `%test-${name}`; +} + +function snippetTaskName(name: string): string { + return `%snippet-${name}`; +} + +class TargetNode { + constructor( + public target: Target, + private activeTasks: Set + ) {} + + get name(): string { + return this.target.name; + } + + get args(): string[] { + return [this.name]; + } + + toTreeItem(): vscode.TreeItem { + const name = this.target.name; + const hasChildren = this.getChildren().length > 0; + const item = new vscode.TreeItem( + name, + hasChildren + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.None + ); + item.id = `${this.target.type}:${name}`; + item.iconPath = new vscode.ThemeIcon(this.icon()); + item.contextValue = this.contextValue(); + item.accessibilityInformation = { label: name }; + return item; + } + + private icon(): string { + if (this.activeTasks.has(this.name)) { + return LOADING_ICON; + } + + switch (this.target.type) { + case "executable": + return "output"; + case "library": + return "library"; + case "test": + if (this.activeTasks.has(testTaskName(this.name))) { + return LOADING_ICON; + } + return "test-view-icon"; + case "snippet": + if (this.activeTasks.has(snippetTaskName(this.name))) { + return LOADING_ICON; + } + return "notebook"; + case "plugin": + return "plug"; + } + } + + private contextValue(): string | undefined { + switch (this.target.type) { + case "executable": + return "runnable"; + case "snippet": + return "snippet_runnable"; + case "test": + return "test_runnable"; + default: + return undefined; + } + } + + getChildren(): TreeNode[] { + return []; + } +} + +class HeaderNode { + constructor( + private id: string, + public name: string, + private icon: string, + private _getChildren: () => Promise + ) {} + + get path(): string { + return ""; + } + + toTreeItem(): vscode.TreeItem { + const item = new vscode.TreeItem(this.name, vscode.TreeItemCollapsibleState.Collapsed); + item.id = `${this.id}-${this.name}`; + item.iconPath = new vscode.ThemeIcon(this.icon); + item.contextValue = "header"; + item.accessibilityInformation = { label: this.name }; + return item; + } + + getChildren(): Promise { + return this._getChildren(); + } +} + +/** + * A node in the Package Dependencies {@link vscode.TreeView TreeView}. + * + * Can be either a {@link PackageNode}, {@link FileNode}, {@link TargetNode}, {@link TaskNode} or {@link HeaderNode}. + */ +type TreeNode = PackageNode | FileNode | HeaderNode | TaskNode | TargetNode; + +/** + * A {@link vscode.TreeDataProvider TreeDataProvider} for project dependencies, tasks and commands {@link vscode.TreeView TreeView}. + */ +export class ProjectPanelProvider implements vscode.TreeDataProvider { + private didChangeTreeDataEmitter = new vscode.EventEmitter< + TreeNode | undefined | null | void + >(); + private workspaceObserver?: vscode.Disposable; + private disposables: vscode.Disposable[] = []; + private activeTasks: Set = new Set(); + + onDidChangeTreeData = this.didChangeTreeDataEmitter.event; + + constructor(private workspaceContext: WorkspaceContext) { + // default context key to false. These will be updated as folders are given focus + contextKeys.hasPackage = false; + contextKeys.packageHasDependencies = false; + + this.observeTasks(workspaceContext); + } + + dispose() { + this.workspaceObserver?.dispose(); + } + + observeTasks(ctx: WorkspaceContext) { + this.disposables.push( + vscode.tasks.onDidStartTask(e => { + const taskId = e.execution.task.detail ?? e.execution.task.name; + this.activeTasks.add(taskId); + this.didChangeTreeDataEmitter.fire(); + }), + vscode.tasks.onDidEndTask(e => { + const taskId = e.execution.task.detail ?? e.execution.task.name; + this.activeTasks.delete(taskId); + this.didChangeTreeDataEmitter.fire(); + }), + ctx.onDidStartBuild(e => { + if (e.launchConfig.runType === "snippet") { + this.activeTasks.add(snippetTaskName(e.targetName)); + } else { + this.activeTasks.add(e.targetName); + } + this.didChangeTreeDataEmitter.fire(); + }), + ctx.onDidFinishBuild(e => { + if (e.launchConfig.runType === "snippet") { + this.activeTasks.delete(snippetTaskName(e.targetName)); + } else { + this.activeTasks.delete(e.targetName); + } + this.didChangeTreeDataEmitter.fire(); + }), + ctx.onDidStartTests(e => { + for (const target of e.targets) { + this.activeTasks.add(testTaskName(target)); + } + this.didChangeTreeDataEmitter.fire(); + }), + ctx.onDidFinishTests(e => { + for (const target of e.targets) { + this.activeTasks.delete(testTaskName(target)); + } + this.didChangeTreeDataEmitter.fire(); + }) + ); + } + + observeFolders(treeView: vscode.TreeView) { + this.workspaceObserver = this.workspaceContext.onDidChangeFolders( + ({ folder, operation }) => { + switch (operation) { + case FolderOperation.focus: + if (!folder) { + return; + } + treeView.title = `Swift Project (${folder.name})`; + this.didChangeTreeDataEmitter.fire(); + break; + case FolderOperation.unfocus: + treeView.title = `Swift Project`; + this.didChangeTreeDataEmitter.fire(); + break; + case FolderOperation.workspaceStateUpdated: + case FolderOperation.resolvedUpdated: + case FolderOperation.packageViewUpdated: + case FolderOperation.pluginsUpdated: + if (!folder) { + return; + } + if (folder === this.workspaceContext.currentFolder) { + this.didChangeTreeDataEmitter.fire(); + } + } + } + ); + } + + getTreeItem(element: TreeNode): vscode.TreeItem { + return element.toTreeItem(); + } + + async getChildren(element?: TreeNode): Promise { + const folderContext = this.workspaceContext.currentFolder; + if (!folderContext) { + return []; + } + + if (element) { + return element.getChildren(); + } + + const dependencies = this.dependencies(); + const snippets = this.snippets(); + const commands = await this.commands(); + + // TODO: Control ordering + return [ + ...(dependencies.length > 0 + ? [ + new HeaderNode( + "dependencies", + "Dependencies", + "circuit-board", + this.wrapInAsync(this.dependencies.bind(this)) + ), + ] + : []), + new HeaderNode("targets", "Targets", "book", this.wrapInAsync(this.targets.bind(this))), + new HeaderNode("tasks", "Tasks", "debug-continue-small", this.tasks.bind(this)), + ...(snippets.length > 0 + ? [ + new HeaderNode("snippets", "Snippets", "notebook", () => + Promise.resolve(snippets) + ), + ] + : []), + ...(commands.length > 0 + ? [ + new HeaderNode("commands", "Commands", "debug-line-by-line", () => + Promise.resolve(commands) + ), + ] + : []), + ]; + } + + private dependencies(): TreeNode[] { + const folderContext = this.workspaceContext.currentFolder; + if (!folderContext) { + return []; + } + const pkg = folderContext.swiftPackage; + if (contextKeys.flatDependenciesList) { + const existenceMap = new Map(); + const gatherChildren = (dependencies: ResolvedDependency[]): ResolvedDependency[] => { + const result: ResolvedDependency[] = []; + for (const dep of dependencies) { + if (!existenceMap.has(dep.identity)) { + result.push(dep); + existenceMap.set(dep.identity, true); + } + const childDeps = pkg.childDependencies(dep); + result.push(...gatherChildren(childDeps)); + } + return result; + }; + + const rootDeps = pkg.rootDependencies(); + const allDeps = gatherChildren(rootDeps); + return allDeps.map(dependency => new PackageNode(dependency, () => [])); + } else { + const childDeps = pkg.childDependencies.bind(pkg); + return pkg.rootDependencies().map(dep => new PackageNode(dep, childDeps)); + } + } + + private targets(): TreeNode[] { + const folderContext = this.workspaceContext.currentFolder; + if (!folderContext) { + return []; + } + const targetSort = (node: TargetNode) => `${node.target.type}-${node.name}`; + return ( + folderContext.swiftPackage.targets + // Snipepts are shown under the Snippets header + .filter(target => target.type !== "snippet") + .map(target => new TargetNode(target, this.activeTasks)) + .sort((a, b) => targetSort(a).localeCompare(targetSort(b))) + ); + } + + private async tasks(): Promise { + const tasks = await vscode.tasks.fetchTasks(); + return ( + tasks + // Plugin tasks are shown under the Commands header + .filter(task => task.source !== "swift-plugin") + .map( + task => + new TaskNode( + "task", + task.name, + this.activeTasks.has(task.detail ?? task.name) + ) + ) + .sort((a, b) => a.name.localeCompare(b.name)) + ); + } + + private async commands(): Promise { + const provider = new SwiftPluginTaskProvider(this.workspaceContext); + const tasks = await provider.provideTasks(new vscode.CancellationTokenSource().token); + return tasks + .map( + task => + new TaskNode( + "command", + task.name, + this.activeTasks.has(task.detail ?? task.name) + ) + ) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + private snippets(): TreeNode[] { + const folderContext = this.workspaceContext.currentFolder; + if (!folderContext) { + return []; + } + return folderContext.swiftPackage.targets + .filter(target => target.type === "snippet") + .flatMap(target => new TargetNode(target, this.activeTasks)) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + private wrapInAsync(fn: () => T): () => Promise { + return async () => fn(); + } +} diff --git a/src/ui/StatusItem.ts b/src/ui/StatusItem.ts index 107429845..3d78ec29a 100644 --- a/src/ui/StatusItem.ts +++ b/src/ui/StatusItem.ts @@ -116,9 +116,9 @@ export class StatusItem { private showTask(task: RunningTask, message?: string) { message = message ?? task.name; if (typeof task.task !== "string") { - this.show(`$(sync~spin) ${message}`, message, "workbench.action.tasks.showTasks"); + this.show(`$(loading~spin) ${message}`, message, "workbench.action.tasks.showTasks"); } else { - this.show(`$(sync~spin) ${message}`, message); + this.show(`$(loading~spin) ${message}`, message); } } diff --git a/test/integration-tests/commands/dependency.test.ts b/test/integration-tests/commands/dependency.test.ts index afa7e40e4..771d3c8a4 100644 --- a/test/integration-tests/commands/dependency.test.ts +++ b/test/integration-tests/commands/dependency.test.ts @@ -14,10 +14,7 @@ import { expect } from "chai"; import * as vscode from "vscode"; -import { - PackageDependenciesProvider, - PackageNode, -} from "../../../src/ui/PackageDependencyProvider"; +import { PackageNode, ProjectPanelProvider } from "../../../src/ui/ProjectPanelProvider"; import { testAssetUri } from "../../fixtures"; import { FolderContext } from "../../../src/FolderContext"; import { WorkspaceContext } from "../../../src/WorkspaceContext"; @@ -28,8 +25,8 @@ import { getBuildAllTask, SwiftTask } from "../../../src/tasks/SwiftTaskProvider suite("Dependency Commmands Test Suite", function () { // full workflow's interaction with spm is longer than the default timeout - // 60 seconds for each test should be more than enough - this.timeout(60 * 1000); + // 120 seconds for each test should be more than enough + this.timeout(120 * 1000); let defaultContext: FolderContext; let depsContext: FolderContext; @@ -57,12 +54,12 @@ suite("Dependency Commmands Test Suite", function () { }); suite("Swift: Use Local Dependency", function () { - let treeProvider: PackageDependenciesProvider; + let treeProvider: ProjectPanelProvider; setup(async () => { await workspaceContext.focusFolder(depsContext); await executeTaskAndWaitForResult((await getBuildAllTask(depsContext)) as SwiftTask); - treeProvider = new PackageDependenciesProvider(workspaceContext); + treeProvider = new ProjectPanelProvider(workspaceContext); }); teardown(() => { @@ -70,8 +67,11 @@ suite("Dependency Commmands Test Suite", function () { }); async function getDependency() { - const items = await treeProvider.getChildren(); - return items.find(n => n.name === "swift-markdown") as PackageNode; + const headers = await treeProvider.getChildren(); + const header = headers.find(n => n.name === "Dependencies") as PackageNode; + expect(header).to.not.be.undefined; + const children = await header.getChildren(); + return children.find(n => n.name === "swift-markdown") as PackageNode; } // Wait for the dependency to switch to the expected state. diff --git a/test/integration-tests/ui/PackageDependencyProvider.test.ts b/test/integration-tests/ui/PackageDependencyProvider.test.ts deleted file mode 100644 index 72b7ddaa3..000000000 --- a/test/integration-tests/ui/PackageDependencyProvider.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the VS Code Swift open source project -// -// Copyright (c) 2024 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 { expect } from "chai"; -import * as vscode from "vscode"; -import * as path from "path"; -import { - PackageDependenciesProvider, - PackageNode, -} from "../../../src/ui/PackageDependencyProvider"; -import { executeTaskAndWaitForResult, waitForNoRunningTasks } from "../../utilities/tasks"; -import { getBuildAllTask, SwiftTask } from "../../../src/tasks/SwiftTaskProvider"; -import { testAssetPath } from "../../fixtures"; -import { activateExtensionForSuite, folderInRootWorkspace } from "../utilities/testutilities"; -import contextKeys from "../../../src/contextKeys"; -import { FolderContext } from "../../../src/FolderContext"; -import { WorkspaceContext } from "../../../src/WorkspaceContext"; - -suite("PackageDependencyProvider Test Suite", function () { - let workspaceContext: WorkspaceContext; - let folderContext: FolderContext; - let treeProvider: PackageDependenciesProvider; - this.timeout(3 * 60 * 1000); // Allow up to 3 minutes to build - - activateExtensionForSuite({ - async setup(ctx) { - workspaceContext = ctx; - await waitForNoRunningTasks(); - folderContext = await folderInRootWorkspace("dependencies", workspaceContext); - await executeTaskAndWaitForResult((await getBuildAllTask(folderContext)) as SwiftTask); - await folderContext.reload(); - treeProvider = new PackageDependenciesProvider(workspaceContext); - await workspaceContext.focusFolder(folderContext); - }, - async teardown() { - contextKeys.flatDependenciesList = false; - treeProvider.dispose(); - }, - }); - - setup(async () => { - await workspaceContext.focusFolder(folderContext); - }); - - test("Includes remote dependency", async () => { - contextKeys.flatDependenciesList = false; - const items = await treeProvider.getChildren(); - - const dep = items.find(n => n.name === "swift-markdown") as PackageNode; - expect(dep, `${JSON.stringify(items, null, 2)}`).to.not.be.undefined; - expect(dep?.location).to.equal("https://github.com/swiftlang/swift-markdown.git"); - assertPathsEqual( - dep?.path, - path.join(testAssetPath("dependencies"), ".build/checkouts/swift-markdown") - ); - }); - - test("Includes local dependency", async () => { - const items = await treeProvider.getChildren(); - - const dep = items.find(n => n.name === "defaultpackage") as PackageNode; - expect( - dep, - `Expected to find defaultPackage, but instead items were ${items.map(n => n.name)}` - ).to.not.be.undefined; - assertPathsEqual(dep?.location, testAssetPath("defaultPackage")); - assertPathsEqual(dep?.path, testAssetPath("defaultPackage")); - }); - - test("Lists local dependency file structure", async () => { - contextKeys.flatDependenciesList = false; - const items = await treeProvider.getChildren(); - - const dep = items.find(n => n.name === "defaultpackage") as PackageNode; - expect( - dep, - `Expected to find defaultPackage, but instead items were ${items.map(n => n.name)}` - ).to.not.be.undefined; - - const folders = await treeProvider.getChildren(dep); - const folder = folders.find(n => n.name === "Sources"); - expect(folder).to.not.be.undefined; - - assertPathsEqual(folder?.path, path.join(testAssetPath("defaultPackage"), "Sources")); - - const childFolders = await treeProvider.getChildren(folder); - const childFolder = childFolders.find(n => n.name === "PackageExe"); - expect(childFolder).to.not.be.undefined; - - assertPathsEqual( - childFolder?.path, - path.join(testAssetPath("defaultPackage"), "Sources/PackageExe") - ); - - const files = await treeProvider.getChildren(childFolder); - const file = files.find(n => n.name === "main.swift"); - expect(file).to.not.be.undefined; - - assertPathsEqual( - file?.path, - path.join(testAssetPath("defaultPackage"), "Sources/PackageExe/main.swift") - ); - }); - - test("Lists remote dependency file structure", async () => { - contextKeys.flatDependenciesList = false; - const items = await treeProvider.getChildren(); - - const dep = items.find(n => n.name === "swift-markdown") as PackageNode; - expect(dep, `${JSON.stringify(items, null, 2)}`).to.not.be.undefined; - - const folders = await treeProvider.getChildren(dep); - const folder = folders.find(n => n.name === "Sources"); - expect(folder).to.not.be.undefined; - - const depPath = path.join(testAssetPath("dependencies"), ".build/checkouts/swift-markdown"); - assertPathsEqual(folder?.path, path.join(depPath, "Sources")); - - const childFolders = await treeProvider.getChildren(folder); - const childFolder = childFolders.find(n => n.name === "CAtomic"); - expect(childFolder).to.not.be.undefined; - - assertPathsEqual(childFolder?.path, path.join(depPath, "Sources/CAtomic")); - - const files = await treeProvider.getChildren(childFolder); - const file = files.find(n => n.name === "CAtomic.c"); - expect(file).to.not.be.undefined; - - assertPathsEqual(file?.path, path.join(depPath, "Sources/CAtomic/CAtomic.c")); - }); - - test("Shows a flat dependency list", async () => { - contextKeys.flatDependenciesList = true; - const items = await treeProvider.getChildren(); - expect(items.length).to.equal(3); - expect(items.find(n => n.name === "swift-markdown")).to.not.be.undefined; - expect(items.find(n => n.name === "swift-cmark")).to.not.be.undefined; - expect(items.find(n => n.name === "defaultpackage")).to.not.be.undefined; - }); - - test("Shows a nested dependency list", async () => { - contextKeys.flatDependenciesList = false; - const items = await treeProvider.getChildren(); - expect(items.length).to.equal(2); - expect(items.find(n => n.name === "swift-markdown")).to.not.be.undefined; - expect(items.find(n => n.name === "defaultpackage")).to.not.be.undefined; - }); - - function assertPathsEqual(path1: string | undefined, path2: string | undefined) { - expect(path1).to.not.be.undefined; - expect(path2).to.not.be.undefined; - // Convert to vscode.Uri to normalize paths, including drive letter capitalization on Windows. - expect(vscode.Uri.file(path1!).fsPath).to.equal(vscode.Uri.file(path2!).fsPath); - } -}); diff --git a/test/integration-tests/ui/ProjectPanelProvider.test.ts b/test/integration-tests/ui/ProjectPanelProvider.test.ts new file mode 100644 index 000000000..e1cb5c433 --- /dev/null +++ b/test/integration-tests/ui/ProjectPanelProvider.test.ts @@ -0,0 +1,300 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2024 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 { expect } from "chai"; +import { beforeEach, afterEach } from "mocha"; +import * as vscode from "vscode"; +import * as path from "path"; +import { ProjectPanelProvider, PackageNode, FileNode } from "../../../src/ui/ProjectPanelProvider"; +import { executeTaskAndWaitForResult, waitForNoRunningTasks } from "../../utilities/tasks"; +import { getBuildAllTask, SwiftTask } from "../../../src/tasks/SwiftTaskProvider"; +import { testAssetPath } from "../../fixtures"; +import { + activateExtensionForSuite, + folderInRootWorkspace, + updateSettings, +} from "../utilities/testutilities"; +import contextKeys from "../../../src/contextKeys"; +import { WorkspaceContext } from "../../../src/WorkspaceContext"; +import { Version } from "../../../src/utilities/version"; + +suite("ProjectPanelProvider Test Suite", function () { + let workspaceContext: WorkspaceContext; + let treeProvider: ProjectPanelProvider; + this.timeout(2 * 60 * 1000); // Allow up to 2 minutes to build + + activateExtensionForSuite({ + async setup(ctx) { + workspaceContext = ctx; + await waitForNoRunningTasks(); + const folderContext = await folderInRootWorkspace("targets", workspaceContext); + await vscode.workspace.openTextDocument( + path.join(folderContext.folder.fsPath, "Package.swift") + ); + const buildAllTask = await getBuildAllTask(folderContext); + buildAllTask.definition.dontTriggerTestDiscovery = true; + await executeTaskAndWaitForResult(buildAllTask as SwiftTask); + await folderContext.loadSwiftPlugins(); + treeProvider = new ProjectPanelProvider(workspaceContext); + await workspaceContext.focusFolder(folderContext); + }, + async teardown() { + contextKeys.flatDependenciesList = false; + treeProvider.dispose(); + }, + testAssets: ["targets"], + }); + + let resetSettings: (() => Promise) | undefined; + beforeEach(async function () { + resetSettings = await updateSettings({ + "swift.debugger.debugAdapter": "CodeLLDB", + }); + }); + + afterEach(async () => { + if (resetSettings) { + await resetSettings(); + } + }); + + test("Includes top level nodes", async () => { + const commands = await treeProvider.getChildren(); + const commandNames = commands.map(n => n.name); + expect(commandNames).to.deep.equal([ + "Dependencies", + "Targets", + "Tasks", + "Snippets", + "Commands", + ]); + }); + + suite("Targets", () => { + test("Includes targets", async () => { + const targets = await getHeaderChildren("Targets"); + const targetNames = targets.map(target => target.name); + expect( + targetNames, + `Expected to find dependencies target, but instead items were ${targetNames}` + ).to.deep.equal([ + "ExecutableTarget", + "LibraryTarget", + "PluginTarget", + "AnotherTests", + "TargetsTests", + ]); + }); + }); + + suite("Tasks", () => { + beforeEach(async () => { + await waitForNoRunningTasks(); + }); + + async function getBuildAllTask() { + // In Swift 5.10 and below the build tasks are disabled while other tasks that could modify .build are running. + // Typically because the extension has just started up in tests its `swift test list` that runs to gather tests + // for the test explorer. If we're running 5.10 or below, poll for the build all task for up to 60 seconds. + if (workspaceContext.toolchain.swiftVersion.isLessThan(new Version(6, 0, 0))) { + const startTime = Date.now(); + let task: PackageNode | undefined; + while (!task && Date.now() - startTime < 45 * 1000) { + const tasks = await getHeaderChildren("Tasks"); + task = tasks.find(n => n.name === "Build All (targets)") as PackageNode; + await new Promise(resolve => setTimeout(resolve, 1000)); + } + return task; + } else { + const tasks = await getHeaderChildren("Tasks"); + return tasks.find(n => n.name === "Build All (targets)") as PackageNode; + } + } + + test("Includes tasks", async () => { + const dep = await getBuildAllTask(); + expect(dep).to.not.be.undefined; + }); + + test("Executes a task", async () => { + const task = await getBuildAllTask(); + expect(task).to.not.be.undefined; + const treeItem = task?.toTreeItem(); + expect(treeItem?.command).to.not.be.undefined; + expect(treeItem?.command?.arguments).to.not.be.undefined; + if (treeItem && treeItem.command && treeItem.command.arguments) { + const command = treeItem.command.command; + const args = treeItem.command.arguments; + const result = await vscode.commands.executeCommand(command, ...args); + expect(result).to.be.true; + } + }); + }); + + suite("Snippets", () => { + test("Includes snippets", async () => { + const snippets = await getHeaderChildren("Snippets"); + const snippetNames = snippets.map(n => n.name); + expect(snippetNames).to.deep.equal(["AnotherSnippet", "Snippet"]); + }); + + test("Executes a snippet", async () => { + const snippets = await getHeaderChildren("Snippets"); + const snippet = snippets.find(n => n.name === "Snippet"); + expect(snippet).to.not.be.undefined; + + const result = await vscode.commands.executeCommand("swift.runSnippet", snippet?.name); + expect(result).to.be.true; + }); + }); + + suite("Commands", () => { + test("Includes commands", async () => { + const commands = await getHeaderChildren("Commands"); + const commandNames = commands.map(n => n.name); + expect(commandNames).to.deep.equal(["PluginTarget"]); + }); + + test("Executes a command", async () => { + const commands = await getHeaderChildren("Commands"); + const command = commands.find(n => n.name === "PluginTarget"); + expect(command).to.not.be.undefined; + const treeItem = command?.toTreeItem(); + expect(treeItem?.command).to.not.be.undefined; + expect(treeItem?.command?.arguments).to.not.be.undefined; + if (treeItem && treeItem.command && treeItem.command.arguments) { + const command = treeItem.command.command; + const args = treeItem.command.arguments; + const result = await vscode.commands.executeCommand(command, ...args); + expect(result).to.be.true; + } + }); + }); + + suite("Dependencies", () => { + test("Includes remote dependency", async () => { + contextKeys.flatDependenciesList = false; + const items = await getHeaderChildren("Dependencies"); + const dep = items.find(n => n.name === "swift-markdown") as PackageNode; + expect(dep, `${JSON.stringify(items, null, 2)}`).to.not.be.undefined; + expect(dep?.location).to.equal("https://github.com/swiftlang/swift-markdown.git"); + assertPathsEqual( + dep?.path, + path.join(testAssetPath("targets"), ".build/checkouts/swift-markdown") + ); + }); + + test("Includes local dependency", async () => { + const items = await getHeaderChildren("Dependencies"); + const dep = items.find(n => n.name === "defaultpackage") as PackageNode; + expect( + dep, + `Expected to find defaultPackage, but instead items were ${items.map(n => n.name)}` + ).to.not.be.undefined; + assertPathsEqual(dep?.location, testAssetPath("defaultPackage")); + assertPathsEqual(dep?.path, testAssetPath("defaultPackage")); + }); + + test("Lists local dependency file structure", async () => { + contextKeys.flatDependenciesList = false; + const children = await getHeaderChildren("Dependencies"); + const dep = children.find(n => n.name === "defaultpackage") as PackageNode; + expect( + dep, + `Expected to find defaultPackage, but instead items were ${children.map(n => n.name)}` + ).to.not.be.undefined; + + const folders = await treeProvider.getChildren(dep); + const folder = folders.find(n => n.name === "Sources") as FileNode; + expect(folder).to.not.be.undefined; + + assertPathsEqual(folder?.path, path.join(testAssetPath("defaultPackage"), "Sources")); + + const childFolders = await treeProvider.getChildren(folder); + const childFolder = childFolders.find(n => n.name === "PackageExe") as FileNode; + expect(childFolder).to.not.be.undefined; + + assertPathsEqual( + childFolder?.path, + path.join(testAssetPath("defaultPackage"), "Sources/PackageExe") + ); + + const files = await treeProvider.getChildren(childFolder); + const file = files.find(n => n.name === "main.swift") as FileNode; + expect(file).to.not.be.undefined; + + assertPathsEqual( + file?.path, + path.join(testAssetPath("defaultPackage"), "Sources/PackageExe/main.swift") + ); + }); + + test("Lists remote dependency file structure", async () => { + contextKeys.flatDependenciesList = false; + const children = await getHeaderChildren("Dependencies"); + const dep = children.find(n => n.name === "swift-markdown") as PackageNode; + expect(dep, `${JSON.stringify(children, null, 2)}`).to.not.be.undefined; + + const folders = await treeProvider.getChildren(dep); + const folder = folders.find(n => n.name === "Sources") as FileNode; + expect(folder).to.not.be.undefined; + + const depPath = path.join(testAssetPath("targets"), ".build/checkouts/swift-markdown"); + assertPathsEqual(folder?.path, path.join(depPath, "Sources")); + + const childFolders = await treeProvider.getChildren(folder); + const childFolder = childFolders.find(n => n.name === "CAtomic") as FileNode; + expect(childFolder).to.not.be.undefined; + + assertPathsEqual(childFolder?.path, path.join(depPath, "Sources/CAtomic")); + + const files = await treeProvider.getChildren(childFolder); + const file = files.find(n => n.name === "CAtomic.c") as FileNode; + expect(file).to.not.be.undefined; + + assertPathsEqual(file?.path, path.join(depPath, "Sources/CAtomic/CAtomic.c")); + }); + + test("Shows a flat dependency list", async () => { + contextKeys.flatDependenciesList = true; + const items = await getHeaderChildren("Dependencies"); + expect(items.length).to.equal(3); + expect(items.find(n => n.name === "swift-markdown")).to.not.be.undefined; + expect(items.find(n => n.name === "swift-cmark")).to.not.be.undefined; + expect(items.find(n => n.name === "defaultpackage")).to.not.be.undefined; + }); + + test("Shows a nested dependency list", async () => { + contextKeys.flatDependenciesList = false; + const items = await getHeaderChildren("Dependencies"); + expect(items.length).to.equal(2); + expect(items.find(n => n.name === "swift-markdown")).to.not.be.undefined; + expect(items.find(n => n.name === "defaultpackage")).to.not.be.undefined; + }); + }); + + async function getHeaderChildren(headerName: string) { + const headers = await treeProvider.getChildren(); + const header = headers.find(n => n.name === headerName) as PackageNode; + expect(header).to.not.be.undefined; + return await header.getChildren(); + } + + function assertPathsEqual(path1: string | undefined, path2: string | undefined) { + expect(path1).to.not.be.undefined; + expect(path2).to.not.be.undefined; + // Convert to vscode.Uri to normalize paths, including drive letter capitalization on Windows. + expect(vscode.Uri.file(path1!).fsPath).to.equal(vscode.Uri.file(path2!).fsPath); + } +}); diff --git a/test/integration-tests/utilities/testutilities.ts b/test/integration-tests/utilities/testutilities.ts index 6393e39a5..321b8ccf3 100644 --- a/test/integration-tests/utilities/testutilities.ts +++ b/test/integration-tests/utilities/testutilities.ts @@ -311,7 +311,7 @@ export type SettingsMap = { [key: string]: unknown }; export async function updateSettings(settings: SettingsMap): Promise<() => Promise> { const applySettings = async (settings: SettingsMap) => { const savedOriginalSettings: SettingsMap = {}; - Object.keys(settings).forEach(async setting => { + for (const setting of Object.keys(settings)) { const { section, name } = decomposeSettingName(setting); const config = vscode.workspace.getConfiguration(section, { languageId: "swift" }); savedOriginalSettings[setting] = config.get(name); @@ -320,7 +320,7 @@ export async function updateSettings(settings: SettingsMap): Promise<() => Promi settings[setting] === "" ? undefined : settings[setting], vscode.ConfigurationTarget.Workspace ); - }); + } // There is actually a delay between when the config.update promise resolves and when // the setting is actually written. If we exit this function right away the test might diff --git a/test/unit-tests/ui/PackageDependencyProvider.test.ts b/test/unit-tests/ui/PackageDependencyProvider.test.ts index d7d14b994..b7cca7b3a 100644 --- a/test/unit-tests/ui/PackageDependencyProvider.test.ts +++ b/test/unit-tests/ui/PackageDependencyProvider.test.ts @@ -15,7 +15,7 @@ import { expect } from "chai"; import * as vscode from "vscode"; import * as fs from "fs/promises"; -import { FileNode, PackageNode } from "../../../src/ui/PackageDependencyProvider"; +import { FileNode, PackageNode } from "../../../src/ui/ProjectPanelProvider"; import { mockGlobalModule } from "../../MockUtils"; suite("PackageDependencyProvider Unit Test Suite", function () {