diff --git a/src/winapp-VSC/README.md b/src/winapp-VSC/README.md index 9181f5a4..4fae4f4c 100644 --- a/src/winapp-VSC/README.md +++ b/src/winapp-VSC/README.md @@ -19,6 +19,53 @@ 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 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 | +| 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). + +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. + ### Command Palette All commands are accessible from the Command Palette (`Ctrl+Shift+P`). Type **WinApp** to see the full list. @@ -121,7 +168,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 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 83c8cff1..13c89ac6 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", @@ -184,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", @@ -204,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 6d6428d0..e8afae35 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, getDisplayFilePath } from './project-detection'; import { glob } from 'glob'; const WINAPP_DEBUG_TYPE = 'winapp'; @@ -66,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 */ @@ -107,6 +116,82 @@ async function selectFolder(title: string): Promise { return result?.[0]?.fsPath; } +/** + * Resolves the project directory for commands that need a winapp project context. + * 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) { + // 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); + }); + + 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 = await 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: getDisplayFilePath(p), + 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; @@ -392,12 +477,20 @@ export function activate(context: vscode.ExtensionContext) { return; } + // 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 . --use-defaults'; + let command = `init ${escapePowerShellArg(selectedPath)} --use-defaults`; if (sdkMode && sdkMode !== 'stable') { command += ` --setup-sdks ${sdkMode}`; } @@ -414,7 +507,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); }) ); @@ -426,6 +524,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)' } @@ -436,7 +539,7 @@ export function activate(context: vscode.ExtensionContext) { command += ` --setup-sdks ${sdkMode}`; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -448,6 +551,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; @@ -471,7 +579,7 @@ export function activate(context: vscode.ExtensionContext) { command += ' --self-contained'; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -483,12 +591,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); }) ); @@ -499,6 +612,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': ['*'] @@ -509,7 +628,7 @@ export function activate(context: vscode.ExtensionContext) { command += ` "${entrypoint}"`; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -521,6 +640,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' } @@ -531,7 +655,7 @@ export function activate(context: vscode.ExtensionContext) { command += ` --template ${template}`; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -543,6 +667,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'] }); @@ -552,7 +681,7 @@ export function activate(context: vscode.ExtensionContext) { return; } - await runWinappCommand(extensionPath, `manifest update-assets "${imagePath}"`, workspacePath); + await runWinappCommand(extensionPath, `manifest update-assets "${imagePath}"`, projectDir); }) ); @@ -564,6 +693,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?' } @@ -574,7 +708,7 @@ export function activate(context: vscode.ExtensionContext) { command += ' --install'; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -685,6 +819,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?' } @@ -695,7 +834,7 @@ export function activate(context: vscode.ExtensionContext) { command += ' --global'; } - await runWinappCommand(extensionPath, command, workspacePath); + await runWinappCommand(extensionPath, command, projectDir); }) ); @@ -707,7 +846,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); }) ); @@ -719,7 +863,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); }) ); diff --git a/src/winapp-VSC/src/project-detection.ts b/src/winapp-VSC/src/project-detection.ts new file mode 100644 index 00000000..b819389f --- /dev/null +++ b/src/winapp-VSC/src/project-detection.ts @@ -0,0 +1,233 @@ +import * as fsp from 'fs/promises'; +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', 'debug', 'release', + '.vs', '.vscode', '.idea', 'packages', 'dist', 'build', 'out', + 'target', '.winapp', 'artifacts', 'testresults', + '__pycache__', '.gradle', '.dart_tool', '.pub-cache', '.nuget', '.cargo' +]); + +/** + * Detects a project at a single directory (does not recurse). + * Mirrors ProjectDetectionService.DetectProject from WinApp.Cli. + */ +export async function detectProjectAt(directory: string, searchRoot: string): Promise { + const displayPath = getRelativeDisplayPath(directory, searchRoot); + + // Tauri: check immediate subdirectories for tauri.conf.json + const tauriConf = await findTauriConfFile(directory); + if (tauriConf) { + return { type: 'Tauri', directory, displayPath, projectFileName: tauriConf }; + } + + // Electron: package.json with electron dependency + if (await isElectronProject(directory)) { + return { type: 'Electron', directory, displayPath, projectFileName: 'package.json' }; + } + + // Flutter: 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 = await findExecutableCsproj(directory); + if (csprojName) { + return { type: '.NET', directory, displayPath, projectFileName: csprojName }; + } + + // Rust: Cargo.toml + if (await fileExists(path.join(directory, 'Cargo.toml'))) { + return { type: 'Rust', directory, displayPath, projectFileName: 'Cargo.toml' }; + } + + // C++: CMakeLists.txt + if (await fileExists(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. + * Uses async I/O with periodic yielding to keep the UI responsive. + */ +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()!; + const detected = await 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 = 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.toLowerCase())) { continue; } + const fullPath = path.join(current, entry.name); + // Skip symlinks and junctions (reparse points) + if (entry.isSymbolicLink()) { continue; } + try { + const stat = await fsp.stat(fullPath); + if (!stat.isDirectory()) { continue; } + } catch { + continue; + } + queue.push(fullPath); + } + } 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; +} + +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 === '.') { + return '.'; + } + return relative.replace(/\\/g, '/'); +} + +async function findTauriConfFile(directory: string): Promise { + try { + 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 = await fsp.lstat(subDir); + if (stat.isSymbolicLink()) { continue; } + } catch { + continue; + } + if (await fileExists(path.join(subDir, 'tauri.conf.json'))) { + return `${entry.name}/tauri.conf.json`; + } + } + } catch { + // Skip if we can't read + } + return undefined; +} + +async function isElectronProject(directory: string): Promise { + const packageJsonPath = path.join(directory, 'package.json'); + if (!await fileExists(packageJsonPath)) { return false; } + try { + const content = await fsp.readFile(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(content); + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + return 'electron' in deps; + } catch { + return false; + } +} + +async function findExecutableCsproj(directory: string): Promise { + try { + 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 = await fsp.readFile(filePath, 'utf-8'); + if (isExecutableCsproj(content)) { + return entry; + } + } catch { + continue; + } + } + } catch { + // Skip if we can't read + } + return undefined; +} + +/** + * Parses csproj XML content to determine if it's an executable, non-test project. + * 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 + 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; +} 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..0d726b25 --- /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', async () => { + createFile(path.join(tempDir, 'MyApp.csproj'), ` + + + Exe + net8.0 + + + `); + + 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', async () => { + createFile(path.join(tempDir, 'WpfApp.csproj'), ` + + + WinExe + + + `); + + const result = await detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, '.NET'); + }); + + it('does not detect a .NET test project', async () => { + createFile(path.join(tempDir, 'MyApp.Tests.csproj'), ` + + + Exe + true + + + `); + + const result = await detectProjectAt(tempDir, tempDir); + assert.strictEqual(result, undefined); + }); + + it('does not detect a .NET library project', async () => { + createFile(path.join(tempDir, 'MyLib.csproj'), ` + + + net8.0 + + + `); + + const result = await detectProjectAt(tempDir, tempDir); + assert.strictEqual(result, undefined); + }); + + 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 = 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', async () => { + createFile(path.join(tempDir, 'package.json'), JSON.stringify({ + name: 'my-lib', + dependencies: { express: '^4.0.0' } + })); + + const result = await detectProjectAt(tempDir, tempDir); + assert.strictEqual(result, undefined); + }); + + it('detects a Flutter project', async () => { + createFile(path.join(tempDir, 'pubspec.yaml'), 'name: my_app\n'); + + 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', async () => { + createFile(path.join(tempDir, 'Cargo.toml'), '[package]\nname = "my_app"\n'); + + 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', async () => { + createFile(path.join(tempDir, 'CMakeLists.txt'), 'cmake_minimum_required(VERSION 3.20)\n'); + + 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', async () => { + createFile(path.join(tempDir, 'src-tauri', 'tauri.conf.json'), '{}'); + + 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', async () => { + const result = await detectProjectAt(tempDir, tempDir); + assert.strictEqual(result, undefined); + }); + + it('sets displayPath to "." for root directory', async () => { + createFile(path.join(tempDir, 'Cargo.toml'), '[package]\n'); + + const result = await detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.displayPath, '.'); + }); + + 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 = await detectProjectAt(nested, tempDir); + assert.ok(result); + assert.strictEqual(result.displayPath, 'apps/my-app'); + }); + + 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 = await detectProjectAt(tempDir, tempDir); + assert.ok(result); + assert.strictEqual(result.type, 'Tauri'); + }); + }); + + describe('detectProjects', () => { + 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 = 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', async () => { + for (let i = 0; i < 5; i++) { + createFile(path.join(tempDir, `app${i}`, 'Cargo.toml'), '[package]\n'); + } + + const results = await detectProjects(tempDir, 3); + assert.strictEqual(results.length, 3); + }); + + 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 = 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', 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 = await detectProjects(tempDir); + assert.strictEqual(results.length, 1); + assert.ok(results[0].directory.includes('src')); + }); + + 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 = await detectProjects(tempDir); + assert.strictEqual(results.length, 1); + assert.ok(results[0].directory.includes('visible')); + }); + + 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 = await detectProjects(tempDir); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].type, 'Rust'); + }); + + 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 = 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', async () => { + createFile(path.join(tempDir, 'readme.md'), '# Hello\n'); + + const results = await detectProjects(tempDir); + assert.strictEqual(results.length, 0); + }); + }); + + describe('getDisplayFilePath', () => { + it('formats root project path correctly', async () => { + const project: DetectedProject = { + type: 'Rust', + directory: '/some/path', + displayPath: '.', + projectFileName: 'Cargo.toml' + }; + assert.strictEqual(getDisplayFilePath(project), './Cargo.toml'); + }); + + it('formats nested project path correctly', async () => { + 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', async () => { + 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. */