From 2514f9c8cdd3f3d00eac7fbf413e1b29f4bc015b Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:05:52 -0700 Subject: [PATCH 1/9] VSC extension: add project detection to winapp init command When no project is found at the workspace root, the extension now searches for compatible app projects (Tauri, Electron, Flutter, .NET, Rust, C++) and presents them in a QuickPick for the user to select. Mirrors the CLI's ProjectDetectionService behavior (PR #530): - 0 projects: QuickPick with current directory fallback - 1 project at root: proceeds directly to SDK mode selection - 1 project nested: QuickPick with project + current directory - 2+ projects: QuickPick with all projects + current directory - 10+ projects: adds note that search stopped at 10 entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/src/extension.ts | 81 +++++++++- src/winapp-VSC/src/project-detection.ts | 194 ++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/winapp-VSC/src/project-detection.ts diff --git a/src/winapp-VSC/src/extension.ts b/src/winapp-VSC/src/extension.ts index 6d6428d0..33897c2e 100644 --- a/src/winapp-VSC/src/extension.ts +++ b/src/winapp-VSC/src/extension.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { spawn } from 'child_process'; import { getWinappCliPath, WINAPP_CLI_CALLER_VALUE } from './winapp-cli-utils'; +import { detectProjectAt, detectProjects, getProjectLabel, DetectedProject } from './project-detection'; import { glob } from 'glob'; const WINAPP_DEBUG_TYPE = 'winapp'; @@ -392,12 +393,90 @@ export function activate(context: vscode.ExtensionContext) { return; } + // Check if there's a project at the workspace root + const rootProject = detectProjectAt(workspacePath, workspacePath); + + let selectedPath: string; + if (rootProject) { + // Project found at root — use it directly + selectedPath = '.'; + } else { + // No project at root — search for projects in the workspace + const projects = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Searching for app projects...' }, + async () => detectProjects(workspacePath) + ); + + if (projects.length === 0) { + // No projects found — offer to initialize in current directory via QuickPick + const picked = await vscode.window.showQuickPick( + [ + { label: '$(folder) Current directory', description: './ — no project detected' } + ], + { placeHolder: 'No compatible app projects were found. Initialize with winapp here anyway?' } + ); + if (!picked) { + return; + } + selectedPath = '.'; + } else if (projects.length === 1) { + // Single project found — let user confirm via QuickPick + const project = projects[0]; + const picked = await vscode.window.showQuickPick( + [ + { + label: `$(file-code) ${project.type} project`, + description: getProjectLabel(project).replace(`${project.type} project `, ''), + project + }, + { + label: '$(folder) Current directory', + description: './ — no project detected', + project: undefined as typeof project | undefined + } + ], + { placeHolder: 'Which project would you like to initialize with winapp?' } + ); + if (!picked) { + return; + } + selectedPath = picked.project + ? (path.relative(workspacePath, picked.project.directory) || '.') + : '.'; + } else { + // Multiple projects found — let user pick + const maxProjects = 10; + const items = projects.map(p => ({ + label: `$(file-code) ${p.type} project`, + description: getProjectLabel(p).replace(`${p.type} project `, ''), + project: p + })); + items.push({ + label: '$(folder) Current directory', + description: './ — no project detected', + project: undefined as unknown as DetectedProject + }); + + const placeHolder = projects.length >= maxProjects + ? 'Which project would you like to initialize with winapp? (Search stopped at 10 entries)' + : 'Which project would you like to initialize with winapp?'; + + const picked = await vscode.window.showQuickPick(items, { placeHolder }); + if (!picked) { + return; + } + selectedPath = picked.project + ? (path.relative(workspacePath, picked.project.directory) || '.') + : '.'; + } + } + const sdkMode = await vscode.window.showQuickPick( ['stable', 'preview', 'experimental', 'none'], { placeHolder: 'Select SDK installation mode' } ); - let command = 'init . --use-defaults'; + let command = `init "${selectedPath}" --use-defaults`; if (sdkMode && sdkMode !== 'stable') { command += ` --setup-sdks ${sdkMode}`; } diff --git a/src/winapp-VSC/src/project-detection.ts b/src/winapp-VSC/src/project-detection.ts new file mode 100644 index 00000000..e116105d --- /dev/null +++ b/src/winapp-VSC/src/project-detection.ts @@ -0,0 +1,194 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Mirrors the C# DetectedProjectType enum from WinApp.Cli. + */ +export type DetectedProjectType = 'Tauri' | 'Electron' | 'Flutter' | '.NET' | 'Rust' | 'C++'; + +/** + * Represents a project detected during directory scanning. + * Mirrors the C# DetectedProject record from WinApp.Cli. + */ +export interface DetectedProject { + type: DetectedProjectType; + directory: string; + displayPath: string; + projectFileName: string; +} + +/** + * Returns a display string like ".NET project (./src/MyApp/MyApp.csproj)" + */ +export function getDisplayFilePath(project: DetectedProject): string { + return project.displayPath === '.' + ? `./${project.projectFileName}` + : `./${project.displayPath}/${project.projectFileName}`; +} + +/** + * Returns a human-readable label like ".NET project (./src/MyApp/MyApp.csproj)" + */ +export function getProjectLabel(project: DetectedProject): string { + return `${project.type} project (${getDisplayFilePath(project)})`; +} + +const SKIP_DIRS = new Set([ + 'node_modules', '.git', 'bin', 'obj', 'target', 'build', + '.winapp', '.vs', '.idea', 'dist', 'out', 'packages', + '__pycache__', '.dart_tool', '.pub-cache' +]); + +/** + * Detects a project at a single directory (does not recurse). + * Mirrors ProjectDetectionService.DetectProject from WinApp.Cli. + */ +export function detectProjectAt(directory: string, searchRoot: string): DetectedProject | undefined { + const displayPath = getRelativeDisplayPath(directory, searchRoot); + + // Tauri: check immediate subdirectories for tauri.conf.json + const tauriConf = findTauriConfFile(directory); + if (tauriConf) { + return { type: 'Tauri', directory, displayPath, projectFileName: tauriConf }; + } + + // Electron: package.json with electron dependency + if (isElectronProject(directory)) { + return { type: 'Electron', directory, displayPath, projectFileName: 'package.json' }; + } + + // Flutter: pubspec.yaml + if (fs.existsSync(path.join(directory, 'pubspec.yaml'))) { + return { type: 'Flutter', directory, displayPath, projectFileName: 'pubspec.yaml' }; + } + + // .NET: *.csproj (only executable, non-test projects) + const csprojName = findExecutableCsproj(directory); + if (csprojName) { + return { type: '.NET', directory, displayPath, projectFileName: csprojName }; + } + + // Rust: Cargo.toml + if (fs.existsSync(path.join(directory, 'Cargo.toml'))) { + return { type: 'Rust', directory, displayPath, projectFileName: 'Cargo.toml' }; + } + + // C++: CMakeLists.txt + if (fs.existsSync(path.join(directory, 'CMakeLists.txt'))) { + return { type: 'C++', directory, displayPath, projectFileName: 'CMakeLists.txt' }; + } + + return undefined; +} + +/** + * Performs a breadth-first search of the directory tree to find compatible projects. + * Mirrors ProjectDetectionService.DetectProjectsAsync from WinApp.Cli. + */ +export function detectProjects(root: string, maxProjects: number = 10): DetectedProject[] { + const results: DetectedProject[] = []; + const queue: string[] = [root]; + + while (queue.length > 0 && results.length < maxProjects) { + const current = queue.shift()!; + const detected = detectProjectAt(current, root); + if (detected) { + results.push(detected); + // Don't recurse into detected project directories + continue; + } + + // Enqueue child directories (skip known non-project dirs) + try { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { continue; } + if (entry.name.startsWith('.') && entry.name !== '.') { continue; } + if (SKIP_DIRS.has(entry.name)) { continue; } + // Skip symlinks/junctions + const fullPath = path.join(current, entry.name); + try { + const stat = fs.lstatSync(fullPath); + if (stat.isSymbolicLink()) { continue; } + } catch { + continue; + } + queue.push(fullPath); + } + } catch { + // Skip directories we can't read + } + } + + return results; +} + +function getRelativeDisplayPath(directory: string, searchRoot: string): string { + const relative = path.relative(searchRoot, directory); + if (!relative || relative === '.') { + return '.'; + } + return relative.replace(/\\/g, '/'); +} + +function findTauriConfFile(directory: string): string | undefined { + try { + const entries = fs.readdirSync(directory, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { continue; } + if (entry.name.startsWith('.')) { continue; } + const subDir = path.join(directory, entry.name); + try { + const stat = fs.lstatSync(subDir); + if (stat.isSymbolicLink()) { continue; } + } catch { + continue; + } + if (fs.existsSync(path.join(subDir, 'tauri.conf.json'))) { + return `${entry.name}/tauri.conf.json`; + } + } + } catch { + // Skip if we can't read + } + return undefined; +} + +function isElectronProject(directory: string): boolean { + const packageJsonPath = path.join(directory, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { return false; } + try { + const content = fs.readFileSync(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(content); + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + return 'electron' in deps; + } catch { + return false; + } +} + +function findExecutableCsproj(directory: string): string | undefined { + try { + const entries = fs.readdirSync(directory); + for (const entry of entries) { + if (!entry.endsWith('.csproj')) { continue; } + const filePath = path.join(directory, entry); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + // Skip test projects + if (content.includes('Microsoft.NET.Test.Sdk') || content.includes('xunit') || content.includes('MSTest')) { + continue; + } + // Must be an executable output type (Exe or WinExe) + if (content.includes('Exe') || content.includes('WinExe')) { + return entry; + } + } catch { + continue; + } + } + } catch { + // Skip if we can't read + } + return undefined; +} From 947c502891d4da3fca9a9cd28c37e8c1b7343bb5 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:29:14 -0700 Subject: [PATCH 2/9] VSC extension: resolve project directory for all commands Commands that need a project context (restore, update, pack, run, create-debug-identity, manifest generate, manifest update-assets, cert generate, get-winapp-path, manifest add-alias, unregister) now use a shared resolveProjectDirectory() helper that: 1. If workspace root contains a project, uses it directly (no prompt) 2. Otherwise searches for projects in the workspace 3. Single project found: auto-selects it 4. Multiple projects found: shows QuickPick to choose 5. No projects found: falls back to workspace root Commands that take explicit file paths (sign, cert install, cert info) are unchanged. Resolves: #492 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/src/extension.ts | 126 +++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 11 deletions(-) diff --git a/src/winapp-VSC/src/extension.ts b/src/winapp-VSC/src/extension.ts index 33897c2e..11eebfb2 100644 --- a/src/winapp-VSC/src/extension.ts +++ b/src/winapp-VSC/src/extension.ts @@ -108,6 +108,54 @@ async function selectFolder(title: string): Promise { return result?.[0]?.fsPath; } +/** + * Resolves the project directory for commands that need a winapp project context. + * If the workspace root contains a project, uses it directly. + * Otherwise, searches for projects and presents a QuickPick. + * Returns the absolute path to the selected project directory, or undefined if cancelled. + */ +async function resolveProjectDirectory(workspacePath: string): Promise { + // If there's a project at the workspace root, use it directly + const rootProject = detectProjectAt(workspacePath, workspacePath); + if (rootProject) { + return workspacePath; + } + + // Search for projects in the workspace + const projects = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Searching for app projects...' }, + async () => detectProjects(workspacePath) + ); + + if (projects.length === 0) { + // No projects found — fall back to workspace root + return workspacePath; + } + + if (projects.length === 1) { + // Single project — auto-select it + return projects[0].directory; + } + + // Multiple projects — let user pick + const maxProjects = 10; + const items = projects.map(p => ({ + label: `$(file-code) ${p.type} project`, + description: getProjectLabel(p).replace(`${p.type} project `, ''), + directory: p.directory + })); + + const placeHolder = projects.length >= maxProjects + ? 'Which project? (Search stopped at 10 entries)' + : 'Which project would you like to target?'; + + const picked = await vscode.window.showQuickPick(items, { placeHolder }); + if (!picked) { + return undefined; + } + return picked.directory; +} + class WinAppDebugConfigurationProvider implements vscode.DebugConfigurationProvider { private extensionPath: string; @@ -493,7 +541,12 @@ export function activate(context: vscode.ExtensionContext) { return; } - await runWinappCommand(extensionPath, 'restore', workspacePath); + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + + await runWinappCommand(extensionPath, 'restore', projectDir); }) ); @@ -505,6 +558,11 @@ export function activate(context: vscode.ExtensionContext) { return; } + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + const sdkMode = await vscode.window.showQuickPick( ['stable', 'preview', 'experimental'], { placeHolder: 'Select SDK installation mode (optional)' } @@ -515,7 +573,7 @@ export function activate(context: vscode.ExtensionContext) { command += ` --setup-sdks ${sdkMode}`; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -527,6 +585,11 @@ export function activate(context: vscode.ExtensionContext) { return; } + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + const inputFolder = await selectFolder('Select input folder to package'); if (!inputFolder) { return; @@ -550,7 +613,7 @@ export function activate(context: vscode.ExtensionContext) { command += ' --self-contained'; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -562,12 +625,17 @@ export function activate(context: vscode.ExtensionContext) { return; } + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + const inputFolder = await selectFolder('Select input folder containing the app to run'); if (!inputFolder) { return; } - await runWinappCommand(extensionPath, `run "${inputFolder}"`, workspacePath); + await runWinappCommand(extensionPath, `run "${inputFolder}"`, projectDir); }) ); @@ -578,6 +646,12 @@ export function activate(context: vscode.ExtensionContext) { if (!workspacePath) { return; } + + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + const entrypoint = await selectFile('Select executable', { 'Executables': ['exe'], 'All files': ['*'] @@ -588,7 +662,7 @@ export function activate(context: vscode.ExtensionContext) { command += ` "${entrypoint}"`; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -600,6 +674,11 @@ export function activate(context: vscode.ExtensionContext) { return; } + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + const template = await vscode.window.showQuickPick( ['packaged', 'sparse'], { placeHolder: 'Select manifest template type' } @@ -610,7 +689,7 @@ export function activate(context: vscode.ExtensionContext) { command += ` --template ${template}`; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -622,6 +701,11 @@ export function activate(context: vscode.ExtensionContext) { return; } + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + const imagePath = await selectFile('Select source image for assets', { 'Images': ['png', 'jpg', 'jpeg', 'gif', 'bmp'] }); @@ -631,7 +715,7 @@ export function activate(context: vscode.ExtensionContext) { return; } - await runWinappCommand(extensionPath, `manifest update-assets "${imagePath}"`, workspacePath); + await runWinappCommand(extensionPath, `manifest update-assets "${imagePath}"`, projectDir); }) ); @@ -643,6 +727,11 @@ export function activate(context: vscode.ExtensionContext) { return; } + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + const install = await vscode.window.showQuickPick( ['Yes', 'No'], { placeHolder: 'Install certificate after generation?' } @@ -653,7 +742,7 @@ export function activate(context: vscode.ExtensionContext) { command += ' --install'; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -764,6 +853,11 @@ export function activate(context: vscode.ExtensionContext) { return; } + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + const global = await vscode.window.showQuickPick( ['Local (.winapp in workspace)', 'Global (shared cache)'], { placeHolder: 'Which path to retrieve?' } @@ -774,7 +868,7 @@ export function activate(context: vscode.ExtensionContext) { command += ' --global'; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -786,7 +880,12 @@ export function activate(context: vscode.ExtensionContext) { return; } - await runWinappCommand(extensionPath, 'manifest add-alias', workspacePath); + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + + await runWinappCommand(extensionPath, 'manifest add-alias', projectDir); }) ); @@ -798,7 +897,12 @@ export function activate(context: vscode.ExtensionContext) { return; } - await runWinappCommand(extensionPath, 'unregister', workspacePath); + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; + } + + await runWinappCommand(extensionPath, 'unregister', projectDir); }) ); From eef59e1713b0638d7ab78b272c599a4b7cc4b829 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:31:56 -0700 Subject: [PATCH 3/9] Docs: document workspace and multi-project support in VSC README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/winapp-VSC/README.md b/src/winapp-VSC/README.md index 9181f5a4..44a1299c 100644 --- a/src/winapp-VSC/README.md +++ b/src/winapp-VSC/README.md @@ -19,6 +19,27 @@ Simply navigate to the 'Extensions' tab in VS Code, and select the option to 'In ## Features +### Workspace & Multi-Project Support + +The extension supports workspaces where the app project is **not** at the root — such as monorepos, multi-app repositories, or nested project structures. + +**How it works:** + +When you run any WinApp command, the extension automatically detects project locations: + +| Scenario | Behavior | +|----------|----------| +| Project at workspace root | Command runs directly — no prompt (same as before) | +| No project at root, 1 project found elsewhere | Auto-selects that project | +| No project at root, multiple projects found | Shows a QuickPick list to choose which project to target | +| No projects found anywhere | Falls back to workspace root (the CLI will report an error if initialization is required) | + +**Supported project types:** .NET (WPF, WinForms, WinUI 3, Console), Electron, Tauri, Flutter, Rust, and C++ (CMake). + +The **WinApp: Initialize Project** command has additional behavior: when no project is at the root, it searches and lets you pick which project to initialize. If no projects are found at all, it offers to initialize in the current directory anyway. + +> **Note:** If more than 10 projects are discovered, the search stops and the QuickPick indicates that the list may be incomplete. + ### Command Palette All commands are accessible from the Command Palette (`Ctrl+Shift+P`). Type **WinApp** to see the full list. @@ -121,7 +142,11 @@ The extension provides a **custom `winapp` debug type** that launches your app w ### Initialize and set up a project -Run **WinApp: Initialize Project** to configure your project with the Windows SDK and/or Windows App SDK. The command walks you through selecting an SDK channel and sets up the necessary dependencies. +Run **WinApp: Initialize Project** to configure your project with the Windows SDK and/or Windows App SDK. The command: + +1. **Detects your project** — If there's a recognized app project at the workspace root, it proceeds immediately. Otherwise, it searches the workspace and presents a list of discovered projects for you to choose from. +2. **Asks for SDK channel** — Select stable, preview, experimental, or none (for projects like Rust/Tauri that bring their own SDK bindings). +3. **Runs `winapp init`** — Sets up the manifest, SDK packages, and configuration for the selected project. ### Debug with package identity From 8976dc2b966fc9ab34e3bdfa134a32337343fec0 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:51:55 -0700 Subject: [PATCH 4/9] Sync SKIP_DIRS with CLI's DefaultIgnoredDirectories Adds: debug, release, .vscode, artifacts, TestResults, .gradle, .nuget, .cargo Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/src/project-detection.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/winapp-VSC/src/project-detection.ts b/src/winapp-VSC/src/project-detection.ts index e116105d..929031b4 100644 --- a/src/winapp-VSC/src/project-detection.ts +++ b/src/winapp-VSC/src/project-detection.ts @@ -34,9 +34,10 @@ export function getProjectLabel(project: DetectedProject): string { } const SKIP_DIRS = new Set([ - 'node_modules', '.git', 'bin', 'obj', 'target', 'build', - '.winapp', '.vs', '.idea', 'dist', 'out', 'packages', - '__pycache__', '.dart_tool', '.pub-cache' + 'node_modules', '.git', 'bin', 'obj', 'debug', 'release', + '.vs', '.vscode', '.idea', 'packages', 'dist', 'build', 'out', + 'target', '.winapp', 'artifacts', 'TestResults', + '__pycache__', '.gradle', '.dart_tool', '.pub-cache', '.nuget', '.cargo' ]); /** From 4ea202f2231b90e7430615dfc018a61612b29d65 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:58:16 -0700 Subject: [PATCH 5/9] Fix project detection to match CLI behavior 1. Symlinks/junctions: use Dirent.isSymbolicLink() which on Windows correctly reports both symlinks and junctions (reparse points), matching the CLI's FileAttributes.ReparsePoint check. 2. csproj detection: use regex to extract and element values (case-insensitive) instead of naive string.includes() checks. Mirrors the CLI's XDocument-based parsing logic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/src/project-detection.ts | 40 ++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/winapp-VSC/src/project-detection.ts b/src/winapp-VSC/src/project-detection.ts index 929031b4..8743e5fd 100644 --- a/src/winapp-VSC/src/project-detection.ts +++ b/src/winapp-VSC/src/project-detection.ts @@ -103,14 +103,15 @@ export function detectProjects(root: string, maxProjects: number = 10): Detected try { const entries = fs.readdirSync(current, { withFileTypes: true }); for (const entry of entries) { - if (!entry.isDirectory()) { continue; } + if (!entry.isDirectory() && !entry.isSymbolicLink()) { continue; } if (entry.name.startsWith('.') && entry.name !== '.') { continue; } if (SKIP_DIRS.has(entry.name)) { continue; } - // Skip symlinks/junctions const fullPath = path.join(current, entry.name); + // Skip symlinks and junctions (reparse points) + if (entry.isSymbolicLink()) { continue; } try { - const stat = fs.lstatSync(fullPath); - if (stat.isSymbolicLink()) { continue; } + const stat = fs.statSync(fullPath); + if (!stat.isDirectory()) { continue; } } catch { continue; } @@ -176,12 +177,7 @@ function findExecutableCsproj(directory: string): string | undefined { const filePath = path.join(directory, entry); try { const content = fs.readFileSync(filePath, 'utf-8'); - // Skip test projects - if (content.includes('Microsoft.NET.Test.Sdk') || content.includes('xunit') || content.includes('MSTest')) { - continue; - } - // Must be an executable output type (Exe or WinExe) - if (content.includes('Exe') || content.includes('WinExe')) { + if (isExecutableCsproj(content)) { return entry; } } catch { @@ -193,3 +189,27 @@ function findExecutableCsproj(directory: string): string | undefined { } return undefined; } + +/** + * Parses csproj XML content to determine if it's an executable, non-test project. + * Mirrors the CLI's IsExecutableProject logic. + */ +function isExecutableCsproj(content: string): boolean { + // Extract OutputType value from PropertyGroup elements + const outputTypeMatch = content.match(/\s*(.*?)\s*<\/OutputType>/i); + if (!outputTypeMatch) { + return false; + } + const outputType = outputTypeMatch[1].toLowerCase(); + if (outputType !== 'exe' && outputType !== 'winexe') { + return false; + } + + // Check IsTestProject property + const isTestMatch = content.match(/\s*(.*?)\s*<\/IsTestProject>/i); + if (isTestMatch && isTestMatch[1].toLowerCase() === 'true') { + return false; + } + + return true; +} From e663e390275a2fa603ed107ab53a23e68260a7ca Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:42:55 -0700 Subject: [PATCH 6/9] Add winapp.appDirectories setting to skip auto-scanning When configured in .vscode/settings.json, commands use the listed directories directly instead of scanning the workspace. Single entry auto-selects; multiple entries show a QuickPick. This is useful for users who know their project locations and want to avoid the scan on every command invocation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/README.md | 30 ++++++++++++++++++++++++++++-- src/winapp-VSC/package.json | 15 ++++++++++++++- src/winapp-VSC/src/extension.ts | 24 ++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/winapp-VSC/README.md b/src/winapp-VSC/README.md index 44a1299c..ab08c8b3 100644 --- a/src/winapp-VSC/README.md +++ b/src/winapp-VSC/README.md @@ -25,11 +25,37 @@ The extension supports workspaces where the app project is **not** at the root **How it works:** -When you run any WinApp command, the extension automatically detects project locations: +When you run any WinApp command, the extension resolves the target project directory using this priority: + +1. **`winapp.appDirectories` setting** — If specified in `.vscode/settings.json`, the extension uses these paths directly (no scanning). With one entry, it auto-selects; with multiple, it shows a QuickPick. +2. **Project at workspace root** — If a recognized project exists at the root, commands run there immediately. +3. **Automatic scan** — Searches the workspace for compatible projects and prompts if multiple are found. + +**Configuration (optional):** + +To skip automatic scanning, add the `winapp.appDirectories` setting to your workspace: + +```jsonc +// .vscode/settings.json +{ + "winapp.appDirectories": [ + "apps/my-app", + "apps/shell" + ] +} +``` + +| Scenario | Behavior | +|----------|----------| +| Setting has 1 entry | All commands auto-target that directory | +| Setting has multiple entries | QuickPick prompt to choose which project | +| Setting is absent or empty | Falls back to auto-detection (see below) | + +**Auto-detection behavior (when setting is not configured):** | Scenario | Behavior | |----------|----------| -| Project at workspace root | Command runs directly — no prompt (same as before) | +| Project at workspace root | Command runs directly — no prompt | | No project at root, 1 project found elsewhere | Auto-selects that project | | No project at root, multiple projects found | Shows a QuickPick list to choose which project to target | | No projects found anywhere | Falls back to workspace root (the CLI will report an error if initialization is required) | diff --git a/src/winapp-VSC/package.json b/src/winapp-VSC/package.json index 83c8cff1..62d4806f 100644 --- a/src/winapp-VSC/package.json +++ b/src/winapp-VSC/package.json @@ -173,7 +173,20 @@ } ] } - ] + ], + "configuration": { + "title": "WinApp", + "properties": { + "winapp.appDirectories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "List of relative paths to app project directories. When specified, WinApp commands will target these directories instead of scanning the workspace. If a single directory is listed, it is used automatically. If multiple are listed, you'll be prompted to choose." + } + } + } }, "scripts": { "vscode:prepublish": "npm run package", diff --git a/src/winapp-VSC/src/extension.ts b/src/winapp-VSC/src/extension.ts index 11eebfb2..c763c739 100644 --- a/src/winapp-VSC/src/extension.ts +++ b/src/winapp-VSC/src/extension.ts @@ -110,11 +110,31 @@ async function selectFolder(title: string): Promise { /** * Resolves the project directory for commands that need a winapp project context. - * If the workspace root contains a project, uses it directly. - * Otherwise, searches for projects and presents a QuickPick. + * Priority: 1) winapp.appDirectories setting, 2) project at workspace root, 3) scan workspace. * Returns the absolute path to the selected project directory, or undefined if cancelled. */ async function resolveProjectDirectory(workspacePath: string): Promise { + // Check for explicit appDirectories setting + const config = vscode.workspace.getConfiguration('winapp'); + const appDirs: string[] = config.get('appDirectories', []); + + if (appDirs.length > 0) { + if (appDirs.length === 1) { + return path.resolve(workspacePath, appDirs[0]); + } + + // Multiple configured directories — show QuickPick + const items = appDirs.map(dir => ({ + label: `$(folder) ${dir}`, + directory: path.resolve(workspacePath, dir) + })); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Which project would you like to target?' + }); + return picked?.directory; + } + // If there's a project at the workspace root, use it directly const rootProject = detectProjectAt(workspacePath, workspacePath); if (rootProject) { From 8262e093752b20f6fcc4b41104dc5975621bfab1 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:23:35 -0700 Subject: [PATCH 7/9] Add unit tests for project detection 25 tests covering: - detectProjectAt: all project types (.NET, Electron, Flutter, Rust, C++, Tauri), test project exclusion, library exclusion, priority ordering, display path formatting - detectProjects: BFS traversal, maxProjects limit, ignored directory skipping (node_modules, bin, obj, etc.), hidden dir skipping, no recursion into detected projects - getDisplayFilePath / getProjectLabel: formatting helpers Run with: npm run test:unit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/package.json | 1 + .../src/test/project-detection.test.ts | 311 ++++++++++++++++++ src/winapp-VSC/tsconfig.json | 2 +- 3 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 src/winapp-VSC/src/test/project-detection.test.ts diff --git a/src/winapp-VSC/package.json b/src/winapp-VSC/package.json index 62d4806f..86d4869e 100644 --- a/src/winapp-VSC/package.json +++ b/src/winapp-VSC/package.json @@ -197,6 +197,7 @@ "pretest": "npm run compile-tsc && npm run lint", "lint": "eslint src", "test": "vscode-test", + "test:unit": "tsc -p ./ && mocha out/test/**/*.test.js", "clean": "node -e \"require('fs').rmSync('bin', {recursive: true, force: true}); require('fs').rmSync('out', {recursive: true, force: true}); require('fs').rmSync('dist', {recursive: true, force: true});\"", "build-cli": "npm run build-cli-x64 && npm run build-cli-arm64", "build-cli-x64": "dotnet publish ../winapp-CLI/WinApp.Cli/WinApp.Cli.csproj -c Release -r win-x64 --self-contained -o bin/win-x64", diff --git a/src/winapp-VSC/src/test/project-detection.test.ts b/src/winapp-VSC/src/test/project-detection.test.ts new file mode 100644 index 00000000..ba267d2a --- /dev/null +++ b/src/winapp-VSC/src/test/project-detection.test.ts @@ -0,0 +1,311 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + detectProjectAt, + detectProjects, + getProjectLabel, + getDisplayFilePath, + DetectedProject +} from '../project-detection'; + +/** + * Creates a temporary directory for test fixtures. + */ +function createTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'winapp-test-')); +} + +/** + * Recursively removes a directory. + */ +function removeTempDir(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +/** + * Creates a file with the given content, creating parent directories as needed. + */ +function createFile(filePath: string, content: string = ''): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +describe('project-detection', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + removeTempDir(tempDir); + }); + + describe('detectProjectAt', () => { + it('detects a .NET executable project', () => { + createFile(path.join(tempDir, 'MyApp.csproj'), ` + + + Exe + net8.0 + + + `); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, '.NET'); + assert.strictEqual(result.projectFileName, 'MyApp.csproj'); + }); + + it('detects a WinExe .NET project', () => { + createFile(path.join(tempDir, 'WpfApp.csproj'), ` + + + WinExe + + + `); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, '.NET'); + }); + + it('does not detect a .NET test project', () => { + createFile(path.join(tempDir, 'MyApp.Tests.csproj'), ` + + + Exe + true + + + `); + + const result = detectProjectAt(tempDir, tempDir); + assert.strictEqual(result, undefined); + }); + + it('does not detect a .NET library project', () => { + createFile(path.join(tempDir, 'MyLib.csproj'), ` + + + net8.0 + + + `); + + const result = detectProjectAt(tempDir, tempDir); + assert.strictEqual(result, undefined); + }); + + it('detects an Electron project', () => { + createFile(path.join(tempDir, 'package.json'), JSON.stringify({ + name: 'my-electron-app', + dependencies: { electron: '^28.0.0' } + })); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, 'Electron'); + assert.strictEqual(result.projectFileName, 'package.json'); + }); + + it('does not detect a non-Electron package.json', () => { + createFile(path.join(tempDir, 'package.json'), JSON.stringify({ + name: 'my-lib', + dependencies: { express: '^4.0.0' } + })); + + const result = detectProjectAt(tempDir, tempDir); + assert.strictEqual(result, undefined); + }); + + it('detects a Flutter project', () => { + createFile(path.join(tempDir, 'pubspec.yaml'), 'name: my_app\n'); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, 'Flutter'); + assert.strictEqual(result.projectFileName, 'pubspec.yaml'); + }); + + it('detects a Rust project', () => { + createFile(path.join(tempDir, 'Cargo.toml'), '[package]\nname = "my_app"\n'); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, 'Rust'); + assert.strictEqual(result.projectFileName, 'Cargo.toml'); + }); + + it('detects a C++ project', () => { + createFile(path.join(tempDir, 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, 'C++'); + assert.strictEqual(result.projectFileName, 'CMakeLists.txt'); + }); + + it('detects a Tauri project', () => { + createFile(path.join(tempDir, 'src-tauri', 'tauri.conf.json'), '{}'); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, 'Tauri'); + assert.strictEqual(result.projectFileName, 'src-tauri/tauri.conf.json'); + }); + + it('returns undefined for empty directory', () => { + const result = detectProjectAt(tempDir, tempDir); + assert.strictEqual(result, undefined); + }); + + it('sets displayPath to "." for root directory', () => { + createFile(path.join(tempDir, 'Cargo.toml'), '[package]\n'); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.displayPath, '.'); + }); + + it('sets relative displayPath for nested directory', () => { + const nested = path.join(tempDir, 'apps', 'my-app'); + createFile(path.join(nested, 'Cargo.toml'), '[package]\n'); + + const result = detectProjectAt(nested, tempDir); + assert.ok(result); + assert.strictEqual(result.displayPath, 'apps/my-app'); + }); + + it('prioritizes Tauri over Electron when both markers present', () => { + createFile(path.join(tempDir, 'package.json'), JSON.stringify({ + dependencies: { electron: '^28.0.0' } + })); + createFile(path.join(tempDir, 'src-tauri', 'tauri.conf.json'), '{}'); + + const result = detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, 'Tauri'); + }); + }); + + describe('detectProjects', () => { + it('finds multiple projects in different subdirectories', () => { + createFile(path.join(tempDir, 'app1', 'Cargo.toml'), '[package]\n'); + createFile(path.join(tempDir, 'app2', 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); + createFile(path.join(tempDir, 'app3', 'pubspec.yaml'), 'name: app3\n'); + + const results = detectProjects(tempDir); + assert.strictEqual(results.length, 3); + const types = results.map(r => r.type).sort(); + assert.deepStrictEqual(types, ['C++', 'Flutter', 'Rust']); + }); + + it('respects maxProjects limit', () => { + for (let i = 0; i < 5; i++) { + createFile(path.join(tempDir, `app${i}`, 'Cargo.toml'), '[package]\n'); + } + + const results = detectProjects(tempDir, 3); + assert.strictEqual(results.length, 3); + }); + + it('skips node_modules directory', () => { + createFile(path.join(tempDir, 'node_modules', 'some-pkg', 'Cargo.toml'), '[package]\n'); + createFile(path.join(tempDir, 'src', 'Cargo.toml'), '[package]\n'); + + const results = detectProjects(tempDir); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].type, 'Rust'); + assert.ok(results[0].directory.includes('src')); + }); + + it('skips other ignored directories', () => { + const ignoredDirs = ['bin', 'obj', 'target', 'dist', 'build', '.git']; + for (const dir of ignoredDirs) { + createFile(path.join(tempDir, dir, 'Cargo.toml'), '[package]\n'); + } + createFile(path.join(tempDir, 'src', 'Cargo.toml'), '[package]\n'); + + const results = detectProjects(tempDir); + assert.strictEqual(results.length, 1); + assert.ok(results[0].directory.includes('src')); + }); + + it('skips hidden directories', () => { + createFile(path.join(tempDir, '.hidden', 'Cargo.toml'), '[package]\n'); + createFile(path.join(tempDir, 'visible', 'Cargo.toml'), '[package]\n'); + + const results = detectProjects(tempDir); + assert.strictEqual(results.length, 1); + assert.ok(results[0].directory.includes('visible')); + }); + + it('does not recurse into detected project directories', () => { + // Parent is a Rust project + createFile(path.join(tempDir, 'app', 'Cargo.toml'), '[package]\n'); + // Nested CMake inside the Rust project should not be found + createFile(path.join(tempDir, 'app', 'subdir', 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); + + const results = detectProjects(tempDir); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].type, 'Rust'); + }); + + it('detects project at root when present', () => { + createFile(path.join(tempDir, 'Cargo.toml'), '[package]\n'); + createFile(path.join(tempDir, 'subdir', 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); + + const results = detectProjects(tempDir); + // Root project found, so we don't recurse + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].type, 'Rust'); + assert.strictEqual(results[0].displayPath, '.'); + }); + + it('returns empty array when no projects exist', () => { + createFile(path.join(tempDir, 'readme.md'), '# Hello\n'); + + const results = detectProjects(tempDir); + assert.strictEqual(results.length, 0); + }); + }); + + describe('getDisplayFilePath', () => { + it('formats root project path correctly', () => { + const project: DetectedProject = { + type: 'Rust', + directory: '/some/path', + displayPath: '.', + projectFileName: 'Cargo.toml' + }; + assert.strictEqual(getDisplayFilePath(project), './Cargo.toml'); + }); + + it('formats nested project path correctly', () => { + const project: DetectedProject = { + type: '.NET', + directory: '/some/path/apps/myapp', + displayPath: 'apps/myapp', + projectFileName: 'MyApp.csproj' + }; + assert.strictEqual(getDisplayFilePath(project), './apps/myapp/MyApp.csproj'); + }); + }); + + describe('getProjectLabel', () => { + it('formats label correctly', () => { + const project: DetectedProject = { + type: '.NET', + directory: '/some/path', + displayPath: 'src/app', + projectFileName: 'App.csproj' + }; + assert.strictEqual(getProjectLabel(project), '.NET project (./src/app/App.csproj)'); + }); + }); +}); diff --git a/src/winapp-VSC/tsconfig.json b/src/winapp-VSC/tsconfig.json index 4e1e91ae..4055a6de 100644 --- a/src/winapp-VSC/tsconfig.json +++ b/src/winapp-VSC/tsconfig.json @@ -9,7 +9,7 @@ "sourceMap": true, "rootDir": "src", "strict": true, /* enable all strict type-checking options */ - "types": ["node"] + "types": ["node", "mocha"] /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ From d6034270176b0a50520536599f9aa32863d97562 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:25:45 -0700 Subject: [PATCH 8/9] Address PR review comments: async detection, case-insensitive skip, explicit mocha dep - Make detectProjects() async with fs/promises and periodic yielding so withProgress UI stays responsive during large workspace scans - Use case-insensitive comparison for SKIP_DIRS (matches CLI behavior) - Replace fragile .replace() pattern with getDisplayFilePath() import - Add mocha as explicit devDependency (was only transitive) - Update isExecutableCsproj docstring to reflect simplified heuristic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/package-lock.json | 6 ++++ src/winapp-VSC/package.json | 1 + src/winapp-VSC/src/extension.ts | 8 ++--- src/winapp-VSC/src/project-detection.ts | 22 +++++++++---- .../src/test/project-detection.test.ts | 32 +++++++++---------- 5 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/winapp-VSC/package-lock.json b/src/winapp-VSC/package-lock.json index b3aad407..88a3e94b 100644 --- a/src/winapp-VSC/package-lock.json +++ b/src/winapp-VSC/package-lock.json @@ -19,6 +19,7 @@ "@vscode/vsce": "^3.3.2", "esbuild": "^0.28.1", "eslint": "^10.0.2", + "mocha": "^11.7.5", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1" }, @@ -1453,6 +1454,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2069,6 +2071,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3234,6 +3237,7 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6615,6 +6619,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6733,6 +6738,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/winapp-VSC/package.json b/src/winapp-VSC/package.json index 86d4869e..13c89ac6 100644 --- a/src/winapp-VSC/package.json +++ b/src/winapp-VSC/package.json @@ -218,6 +218,7 @@ "@vscode/vsce": "^3.3.2", "esbuild": "^0.28.1", "eslint": "^10.0.2", + "mocha": "^11.7.5", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1" }, diff --git a/src/winapp-VSC/src/extension.ts b/src/winapp-VSC/src/extension.ts index c763c739..d875e189 100644 --- a/src/winapp-VSC/src/extension.ts +++ b/src/winapp-VSC/src/extension.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { spawn } from 'child_process'; import { getWinappCliPath, WINAPP_CLI_CALLER_VALUE } from './winapp-cli-utils'; -import { detectProjectAt, detectProjects, getProjectLabel, DetectedProject } from './project-detection'; +import { detectProjectAt, detectProjects, getProjectLabel, getDisplayFilePath, DetectedProject } from './project-detection'; import { glob } from 'glob'; const WINAPP_DEBUG_TYPE = 'winapp'; @@ -161,7 +161,7 @@ async function resolveProjectDirectory(workspacePath: string): Promise ({ label: `$(file-code) ${p.type} project`, - description: getProjectLabel(p).replace(`${p.type} project `, ''), + description: getDisplayFilePath(p), directory: p.directory })); @@ -494,7 +494,7 @@ export function activate(context: vscode.ExtensionContext) { [ { label: `$(file-code) ${project.type} project`, - description: getProjectLabel(project).replace(`${project.type} project `, ''), + description: getDisplayFilePath(project), project }, { @@ -516,7 +516,7 @@ export function activate(context: vscode.ExtensionContext) { const maxProjects = 10; const items = projects.map(p => ({ label: `$(file-code) ${p.type} project`, - description: getProjectLabel(p).replace(`${p.type} project `, ''), + description: getDisplayFilePath(p), project: p })); items.push({ diff --git a/src/winapp-VSC/src/project-detection.ts b/src/winapp-VSC/src/project-detection.ts index 8743e5fd..3ff2c1c8 100644 --- a/src/winapp-VSC/src/project-detection.ts +++ b/src/winapp-VSC/src/project-detection.ts @@ -1,4 +1,5 @@ import * as fs from 'fs'; +import * as fsp from 'fs/promises'; import * as path from 'path'; /** @@ -36,7 +37,7 @@ export function getProjectLabel(project: DetectedProject): string { const SKIP_DIRS = new Set([ 'node_modules', '.git', 'bin', 'obj', 'debug', 'release', '.vs', '.vscode', '.idea', 'packages', 'dist', 'build', 'out', - 'target', '.winapp', 'artifacts', 'TestResults', + 'target', '.winapp', 'artifacts', 'testresults', '__pycache__', '.gradle', '.dart_tool', '.pub-cache', '.nuget', '.cargo' ]); @@ -85,10 +86,12 @@ export function detectProjectAt(directory: string, searchRoot: string): Detected /** * Performs a breadth-first search of the directory tree to find compatible projects. * Mirrors ProjectDetectionService.DetectProjectsAsync from WinApp.Cli. + * Uses async I/O with periodic yielding to keep the UI responsive. */ -export function detectProjects(root: string, maxProjects: number = 10): DetectedProject[] { +export async function detectProjects(root: string, maxProjects: number = 10): Promise { const results: DetectedProject[] = []; const queue: string[] = [root]; + let iterations = 0; while (queue.length > 0 && results.length < maxProjects) { const current = queue.shift()!; @@ -101,16 +104,16 @@ export function detectProjects(root: string, maxProjects: number = 10): Detected // Enqueue child directories (skip known non-project dirs) try { - const entries = fs.readdirSync(current, { withFileTypes: true }); + const entries = await fsp.readdir(current, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() && !entry.isSymbolicLink()) { continue; } if (entry.name.startsWith('.') && entry.name !== '.') { continue; } - if (SKIP_DIRS.has(entry.name)) { continue; } + if (SKIP_DIRS.has(entry.name.toLowerCase())) { continue; } const fullPath = path.join(current, entry.name); // Skip symlinks and junctions (reparse points) if (entry.isSymbolicLink()) { continue; } try { - const stat = fs.statSync(fullPath); + const stat = await fsp.stat(fullPath); if (!stat.isDirectory()) { continue; } } catch { continue; @@ -120,6 +123,11 @@ export function detectProjects(root: string, maxProjects: number = 10): Detected } catch { // Skip directories we can't read } + + // Yield to the event loop periodically to keep the UI responsive + if (++iterations % 50 === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + } } return results; @@ -192,7 +200,9 @@ function findExecutableCsproj(directory: string): string | undefined { /** * Parses csproj XML content to determine if it's an executable, non-test project. - * Mirrors the CLI's IsExecutableProject logic. + * Simplified heuristic inspired by the CLI's IsExecutableProject logic — uses regex + * to match the first and elements. Does not handle + * multiple/conditional PropertyGroups or values inside XML comments. */ function isExecutableCsproj(content: string): boolean { // Extract OutputType value from PropertyGroup elements diff --git a/src/winapp-VSC/src/test/project-detection.test.ts b/src/winapp-VSC/src/test/project-detection.test.ts index ba267d2a..48086b01 100644 --- a/src/winapp-VSC/src/test/project-detection.test.ts +++ b/src/winapp-VSC/src/test/project-detection.test.ts @@ -194,83 +194,83 @@ describe('project-detection', () => { }); describe('detectProjects', () => { - it('finds multiple projects in different subdirectories', () => { + it('finds multiple projects in different subdirectories', async () => { createFile(path.join(tempDir, 'app1', 'Cargo.toml'), '[package]\n'); createFile(path.join(tempDir, 'app2', 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); createFile(path.join(tempDir, 'app3', 'pubspec.yaml'), 'name: app3\n'); - const results = detectProjects(tempDir); + const results = await detectProjects(tempDir); assert.strictEqual(results.length, 3); const types = results.map(r => r.type).sort(); assert.deepStrictEqual(types, ['C++', 'Flutter', 'Rust']); }); - it('respects maxProjects limit', () => { + it('respects maxProjects limit', async () => { for (let i = 0; i < 5; i++) { createFile(path.join(tempDir, `app${i}`, 'Cargo.toml'), '[package]\n'); } - const results = detectProjects(tempDir, 3); + const results = await detectProjects(tempDir, 3); assert.strictEqual(results.length, 3); }); - it('skips node_modules directory', () => { + it('skips node_modules directory', async () => { createFile(path.join(tempDir, 'node_modules', 'some-pkg', 'Cargo.toml'), '[package]\n'); createFile(path.join(tempDir, 'src', 'Cargo.toml'), '[package]\n'); - const results = detectProjects(tempDir); + const results = await detectProjects(tempDir); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].type, 'Rust'); assert.ok(results[0].directory.includes('src')); }); - it('skips other ignored directories', () => { + it('skips other ignored directories', async () => { const ignoredDirs = ['bin', 'obj', 'target', 'dist', 'build', '.git']; for (const dir of ignoredDirs) { createFile(path.join(tempDir, dir, 'Cargo.toml'), '[package]\n'); } createFile(path.join(tempDir, 'src', 'Cargo.toml'), '[package]\n'); - const results = detectProjects(tempDir); + const results = await detectProjects(tempDir); assert.strictEqual(results.length, 1); assert.ok(results[0].directory.includes('src')); }); - it('skips hidden directories', () => { + it('skips hidden directories', async () => { createFile(path.join(tempDir, '.hidden', 'Cargo.toml'), '[package]\n'); createFile(path.join(tempDir, 'visible', 'Cargo.toml'), '[package]\n'); - const results = detectProjects(tempDir); + const results = await detectProjects(tempDir); assert.strictEqual(results.length, 1); assert.ok(results[0].directory.includes('visible')); }); - it('does not recurse into detected project directories', () => { + it('does not recurse into detected project directories', async () => { // Parent is a Rust project createFile(path.join(tempDir, 'app', 'Cargo.toml'), '[package]\n'); // Nested CMake inside the Rust project should not be found createFile(path.join(tempDir, 'app', 'subdir', 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); - const results = detectProjects(tempDir); + const results = await detectProjects(tempDir); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].type, 'Rust'); }); - it('detects project at root when present', () => { + it('detects project at root when present', async () => { createFile(path.join(tempDir, 'Cargo.toml'), '[package]\n'); createFile(path.join(tempDir, 'subdir', 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); - const results = detectProjects(tempDir); + const results = await detectProjects(tempDir); // Root project found, so we don't recurse assert.strictEqual(results.length, 1); assert.strictEqual(results[0].type, 'Rust'); assert.strictEqual(results[0].displayPath, '.'); }); - it('returns empty array when no projects exist', () => { + it('returns empty array when no projects exist', async () => { createFile(path.join(tempDir, 'readme.md'), '# Hello\n'); - const results = detectProjects(tempDir); + const results = await detectProjects(tempDir); assert.strictEqual(results.length, 0); }); }); From 748264839ffd37b009a28654a4cc8faa59829db1 Mon Sep 17 00:00:00 2001 From: Chiara Mooney <34109996+chiaramooney@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:29:21 -0700 Subject: [PATCH 9/9] Address code review: security fixes, full async detection, init refactor Security: - H2: Add escapePowerShellArg() to prevent command injection via folder names - M3: Validate appDirectories entries stay within workspace (reject ../ and absolute paths) Correctness: - M7: Make detectProjectAt and all helpers fully async (fs/promises) so the extension host is never blocked during detection - M4/L2: Refactor init command to reuse resolveProjectDirectory, eliminating ~60 lines of duplicated picker logic and honoring appDirectories setting Cleanup: - L1: Remove unused getProjectLabel/DetectedProject imports from extension.ts - M1: Update README init description to reflect unified resolution behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/winapp-VSC/README.md | 2 +- src/winapp-VSC/src/extension.ts | 130 +++++------------- src/winapp-VSC/src/project-detection.ts | 46 ++++--- .../src/test/project-detection.test.ts | 62 ++++----- 4 files changed, 97 insertions(+), 143 deletions(-) diff --git a/src/winapp-VSC/README.md b/src/winapp-VSC/README.md index ab08c8b3..4fae4f4c 100644 --- a/src/winapp-VSC/README.md +++ b/src/winapp-VSC/README.md @@ -62,7 +62,7 @@ To skip automatic scanning, add the `winapp.appDirectories` setting to your work **Supported project types:** .NET (WPF, WinForms, WinUI 3, Console), Electron, Tauri, Flutter, Rust, and C++ (CMake). -The **WinApp: Initialize Project** command has additional behavior: when no project is at the root, it searches and lets you pick which project to initialize. If no projects are found at all, it offers to initialize in the current directory anyway. +All commands, including **WinApp: Initialize Project**, follow the same resolution logic. When no projects are found, the command falls back to the workspace root. > **Note:** If more than 10 projects are discovered, the search stops and the QuickPick indicates that the list may be incomplete. diff --git a/src/winapp-VSC/src/extension.ts b/src/winapp-VSC/src/extension.ts index d875e189..e8afae35 100644 --- a/src/winapp-VSC/src/extension.ts +++ b/src/winapp-VSC/src/extension.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { spawn } from 'child_process'; import { getWinappCliPath, WINAPP_CLI_CALLER_VALUE } from './winapp-cli-utils'; -import { detectProjectAt, detectProjects, getProjectLabel, getDisplayFilePath, DetectedProject } from './project-detection'; +import { detectProjectAt, detectProjects, getDisplayFilePath } from './project-detection'; import { glob } from 'glob'; const WINAPP_DEBUG_TYPE = 'winapp'; @@ -67,6 +67,14 @@ async function runWinappCommand(extensionPath: string, command: string, cwd: str return ''; } +/** + * Escapes a string for safe interpolation in a PowerShell single-quoted string. + * Single quotes in the value are doubled per PowerShell rules. + */ +function escapePowerShellArg(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + /** * Get the current workspace folder path */ @@ -119,24 +127,32 @@ async function resolveProjectDirectory(workspacePath: string): Promise 0) { - if (appDirs.length === 1) { - return path.resolve(workspacePath, appDirs[0]); - } - - // Multiple configured directories — show QuickPick - const items = appDirs.map(dir => ({ - label: `$(folder) ${dir}`, - directory: path.resolve(workspacePath, dir) - })); - - const picked = await vscode.window.showQuickPick(items, { - placeHolder: 'Which project would you like to target?' + // Validate paths are contained within workspace + const validDirs = appDirs.filter(dir => { + const resolved = path.resolve(workspacePath, dir); + const relative = path.relative(workspacePath, resolved); + return !relative.startsWith('..') && !path.isAbsolute(relative); }); - return picked?.directory; + + if (validDirs.length === 0) { + vscode.window.showWarningMessage('All winapp.appDirectories entries resolve outside the workspace and were ignored.'); + } else if (validDirs.length === 1) { + return path.resolve(workspacePath, validDirs[0]); + } else { + const items = validDirs.map(dir => ({ + label: `$(folder) ${dir}`, + directory: path.resolve(workspacePath, dir) + })); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Which project would you like to target?' + }); + return picked?.directory; + } } // If there's a project at the workspace root, use it directly - const rootProject = detectProjectAt(workspacePath, workspacePath); + const rootProject = await detectProjectAt(workspacePath, workspacePath); if (rootProject) { return workspacePath; } @@ -461,90 +477,20 @@ export function activate(context: vscode.ExtensionContext) { return; } - // Check if there's a project at the workspace root - const rootProject = detectProjectAt(workspacePath, workspacePath); - - let selectedPath: string; - if (rootProject) { - // Project found at root — use it directly - selectedPath = '.'; - } else { - // No project at root — search for projects in the workspace - const projects = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: 'Searching for app projects...' }, - async () => detectProjects(workspacePath) - ); - - if (projects.length === 0) { - // No projects found — offer to initialize in current directory via QuickPick - const picked = await vscode.window.showQuickPick( - [ - { label: '$(folder) Current directory', description: './ — no project detected' } - ], - { placeHolder: 'No compatible app projects were found. Initialize with winapp here anyway?' } - ); - if (!picked) { - return; - } - selectedPath = '.'; - } else if (projects.length === 1) { - // Single project found — let user confirm via QuickPick - const project = projects[0]; - const picked = await vscode.window.showQuickPick( - [ - { - label: `$(file-code) ${project.type} project`, - description: getDisplayFilePath(project), - project - }, - { - label: '$(folder) Current directory', - description: './ — no project detected', - project: undefined as typeof project | undefined - } - ], - { placeHolder: 'Which project would you like to initialize with winapp?' } - ); - if (!picked) { - return; - } - selectedPath = picked.project - ? (path.relative(workspacePath, picked.project.directory) || '.') - : '.'; - } else { - // Multiple projects found — let user pick - const maxProjects = 10; - const items = projects.map(p => ({ - label: `$(file-code) ${p.type} project`, - description: getDisplayFilePath(p), - project: p - })); - items.push({ - label: '$(folder) Current directory', - description: './ — no project detected', - project: undefined as unknown as DetectedProject - }); - - const placeHolder = projects.length >= maxProjects - ? 'Which project would you like to initialize with winapp? (Search stopped at 10 entries)' - : 'Which project would you like to initialize with winapp?'; - - const picked = await vscode.window.showQuickPick(items, { placeHolder }); - if (!picked) { - return; - } - selectedPath = picked.project - ? (path.relative(workspacePath, picked.project.directory) || '.') - : '.'; - } + // Resolve project directory (honors appDirectories setting) + const projectDir = await resolveProjectDirectory(workspacePath); + if (!projectDir) { + return; } + const selectedPath = path.relative(workspacePath, projectDir) || '.'; + const sdkMode = await vscode.window.showQuickPick( ['stable', 'preview', 'experimental', 'none'], { placeHolder: 'Select SDK installation mode' } ); - let command = `init "${selectedPath}" --use-defaults`; + let command = `init ${escapePowerShellArg(selectedPath)} --use-defaults`; if (sdkMode && sdkMode !== 'stable') { command += ` --setup-sdks ${sdkMode}`; } diff --git a/src/winapp-VSC/src/project-detection.ts b/src/winapp-VSC/src/project-detection.ts index 3ff2c1c8..b819389f 100644 --- a/src/winapp-VSC/src/project-detection.ts +++ b/src/winapp-VSC/src/project-detection.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs'; import * as fsp from 'fs/promises'; import * as path from 'path'; @@ -45,38 +44,38 @@ const SKIP_DIRS = new Set([ * Detects a project at a single directory (does not recurse). * Mirrors ProjectDetectionService.DetectProject from WinApp.Cli. */ -export function detectProjectAt(directory: string, searchRoot: string): DetectedProject | undefined { +export async function detectProjectAt(directory: string, searchRoot: string): Promise { const displayPath = getRelativeDisplayPath(directory, searchRoot); // Tauri: check immediate subdirectories for tauri.conf.json - const tauriConf = findTauriConfFile(directory); + const tauriConf = await findTauriConfFile(directory); if (tauriConf) { return { type: 'Tauri', directory, displayPath, projectFileName: tauriConf }; } // Electron: package.json with electron dependency - if (isElectronProject(directory)) { + if (await isElectronProject(directory)) { return { type: 'Electron', directory, displayPath, projectFileName: 'package.json' }; } // Flutter: pubspec.yaml - if (fs.existsSync(path.join(directory, 'pubspec.yaml'))) { + if (await fileExists(path.join(directory, 'pubspec.yaml'))) { return { type: 'Flutter', directory, displayPath, projectFileName: 'pubspec.yaml' }; } // .NET: *.csproj (only executable, non-test projects) - const csprojName = findExecutableCsproj(directory); + const csprojName = await findExecutableCsproj(directory); if (csprojName) { return { type: '.NET', directory, displayPath, projectFileName: csprojName }; } // Rust: Cargo.toml - if (fs.existsSync(path.join(directory, 'Cargo.toml'))) { + if (await fileExists(path.join(directory, 'Cargo.toml'))) { return { type: 'Rust', directory, displayPath, projectFileName: 'Cargo.toml' }; } // C++: CMakeLists.txt - if (fs.existsSync(path.join(directory, 'CMakeLists.txt'))) { + if (await fileExists(path.join(directory, 'CMakeLists.txt'))) { return { type: 'C++', directory, displayPath, projectFileName: 'CMakeLists.txt' }; } @@ -95,7 +94,7 @@ export async function detectProjects(root: string, maxProjects: number = 10): Pr while (queue.length > 0 && results.length < maxProjects) { const current = queue.shift()!; - const detected = detectProjectAt(current, root); + const detected = await detectProjectAt(current, root); if (detected) { results.push(detected); // Don't recurse into detected project directories @@ -133,6 +132,15 @@ export async function detectProjects(root: string, maxProjects: number = 10): Pr return results; } +async function fileExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } +} + function getRelativeDisplayPath(directory: string, searchRoot: string): string { const relative = path.relative(searchRoot, directory); if (!relative || relative === '.') { @@ -141,20 +149,20 @@ function getRelativeDisplayPath(directory: string, searchRoot: string): string { return relative.replace(/\\/g, '/'); } -function findTauriConfFile(directory: string): string | undefined { +async function findTauriConfFile(directory: string): Promise { try { - const entries = fs.readdirSync(directory, { withFileTypes: true }); + const entries = await fsp.readdir(directory, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) { continue; } if (entry.name.startsWith('.')) { continue; } const subDir = path.join(directory, entry.name); try { - const stat = fs.lstatSync(subDir); + const stat = await fsp.lstat(subDir); if (stat.isSymbolicLink()) { continue; } } catch { continue; } - if (fs.existsSync(path.join(subDir, 'tauri.conf.json'))) { + if (await fileExists(path.join(subDir, 'tauri.conf.json'))) { return `${entry.name}/tauri.conf.json`; } } @@ -164,11 +172,11 @@ function findTauriConfFile(directory: string): string | undefined { return undefined; } -function isElectronProject(directory: string): boolean { +async function isElectronProject(directory: string): Promise { const packageJsonPath = path.join(directory, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { return false; } + if (!await fileExists(packageJsonPath)) { return false; } try { - const content = fs.readFileSync(packageJsonPath, 'utf-8'); + const content = await fsp.readFile(packageJsonPath, 'utf-8'); const pkg = JSON.parse(content); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; return 'electron' in deps; @@ -177,14 +185,14 @@ function isElectronProject(directory: string): boolean { } } -function findExecutableCsproj(directory: string): string | undefined { +async function findExecutableCsproj(directory: string): Promise { try { - const entries = fs.readdirSync(directory); + const entries = await fsp.readdir(directory); for (const entry of entries) { if (!entry.endsWith('.csproj')) { continue; } const filePath = path.join(directory, entry); try { - const content = fs.readFileSync(filePath, 'utf-8'); + const content = await fsp.readFile(filePath, 'utf-8'); if (isExecutableCsproj(content)) { return entry; } diff --git a/src/winapp-VSC/src/test/project-detection.test.ts b/src/winapp-VSC/src/test/project-detection.test.ts index 48086b01..0d726b25 100644 --- a/src/winapp-VSC/src/test/project-detection.test.ts +++ b/src/winapp-VSC/src/test/project-detection.test.ts @@ -44,7 +44,7 @@ describe('project-detection', () => { }); describe('detectProjectAt', () => { - it('detects a .NET executable project', () => { + it('detects a .NET executable project', async () => { createFile(path.join(tempDir, 'MyApp.csproj'), ` @@ -54,13 +54,13 @@ describe('project-detection', () => { `); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.type, '.NET'); assert.strictEqual(result.projectFileName, 'MyApp.csproj'); }); - it('detects a WinExe .NET project', () => { + it('detects a WinExe .NET project', async () => { createFile(path.join(tempDir, 'WpfApp.csproj'), ` @@ -69,12 +69,12 @@ describe('project-detection', () => { `); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.type, '.NET'); }); - it('does not detect a .NET test project', () => { + it('does not detect a .NET test project', async () => { createFile(path.join(tempDir, 'MyApp.Tests.csproj'), ` @@ -84,11 +84,11 @@ describe('project-detection', () => { `); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.strictEqual(result, undefined); }); - it('does not detect a .NET library project', () => { + it('does not detect a .NET library project', async () => { createFile(path.join(tempDir, 'MyLib.csproj'), ` @@ -97,97 +97,97 @@ describe('project-detection', () => { `); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.strictEqual(result, undefined); }); - it('detects an Electron project', () => { + it('detects an Electron project', async () => { createFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'my-electron-app', dependencies: { electron: '^28.0.0' } })); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.type, 'Electron'); assert.strictEqual(result.projectFileName, 'package.json'); }); - it('does not detect a non-Electron package.json', () => { + it('does not detect a non-Electron package.json', async () => { createFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'my-lib', dependencies: { express: '^4.0.0' } })); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.strictEqual(result, undefined); }); - it('detects a Flutter project', () => { + it('detects a Flutter project', async () => { createFile(path.join(tempDir, 'pubspec.yaml'), 'name: my_app\n'); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.type, 'Flutter'); assert.strictEqual(result.projectFileName, 'pubspec.yaml'); }); - it('detects a Rust project', () => { + it('detects a Rust project', async () => { createFile(path.join(tempDir, 'Cargo.toml'), '[package]\nname = "my_app"\n'); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.type, 'Rust'); assert.strictEqual(result.projectFileName, 'Cargo.toml'); }); - it('detects a C++ project', () => { + it('detects a C++ project', async () => { createFile(path.join(tempDir, 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.type, 'C++'); assert.strictEqual(result.projectFileName, 'CMakeLists.txt'); }); - it('detects a Tauri project', () => { + it('detects a Tauri project', async () => { createFile(path.join(tempDir, 'src-tauri', 'tauri.conf.json'), '{}'); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.type, 'Tauri'); assert.strictEqual(result.projectFileName, 'src-tauri/tauri.conf.json'); }); - it('returns undefined for empty directory', () => { - const result = detectProjectAt(tempDir, tempDir); + it('returns undefined for empty directory', async () => { + const result = await detectProjectAt(tempDir, tempDir); assert.strictEqual(result, undefined); }); - it('sets displayPath to "." for root directory', () => { + it('sets displayPath to "." for root directory', async () => { createFile(path.join(tempDir, 'Cargo.toml'), '[package]\n'); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.displayPath, '.'); }); - it('sets relative displayPath for nested directory', () => { + it('sets relative displayPath for nested directory', async () => { const nested = path.join(tempDir, 'apps', 'my-app'); createFile(path.join(nested, 'Cargo.toml'), '[package]\n'); - const result = detectProjectAt(nested, tempDir); + const result = await detectProjectAt(nested, tempDir); assert.ok(result); assert.strictEqual(result.displayPath, 'apps/my-app'); }); - it('prioritizes Tauri over Electron when both markers present', () => { + it('prioritizes Tauri over Electron when both markers present', async () => { createFile(path.join(tempDir, 'package.json'), JSON.stringify({ dependencies: { electron: '^28.0.0' } })); createFile(path.join(tempDir, 'src-tauri', 'tauri.conf.json'), '{}'); - const result = detectProjectAt(tempDir, tempDir); + const result = await detectProjectAt(tempDir, tempDir); assert.ok(result); assert.strictEqual(result.type, 'Tauri'); }); @@ -276,7 +276,7 @@ describe('project-detection', () => { }); describe('getDisplayFilePath', () => { - it('formats root project path correctly', () => { + it('formats root project path correctly', async () => { const project: DetectedProject = { type: 'Rust', directory: '/some/path', @@ -286,7 +286,7 @@ describe('project-detection', () => { assert.strictEqual(getDisplayFilePath(project), './Cargo.toml'); }); - it('formats nested project path correctly', () => { + it('formats nested project path correctly', async () => { const project: DetectedProject = { type: '.NET', directory: '/some/path/apps/myapp', @@ -298,7 +298,7 @@ describe('project-detection', () => { }); describe('getProjectLabel', () => { - it('formats label correctly', () => { + it('formats label correctly', async () => { const project: DetectedProject = { type: '.NET', directory: '/some/path',