From ddfa78ad42bb386ae5253a48877ce4d042a974ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:54:57 +0000 Subject: [PATCH 01/28] Add 'Convert to Project' command for file-based C# apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements https://github.com/microsoft/vscode-dotnettools/issues/2369. Adds a new `dotnet.convertToProject` command that lets users convert a file-based C# app to a project-based app by running `dotnet project convert ` in an integrated terminal. Two entry points are provided: - Right-click on a .cs file in the Explorer or editor → "Convert C# File-based App to Project" - Command palette → "Convert C# File-based App to Project" → quick pick of all discoverable file-based app entry points in the workspace (files starting with `#!` or containing `#:` directives) Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- l10n/bundle.l10n.json | 4 + package.json | 16 ++ package.nls.cs.json | 1 + package.nls.de.json | 1 + package.nls.es.json | 1 + package.nls.fr.json | 1 + package.nls.it.json | 1 + package.nls.ja.json | 1 + package.nls.json | 1 + package.nls.ko.json | 1 + package.nls.pl.json | 1 + package.nls.pt-br.json | 1 + package.nls.ru.json | 1 + package.nls.tr.json | 1 + package.nls.zh-cn.json | 1 + package.nls.zh-tw.json | 1 + src/lsptoolshost/activate.ts | 3 + .../fileBasedApps/convertToProject.ts | 184 ++++++++++++++++++ 18 files changed, 221 insertions(+) create mode 100644 src/lsptoolshost/fileBasedApps/convertToProject.ts diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 5dc360c3b..311f88716 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -207,6 +207,10 @@ "Recording trace... Click Cancel to stop and save.": "Recording trace... Click Cancel to stop and save.", "Recording logs... Click Cancel to stop and save.": "Recording logs... Click Cancel to stop and save.", "Collecting C# Logs": "Collecting C# Logs", + "Only C# (.cs) files can be converted to a project.": "Only C# (.cs) files can be converted to a project.", + "No C# files found in the workspace.": "No C# files found in the workspace.", + "Select a file-based C# app to convert to a project": "Select a file-based C# app to convert to a project", + "dotnet project convert": "dotnet project convert", "Nested Code Action": "Nested Code Action", "Fix All: ": "Fix All: ", "Pick a fix all scope": "Pick a fix all scope", diff --git a/package.json b/package.json index 9802b1c60..41abdf2eb 100644 --- a/package.json +++ b/package.json @@ -2016,6 +2016,12 @@ "title": "%command.dotnet.restartServer%", "category": ".NET", "enablement": "isWorkspaceTrusted && dotnet.server.activationContext == 'Roslyn'" + }, + { + "command": "dotnet.convertToProject", + "title": "%command.dotnet.convertToProject%", + "category": ".NET", + "enablement": "isWorkspaceTrusted && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')" } ], "keybindings": [ @@ -5733,6 +5739,11 @@ "when": "(resourceLangId == csharp || resourceLangId == aspnetcorerazor) && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@1" }, + { + "command": "dotnet.convertToProject", + "when": "resourceLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "group": "2_dotnet@2" + }, { "command": "dotnet.test.runTestsInContext", "when": "editorLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'OmniSharp')", @@ -5749,6 +5760,11 @@ "command": "csharp.changeProjectContextFileExplorer", "when": "(resourceLangId == csharp || resourceLangId == aspnetcorerazor) && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@1" + }, + { + "command": "dotnet.convertToProject", + "when": "resourceLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "group": "2_dotnet@2" } ], "issue/reporter": [ diff --git a/package.nls.cs.json b/package.nls.cs.json index 3966f19a9..58d54df4b 100644 --- a/package.nls.cs.json +++ b/package.nls.cs.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Zobrazit smlouvu o podmínkách dekompilátoru", "command.dotnet.generateAssets.currentProject": "Generovat prostředky pro sestavení a ladění", "command.dotnet.openSolution": "Otevřít řešení", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Restartovat jazykový server", "command.dotnet.restore.all": "Obnovit všechny projekty", "command.dotnet.restore.project": "Obnovit projekt", diff --git a/package.nls.de.json b/package.nls.de.json index 63c2151e4..e875aacc4 100644 --- a/package.nls.de.json +++ b/package.nls.de.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Vereinbarung zu den Decompilerbedingungen anzeigen", "command.dotnet.generateAssets.currentProject": "Ressourcen für Build und Debuggen generieren", "command.dotnet.openSolution": "Projektmappe öffnen", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Sprachserver neu starten", "command.dotnet.restore.all": "Alle Projekte wiederherstellen", "command.dotnet.restore.project": "Projekt wiederherstellen", diff --git a/package.nls.es.json b/package.nls.es.json index fb394aba2..a7f2a4ced 100644 --- a/package.nls.es.json +++ b/package.nls.es.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Mostrar el contrato de términos del descompilador", "command.dotnet.generateAssets.currentProject": "Generar recursos para compilar y depurar", "command.dotnet.openSolution": "Abrir solución", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Reiniciar servidor de lenguaje", "command.dotnet.restore.all": "Restaurar todos los proyectos", "command.dotnet.restore.project": "Restaurar proyecto", diff --git a/package.nls.fr.json b/package.nls.fr.json index 64611f496..e6b936150 100644 --- a/package.nls.fr.json +++ b/package.nls.fr.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Afficher l'accord sur les termes du décompilateur", "command.dotnet.generateAssets.currentProject": "Générer des actifs pour la construction et le débogage", "command.dotnet.openSolution": "Solution ouverte", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Redémarrer le serveur de langue", "command.dotnet.restore.all": "Restaurer tous les projets", "command.dotnet.restore.project": "Restaurer le projet", diff --git a/package.nls.it.json b/package.nls.it.json index ab6c277d8..9a3458970 100644 --- a/package.nls.it.json +++ b/package.nls.it.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Mostra il contratto per i termini del decompilatore", "command.dotnet.generateAssets.currentProject": "Genera gli asset per la compilazione e il debug", "command.dotnet.openSolution": "Apri soluzione", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Riavvia il server di linguaggio", "command.dotnet.restore.all": "Ripristina tutti i progetti", "command.dotnet.restore.project": "Ripristina progetto", diff --git a/package.nls.ja.json b/package.nls.ja.json index 861ac5659..bbe294888 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "逆コンパイラの使用契約条件を表示する", "command.dotnet.generateAssets.currentProject": "ビルド用およびデバッグ用の資産を生成する", "command.dotnet.openSolution": "ソリューションを開く", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "言語サーバーを再起動する", "command.dotnet.restore.all": "すべてのプロジェクトを復元する", "command.dotnet.restore.project": "プロジェクトの復元", diff --git a/package.nls.json b/package.nls.json index b11039058..205c37457 100644 --- a/package.nls.json +++ b/package.nls.json @@ -27,6 +27,7 @@ "command.dotnet.test.runTestsInContext": "Run Tests in Context", "command.dotnet.test.debugTestsInContext": "Debug Tests in Context", "command.dotnet.restartServer": "Restart Language Server", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "configuration.dotnet.autoInsert.enableAutoInsert": "Enable automatic adjustments of code constructs on typing, including documentation comment insertion, brace formatting adjustments, and raw string literal support.", "configuration.dotnet.formatting.organizeImportsOnFormat": "Specifies whether 'using' directives should be grouped and sorted during document formatting.", "configuration.dotnet.defaultSolution.description": "The path of the default solution to be opened in the workspace when multiple solutions are available.", diff --git a/package.nls.ko.json b/package.nls.ko.json index 5916ba856..437458431 100644 --- a/package.nls.ko.json +++ b/package.nls.ko.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "디컴파일러 계약 표시", "command.dotnet.generateAssets.currentProject": "빌드 및 디버그에 대한 자산 생성", "command.dotnet.openSolution": "솔루션 열기", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "언어 서버 다시 시작", "command.dotnet.restore.all": "모든 프로젝트 복원", "command.dotnet.restore.project": "프로젝트 복원", diff --git a/package.nls.pl.json b/package.nls.pl.json index fd2ed30f4..798cbbb51 100644 --- a/package.nls.pl.json +++ b/package.nls.pl.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Pokaż umowę warunków dekompilowania", "command.dotnet.generateAssets.currentProject": "Generuj zasoby na potrzeby kompilacji i debugowania", "command.dotnet.openSolution": "Otwórz rozwiązanie", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Ponownie uruchom serwer języka", "command.dotnet.restore.all": "Przywróć wszystkie projekty", "command.dotnet.restore.project": "Przywróć projekt", diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index 7b53204e1..8a3798d69 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Mostrar o contrato de termos do descompilador", "command.dotnet.generateAssets.currentProject": "Gerar Ativos para Compilação e Depuração", "command.dotnet.openSolution": "Abrir Solução", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Reiniciar o Servidor de Linguagem", "command.dotnet.restore.all": "Restaurar Todos os Projetos", "command.dotnet.restore.project": "Restaurar Projeto", diff --git a/package.nls.ru.json b/package.nls.ru.json index f1eb99a77..4a84b22c7 100644 --- a/package.nls.ru.json +++ b/package.nls.ru.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Показать соглашение об условиях декомпиляции", "command.dotnet.generateAssets.currentProject": "Создание ресурсов для сборки и отладки", "command.dotnet.openSolution": "Открыть решение", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Перезапустить языковой сервер", "command.dotnet.restore.all": "Восстановить все проекты", "command.dotnet.restore.project": "Восстановить проект", diff --git a/package.nls.tr.json b/package.nls.tr.json index e36ce4a05..b36fb451c 100644 --- a/package.nls.tr.json +++ b/package.nls.tr.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "Derleyici koşulları sözleşmesini göster", "command.dotnet.generateAssets.currentProject": "Derleme ve Hata Ayıklama için Varlıklar Oluşturun", "command.dotnet.openSolution": "Çözümü Aç", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "Dil Sunucusunu Yeniden Başlat", "command.dotnet.restore.all": "Tüm Projeleri Geri Yükleyin", "command.dotnet.restore.project": "Projeyi Geri Yükle", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 17b5559d9..67c97e4c5 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "显示反编译程序条款协议", "command.dotnet.generateAssets.currentProject": "生成用于生成和调试的资产", "command.dotnet.openSolution": "打开解决方案", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "重启语言服务器", "command.dotnet.restore.all": "还原所有项目", "command.dotnet.restore.project": "还原项目", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index a95c79d90..7a8600055 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -13,6 +13,7 @@ "command.csharp.showDecompilationTerms": "顯示解編程式條款合約", "command.dotnet.generateAssets.currentProject": "產生用於建置和偵錯的資產", "command.dotnet.openSolution": "開啟方案", + "command.dotnet.convertToProject": "Convert C# File-based App to Project", "command.dotnet.restartServer": "重新啟動語言伺服器", "command.dotnet.restore.all": "還原所有專案", "command.dotnet.restore.project": "還原專案", diff --git a/src/lsptoolshost/activate.ts b/src/lsptoolshost/activate.ts index 52f4acb38..b0c38d0d1 100644 --- a/src/lsptoolshost/activate.ts +++ b/src/lsptoolshost/activate.ts @@ -20,6 +20,7 @@ import { registerCodeActionFixAllCommands } from './diagnostics/fixAllCodeAction import { commonOptions, languageServerOptions } from '../shared/options'; import { registerNestedCodeActionCommands } from './diagnostics/nestedCodeAction'; import { registerRestoreCommands } from './projectRestore/restore'; +import { registerConvertToProjectCommands } from './fileBasedApps/convertToProject'; import { registerMiscellaneousFileNotifier } from './workspace/miscellaneousFileNotifier'; import { TelemetryEventNames } from '../shared/telemetryEventNames'; import { WorkspaceStatus } from './workspace/workspaceStatus'; @@ -93,6 +94,8 @@ export async function activateRoslynLanguageServer( registerRestoreCommands(context, languageServer, _channel); + registerConvertToProjectCommands(context); + registerSourceGeneratorRefresh(context, languageServer, _channel); context.subscriptions.push(registerLanguageServerOptionChanges(optionObservable)); diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts new file mode 100644 index 000000000..671c04994 --- /dev/null +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +export const convertToProjectCommandName = 'dotnet.convertToProject'; + +/** + * Registers the command for converting a file-based C# app to a project-based app. + * + * This provides two entry points: + * 1. Right-click on a .cs file → "Convert to Project" → runs `dotnet project convert ` + * 2. Command palette → "Convert C# File-based App to Project" → quick pick of discoverable + * file-based apps in the workspace, then runs conversion on the selected file. + * + * Requires .NET 10 SDK or later (the `dotnet project convert` command was introduced in .NET 10). + */ +export function registerConvertToProjectCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand(convertToProjectCommandName, async (uri?: vscode.Uri): Promise => { + if (uri !== undefined) { + // Invoked from the right-click context menu with a specific file. + await convertToProject(uri); + } else { + // Invoked from the command palette — let the user pick from discoverable apps. + await pickAndConvertToProject(); + } + }) + ); +} + +/** + * Converts the given file-based C# app to a project-based app by running + * `dotnet project convert ` in an integrated terminal. + */ +async function convertToProject(uri: vscode.Uri): Promise { + const filePath = uri.fsPath; + + if (!filePath.endsWith('.cs')) { + vscode.window.showErrorMessage(vscode.l10n.t('Only C# (.cs) files can be converted to a project.')); + return; + } + + await runConvertCommand(filePath); +} + +/** + * Shows a quick-pick list of discoverable file-based C# apps in the workspace and + * converts the one the user selects. + * + * A file is considered a discoverable file-based app entry point when it starts with + * the `#!` shebang sequence (as required by the Roslyn automatic discovery algorithm). + * Files with `#:` directives are also offered because they are classified as file-based + * apps when opened in the editor even without `#!`. + */ +async function pickAndConvertToProject(): Promise { + const csFiles = await vscode.workspace.findFiles('**/*.cs', '**/obj/**'); + if (csFiles.length === 0) { + vscode.window.showInformationMessage(vscode.l10n.t('No C# files found in the workspace.')); + return; + } + + const entryPoints: vscode.QuickPickItem[] = []; + + for (const fileUri of csFiles) { + const kind = detectFileBasedAppKind(fileUri.fsPath); + if (kind !== FileBasedAppKind.None) { + const label = path.basename(fileUri.fsPath); + const description = vscode.workspace.asRelativePath(fileUri, true); + entryPoints.push({ label, description, detail: fileUri.fsPath }); + } + } + + if (entryPoints.length === 0) { + vscode.window.showInformationMessage( + vscode.l10n.t( + 'No file-based C# apps were found in the workspace. ' + + 'A file-based app entry point must contain a `#!` or `#:` directive.' + ) + ); + return; + } + + const picked = await vscode.window.showQuickPick(entryPoints, { + placeHolder: vscode.l10n.t('Select a file-based C# app to convert to a project'), + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!picked?.detail) { + return; + } + + await runConvertCommand(picked.detail); +} + +/** + * Runs `dotnet project convert ` in an integrated terminal, targeting the + * directory that contains the file so the SDK resolves paths correctly. + */ +async function runConvertCommand(filePath: string): Promise { + const workingDir = path.dirname(filePath); + const fileName = path.basename(filePath); + + // Reuse an existing terminal if one with our name already exists to keep the UI tidy. + const terminalName = vscode.l10n.t('dotnet project convert'); + const existing = vscode.window.terminals.find((t) => t.name === terminalName); + const terminal = + existing ?? + vscode.window.createTerminal({ + name: terminalName, + cwd: workingDir, + }); + + terminal.show(/*preserveFocus:*/ true); + + // Change to the file's directory first in case the terminal was reused with a different cwd. + if (existing) { + const cdCommand = process.platform === 'win32' ? `cd /d "${workingDir}"` : `cd "${workingDir}"`; + terminal.sendText(cdCommand); + } + + terminal.sendText(`dotnet project convert "${fileName}"`); +} + +const enum FileBasedAppKind { + /** The file is not a file-based app entry point. */ + None, + /** The file starts with `#!`, making it a discoverable entry point. */ + Shebang, + /** The file contains `#:` directives (package/sdk/property), strongly suggesting it is an entry point. */ + Directives, +} + +/** + * Reads the beginning of a file on disk to determine whether it looks like a + * file-based C# app entry point. This intentionally mirrors the heuristics used + * by the Roslyn `FileBasedProgramsEntryPointDiscovery` class. + */ +function detectFileBasedAppKind(filePath: string): FileBasedAppKind { + try { + // Read only the first 4 KB — sufficient to find `#!` / `#:` near the top without + // loading potentially large source files. + const buffer = Buffer.alloc(4096); + const fd = fs.openSync(filePath, 'r'); + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); + fs.closeSync(fd); + + const content = buffer.subarray(0, bytesRead).toString('utf8'); + + // Strip an optional UTF-8 BOM before checking for `#!`. + const stripped = content.startsWith('\uFEFF') ? content.slice(1) : content; + + if (stripped.startsWith('#!')) { + return FileBasedAppKind.Shebang; + } + + // Check the first few non-empty lines for `#:` directives. + const lines = stripped.split(/\r?\n/); + let checkedLines = 0; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + if (trimmed.startsWith('#:')) { + return FileBasedAppKind.Directives; + } + // After encountering a line that is neither blank nor a directive, stop scanning. + // Real entry points put their directives at the very top. + if (++checkedLines >= 5) { + break; + } + } + } catch { + // Ignore unreadable files. + } + + return FileBasedAppKind.None; +} From a65db44cc04787da96b9eae91cf25dff1875dd41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:03:18 +0000 Subject: [PATCH 02/28] fileBasedApps: refactor for testability, add unit tests, no-csproj cone, language-ID check - Export `FileBasedAppKind` enum and `detectFileBasedAppKind` with injectable reader so unit tests don't need fs mocks - Export `isInProjectCone` helper (walk ancestor dirs to find .csproj) - pickAndConvertToProject: include C# files outside any .csproj cone as FBA entry points even when they lack explicit #! / #: markers - pickAndConvertToProject: augment the findFiles result with any open text documents that VS Code already treats as csharp language but lack .cs ext - convertToProject (right-click path): check document.languageId === 'csharp' via openTextDocument instead of filePath.endsWith('.cs') - Add TODO noting that the .NET 10 SDK has no CLI command for FBA detection (only dotnet project convert); flag the Roslyn LSP request as the future fix - Add 20 unit tests covering shebang, directives, BOM, None edge cases, and all isInProjectCone permutations Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- l10n/bundle.l10n.json | 2 +- .../fileBasedApps/convertToProject.ts | 184 +++++++++++++----- .../unitTests/convertToProject.test.ts | 150 ++++++++++++++ 3 files changed, 282 insertions(+), 54 deletions(-) create mode 100644 test/lsptoolshost/unitTests/convertToProject.test.ts diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 311f88716..f5c705729 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -207,7 +207,7 @@ "Recording trace... Click Cancel to stop and save.": "Recording trace... Click Cancel to stop and save.", "Recording logs... Click Cancel to stop and save.": "Recording logs... Click Cancel to stop and save.", "Collecting C# Logs": "Collecting C# Logs", - "Only C# (.cs) files can be converted to a project.": "Only C# (.cs) files can be converted to a project.", + "Only C# files can be converted to a project.": "Only C# files can be converted to a project.", "No C# files found in the workspace.": "No C# files found in the workspace.", "Select a file-based C# app to convert to a project": "Select a file-based C# app to convert to a project", "dotnet project convert": "dotnet project convert", diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 671c04994..3a32e5b3a 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -13,7 +13,7 @@ export const convertToProjectCommandName = 'dotnet.convertToProject'; * Registers the command for converting a file-based C# app to a project-based app. * * This provides two entry points: - * 1. Right-click on a .cs file → "Convert to Project" → runs `dotnet project convert ` + * 1. Right-click on a C# file → "Convert to Project" → runs `dotnet project convert ` * 2. Command palette → "Convert C# File-based App to Project" → quick pick of discoverable * file-based apps in the workspace, then runs conversion on the selected file. * @@ -38,40 +38,81 @@ export function registerConvertToProjectCommands(context: vscode.ExtensionContex * `dotnet project convert ` in an integrated terminal. */ async function convertToProject(uri: vscode.Uri): Promise { - const filePath = uri.fsPath; + // Use VS Code's language ID rather than the file extension, so that files VS Code + // recognises as C# (e.g. via a language association override) are accepted regardless + // of their extension. + const document = + vscode.workspace.textDocuments.find((d) => d.uri.fsPath === uri.fsPath) ?? + (await vscode.workspace.openTextDocument(uri)); - if (!filePath.endsWith('.cs')) { - vscode.window.showErrorMessage(vscode.l10n.t('Only C# (.cs) files can be converted to a project.')); + if (document.languageId !== 'csharp') { + vscode.window.showErrorMessage(vscode.l10n.t('Only C# files can be converted to a project.')); return; } - await runConvertCommand(filePath); + await runConvertCommand(uri.fsPath); } /** * Shows a quick-pick list of discoverable file-based C# apps in the workspace and * converts the one the user selects. * - * A file is considered a discoverable file-based app entry point when it starts with - * the `#!` shebang sequence (as required by the Roslyn automatic discovery algorithm). - * Files with `#:` directives are also offered because they are classified as file-based - * apps when opened in the editor even without `#!`. + * A file is included in the list when any of the following is true: + * 1. It starts with the `#!` shebang sequence (Roslyn automatic-discovery algorithm). + * 2. It contains `#:` directives near the top (package/sdk/property directives). + * 3. It is not in the directory cone of any `.csproj` file in the workspace, meaning it + * is a standalone C# file that is likely intended to be run as a file-based app. + * + * C# files are identified by VS Code's language ID (`csharp`) so that non-`.cs` files + * that the user has associated with the C# language are also considered. + * + * TODO: Replace the client-side scan with an authoritative LSP request + * (e.g. `workspace/_ms_fileBasedProgramEntryPoints`) once the Roslyn language + * server exposes one. The .NET 10 SDK (`dotnet project`) does not currently offer + * a CLI command to detect FBA entry points without converting or running them. */ async function pickAndConvertToProject(): Promise { - const csFiles = await vscode.workspace.findFiles('**/*.cs', '**/obj/**'); - if (csFiles.length === 0) { + // Collect C# files from the workspace by extension, then augment with any already-open + // documents that VS Code treats as C# even if they lack a .cs extension. + const csFilesByExtension = await vscode.workspace.findFiles('**/*.cs', '**/obj/**'); + const csFileSet = new Set(csFilesByExtension.map((u) => u.fsPath)); + + const allCsUris: vscode.Uri[] = [...csFilesByExtension]; + for (const doc of vscode.workspace.textDocuments) { + if (doc.languageId === 'csharp' && !csFileSet.has(doc.uri.fsPath)) { + allCsUris.push(doc.uri); + } + } + + if (allCsUris.length === 0) { vscode.window.showInformationMessage(vscode.l10n.t('No C# files found in the workspace.')); return; } + // Build a set of directories that contain a .csproj so we can quickly check whether + // a given .cs file lives inside a project cone. + const csprojFiles = await vscode.workspace.findFiles('**/*.csproj', '**/obj/**'); + const csprojDirs = new Set(csprojFiles.map((u) => path.dirname(u.fsPath))); + const entryPoints: vscode.QuickPickItem[] = []; - for (const fileUri of csFiles) { - const kind = detectFileBasedAppKind(fileUri.fsPath); - if (kind !== FileBasedAppKind.None) { - const label = path.basename(fileUri.fsPath); + for (const fileUri of allCsUris) { + const filePath = fileUri.fsPath; + const kind = detectFileBasedAppKind(filePath); + + let isEntryPoint = kind !== FileBasedAppKind.None; + + // Also include files that are not inside any .csproj directory cone even when + // they lack explicit FBA markers, because such files have no project to belong + // to and are likely intended as file-based programs. + if (!isEntryPoint && !isInProjectCone(filePath, csprojDirs)) { + isEntryPoint = true; + } + + if (isEntryPoint) { + const label = path.basename(filePath); const description = vscode.workspace.asRelativePath(fileUri, true); - entryPoints.push({ label, description, detail: fileUri.fsPath }); + entryPoints.push({ label, description, detail: filePath }); } } @@ -79,7 +120,8 @@ async function pickAndConvertToProject(): Promise { vscode.window.showInformationMessage( vscode.l10n.t( 'No file-based C# apps were found in the workspace. ' + - 'A file-based app entry point must contain a `#!` or `#:` directive.' + 'A file-based app entry point must contain a `#!` or `#:` directive, ' + + 'or not be part of any `.csproj` project.' ) ); return; @@ -98,6 +140,25 @@ async function pickAndConvertToProject(): Promise { await runConvertCommand(picked.detail); } +/** + * Returns `true` when `filePath` resides inside the directory cone of at least one + * `.csproj` file — i.e. when any directory in `csprojDirs` is an ancestor of (or the + * same directory as) the file's parent directory. + */ +export function isInProjectCone(filePath: string, csprojDirs: Set): boolean { + let dir = path.dirname(filePath); + let parent = path.dirname(dir); + while (parent !== dir) { + if (csprojDirs.has(dir)) { + return true; + } + dir = parent; + parent = path.dirname(dir); + } + // Check the final (root) directory. + return csprojDirs.has(dir); +} + /** * Runs `dotnet project convert ` in an integrated terminal, targeting the * directory that contains the file so the SDK resolves paths correctly. @@ -127,7 +188,7 @@ async function runConvertCommand(filePath: string): Promise { terminal.sendText(`dotnet project convert "${fileName}"`); } -const enum FileBasedAppKind { +export enum FileBasedAppKind { /** The file is not a file-based app entry point. */ None, /** The file starts with `#!`, making it a discoverable entry point. */ @@ -137,48 +198,65 @@ const enum FileBasedAppKind { } /** - * Reads the beginning of a file on disk to determine whether it looks like a - * file-based C# app entry point. This intentionally mirrors the heuristics used - * by the Roslyn `FileBasedProgramsEntryPointDiscovery` class. + * Reads the beginning of `filePath` to determine whether it looks like a file-based C# + * app entry point. This intentionally mirrors the heuristics used by the Roslyn + * `FileBasedProgramsEntryPointDiscovery` class. + * + * An optional `readFileHead` function can be supplied (e.g. in tests) to replace the + * default `fs`-based implementation. It should return the raw file contents as a string, + * or `null` if the file cannot be read. */ -function detectFileBasedAppKind(filePath: string): FileBasedAppKind { - try { - // Read only the first 4 KB — sufficient to find `#!` / `#:` near the top without - // loading potentially large source files. - const buffer = Buffer.alloc(4096); - const fd = fs.openSync(filePath, 'r'); - const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); - fs.closeSync(fd); +export function detectFileBasedAppKind( + filePath: string, + readFileHead: (p: string) => string | null = defaultReadFileHead +): FileBasedAppKind { + const content = readFileHead(filePath); + if (content === null) { + return FileBasedAppKind.None; + } - const content = buffer.subarray(0, bytesRead).toString('utf8'); + // Strip an optional UTF-8 BOM before checking for `#!`. + const stripped = content.startsWith('\uFEFF') ? content.slice(1) : content; - // Strip an optional UTF-8 BOM before checking for `#!`. - const stripped = content.startsWith('\uFEFF') ? content.slice(1) : content; + if (stripped.startsWith('#!')) { + return FileBasedAppKind.Shebang; + } - if (stripped.startsWith('#!')) { - return FileBasedAppKind.Shebang; + // Check the first few non-empty lines for `#:` directives. + const lines = stripped.split(/\r?\n/); + let checkedLines = 0; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; } - - // Check the first few non-empty lines for `#:` directives. - const lines = stripped.split(/\r?\n/); - let checkedLines = 0; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.length === 0) { - continue; - } - if (trimmed.startsWith('#:')) { - return FileBasedAppKind.Directives; - } - // After encountering a line that is neither blank nor a directive, stop scanning. - // Real entry points put their directives at the very top. - if (++checkedLines >= 5) { - break; - } + if (trimmed.startsWith('#:')) { + return FileBasedAppKind.Directives; + } + // After encountering a line that is neither blank nor a directive, stop scanning. + // Real entry points put their directives at the very top. + if (++checkedLines >= 5) { + break; } - } catch { - // Ignore unreadable files. } return FileBasedAppKind.None; } + +/** + * Default implementation of the `readFileHead` parameter for `detectFileBasedAppKind`. + * Reads the first 4 KB of the file — sufficient to find `#!` / `#:` near the top + * without loading potentially large source files. + */ +function defaultReadFileHead(filePath: string): string | null { + try { + const buffer = Buffer.alloc(4096); + const fd = fs.openSync(filePath, 'r'); + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); + fs.closeSync(fd); + return buffer.subarray(0, bytesRead).toString('utf8'); + } catch { + // Ignore unreadable files. + return null; + } +} diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts new file mode 100644 index 000000000..b0722d25f --- /dev/null +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, test, expect } from '@jest/globals'; +import * as path from 'path'; +import { + detectFileBasedAppKind, + FileBasedAppKind, + isInProjectCone, +} from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Creates a `readFileHead` stub that returns a fixed string. */ +function makeReader(content: string): (p: string) => string | null { + return (_p: string) => content; +} + +/** A reader that always simulates an unreadable file. */ +const nullReader: (p: string) => string | null = (_p) => null; + +// --------------------------------------------------------------------------- +// detectFileBasedAppKind +// --------------------------------------------------------------------------- + +describe('detectFileBasedAppKind', () => { + describe('Shebang detection', () => { + test('returns Shebang for a file starting with #!', () => { + expect( + detectFileBasedAppKind('/a/app.cs', makeReader('#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n')) + ).toBe(FileBasedAppKind.Shebang); + }); + + test('returns Shebang when file starts with BOM then #!', () => { + expect( + detectFileBasedAppKind( + '/a/app.cs', + makeReader('\uFEFF#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n') + ) + ).toBe(FileBasedAppKind.Shebang); + }); + + test('does not return Shebang when #! appears after a non-empty line', () => { + const content = 'using System;\n#!/usr/bin/env dotnet\n'; + expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.None); + }); + }); + + describe('Directives detection', () => { + test('returns Directives for a file starting with #: package directive', () => { + const content = '#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'; + expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.Directives); + }); + + test('returns Directives for a file starting with #: sdk directive', () => { + const content = '#:sdk Microsoft.NET.Sdk.Web\nConsole.WriteLine("hi");\n'; + expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.Directives); + }); + + test('returns Directives when #: directive is preceded only by blank lines', () => { + const content = '\n\n#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'; + expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.Directives); + }); + + test('returns Directives when BOM precedes a #: directive', () => { + const content = '\uFEFF#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'; + expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.Directives); + }); + + test('does not return Directives when #: appears after 5 non-blank lines', () => { + const content = ['line1', 'line2', 'line3', 'line4', 'line5', '#:package Foo@1.0.0'].join('\n'); + expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.None); + }); + }); + + describe('None cases', () => { + test('returns None for a normal C# class file', () => { + const content = 'using System;\n\nnamespace Foo {\n public class Bar {}\n}\n'; + expect(detectFileBasedAppKind('/a/Bar.cs', makeReader(content))).toBe(FileBasedAppKind.None); + }); + + test('returns None for an empty file', () => { + expect(detectFileBasedAppKind('/a/empty.cs', makeReader(''))).toBe(FileBasedAppKind.None); + }); + + test('returns None when the reader returns null (unreadable file)', () => { + expect(detectFileBasedAppKind('/a/unreadable.cs', nullReader)).toBe(FileBasedAppKind.None); + }); + + test('returns None for a file that only has blank lines', () => { + expect(detectFileBasedAppKind('/a/blank.cs', makeReader('\n\n\n'))).toBe(FileBasedAppKind.None); + }); + + test('returns None when #: appears inside a string literal on the first line', () => { + // The algorithm checks trimmed lines; this line does NOT start with #: + const content = 'var s = "#: not a directive";\n'; + expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.None); + }); + }); +}); + +// --------------------------------------------------------------------------- +// isInProjectCone +// --------------------------------------------------------------------------- + +describe('isInProjectCone', () => { + const sep = path.sep; + + function dirs(...paths: string[]): Set { + return new Set(paths); + } + + test('returns true when the file is directly inside a csproj directory', () => { + const csprojDirs = dirs(`${sep}workspace${sep}project`); + expect(isInProjectCone(`${sep}workspace${sep}project${sep}Foo.cs`, csprojDirs)).toBe(true); + }); + + test('returns true when the file is in a subdirectory of a csproj directory', () => { + const csprojDirs = dirs(`${sep}workspace${sep}project`); + expect(isInProjectCone(`${sep}workspace${sep}project${sep}src${sep}Foo.cs`, csprojDirs)).toBe(true); + }); + + test('returns false when the file has no csproj in any ancestor directory', () => { + const csprojDirs = dirs(`${sep}workspace${sep}project`); + expect(isInProjectCone(`${sep}workspace${sep}scripts${sep}app.cs`, csprojDirs)).toBe(false); + }); + + test('returns false when csprojDirs is empty', () => { + expect(isInProjectCone(`${sep}workspace${sep}scripts${sep}app.cs`, new Set())).toBe(false); + }); + + test('returns false when the csproj directory is a sibling, not an ancestor', () => { + const csprojDirs = dirs(`${sep}workspace${sep}projectA`); + expect(isInProjectCone(`${sep}workspace${sep}projectB${sep}Foo.cs`, csprojDirs)).toBe(false); + }); + + test('returns true when a file is multiple levels deep under a csproj directory', () => { + const csprojDirs = dirs(`${sep}workspace${sep}project`); + expect(isInProjectCone(`${sep}workspace${sep}project${sep}a${sep}b${sep}c${sep}Foo.cs`, csprojDirs)).toBe(true); + }); + + test('handles multiple csproj directories — returns true if any ancestor matches', () => { + const csprojDirs = dirs(`${sep}workspace${sep}projectA`, `${sep}workspace${sep}projectB`); + expect(isInProjectCone(`${sep}workspace${sep}projectB${sep}Foo.cs`, csprojDirs)).toBe(true); + }); +}); From f06ecb74f8542b6b091d0b968faabde656b476f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:01:28 +0000 Subject: [PATCH 03/28] Restrict convert-to-project options in project cones Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../fileBasedApps/convertToProject.ts | 38 +++++++++++-------- .../unitTests/convertToProject.test.ts | 28 ++++++++++++++ 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 3a32e5b3a..6bb8f3cc9 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -58,10 +58,9 @@ async function convertToProject(uri: vscode.Uri): Promise { * converts the one the user selects. * * A file is included in the list when any of the following is true: - * 1. It starts with the `#!` shebang sequence (Roslyn automatic-discovery algorithm). - * 2. It contains `#:` directives near the top (package/sdk/property directives). - * 3. It is not in the directory cone of any `.csproj` file in the workspace, meaning it + * 1. It is not in the directory cone of any `.csproj` file in the workspace, meaning it * is a standalone C# file that is likely intended to be run as a file-based app. + * 2. If it is inside a `.csproj` cone, it contains a top-of-file `#:` directive. * * C# files are identified by VS Code's language ID (`csharp`) so that non-`.cs` files * that the user has associated with the C# language are also considered. @@ -100,16 +99,7 @@ async function pickAndConvertToProject(): Promise { const filePath = fileUri.fsPath; const kind = detectFileBasedAppKind(filePath); - let isEntryPoint = kind !== FileBasedAppKind.None; - - // Also include files that are not inside any .csproj directory cone even when - // they lack explicit FBA markers, because such files have no project to belong - // to and are likely intended as file-based programs. - if (!isEntryPoint && !isInProjectCone(filePath, csprojDirs)) { - isEntryPoint = true; - } - - if (isEntryPoint) { + if (shouldShowConvertToProjectOption(filePath, kind, csprojDirs)) { const label = path.basename(filePath); const description = vscode.workspace.asRelativePath(fileUri, true); entryPoints.push({ label, description, detail: filePath }); @@ -120,8 +110,8 @@ async function pickAndConvertToProject(): Promise { vscode.window.showInformationMessage( vscode.l10n.t( 'No file-based C# apps were found in the workspace. ' + - 'A file-based app entry point must contain a `#!` or `#:` directive, ' + - 'or not be part of any `.csproj` project.' + 'A file-based app entry point must not be part of any `.csproj` project, ' + + 'unless it contains a top-of-file `#:` directive.' ) ); return; @@ -152,6 +142,24 @@ export function isInProjectCone(filePath: string, csprojDirs: Set): bool if (csprojDirs.has(dir)) { return true; } + + /** + * Returns `true` when the file should be shown as a "Convert to Project" option. + * + * Files outside all `.csproj` cones are always shown. Files inside a `.csproj` cone are + * hidden unless they contain a top-of-file `#:` directive. + */ + export function shouldShowConvertToProjectOption( + filePath: string, + kind: FileBasedAppKind, + csprojDirs: Set + ): boolean { + if (!isInProjectCone(filePath, csprojDirs)) { + return true; + } + + return kind === FileBasedAppKind.Directives; + } dir = parent; parent = path.dirname(dir); } diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index b0722d25f..41dc8c49b 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -9,6 +9,7 @@ import { detectFileBasedAppKind, FileBasedAppKind, isInProjectCone, + shouldShowConvertToProjectOption, } from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; // --------------------------------------------------------------------------- @@ -148,3 +149,30 @@ describe('isInProjectCone', () => { expect(isInProjectCone(`${sep}workspace${sep}projectB${sep}Foo.cs`, csprojDirs)).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// shouldShowConvertToProjectOption +// --------------------------------------------------------------------------- + +describe('shouldShowConvertToProjectOption', () => { + const sep = path.sep; + const fileInCone = `${sep}workspace${sep}project${sep}app.cs`; + const fileOutsideCone = `${sep}workspace${sep}scripts${sep}app.cs`; + const csprojDirs = new Set([`${sep}workspace${sep}project`]); + + test('returns false for a file in a csproj cone without file-based app directives', () => { + expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.None, csprojDirs)).toBe(false); + }); + + test('returns false for a shebang file in a csproj cone', () => { + expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.Shebang, csprojDirs)).toBe(false); + }); + + test('returns true for a directives file in a csproj cone', () => { + expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.Directives, csprojDirs)).toBe(true); + }); + + test('returns true for a file outside any csproj cone without directives', () => { + expect(shouldShowConvertToProjectOption(fileOutsideCone, FileBasedAppKind.None, csprojDirs)).toBe(true); + }); +}); From d591dfb74a309444deb200eae167081f601d52dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:02:58 +0000 Subject: [PATCH 04/28] Fix convert-to-project eligibility helper placement Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../fileBasedApps/convertToProject.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 6bb8f3cc9..0c6b26577 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -142,24 +142,6 @@ export function isInProjectCone(filePath: string, csprojDirs: Set): bool if (csprojDirs.has(dir)) { return true; } - - /** - * Returns `true` when the file should be shown as a "Convert to Project" option. - * - * Files outside all `.csproj` cones are always shown. Files inside a `.csproj` cone are - * hidden unless they contain a top-of-file `#:` directive. - */ - export function shouldShowConvertToProjectOption( - filePath: string, - kind: FileBasedAppKind, - csprojDirs: Set - ): boolean { - if (!isInProjectCone(filePath, csprojDirs)) { - return true; - } - - return kind === FileBasedAppKind.Directives; - } dir = parent; parent = path.dirname(dir); } @@ -167,6 +149,24 @@ export function isInProjectCone(filePath: string, csprojDirs: Set): bool return csprojDirs.has(dir); } +/** + * Returns `true` when the file should be shown as a "Convert to Project" option. + * + * Files outside all `.csproj` cones are always shown. Files inside a `.csproj` cone are + * hidden unless they contain a top-of-file `#:` directive. + */ +export function shouldShowConvertToProjectOption( + filePath: string, + kind: FileBasedAppKind, + csprojDirs: Set +): boolean { + if (!isInProjectCone(filePath, csprojDirs)) { + return true; + } + + return kind === FileBasedAppKind.Directives; +} + /** * Runs `dotnet project convert ` in an integrated terminal, targeting the * directory that contains the file so the SDK resolves paths correctly. From cb4abddc2cfdb996e865134926a4f86fc644b3c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:17:28 +0000 Subject: [PATCH 05/28] Allow in-cone shebang convert-to-project option Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 4 ++-- test/lsptoolshost/unitTests/convertToProject.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 0c6b26577..075ce4f58 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -153,7 +153,7 @@ export function isInProjectCone(filePath: string, csprojDirs: Set): bool * Returns `true` when the file should be shown as a "Convert to Project" option. * * Files outside all `.csproj` cones are always shown. Files inside a `.csproj` cone are - * hidden unless they contain a top-of-file `#:` directive. + * shown only when they contain top-of-file file-based app markers (`#!` or `#:`). */ export function shouldShowConvertToProjectOption( filePath: string, @@ -164,7 +164,7 @@ export function shouldShowConvertToProjectOption( return true; } - return kind === FileBasedAppKind.Directives; + return kind === FileBasedAppKind.Shebang || kind === FileBasedAppKind.Directives; } /** diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index 41dc8c49b..7d8bf59ec 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -164,8 +164,8 @@ describe('shouldShowConvertToProjectOption', () => { expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.None, csprojDirs)).toBe(false); }); - test('returns false for a shebang file in a csproj cone', () => { - expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.Shebang, csprojDirs)).toBe(false); + test('returns true for a shebang file in a csproj cone', () => { + expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.Shebang, csprojDirs)).toBe(true); }); test('returns true for a directives file in a csproj cone', () => { From fca7f3099d000c456446fdc2f8e8b2ca815e9d74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:29:26 +0000 Subject: [PATCH 06/28] Remove TODO comment from convert-to-project flow Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 075ce4f58..dea60a978 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -65,10 +65,10 @@ async function convertToProject(uri: vscode.Uri): Promise { * C# files are identified by VS Code's language ID (`csharp`) so that non-`.cs` files * that the user has associated with the C# language are also considered. * - * TODO: Replace the client-side scan with an authoritative LSP request - * (e.g. `workspace/_ms_fileBasedProgramEntryPoints`) once the Roslyn language - * server exposes one. The .NET 10 SDK (`dotnet project`) does not currently offer - * a CLI command to detect FBA entry points without converting or running them. + * This uses a client-side scan because the Roslyn language server does not currently + * expose an authoritative file-based program entry-point request, and the .NET 10 SDK + * (`dotnet project`) does not currently offer a CLI command to detect FBA entry points + * without converting or running them. */ async function pickAndConvertToProject(): Promise { // Collect C# files from the workspace by extension, then augment with any already-open From ea45b95053668554dfbefa45d93af42ead0664a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:41:08 +0000 Subject: [PATCH 07/28] Update comments to say "C# files" instead of generic "files/all files" Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index dea60a978..e93246aa3 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -57,13 +57,13 @@ async function convertToProject(uri: vscode.Uri): Promise { * Shows a quick-pick list of discoverable file-based C# apps in the workspace and * converts the one the user selects. * - * A file is included in the list when any of the following is true: - * 1. It is not in the directory cone of any `.csproj` file in the workspace, meaning it - * is a standalone C# file that is likely intended to be run as a file-based app. - * 2. If it is inside a `.csproj` cone, it contains a top-of-file `#:` directive. + * All C# files are considered possible entry points by default. C# files that have a + * `.csproj` file in their directory cone are ignored. C# files inside a `.csproj` cone + * are re-included when they contain FBA-specific markers (`#!` shebang or `#:` directives). * - * C# files are identified by VS Code's language ID (`csharp`) so that non-`.cs` files - * that the user has associated with the C# language are also considered. + * Open files are identified by VS Code's language ID (`csharp`) so that non-`.cs` files + * that the user has associated with the C# language are also considered. Workspace files + * that are not currently open are matched by their `.cs` extension. * * This uses a client-side scan because the Roslyn language server does not currently * expose an authoritative file-based program entry-point request, and the .NET 10 SDK @@ -152,7 +152,7 @@ export function isInProjectCone(filePath: string, csprojDirs: Set): bool /** * Returns `true` when the file should be shown as a "Convert to Project" option. * - * Files outside all `.csproj` cones are always shown. Files inside a `.csproj` cone are + * C# files outside all `.csproj` cones are always shown. C# files inside a `.csproj` cone are * shown only when they contain top-of-file file-based app markers (`#!` or `#:`). */ export function shouldShowConvertToProjectOption( From e244f02345c793d41bcfc5fe91bf0e01349a7221 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:09:38 +0000 Subject: [PATCH 08/28] Shorten long comments; add tests for all untested functions Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../fileBasedApps/convertToProject.ts | 42 +-- .../unitTests/convertToProject.test.ts | 51 +++- .../convertToProjectCommands.test.ts | 160 ++++++++++++ .../unitTests/convertToProjectPick.test.ts | 246 ++++++++++++++++++ 4 files changed, 467 insertions(+), 32 deletions(-) create mode 100644 test/lsptoolshost/unitTests/convertToProjectCommands.test.ts create mode 100644 test/lsptoolshost/unitTests/convertToProjectPick.test.ts diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index e93246aa3..7d15adec7 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -10,14 +10,9 @@ import * as vscode from 'vscode'; export const convertToProjectCommandName = 'dotnet.convertToProject'; /** - * Registers the command for converting a file-based C# app to a project-based app. - * - * This provides two entry points: - * 1. Right-click on a C# file → "Convert to Project" → runs `dotnet project convert ` - * 2. Command palette → "Convert C# File-based App to Project" → quick pick of discoverable - * file-based apps in the workspace, then runs conversion on the selected file. - * - * Requires .NET 10 SDK or later (the `dotnet project convert` command was introduced in .NET 10). + * Registers the `dotnet.convertToProject` command. When invoked with a URI (context menu) + * it converts that file directly; without a URI (command palette) it shows a quick pick of + * discoverable FBA entry points. Requires .NET 10 SDK or later. */ export function registerConvertToProjectCommands(context: vscode.ExtensionContext): void { context.subscriptions.push( @@ -38,9 +33,7 @@ export function registerConvertToProjectCommands(context: vscode.ExtensionContex * `dotnet project convert ` in an integrated terminal. */ async function convertToProject(uri: vscode.Uri): Promise { - // Use VS Code's language ID rather than the file extension, so that files VS Code - // recognises as C# (e.g. via a language association override) are accepted regardless - // of their extension. + // Use language ID (not extension) so files with custom language associations are accepted. const document = vscode.workspace.textDocuments.find((d) => d.uri.fsPath === uri.fsPath) ?? (await vscode.workspace.openTextDocument(uri)); @@ -54,21 +47,11 @@ async function convertToProject(uri: vscode.Uri): Promise { } /** - * Shows a quick-pick list of discoverable file-based C# apps in the workspace and - * converts the one the user selects. - * - * All C# files are considered possible entry points by default. C# files that have a - * `.csproj` file in their directory cone are ignored. C# files inside a `.csproj` cone - * are re-included when they contain FBA-specific markers (`#!` shebang or `#:` directives). - * - * Open files are identified by VS Code's language ID (`csharp`) so that non-`.cs` files - * that the user has associated with the C# language are also considered. Workspace files - * that are not currently open are matched by their `.cs` extension. + * Shows a quick-pick list of discoverable FBA entry points and converts the one selected. * - * This uses a client-side scan because the Roslyn language server does not currently - * expose an authoritative file-based program entry-point request, and the .NET 10 SDK - * (`dotnet project`) does not currently offer a CLI command to detect FBA entry points - * without converting or running them. + * All C# files are candidates. Files inside a `.csproj` cone are filtered out unless they + * contain `#!` or `#:` markers. Open non-`.cs` files are included via language ID; closed + * files are matched by `.cs` extension (VS Code only exposes language IDs for open files). */ async function pickAndConvertToProject(): Promise { // Collect C# files from the workspace by extension, then augment with any already-open @@ -206,13 +189,10 @@ export enum FileBasedAppKind { } /** - * Reads the beginning of `filePath` to determine whether it looks like a file-based C# - * app entry point. This intentionally mirrors the heuristics used by the Roslyn - * `FileBasedProgramsEntryPointDiscovery` class. + * Detects whether `filePath` looks like an FBA entry point by scanning the first few lines + * for `#!` or `#:` markers. Mirrors Roslyn's `FileBasedProgramsEntryPointDiscovery`. * - * An optional `readFileHead` function can be supplied (e.g. in tests) to replace the - * default `fs`-based implementation. It should return the raw file contents as a string, - * or `null` if the file cannot be read. + * Supply `readFileHead` to override the default `fs`-based reader (e.g. in tests). */ export function detectFileBasedAppKind( filePath: string, diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index 7d8bf59ec..22d2c5254 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { describe, test, expect } from '@jest/globals'; +import { afterEach, describe, test, expect } from '@jest/globals'; +import * as fs from 'fs'; import * as path from 'path'; import { detectFileBasedAppKind, @@ -176,3 +177,51 @@ describe('shouldShowConvertToProjectOption', () => { expect(shouldShowConvertToProjectOption(fileOutsideCone, FileBasedAppKind.None, csprojDirs)).toBe(true); }); }); + +describe('defaultReadFileHead (via detectFileBasedAppKind without custom reader)', () => { + const tempDir = path.join(__dirname, '.convertToProject-test-files'); + const tempFiles: string[] = []; + + afterEach(() => { + for (const filePath of tempFiles.splice(0)) { + try { + fs.unlinkSync(filePath); + } catch { + // Ignore cleanup failures. + } + } + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup failures. + } + }); + + function writeTempFile(content: string): string { + fs.mkdirSync(tempDir, { recursive: true }); + const filePath = path.join(tempDir, `app-${Date.now()}-${tempFiles.length}.cs`); + fs.writeFileSync(filePath, content); + tempFiles.push(filePath); + return filePath; + } + + test('returns Shebang for a file starting with #! using the default reader', () => { + const filePath = writeTempFile('#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Shebang); + }); + + test('returns Directives for a file starting with #: using the default reader', () => { + const filePath = writeTempFile('#:package Foo@1.0.0\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); + }); + + test('returns None for a normal C# file using the default reader', () => { + const filePath = writeTempFile('using System;\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); + }); + + test('returns None when the default reader cannot read the file', () => { + expect(detectFileBasedAppKind('/nonexistent/path/that/does/not/exist.cs')).toBe(FileBasedAppKind.None); + }); +}); diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts new file mode 100644 index 000000000..a1bee6d08 --- /dev/null +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, jest, test } from '@jest/globals'; +import * as vscode from 'vscode'; +import { + convertToProjectCommandName, + registerConvertToProjectCommands, +} from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; + +jest.mock('vscode', () => ({ + commands: { + registerCommand: jest.fn((_name, _handler) => ({ dispose: jest.fn() })), + }, + workspace: { + textDocuments: [], + openTextDocument: jest.fn(), + findFiles: jest.fn(), + asRelativePath: jest.fn((uri: { fsPath: string }) => uri.fsPath), + }, + window: { + showErrorMessage: jest.fn(), + showInformationMessage: jest.fn(), + terminals: [], + createTerminal: jest.fn(), + showQuickPick: jest.fn(), + }, + l10n: { + t: jest.fn((message: string) => message), + }, +})); + +type MockDocument = Pick; +type MockTerminal = Pick; + +type WorkspaceMock = { + textDocuments: MockDocument[]; + openTextDocument: jest.Mock<(uri: vscode.Uri) => Promise>; + findFiles: jest.Mock<(include: string, exclude: string) => Promise>; + asRelativePath: jest.Mock<(uri: { fsPath: string }) => string>; +}; + +type WindowMock = { + showErrorMessage: jest.Mock<(message: string) => void>; + showInformationMessage: jest.Mock<(message: string) => void>; + terminals: MockTerminal[]; + createTerminal: jest.Mock<(options: { name: string; cwd: string }) => MockTerminal>; + showQuickPick: jest.Mock; +}; + +const workspaceMock = vscode.workspace as unknown as WorkspaceMock; +const windowMock = vscode.window as unknown as WindowMock; +const registerCommandMock = vscode.commands.registerCommand as unknown as jest.Mock< + (name: string, handler: (uri?: vscode.Uri) => Promise) => vscode.Disposable +>; + +function createTerminal(name = 'dotnet project convert'): MockTerminal { + return { + name, + show: jest.fn(), + sendText: jest.fn(), + }; +} + +function getRegisteredHandler(): (uri?: vscode.Uri) => Promise { + return registerCommandMock.mock.calls[0][1] as (uri?: vscode.Uri) => Promise; +} + +beforeEach(() => { + jest.clearAllMocks(); + workspaceMock.textDocuments = []; + workspaceMock.openTextDocument.mockReset(); + workspaceMock.findFiles.mockReset().mockResolvedValue([] as vscode.Uri[]); + workspaceMock.asRelativePath.mockReset().mockImplementation((uri: { fsPath: string }) => uri.fsPath); + windowMock.showErrorMessage.mockReset(); + windowMock.showInformationMessage.mockReset(); + windowMock.terminals = []; + windowMock.createTerminal.mockReset(); + windowMock.showQuickPick.mockReset(); +}); + +describe('registerConvertToProjectCommands', () => { + test('registers the command and pushes the disposable onto subscriptions', async () => { + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + registerConvertToProjectCommands(context); + + expect(registerCommandMock).toHaveBeenCalledWith(convertToProjectCommandName, expect.any(Function)); + expect(context.subscriptions).toContain(registerCommandMock.mock.results[0].value); + + await getRegisteredHandler()(); + + expect(workspaceMock.findFiles).toHaveBeenCalledWith('**/*.cs', '**/obj/**'); + expect(windowMock.showInformationMessage).toHaveBeenCalledWith('No C# files found in the workspace.'); + }); +}); + +describe('convertToProject command handler', () => { + test('uses an already open C# document and creates a new terminal', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document: MockDocument = { uri, languageId: 'csharp' }; + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + registerConvertToProjectCommands(context); + await getRegisteredHandler()(uri); + + expect(workspaceMock.openTextDocument).not.toHaveBeenCalled(); + expect(windowMock.createTerminal).toHaveBeenCalledWith({ + name: 'dotnet project convert', + cwd: '/workspace', + }); + expect(terminal.show).toHaveBeenCalledWith(true); + expect(terminal.sendText).toHaveBeenCalledTimes(1); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + }); + + test('opens a closed C# document and reuses an existing terminal', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document: MockDocument = { uri, languageId: 'csharp' }; + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + const expectedCdCommand = process.platform === 'win32' ? 'cd /d "/workspace"' : 'cd "/workspace"'; + + workspaceMock.openTextDocument.mockResolvedValue(document as vscode.TextDocument); + windowMock.terminals = [terminal]; + + registerConvertToProjectCommands(context); + await getRegisteredHandler()(uri); + + expect(workspaceMock.openTextDocument).toHaveBeenCalledWith(uri); + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + expect(terminal.show).toHaveBeenCalledWith(true); + expect(terminal.sendText).toHaveBeenNthCalledWith(1, expectedCdCommand); + expect(terminal.sendText).toHaveBeenNthCalledWith(2, 'dotnet project convert "app.cs"'); + }); + + test('shows an error for a non-C# document and does not run the convert command', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document: MockDocument = { uri, languageId: 'plaintext' }; + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.openTextDocument.mockResolvedValue(document as vscode.TextDocument); + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + registerConvertToProjectCommands(context); + await getRegisteredHandler()(uri); + + expect(windowMock.showErrorMessage).toHaveBeenCalledWith('Only C# files can be converted to a project.'); + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + expect(terminal.show).not.toHaveBeenCalled(); + expect(terminal.sendText).not.toHaveBeenCalled(); + }); +}); diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts new file mode 100644 index 000000000..07bdbdb80 --- /dev/null +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -0,0 +1,246 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, jest, test } from '@jest/globals'; +import * as path from 'path'; + +jest.mock('vscode', () => { + const workspace = { + _textDocuments: [] as Array<{ languageId: string; uri: { fsPath: string } }>, + findFiles: jest.fn<(include: string, exclude: string) => Promise>>(), + openTextDocument: jest.fn(), + asRelativePath: jest.fn<(uri: { fsPath: string }, includeWorkspace?: boolean) => string>(), + }; + + Object.defineProperty(workspace, 'textDocuments', { + get: () => workspace._textDocuments, + set: (value) => { + workspace._textDocuments = value; + }, + }); + + const window = { + _terminals: [] as Array<{ + name: string; + show: jest.Mock<(preserveFocus?: boolean) => void>; + sendText: jest.Mock<(text: string) => void>; + }>, + showErrorMessage: jest.fn<(message: string) => void>(), + showInformationMessage: jest.fn<(message: string) => void>(), + showQuickPick: + jest.fn< + ( + items: Array<{ label: string; description: string; detail: string }>, + options: { placeHolder: string; matchOnDescription: boolean; matchOnDetail: boolean } + ) => Promise<{ label: string; description: string; detail: string } | undefined> + >(), + createTerminal: jest.fn< + (options: { name: string; cwd: string }) => { + name: string; + show: jest.Mock<(preserveFocus?: boolean) => void>; + sendText: jest.Mock<(text: string) => void>; + } + >(), + }; + + Object.defineProperty(window, 'terminals', { + get: () => window._terminals, + set: (value) => { + window._terminals = value; + }, + }); + + return { + commands: { + registerCommand: jest.fn((_name: string, _handler: (uri?: { fsPath: string }) => Promise) => ({ + dispose: jest.fn(), + })), + }, + workspace, + window, + l10n: { + t: (message: string) => message, + }, + Uri: { + file: (filePath: string) => ({ fsPath: filePath }), + }, + }; +}); + +import * as vscode from 'vscode'; +import { + convertToProjectCommandName, + registerConvertToProjectCommands, +} from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; + +type MockUri = { fsPath: string }; +type MockTextDocument = { languageId: string; uri: MockUri }; +type MockQuickPickItem = { label: string; description: string; detail: string }; +type MockTerminal = { + name: string; + show: jest.Mock<(preserveFocus?: boolean) => void>; + sendText: jest.Mock<(text: string) => void>; +}; + +type WorkspaceMock = { + textDocuments: MockTextDocument[]; + openTextDocument: jest.Mock; + findFiles: jest.Mock<(include: string, exclude: string) => Promise>; + asRelativePath: jest.Mock<(uri: MockUri, includeWorkspace?: boolean) => string>; +}; + +type WindowMock = { + showErrorMessage: jest.Mock<(message: string) => void>; + showInformationMessage: jest.Mock<(message: string) => void>; + terminals: MockTerminal[]; + createTerminal: jest.Mock<(options: { name: string; cwd: string }) => MockTerminal>; + showQuickPick: jest.Mock< + ( + items: MockQuickPickItem[], + options: { placeHolder: string; matchOnDescription: boolean; matchOnDetail: boolean } + ) => Promise + >; +}; + +const workspaceMock = vscode.workspace as unknown as WorkspaceMock; +const windowMock = vscode.window as unknown as WindowMock; +const registerCommandMock = vscode.commands.registerCommand as unknown as jest.Mock< + (name: string, handler: (uri?: vscode.Uri) => Promise) => vscode.Disposable +>; + +const mockTerminal: MockTerminal = { + name: 'dotnet project convert', + show: jest.fn<(preserveFocus?: boolean) => void>(), + sendText: jest.fn<(text: string) => void>(), +}; +const workspaceRoot = path.join(path.sep, 'workspace'); + +function uri(filePath: string): MockUri { + return vscode.Uri.file(filePath) as MockUri; +} + +function getRegisteredHandler(): (uri?: vscode.Uri) => Promise { + return registerCommandMock.mock.calls[0][1] as (uri?: vscode.Uri) => Promise; +} + +async function invokePickAndConvert(): Promise { + registerConvertToProjectCommands({ subscriptions: [] } as unknown as vscode.ExtensionContext); + await getRegisteredHandler()(); +} + +beforeEach(() => { + jest.clearAllMocks(); + + workspaceMock.textDocuments = []; + workspaceMock.openTextDocument.mockReset(); + workspaceMock.findFiles.mockReset().mockResolvedValue([] as MockUri[]); + workspaceMock.asRelativePath + .mockReset() + .mockImplementation((fileUri: MockUri) => path.relative(workspaceRoot, fileUri.fsPath)); + + windowMock.showErrorMessage.mockReset(); + windowMock.showInformationMessage.mockReset(); + windowMock.terminals = []; + windowMock.createTerminal.mockReset().mockReturnValue(mockTerminal); + windowMock.showQuickPick.mockReset(); + + mockTerminal.show.mockReset(); + mockTerminal.sendText.mockReset(); +}); + +describe('registerConvertToProjectCommands', () => { + test('registers the convert-to-project command', () => { + registerConvertToProjectCommands({ subscriptions: [] } as unknown as vscode.ExtensionContext); + + expect(registerCommandMock).toHaveBeenCalledWith(convertToProjectCommandName, expect.any(Function)); + }); +}); + +describe('pickAndConvertToProject', () => { + test('shows an info message when no C# files are found', async () => { + await invokePickAndConvert(); + + expect(windowMock.showInformationMessage).toHaveBeenCalledWith('No C# files found in the workspace.'); + }); + + test('shows an info message when all C# files are inside project cones', async () => { + const filePath = path.join(workspaceRoot, 'app', 'Program.cs'); + const projectPath = path.join(workspaceRoot, 'app', 'App.csproj'); + + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]).mockResolvedValueOnce([uri(projectPath)]); + + await invokePickAndConvert(); + + expect(windowMock.showInformationMessage).toHaveBeenCalledWith( + 'No file-based C# apps were found in the workspace. ' + + 'A file-based app entry point must not be part of any `.csproj` project, ' + + 'unless it contains a top-of-file `#:` directive.' + ); + }); + + test('shows a quick pick for file-based apps outside project cones', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.showQuickPick.mockResolvedValue(undefined); + + await invokePickAndConvert(); + + expect(windowMock.showQuickPick).toHaveBeenCalledWith( + [{ label: 'Program.cs', description: path.join('scripts', 'Program.cs'), detail: filePath }], + { + placeHolder: 'Select a file-based C# app to convert to a project', + matchOnDescription: true, + matchOnDetail: true, + } + ); + }); + + test('adds open C# documents without a .cs extension to the quick-pick candidates', async () => { + const discoveredPath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + const openPath = path.join(workspaceRoot, 'scripts', 'app'); + + workspaceMock.textDocuments = [ + { languageId: 'csharp', uri: uri(discoveredPath) }, + { languageId: 'csharp', uri: uri(openPath) }, + { languageId: 'plaintext', uri: uri(path.join(workspaceRoot, 'notes.txt')) }, + ]; + workspaceMock.findFiles.mockResolvedValueOnce([uri(discoveredPath)]); + windowMock.showQuickPick.mockResolvedValue(undefined); + + await invokePickAndConvert(); + + expect(windowMock.showQuickPick).toHaveBeenCalledWith( + expect.arrayContaining([{ label: 'app', description: path.join('scripts', 'app'), detail: openPath }]), + expect.any(Object) + ); + }); + + test('does not run conversion when the user dismisses the quick pick', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.showQuickPick.mockResolvedValue(undefined); + + await invokePickAndConvert(); + + expect(mockTerminal.sendText).not.toHaveBeenCalled(); + }); + + test('runs the convert command when the user picks a file', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.showQuickPick.mockResolvedValue({ + label: 'Program.cs', + description: path.join('scripts', 'Program.cs'), + detail: filePath, + }); + + await invokePickAndConvert(); + + expect(mockTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "Program.cs"'); + }); +}); From 0334c392e4eac08c1102dd3e0838b7065e9787d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:53:22 +0000 Subject: [PATCH 09/28] fix: always create new terminal for dotnet project convert, drop cd command Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../fileBasedApps/convertToProject.ts | 26 +++------- .../convertToProjectCommands.test.ts | 52 ++++++++++++++++--- .../unitTests/convertToProjectPick.test.ts | 45 ++++++++++++++++ 3 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 7d15adec7..f13c9b1c5 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -151,31 +151,21 @@ export function shouldShowConvertToProjectOption( } /** - * Runs `dotnet project convert ` in an integrated terminal, targeting the - * directory that contains the file so the SDK resolves paths correctly. + * Runs `dotnet project convert ` in a new integrated terminal whose working + * directory is set to the folder that contains the file. A fresh terminal is always + * created so that no shell-specific `cd` command is needed — the `cwd` option handles + * the working directory in a way that works on Bash, PowerShell, and CMD alike. */ async function runConvertCommand(filePath: string): Promise { const workingDir = path.dirname(filePath); const fileName = path.basename(filePath); - // Reuse an existing terminal if one with our name already exists to keep the UI tidy. - const terminalName = vscode.l10n.t('dotnet project convert'); - const existing = vscode.window.terminals.find((t) => t.name === terminalName); - const terminal = - existing ?? - vscode.window.createTerminal({ - name: terminalName, - cwd: workingDir, - }); + const terminal = vscode.window.createTerminal({ + name: vscode.l10n.t('dotnet project convert'), + cwd: workingDir, + }); terminal.show(/*preserveFocus:*/ true); - - // Change to the file's directory first in case the terminal was reused with a different cwd. - if (existing) { - const cdCommand = process.platform === 'win32' ? `cd /d "${workingDir}"` : `cd "${workingDir}"`; - terminal.sendText(cdCommand); - } - terminal.sendText(`dotnet project convert "${fileName}"`); } diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts index a1bee6d08..8ffc2aa15 100644 --- a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -120,24 +120,64 @@ describe('convertToProject command handler', () => { expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); }); - test('opens a closed C# document and reuses an existing terminal', async () => { + test('opens a closed C# document and creates a new terminal', async () => { const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; const document: MockDocument = { uri, languageId: 'csharp' }; const terminal = createTerminal(); const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; - const expectedCdCommand = process.platform === 'win32' ? 'cd /d "/workspace"' : 'cd "/workspace"'; workspaceMock.openTextDocument.mockResolvedValue(document as vscode.TextDocument); - windowMock.terminals = [terminal]; + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); registerConvertToProjectCommands(context); await getRegisteredHandler()(uri); expect(workspaceMock.openTextDocument).toHaveBeenCalledWith(uri); - expect(windowMock.createTerminal).not.toHaveBeenCalled(); + expect(windowMock.createTerminal).toHaveBeenCalledWith({ + name: 'dotnet project convert', + cwd: '/workspace', + }); expect(terminal.show).toHaveBeenCalledWith(true); - expect(terminal.sendText).toHaveBeenNthCalledWith(1, expectedCdCommand); - expect(terminal.sendText).toHaveBeenNthCalledWith(2, 'dotnet project convert "app.cs"'); + expect(terminal.sendText).toHaveBeenCalledTimes(1); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + }); + + test('always creates a new terminal even when one with the same name already exists', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document: MockDocument = { uri, languageId: 'csharp' }; + const existingTerminal = createTerminal(); + const newTerminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + windowMock.terminals = [existingTerminal]; + windowMock.createTerminal.mockReturnValue(newTerminal as vscode.Terminal); + + registerConvertToProjectCommands(context); + await getRegisteredHandler()(uri); + + expect(windowMock.createTerminal).toHaveBeenCalled(); + expect(existingTerminal.sendText).not.toHaveBeenCalled(); + expect(newTerminal.sendText).toHaveBeenCalledTimes(1); + expect(newTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + }); + + test('does not send any cd command regardless of platform', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + const document: MockDocument = { uri, languageId: 'csharp' }; + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + registerConvertToProjectCommands(context); + await getRegisteredHandler()(uri); + + const sentTexts = (terminal.sendText as jest.Mock).mock.calls.map((c) => c[0] as string); + for (const text of sentTexts) { + expect(text).not.toMatch(/^cd\b/); + } }); test('shows an error for a non-C# document and does not run the convert command', async () => { diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts index 07bdbdb80..8fee75873 100644 --- a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -241,6 +241,51 @@ describe('pickAndConvertToProject', () => { await invokePickAndConvert(); + expect(windowMock.createTerminal).toHaveBeenCalledWith({ + name: 'dotnet project convert', + cwd: path.join(workspaceRoot, 'scripts'), + }); + expect(mockTerminal.sendText).toHaveBeenCalledTimes(1); expect(mockTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "Program.cs"'); }); + + test('does not send any cd command when the user picks a file', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.showQuickPick.mockResolvedValue({ + label: 'Program.cs', + description: path.join('scripts', 'Program.cs'), + detail: filePath, + }); + + await invokePickAndConvert(); + + const sentTexts = mockTerminal.sendText.mock.calls.map((c) => c[0] as string); + for (const text of sentTexts) { + expect(text).not.toMatch(/^cd\b/); + } + }); + + test('always creates a new terminal when converting a picked file', async () => { + const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + const existingTerminal: MockTerminal = { + name: 'dotnet project convert', + show: jest.fn<(preserveFocus?: boolean) => void>(), + sendText: jest.fn<(text: string) => void>(), + }; + + workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); + windowMock.terminals = [existingTerminal]; + windowMock.showQuickPick.mockResolvedValue({ + label: 'Program.cs', + description: path.join('scripts', 'Program.cs'), + detail: filePath, + }); + + await invokePickAndConvert(); + + expect(windowMock.createTerminal).toHaveBeenCalled(); + expect(existingTerminal.sendText).not.toHaveBeenCalled(); + }); }); From 2c6ec4e86bce3be8d7bfbe9f960c4367a13e9485 Mon Sep 17 00:00:00 2001 From: "Mark Wiemer (MSFT)" <80539004+mwiemer-microsoft@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:37:08 -0700 Subject: [PATCH 10/28] Fix function header comment --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index f13c9b1c5..702930781 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -50,7 +50,7 @@ async function convertToProject(uri: vscode.Uri): Promise { * Shows a quick-pick list of discoverable FBA entry points and converts the one selected. * * All C# files are candidates. Files inside a `.csproj` cone are filtered out unless they - * contain `#!` or `#:` markers. Open non-`.cs` files are included via language ID; closed + * contain `#!` or `#:` markers. Open files are included via language ID; closed * files are matched by `.cs` extension (VS Code only exposes language IDs for open files). */ async function pickAndConvertToProject(): Promise { From 6ca31091ea8bb045966d1a14331f483d7df69f8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:37:17 +0000 Subject: [PATCH 11/28] fix: add #! to no-file-based-apps message, add l10n key, replace em dashes Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 10 +++++----- test/lsptoolshost/unitTests/convertToProject.test.ts | 2 +- .../unitTests/convertToProjectPick.test.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 702930781..24994d4c1 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -21,7 +21,7 @@ export function registerConvertToProjectCommands(context: vscode.ExtensionContex // Invoked from the right-click context menu with a specific file. await convertToProject(uri); } else { - // Invoked from the command palette — let the user pick from discoverable apps. + // Invoked from the command palette -- let the user pick from discoverable apps. await pickAndConvertToProject(); } }) @@ -94,7 +94,7 @@ async function pickAndConvertToProject(): Promise { vscode.l10n.t( 'No file-based C# apps were found in the workspace. ' + 'A file-based app entry point must not be part of any `.csproj` project, ' + - 'unless it contains a top-of-file `#:` directive.' + 'unless it contains a top-of-file `#!` or `#:` directive.' ) ); return; @@ -115,7 +115,7 @@ async function pickAndConvertToProject(): Promise { /** * Returns `true` when `filePath` resides inside the directory cone of at least one - * `.csproj` file — i.e. when any directory in `csprojDirs` is an ancestor of (or the + * `.csproj` file -- i.e. when any directory in `csprojDirs` is an ancestor of (or the * same directory as) the file's parent directory. */ export function isInProjectCone(filePath: string, csprojDirs: Set): boolean { @@ -153,7 +153,7 @@ export function shouldShowConvertToProjectOption( /** * Runs `dotnet project convert ` in a new integrated terminal whose working * directory is set to the folder that contains the file. A fresh terminal is always - * created so that no shell-specific `cd` command is needed — the `cwd` option handles + * created so that no shell-specific `cd` command is needed -- the `cwd` option handles * the working directory in a way that works on Bash, PowerShell, and CMD alike. */ async function runConvertCommand(filePath: string): Promise { @@ -223,7 +223,7 @@ export function detectFileBasedAppKind( /** * Default implementation of the `readFileHead` parameter for `detectFileBasedAppKind`. - * Reads the first 4 KB of the file — sufficient to find `#!` / `#:` near the top + * Reads the first 4 KB of the file -- sufficient to find `#!` / `#:` near the top * without loading potentially large source files. */ function defaultReadFileHead(filePath: string): string | null { diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index 22d2c5254..e5d2c03ec 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -145,7 +145,7 @@ describe('isInProjectCone', () => { expect(isInProjectCone(`${sep}workspace${sep}project${sep}a${sep}b${sep}c${sep}Foo.cs`, csprojDirs)).toBe(true); }); - test('handles multiple csproj directories — returns true if any ancestor matches', () => { + test('handles multiple csproj directories -- returns true if any ancestor matches', () => { const csprojDirs = dirs(`${sep}workspace${sep}projectA`, `${sep}workspace${sep}projectB`); expect(isInProjectCone(`${sep}workspace${sep}projectB${sep}Foo.cs`, csprojDirs)).toBe(true); }); diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts index 8fee75873..5334d51de 100644 --- a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -176,7 +176,7 @@ describe('pickAndConvertToProject', () => { expect(windowMock.showInformationMessage).toHaveBeenCalledWith( 'No file-based C# apps were found in the workspace. ' + 'A file-based app entry point must not be part of any `.csproj` project, ' + - 'unless it contains a top-of-file `#:` directive.' + 'unless it contains a top-of-file `#!` or `#:` directive.' ); }); From dc56b32a8bad2459463929d732d55f4477e57262 Mon Sep 17 00:00:00 2001 From: "Mark Wiemer (MSFT)" <80539004+mwiemer-microsoft@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:44:59 -0700 Subject: [PATCH 12/28] Explain when `picked?.detail` would be falsy --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 24994d4c1..89e279863 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -106,6 +106,7 @@ async function pickAndConvertToProject(): Promise { matchOnDetail: true, }); + // If user clicks away, cancelling operation if (!picked?.detail) { return; } From 419e3b47323040d247b883b4da10b8958d304cc0 Mon Sep 17 00:00:00 2001 From: "Mark Wiemer (MSFT)" <80539004+mwiemer-microsoft@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:50:05 -0700 Subject: [PATCH 13/28] Fix explanation for FileBasedAppKind.None --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 89e279863..d85ec7e8e 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -171,7 +171,7 @@ async function runConvertCommand(filePath: string): Promise { } export enum FileBasedAppKind { - /** The file is not a file-based app entry point. */ + /** The file does not include `#!` or `#:` directives. */ None, /** The file starts with `#!`, making it a discoverable entry point. */ Shebang, From 22a90b1f73f606892c5ec8da1d0bff6892d2b270 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:50:19 +0000 Subject: [PATCH 14/28] fix: hide Convert to Project menu for non-FBA files; drop obj exclude from csproj search Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- package.json | 4 +-- .../fileBasedApps/convertToProject.ts | 30 ++++++++++++++++++- .../convertToProjectCommands.test.ts | 9 ++++++ .../unitTests/convertToProjectPick.test.ts | 7 ++++- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 41abdf2eb..3c32c7338 100644 --- a/package.json +++ b/package.json @@ -5741,7 +5741,7 @@ }, { "command": "dotnet.convertToProject", - "when": "resourceLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "when": "resourceLangId == csharp && dotnet.isFileBasedApp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@2" }, { @@ -5763,7 +5763,7 @@ }, { "command": "dotnet.convertToProject", - "when": "resourceLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "when": "resourceLangId == csharp && dotnet.isFileBasedApp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@2" } ], diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index d85ec7e8e..515e8f627 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -9,10 +9,16 @@ import * as vscode from 'vscode'; export const convertToProjectCommandName = 'dotnet.convertToProject'; +/** VS Code `when`-clause key set to `true` when the active editor is an FBA entry point. */ +export const isFileBasedAppContextKey = 'dotnet.isFileBasedApp'; + /** * Registers the `dotnet.convertToProject` command. When invoked with a URI (context menu) * it converts that file directly; without a URI (command palette) it shows a quick pick of * discoverable FBA entry points. Requires .NET 10 SDK or later. + * + * Also keeps the `dotnet.isFileBasedApp` context key in sync with the active editor so that + * the context-menu entry is only visible for files that are FBA entry points. */ export function registerConvertToProjectCommands(context: vscode.ExtensionContext): void { context.subscriptions.push( @@ -26,6 +32,28 @@ export function registerConvertToProjectCommands(context: vscode.ExtensionContex } }) ); + + // Keep the context key in sync so the menu entry is hidden for non-FBA files. + void updateFileBasedAppContext(vscode.window.activeTextEditor); + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => void updateFileBasedAppContext(editor)) + ); +} + +/** + * Updates the `dotnet.isFileBasedApp` context key based on whether `editor`'s file is an + * FBA entry point. The key is used in `when` clauses so the "Convert to Project" menu + * option is only shown for relevant files. + */ +async function updateFileBasedAppContext(editor: vscode.TextEditor | undefined): Promise { + let isFileBasedApp = false; + if (editor !== undefined && editor.document.languageId === 'csharp') { + const csprojFiles = await vscode.workspace.findFiles('**/*.csproj'); + const csprojDirs = new Set(csprojFiles.map((u) => path.dirname(u.fsPath))); + const kind = detectFileBasedAppKind(editor.document.uri.fsPath); + isFileBasedApp = shouldShowConvertToProjectOption(editor.document.uri.fsPath, kind, csprojDirs); + } + await vscode.commands.executeCommand('setContext', isFileBasedAppContextKey, isFileBasedApp); } /** @@ -73,7 +101,7 @@ async function pickAndConvertToProject(): Promise { // Build a set of directories that contain a .csproj so we can quickly check whether // a given .cs file lives inside a project cone. - const csprojFiles = await vscode.workspace.findFiles('**/*.csproj', '**/obj/**'); + const csprojFiles = await vscode.workspace.findFiles('**/*.csproj'); const csprojDirs = new Set(csprojFiles.map((u) => path.dirname(u.fsPath))); const entryPoints: vscode.QuickPickItem[] = []; diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts index 8ffc2aa15..0dc846798 100644 --- a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -13,6 +13,7 @@ import { jest.mock('vscode', () => ({ commands: { registerCommand: jest.fn((_name, _handler) => ({ dispose: jest.fn() })), + executeCommand: jest.fn().mockImplementation(async () => Promise.resolve()), }, workspace: { textDocuments: [], @@ -26,6 +27,8 @@ jest.mock('vscode', () => ({ terminals: [], createTerminal: jest.fn(), showQuickPick: jest.fn(), + activeTextEditor: undefined, + onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), }, l10n: { t: jest.fn((message: string) => message), @@ -48,6 +51,8 @@ type WindowMock = { terminals: MockTerminal[]; createTerminal: jest.Mock<(options: { name: string; cwd: string }) => MockTerminal>; showQuickPick: jest.Mock; + activeTextEditor: vscode.TextEditor | undefined; + onDidChangeActiveTextEditor: jest.Mock; }; const workspaceMock = vscode.workspace as unknown as WorkspaceMock; @@ -55,6 +60,7 @@ const windowMock = vscode.window as unknown as WindowMock; const registerCommandMock = vscode.commands.registerCommand as unknown as jest.Mock< (name: string, handler: (uri?: vscode.Uri) => Promise) => vscode.Disposable >; +const executeCommandMock = vscode.commands.executeCommand as unknown as jest.Mock; function createTerminal(name = 'dotnet project convert'): MockTerminal { return { @@ -79,6 +85,9 @@ beforeEach(() => { windowMock.terminals = []; windowMock.createTerminal.mockReset(); windowMock.showQuickPick.mockReset(); + windowMock.activeTextEditor = undefined; + windowMock.onDidChangeActiveTextEditor.mockReset().mockReturnValue({ dispose: jest.fn() }); + executeCommandMock.mockReset().mockImplementation(async () => Promise.resolve()); }); describe('registerConvertToProjectCommands', () => { diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts index 5334d51de..793ae5d97 100644 --- a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -57,9 +57,14 @@ jest.mock('vscode', () => { registerCommand: jest.fn((_name: string, _handler: (uri?: { fsPath: string }) => Promise) => ({ dispose: jest.fn(), })), + executeCommand: jest.fn().mockImplementation(async () => Promise.resolve()), }, workspace, - window, + window: { + ...window, + activeTextEditor: undefined, + onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), + }, l10n: { t: (message: string) => message, }, From a89a0aeb0b9dee825e9ad595aa34dfaa1172999e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:53:41 +0000 Subject: [PATCH 15/28] Rename shouldShowConvertToProjectOption to isLikelyFbaEntryPoint Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 10 +++------- .../unitTests/convertToProject.test.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 515e8f627..266a6a238 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -51,7 +51,7 @@ async function updateFileBasedAppContext(editor: vscode.TextEditor | undefined): const csprojFiles = await vscode.workspace.findFiles('**/*.csproj'); const csprojDirs = new Set(csprojFiles.map((u) => path.dirname(u.fsPath))); const kind = detectFileBasedAppKind(editor.document.uri.fsPath); - isFileBasedApp = shouldShowConvertToProjectOption(editor.document.uri.fsPath, kind, csprojDirs); + isFileBasedApp = isLikelyFbaEntryPoint(editor.document.uri.fsPath, kind, csprojDirs); } await vscode.commands.executeCommand('setContext', isFileBasedAppContextKey, isFileBasedApp); } @@ -110,7 +110,7 @@ async function pickAndConvertToProject(): Promise { const filePath = fileUri.fsPath; const kind = detectFileBasedAppKind(filePath); - if (shouldShowConvertToProjectOption(filePath, kind, csprojDirs)) { + if (isLikelyFbaEntryPoint(filePath, kind, csprojDirs)) { const label = path.basename(filePath); const description = vscode.workspace.asRelativePath(fileUri, true); entryPoints.push({ label, description, detail: filePath }); @@ -167,11 +167,7 @@ export function isInProjectCone(filePath: string, csprojDirs: Set): bool * C# files outside all `.csproj` cones are always shown. C# files inside a `.csproj` cone are * shown only when they contain top-of-file file-based app markers (`#!` or `#:`). */ -export function shouldShowConvertToProjectOption( - filePath: string, - kind: FileBasedAppKind, - csprojDirs: Set -): boolean { +export function isLikelyFbaEntryPoint(filePath: string, kind: FileBasedAppKind, csprojDirs: Set): boolean { if (!isInProjectCone(filePath, csprojDirs)) { return true; } diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index e5d2c03ec..08221e78c 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -10,7 +10,7 @@ import { detectFileBasedAppKind, FileBasedAppKind, isInProjectCone, - shouldShowConvertToProjectOption, + isLikelyFbaEntryPoint, } from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; // --------------------------------------------------------------------------- @@ -152,29 +152,29 @@ describe('isInProjectCone', () => { }); // --------------------------------------------------------------------------- -// shouldShowConvertToProjectOption +// isLikelyFbaEntryPoint // --------------------------------------------------------------------------- -describe('shouldShowConvertToProjectOption', () => { +describe('isLikelyFbaEntryPoint', () => { const sep = path.sep; const fileInCone = `${sep}workspace${sep}project${sep}app.cs`; const fileOutsideCone = `${sep}workspace${sep}scripts${sep}app.cs`; const csprojDirs = new Set([`${sep}workspace${sep}project`]); test('returns false for a file in a csproj cone without file-based app directives', () => { - expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.None, csprojDirs)).toBe(false); + expect(isLikelyFbaEntryPoint(fileInCone, FileBasedAppKind.None, csprojDirs)).toBe(false); }); test('returns true for a shebang file in a csproj cone', () => { - expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.Shebang, csprojDirs)).toBe(true); + expect(isLikelyFbaEntryPoint(fileInCone, FileBasedAppKind.Shebang, csprojDirs)).toBe(true); }); test('returns true for a directives file in a csproj cone', () => { - expect(shouldShowConvertToProjectOption(fileInCone, FileBasedAppKind.Directives, csprojDirs)).toBe(true); + expect(isLikelyFbaEntryPoint(fileInCone, FileBasedAppKind.Directives, csprojDirs)).toBe(true); }); test('returns true for a file outside any csproj cone without directives', () => { - expect(shouldShowConvertToProjectOption(fileOutsideCone, FileBasedAppKind.None, csprojDirs)).toBe(true); + expect(isLikelyFbaEntryPoint(fileOutsideCone, FileBasedAppKind.None, csprojDirs)).toBe(true); }); }); From 983001aa65b9141ecd17daee5830f20654ac8ce6 Mon Sep 17 00:00:00 2001 From: "Mark Wiemer (MSFT)" <80539004+mwiemer-microsoft@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:06:04 -0700 Subject: [PATCH 16/28] Explain why we scan a few lines --- src/lsptoolshost/fileBasedApps/convertToProject.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 266a6a238..0600a985b 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -237,7 +237,8 @@ export function detectFileBasedAppKind( return FileBasedAppKind.Directives; } // After encountering a line that is neither blank nor a directive, stop scanning. - // Real entry points put their directives at the very top. + // Real FBA entry points must put their directives before other C# tokens, + // though FBA directives can come after comments if (++checkedLines >= 5) { break; } From fa7425949c2edca166edba4110f81d4243cf0da7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:08:33 +0000 Subject: [PATCH 17/28] Simplify detectFileBasedAppKind: remove injectable readFileHead parameter Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../fileBasedApps/convertToProject.ts | 9 +- .../unitTests/convertToProject.test.ts | 147 +++++++----------- 2 files changed, 60 insertions(+), 96 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 0600a985b..3d5a39f87 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -206,14 +206,9 @@ export enum FileBasedAppKind { /** * Detects whether `filePath` looks like an FBA entry point by scanning the first few lines * for `#!` or `#:` markers. Mirrors Roslyn's `FileBasedProgramsEntryPointDiscovery`. - * - * Supply `readFileHead` to override the default `fs`-based reader (e.g. in tests). */ -export function detectFileBasedAppKind( - filePath: string, - readFileHead: (p: string) => string | null = defaultReadFileHead -): FileBasedAppKind { - const content = readFileHead(filePath); +export function detectFileBasedAppKind(filePath: string): FileBasedAppKind { + const content = defaultReadFileHead(filePath); if (content === null) { return FileBasedAppKind.None; } diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index 08221e78c..6c77fd241 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { afterEach, describe, test, expect } from '@jest/globals'; +import { afterEach, beforeEach, describe, test, expect } from '@jest/globals'; import * as fs from 'fs'; import * as path from 'path'; import { @@ -14,93 +14,110 @@ import { } from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; // --------------------------------------------------------------------------- -// Helpers +// detectFileBasedAppKind // --------------------------------------------------------------------------- -/** Creates a `readFileHead` stub that returns a fixed string. */ -function makeReader(content: string): (p: string) => string | null { - return (_p: string) => content; -} +describe('detectFileBasedAppKind', () => { + const tempDir = path.join(__dirname, '.convertToProject-test-files'); + const tempFiles: string[] = []; -/** A reader that always simulates an unreadable file. */ -const nullReader: (p: string) => string | null = (_p) => null; + beforeEach(() => { + fs.mkdirSync(tempDir, { recursive: true }); + }); -// --------------------------------------------------------------------------- -// detectFileBasedAppKind -// --------------------------------------------------------------------------- + afterEach(() => { + for (const filePath of tempFiles.splice(0)) { + try { + fs.unlinkSync(filePath); + } catch { + // Ignore cleanup failures. + } + } + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup failures. + } + }); + + function writeTempFile(content: string): string { + const filePath = path.join(tempDir, `app-${Date.now()}-${tempFiles.length}.cs`); + fs.writeFileSync(filePath, content); + tempFiles.push(filePath); + return filePath; + } -describe('detectFileBasedAppKind', () => { describe('Shebang detection', () => { test('returns Shebang for a file starting with #!', () => { - expect( - detectFileBasedAppKind('/a/app.cs', makeReader('#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n')) - ).toBe(FileBasedAppKind.Shebang); + const filePath = writeTempFile('#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Shebang); }); test('returns Shebang when file starts with BOM then #!', () => { - expect( - detectFileBasedAppKind( - '/a/app.cs', - makeReader('\uFEFF#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n') - ) - ).toBe(FileBasedAppKind.Shebang); + const filePath = writeTempFile('\uFEFF#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Shebang); }); test('does not return Shebang when #! appears after a non-empty line', () => { - const content = 'using System;\n#!/usr/bin/env dotnet\n'; - expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.None); + const filePath = writeTempFile('using System;\n#!/usr/bin/env dotnet\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); }); }); describe('Directives detection', () => { test('returns Directives for a file starting with #: package directive', () => { - const content = '#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'; - expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.Directives); + const filePath = writeTempFile('#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); }); test('returns Directives for a file starting with #: sdk directive', () => { - const content = '#:sdk Microsoft.NET.Sdk.Web\nConsole.WriteLine("hi");\n'; - expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.Directives); + const filePath = writeTempFile('#:sdk Microsoft.NET.Sdk.Web\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); }); test('returns Directives when #: directive is preceded only by blank lines', () => { - const content = '\n\n#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'; - expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.Directives); + const filePath = writeTempFile('\n\n#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); }); test('returns Directives when BOM precedes a #: directive', () => { - const content = '\uFEFF#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'; - expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.Directives); + const filePath = writeTempFile('\uFEFF#:package Newtonsoft.Json@13.0.3\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); }); test('does not return Directives when #: appears after 5 non-blank lines', () => { - const content = ['line1', 'line2', 'line3', 'line4', 'line5', '#:package Foo@1.0.0'].join('\n'); - expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.None); + const filePath = writeTempFile( + ['line1', 'line2', 'line3', 'line4', 'line5', '#:package Foo@1.0.0'].join('\n') + ); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); }); }); describe('None cases', () => { test('returns None for a normal C# class file', () => { - const content = 'using System;\n\nnamespace Foo {\n public class Bar {}\n}\n'; - expect(detectFileBasedAppKind('/a/Bar.cs', makeReader(content))).toBe(FileBasedAppKind.None); + const filePath = writeTempFile('using System;\n\nnamespace Foo {\n public class Bar {}\n}\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); }); test('returns None for an empty file', () => { - expect(detectFileBasedAppKind('/a/empty.cs', makeReader(''))).toBe(FileBasedAppKind.None); + const filePath = writeTempFile(''); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); }); - test('returns None when the reader returns null (unreadable file)', () => { - expect(detectFileBasedAppKind('/a/unreadable.cs', nullReader)).toBe(FileBasedAppKind.None); + test('returns None when the file does not exist (unreadable)', () => { + expect(detectFileBasedAppKind('/nonexistent/path/that/does/not/exist.cs')).toBe(FileBasedAppKind.None); }); test('returns None for a file that only has blank lines', () => { - expect(detectFileBasedAppKind('/a/blank.cs', makeReader('\n\n\n'))).toBe(FileBasedAppKind.None); + const filePath = writeTempFile('\n\n\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); }); test('returns None when #: appears inside a string literal on the first line', () => { // The algorithm checks trimmed lines; this line does NOT start with #: - const content = 'var s = "#: not a directive";\n'; - expect(detectFileBasedAppKind('/a/app.cs', makeReader(content))).toBe(FileBasedAppKind.None); + const filePath = writeTempFile('var s = "#: not a directive";\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); }); }); }); @@ -177,51 +194,3 @@ describe('isLikelyFbaEntryPoint', () => { expect(isLikelyFbaEntryPoint(fileOutsideCone, FileBasedAppKind.None, csprojDirs)).toBe(true); }); }); - -describe('defaultReadFileHead (via detectFileBasedAppKind without custom reader)', () => { - const tempDir = path.join(__dirname, '.convertToProject-test-files'); - const tempFiles: string[] = []; - - afterEach(() => { - for (const filePath of tempFiles.splice(0)) { - try { - fs.unlinkSync(filePath); - } catch { - // Ignore cleanup failures. - } - } - - try { - fs.rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup failures. - } - }); - - function writeTempFile(content: string): string { - fs.mkdirSync(tempDir, { recursive: true }); - const filePath = path.join(tempDir, `app-${Date.now()}-${tempFiles.length}.cs`); - fs.writeFileSync(filePath, content); - tempFiles.push(filePath); - return filePath; - } - - test('returns Shebang for a file starting with #! using the default reader', () => { - const filePath = writeTempFile('#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n'); - expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Shebang); - }); - - test('returns Directives for a file starting with #: using the default reader', () => { - const filePath = writeTempFile('#:package Foo@1.0.0\nConsole.WriteLine("hi");\n'); - expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); - }); - - test('returns None for a normal C# file using the default reader', () => { - const filePath = writeTempFile('using System;\nConsole.WriteLine("hi");\n'); - expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); - }); - - test('returns None when the default reader cannot read the file', () => { - expect(detectFileBasedAppKind('/nonexistent/path/that/does/not/exist.cs')).toBe(FileBasedAppKind.None); - }); -}); From 7e079a5fd581bcbdaa18c4b3f99630a527f8d5b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:12:28 +0000 Subject: [PATCH 18/28] Extract nonEmptyLinesToCheck constant, reduce buffer to 1 KB, use #region in tests Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../fileBasedApps/convertToProject.ts | 7 ++++--- .../unitTests/convertToProject.test.ts | 15 ++++++--------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 3d5a39f87..2913946d4 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -222,6 +222,7 @@ export function detectFileBasedAppKind(filePath: string): FileBasedAppKind { // Check the first few non-empty lines for `#:` directives. const lines = stripped.split(/\r?\n/); + const nonEmptyLinesToCheck = 5; let checkedLines = 0; for (const line of lines) { const trimmed = line.trim(); @@ -234,7 +235,7 @@ export function detectFileBasedAppKind(filePath: string): FileBasedAppKind { // After encountering a line that is neither blank nor a directive, stop scanning. // Real FBA entry points must put their directives before other C# tokens, // though FBA directives can come after comments - if (++checkedLines >= 5) { + if (++checkedLines >= nonEmptyLinesToCheck) { break; } } @@ -244,12 +245,12 @@ export function detectFileBasedAppKind(filePath: string): FileBasedAppKind { /** * Default implementation of the `readFileHead` parameter for `detectFileBasedAppKind`. - * Reads the first 4 KB of the file -- sufficient to find `#!` / `#:` near the top + * Reads the first 1 KB of the file -- sufficient to find `#!` / `#:` near the top * without loading potentially large source files. */ function defaultReadFileHead(filePath: string): string | null { try { - const buffer = Buffer.alloc(4096); + const buffer = Buffer.alloc(1024); const fd = fs.openSync(filePath, 'r'); const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); fs.closeSync(fd); diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index 6c77fd241..be2e5f3db 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -13,9 +13,7 @@ import { isLikelyFbaEntryPoint, } from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; -// --------------------------------------------------------------------------- -// detectFileBasedAppKind -// --------------------------------------------------------------------------- +//#region detectFileBasedAppKind describe('detectFileBasedAppKind', () => { const tempDir = path.join(__dirname, '.convertToProject-test-files'); @@ -121,10 +119,9 @@ describe('detectFileBasedAppKind', () => { }); }); }); +//#endregion detectFileBasedAppKind -// --------------------------------------------------------------------------- -// isInProjectCone -// --------------------------------------------------------------------------- +//#region isInProjectCone describe('isInProjectCone', () => { const sep = path.sep; @@ -167,10 +164,9 @@ describe('isInProjectCone', () => { expect(isInProjectCone(`${sep}workspace${sep}projectB${sep}Foo.cs`, csprojDirs)).toBe(true); }); }); +//#endregion isInProjectCone -// --------------------------------------------------------------------------- -// isLikelyFbaEntryPoint -// --------------------------------------------------------------------------- +//#region isLikelyFbaEntryPoint describe('isLikelyFbaEntryPoint', () => { const sep = path.sep; @@ -194,3 +190,4 @@ describe('isLikelyFbaEntryPoint', () => { expect(isLikelyFbaEntryPoint(fileOutsideCone, FileBasedAppKind.None, csprojDirs)).toBe(true); }); }); +//#endregion isLikelyFbaEntryPoint From fdef088bb0f85db8cbf292cec641471fa4687a3d Mon Sep 17 00:00:00 2001 From: "Mark Wiemer (MSFT)" <80539004+mwiemer-microsoft@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:13:26 -0700 Subject: [PATCH 19/28] Hey, FBAs are normal too --- test/lsptoolshost/unitTests/convertToProject.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index be2e5f3db..4bc0ad69e 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -93,7 +93,7 @@ describe('detectFileBasedAppKind', () => { }); describe('None cases', () => { - test('returns None for a normal C# class file', () => { + test('returns None for a C# class file without directives', () => { const filePath = writeTempFile('using System;\n\nnamespace Foo {\n public class Bar {}\n}\n'); expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.None); }); From 07726488faf454192792fd4dcafc677844bf3cbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:15:01 +0000 Subject: [PATCH 20/28] Remove region comments from test file Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- test/lsptoolshost/unitTests/convertToProject.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index 4bc0ad69e..b50fb5dd3 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -13,8 +13,6 @@ import { isLikelyFbaEntryPoint, } from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; -//#region detectFileBasedAppKind - describe('detectFileBasedAppKind', () => { const tempDir = path.join(__dirname, '.convertToProject-test-files'); const tempFiles: string[] = []; @@ -119,9 +117,6 @@ describe('detectFileBasedAppKind', () => { }); }); }); -//#endregion detectFileBasedAppKind - -//#region isInProjectCone describe('isInProjectCone', () => { const sep = path.sep; @@ -164,9 +159,6 @@ describe('isInProjectCone', () => { expect(isInProjectCone(`${sep}workspace${sep}projectB${sep}Foo.cs`, csprojDirs)).toBe(true); }); }); -//#endregion isInProjectCone - -//#region isLikelyFbaEntryPoint describe('isLikelyFbaEntryPoint', () => { const sep = path.sep; @@ -190,4 +182,3 @@ describe('isLikelyFbaEntryPoint', () => { expect(isLikelyFbaEntryPoint(fileOutsideCone, FileBasedAppKind.None, csprojDirs)).toBe(true); }); }); -//#endregion isLikelyFbaEntryPoint From 72c901be31c817daa1721a24d587879d24de7b11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:18:23 +0000 Subject: [PATCH 21/28] Add test for #: directive after blank line, comment, blank line Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- test/lsptoolshost/unitTests/convertToProject.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/lsptoolshost/unitTests/convertToProject.test.ts b/test/lsptoolshost/unitTests/convertToProject.test.ts index b50fb5dd3..d17545f40 100644 --- a/test/lsptoolshost/unitTests/convertToProject.test.ts +++ b/test/lsptoolshost/unitTests/convertToProject.test.ts @@ -82,6 +82,11 @@ describe('detectFileBasedAppKind', () => { expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); }); + test('returns Directives when #: appears after a blank line, a comment, and another blank line', () => { + const filePath = writeTempFile('\n// some comment\n\n#:package Foo@1.0.0\nConsole.WriteLine("hi");\n'); + expect(detectFileBasedAppKind(filePath)).toBe(FileBasedAppKind.Directives); + }); + test('does not return Directives when #: appears after 5 non-blank lines', () => { const filePath = writeTempFile( ['line1', 'line2', 'line3', 'line4', 'line5', '#:package Foo@1.0.0'].join('\n') From b185a3627fa3d3a3a1c21be991e3af275c38a692 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:27:28 +0000 Subject: [PATCH 22/28] Reuse named terminal instead of always creating a new one Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../fileBasedApps/convertToProject.ts | 27 +++++++++++-------- .../convertToProjectCommands.test.ts | 23 ++++++++-------- .../unitTests/convertToProjectPick.test.ts | 10 ++++--- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 2913946d4..ffce66285 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -176,22 +176,27 @@ export function isLikelyFbaEntryPoint(filePath: string, kind: FileBasedAppKind, } /** - * Runs `dotnet project convert ` in a new integrated terminal whose working - * directory is set to the folder that contains the file. A fresh terminal is always - * created so that no shell-specific `cd` command is needed -- the `cwd` option handles - * the working directory in a way that works on Bash, PowerShell, and CMD alike. + * Runs `dotnet project convert ` in an integrated terminal. A terminal named + * "dotnet project convert" is reused when one already exists to avoid cluttering the + * terminal panel; otherwise a new terminal is created with its working directory set to + * the file's folder. The full file path is always passed to the command so it runs + * correctly regardless of the terminal's current working directory. */ async function runConvertCommand(filePath: string): Promise { - const workingDir = path.dirname(filePath); - const fileName = path.basename(filePath); + const terminalName = vscode.l10n.t('dotnet project convert'); - const terminal = vscode.window.createTerminal({ - name: vscode.l10n.t('dotnet project convert'), - cwd: workingDir, - }); + // Reuse an existing terminal with our known name to avoid creating many instances. + const existing = vscode.window.terminals.find((t) => t.name === terminalName); + const terminal = + existing ?? + vscode.window.createTerminal({ + name: terminalName, + cwd: path.dirname(filePath), + }); terminal.show(/*preserveFocus:*/ true); - terminal.sendText(`dotnet project convert "${fileName}"`); + // Use the full path so the command works regardless of the terminal's current directory. + terminal.sendText(`dotnet project convert "${filePath}"`); } export enum FileBasedAppKind { diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts index 0dc846798..ca23cbfd9 100644 --- a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -126,7 +126,7 @@ describe('convertToProject command handler', () => { }); expect(terminal.show).toHaveBeenCalledWith(true); expect(terminal.sendText).toHaveBeenCalledTimes(1); - expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "/workspace/app.cs"'); }); test('opens a closed C# document and creates a new terminal', async () => { @@ -148,27 +148,28 @@ describe('convertToProject command handler', () => { }); expect(terminal.show).toHaveBeenCalledWith(true); expect(terminal.sendText).toHaveBeenCalledTimes(1); - expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "/workspace/app.cs"'); }); - test('always creates a new terminal even when one with the same name already exists', async () => { - const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; + test('reuses an existing terminal when one with the same name already exists', async () => { + const uri = { fsPath: '/workspace/nested/deep/app.cs' } as vscode.Uri; const document: MockDocument = { uri, languageId: 'csharp' }; - const existingTerminal = createTerminal(); - const newTerminal = createTerminal(); + const existingTerminal = createTerminal('dotnet project convert'); const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; workspaceMock.textDocuments = [document]; windowMock.terminals = [existingTerminal]; - windowMock.createTerminal.mockReturnValue(newTerminal as vscode.Terminal); registerConvertToProjectCommands(context); await getRegisteredHandler()(uri); - expect(windowMock.createTerminal).toHaveBeenCalled(); - expect(existingTerminal.sendText).not.toHaveBeenCalled(); - expect(newTerminal.sendText).toHaveBeenCalledTimes(1); - expect(newTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + expect(existingTerminal.show).toHaveBeenCalledWith(true); + expect(existingTerminal.sendText).toHaveBeenCalledTimes(1); + // The full path must be used so the command is correct regardless of the terminal's cwd. + expect(existingTerminal.sendText).toHaveBeenCalledWith( + 'dotnet project convert "/workspace/nested/deep/app.cs"' + ); }); test('does not send any cd command regardless of platform', async () => { diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts index 793ae5d97..9d0242c13 100644 --- a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -251,7 +251,7 @@ describe('pickAndConvertToProject', () => { cwd: path.join(workspaceRoot, 'scripts'), }); expect(mockTerminal.sendText).toHaveBeenCalledTimes(1); - expect(mockTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "Program.cs"'); + expect(mockTerminal.sendText).toHaveBeenCalledWith(`dotnet project convert "${filePath}"`); }); test('does not send any cd command when the user picks a file', async () => { @@ -272,7 +272,7 @@ describe('pickAndConvertToProject', () => { } }); - test('always creates a new terminal when converting a picked file', async () => { + test('reuses an existing terminal when converting a picked file', async () => { const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); const existingTerminal: MockTerminal = { name: 'dotnet project convert', @@ -290,7 +290,9 @@ describe('pickAndConvertToProject', () => { await invokePickAndConvert(); - expect(windowMock.createTerminal).toHaveBeenCalled(); - expect(existingTerminal.sendText).not.toHaveBeenCalled(); + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + expect(existingTerminal.show).toHaveBeenCalledWith(true); + expect(existingTerminal.sendText).toHaveBeenCalledTimes(1); + expect(existingTerminal.sendText).toHaveBeenCalledWith(`dotnet project convert "${filePath}"`); }); }); From ebd95a10d05aae3cee861a4e07b48d2bc69f45ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:33:18 +0000 Subject: [PATCH 23/28] Revert terminal reuse -- always create a new terminal Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../fileBasedApps/convertToProject.ts | 27 ++++++++----------- .../convertToProjectCommands.test.ts | 23 ++++++++-------- .../unitTests/convertToProjectPick.test.ts | 10 +++---- 3 files changed, 26 insertions(+), 34 deletions(-) diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index ffce66285..2913946d4 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -176,27 +176,22 @@ export function isLikelyFbaEntryPoint(filePath: string, kind: FileBasedAppKind, } /** - * Runs `dotnet project convert ` in an integrated terminal. A terminal named - * "dotnet project convert" is reused when one already exists to avoid cluttering the - * terminal panel; otherwise a new terminal is created with its working directory set to - * the file's folder. The full file path is always passed to the command so it runs - * correctly regardless of the terminal's current working directory. + * Runs `dotnet project convert ` in a new integrated terminal whose working + * directory is set to the folder that contains the file. A fresh terminal is always + * created so that no shell-specific `cd` command is needed -- the `cwd` option handles + * the working directory in a way that works on Bash, PowerShell, and CMD alike. */ async function runConvertCommand(filePath: string): Promise { - const terminalName = vscode.l10n.t('dotnet project convert'); + const workingDir = path.dirname(filePath); + const fileName = path.basename(filePath); - // Reuse an existing terminal with our known name to avoid creating many instances. - const existing = vscode.window.terminals.find((t) => t.name === terminalName); - const terminal = - existing ?? - vscode.window.createTerminal({ - name: terminalName, - cwd: path.dirname(filePath), - }); + const terminal = vscode.window.createTerminal({ + name: vscode.l10n.t('dotnet project convert'), + cwd: workingDir, + }); terminal.show(/*preserveFocus:*/ true); - // Use the full path so the command works regardless of the terminal's current directory. - terminal.sendText(`dotnet project convert "${filePath}"`); + terminal.sendText(`dotnet project convert "${fileName}"`); } export enum FileBasedAppKind { diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts index ca23cbfd9..0dc846798 100644 --- a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -126,7 +126,7 @@ describe('convertToProject command handler', () => { }); expect(terminal.show).toHaveBeenCalledWith(true); expect(terminal.sendText).toHaveBeenCalledTimes(1); - expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "/workspace/app.cs"'); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); }); test('opens a closed C# document and creates a new terminal', async () => { @@ -148,28 +148,27 @@ describe('convertToProject command handler', () => { }); expect(terminal.show).toHaveBeenCalledWith(true); expect(terminal.sendText).toHaveBeenCalledTimes(1); - expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "/workspace/app.cs"'); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); }); - test('reuses an existing terminal when one with the same name already exists', async () => { - const uri = { fsPath: '/workspace/nested/deep/app.cs' } as vscode.Uri; + test('always creates a new terminal even when one with the same name already exists', async () => { + const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; const document: MockDocument = { uri, languageId: 'csharp' }; - const existingTerminal = createTerminal('dotnet project convert'); + const existingTerminal = createTerminal(); + const newTerminal = createTerminal(); const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; workspaceMock.textDocuments = [document]; windowMock.terminals = [existingTerminal]; + windowMock.createTerminal.mockReturnValue(newTerminal as vscode.Terminal); registerConvertToProjectCommands(context); await getRegisteredHandler()(uri); - expect(windowMock.createTerminal).not.toHaveBeenCalled(); - expect(existingTerminal.show).toHaveBeenCalledWith(true); - expect(existingTerminal.sendText).toHaveBeenCalledTimes(1); - // The full path must be used so the command is correct regardless of the terminal's cwd. - expect(existingTerminal.sendText).toHaveBeenCalledWith( - 'dotnet project convert "/workspace/nested/deep/app.cs"' - ); + expect(windowMock.createTerminal).toHaveBeenCalled(); + expect(existingTerminal.sendText).not.toHaveBeenCalled(); + expect(newTerminal.sendText).toHaveBeenCalledTimes(1); + expect(newTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); }); test('does not send any cd command regardless of platform', async () => { diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts index 9d0242c13..793ae5d97 100644 --- a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -251,7 +251,7 @@ describe('pickAndConvertToProject', () => { cwd: path.join(workspaceRoot, 'scripts'), }); expect(mockTerminal.sendText).toHaveBeenCalledTimes(1); - expect(mockTerminal.sendText).toHaveBeenCalledWith(`dotnet project convert "${filePath}"`); + expect(mockTerminal.sendText).toHaveBeenCalledWith('dotnet project convert "Program.cs"'); }); test('does not send any cd command when the user picks a file', async () => { @@ -272,7 +272,7 @@ describe('pickAndConvertToProject', () => { } }); - test('reuses an existing terminal when converting a picked file', async () => { + test('always creates a new terminal when converting a picked file', async () => { const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); const existingTerminal: MockTerminal = { name: 'dotnet project convert', @@ -290,9 +290,7 @@ describe('pickAndConvertToProject', () => { await invokePickAndConvert(); - expect(windowMock.createTerminal).not.toHaveBeenCalled(); - expect(existingTerminal.show).toHaveBeenCalledWith(true); - expect(existingTerminal.sendText).toHaveBeenCalledTimes(1); - expect(existingTerminal.sendText).toHaveBeenCalledWith(`dotnet project convert "${filePath}"`); + expect(windowMock.createTerminal).toHaveBeenCalled(); + expect(existingTerminal.sendText).not.toHaveBeenCalled(); }); }); From c74956fc7b40b4d28a722a2e0a71e99ec877fd5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:12:33 +0000 Subject: [PATCH 24/28] Remove setContext approach -- show option for all C# files Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- package.json | 4 +-- .../fileBasedApps/convertToProject.ts | 28 ------------------- .../convertToProjectCommands.test.ts | 9 ------ 3 files changed, 2 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 3c32c7338..41abdf2eb 100644 --- a/package.json +++ b/package.json @@ -5741,7 +5741,7 @@ }, { "command": "dotnet.convertToProject", - "when": "resourceLangId == csharp && dotnet.isFileBasedApp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "when": "resourceLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@2" }, { @@ -5763,7 +5763,7 @@ }, { "command": "dotnet.convertToProject", - "when": "resourceLangId == csharp && dotnet.isFileBasedApp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "when": "resourceLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@2" } ], diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 2913946d4..0f8a0eff3 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -9,16 +9,10 @@ import * as vscode from 'vscode'; export const convertToProjectCommandName = 'dotnet.convertToProject'; -/** VS Code `when`-clause key set to `true` when the active editor is an FBA entry point. */ -export const isFileBasedAppContextKey = 'dotnet.isFileBasedApp'; - /** * Registers the `dotnet.convertToProject` command. When invoked with a URI (context menu) * it converts that file directly; without a URI (command palette) it shows a quick pick of * discoverable FBA entry points. Requires .NET 10 SDK or later. - * - * Also keeps the `dotnet.isFileBasedApp` context key in sync with the active editor so that - * the context-menu entry is only visible for files that are FBA entry points. */ export function registerConvertToProjectCommands(context: vscode.ExtensionContext): void { context.subscriptions.push( @@ -32,28 +26,6 @@ export function registerConvertToProjectCommands(context: vscode.ExtensionContex } }) ); - - // Keep the context key in sync so the menu entry is hidden for non-FBA files. - void updateFileBasedAppContext(vscode.window.activeTextEditor); - context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor((editor) => void updateFileBasedAppContext(editor)) - ); -} - -/** - * Updates the `dotnet.isFileBasedApp` context key based on whether `editor`'s file is an - * FBA entry point. The key is used in `when` clauses so the "Convert to Project" menu - * option is only shown for relevant files. - */ -async function updateFileBasedAppContext(editor: vscode.TextEditor | undefined): Promise { - let isFileBasedApp = false; - if (editor !== undefined && editor.document.languageId === 'csharp') { - const csprojFiles = await vscode.workspace.findFiles('**/*.csproj'); - const csprojDirs = new Set(csprojFiles.map((u) => path.dirname(u.fsPath))); - const kind = detectFileBasedAppKind(editor.document.uri.fsPath); - isFileBasedApp = isLikelyFbaEntryPoint(editor.document.uri.fsPath, kind, csprojDirs); - } - await vscode.commands.executeCommand('setContext', isFileBasedAppContextKey, isFileBasedApp); } /** diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts index 0dc846798..8ffc2aa15 100644 --- a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -13,7 +13,6 @@ import { jest.mock('vscode', () => ({ commands: { registerCommand: jest.fn((_name, _handler) => ({ dispose: jest.fn() })), - executeCommand: jest.fn().mockImplementation(async () => Promise.resolve()), }, workspace: { textDocuments: [], @@ -27,8 +26,6 @@ jest.mock('vscode', () => ({ terminals: [], createTerminal: jest.fn(), showQuickPick: jest.fn(), - activeTextEditor: undefined, - onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), }, l10n: { t: jest.fn((message: string) => message), @@ -51,8 +48,6 @@ type WindowMock = { terminals: MockTerminal[]; createTerminal: jest.Mock<(options: { name: string; cwd: string }) => MockTerminal>; showQuickPick: jest.Mock; - activeTextEditor: vscode.TextEditor | undefined; - onDidChangeActiveTextEditor: jest.Mock; }; const workspaceMock = vscode.workspace as unknown as WorkspaceMock; @@ -60,7 +55,6 @@ const windowMock = vscode.window as unknown as WindowMock; const registerCommandMock = vscode.commands.registerCommand as unknown as jest.Mock< (name: string, handler: (uri?: vscode.Uri) => Promise) => vscode.Disposable >; -const executeCommandMock = vscode.commands.executeCommand as unknown as jest.Mock; function createTerminal(name = 'dotnet project convert'): MockTerminal { return { @@ -85,9 +79,6 @@ beforeEach(() => { windowMock.terminals = []; windowMock.createTerminal.mockReset(); windowMock.showQuickPick.mockReset(); - windowMock.activeTextEditor = undefined; - windowMock.onDidChangeActiveTextEditor.mockReset().mockReturnValue({ dispose: jest.fn() }); - executeCommandMock.mockReset().mockImplementation(async () => Promise.resolve()); }); describe('registerConvertToProjectCommands', () => { From 8e54b6159f719fb59baaad4ff8253523ce946494 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:29:31 +0000 Subject: [PATCH 25/28] Fix l10n string concatenation so message is properly extracted into localization bundle Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- l10n/bundle.l10n.json | 1 + src/lsptoolshost/fileBasedApps/convertToProject.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index f5c705729..f40ccc3c6 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -209,6 +209,7 @@ "Collecting C# Logs": "Collecting C# Logs", "Only C# files can be converted to a project.": "Only C# files can be converted to a project.", "No C# files found in the workspace.": "No C# files found in the workspace.", + "No file-based C# apps were found in the workspace. A file-based app entry point must not be part of any `.csproj` project, unless it contains a top-of-file `#!` or `#:` directive.": "No file-based C# apps were found in the workspace. A file-based app entry point must not be part of any `.csproj` project, unless it contains a top-of-file `#!` or `#:` directive.", "Select a file-based C# app to convert to a project": "Select a file-based C# app to convert to a project", "dotnet project convert": "dotnet project convert", "Nested Code Action": "Nested Code Action", diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 0f8a0eff3..98ac31a2d 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -92,9 +92,7 @@ async function pickAndConvertToProject(): Promise { if (entryPoints.length === 0) { vscode.window.showInformationMessage( vscode.l10n.t( - 'No file-based C# apps were found in the workspace. ' + - 'A file-based app entry point must not be part of any `.csproj` project, ' + - 'unless it contains a top-of-file `#!` or `#:` directive.' + 'No file-based C# apps were found in the workspace. A file-based app entry point must not be part of any `.csproj` project, unless it contains a top-of-file `#!` or `#:` directive.' ) ); return; From 7090a62c795645227ec1f695b96bed86d3d6a2c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:08:58 +0000 Subject: [PATCH 26/28] Hide convert-to-project for likely FBA files only Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- l10n/bundle.l10n.json | 1 + package.json | 4 +- .../fileBasedApps/convertToProject.ts | 126 +++++++++++++++--- .../convertToProjectCommands.test.ts | 86 ++++++++++-- .../unitTests/convertToProjectPick.test.ts | 109 ++++++++++++--- 5 files changed, 279 insertions(+), 47 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index f40ccc3c6..307e437be 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -208,6 +208,7 @@ "Recording logs... Click Cancel to stop and save.": "Recording logs... Click Cancel to stop and save.", "Collecting C# Logs": "Collecting C# Logs", "Only C# files can be converted to a project.": "Only C# files can be converted to a project.", + "This file is not detected as a file-based app entry point.": "This file is not detected as a file-based app entry point.", "No C# files found in the workspace.": "No C# files found in the workspace.", "No file-based C# apps were found in the workspace. A file-based app entry point must not be part of any `.csproj` project, unless it contains a top-of-file `#!` or `#:` directive.": "No file-based C# apps were found in the workspace. A file-based app entry point must not be part of any `.csproj` project, unless it contains a top-of-file `#!` or `#:` directive.", "Select a file-based C# app to convert to a project": "Select a file-based C# app to convert to a project", diff --git a/package.json b/package.json index 41abdf2eb..058768fe4 100644 --- a/package.json +++ b/package.json @@ -5741,7 +5741,7 @@ }, { "command": "dotnet.convertToProject", - "when": "resourceLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "when": "resourceLangId == csharp && resourcePath in dotnet.likelyFbaEntryPoints && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@2" }, { @@ -5763,7 +5763,7 @@ }, { "command": "dotnet.convertToProject", - "when": "resourceLangId == csharp && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", + "when": "resourceLangId == csharp && resourcePath in dotnet.likelyFbaEntryPoints && (dotnet.server.activationContext == 'Roslyn' || dotnet.server.activationContext == 'RoslynDevKit')", "group": "2_dotnet@2" } ], diff --git a/src/lsptoolshost/fileBasedApps/convertToProject.ts b/src/lsptoolshost/fileBasedApps/convertToProject.ts index 98ac31a2d..e585cda49 100644 --- a/src/lsptoolshost/fileBasedApps/convertToProject.ts +++ b/src/lsptoolshost/fileBasedApps/convertToProject.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; export const convertToProjectCommandName = 'dotnet.convertToProject'; +export const likelyFbaEntryPointsContextKey = 'dotnet.likelyFbaEntryPoints'; /** * Registers the `dotnet.convertToProject` command. When invoked with a URI (context menu) @@ -15,6 +16,8 @@ export const convertToProjectCommandName = 'dotnet.convertToProject'; * discoverable FBA entry points. Requires .NET 10 SDK or later. */ export function registerConvertToProjectCommands(context: vscode.ExtensionContext): void { + const refreshMenuContext = async (): Promise => refreshConvertToProjectMenuContext(); + context.subscriptions.push( vscode.commands.registerCommand(convertToProjectCommandName, async (uri?: vscode.Uri): Promise => { if (uri !== undefined) { @@ -24,8 +27,46 @@ export function registerConvertToProjectCommands(context: vscode.ExtensionContex // Invoked from the command palette -- let the user pick from discoverable apps. await pickAndConvertToProject(); } + }), + vscode.workspace.onDidOpenTextDocument((document) => { + if (document.languageId === 'csharp' && path.extname(document.uri.fsPath) !== '.cs') { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidCloseTextDocument((document) => { + if (document.languageId === 'csharp' && path.extname(document.uri.fsPath) !== '.cs') { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidSaveTextDocument((document) => { + if (document.languageId === 'csharp' || path.extname(document.uri.fsPath) === '.csproj') { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidCreateFiles((event) => { + if (event.files.some((uri) => isConvertToProjectRefreshCandidate(uri))) { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidDeleteFiles((event) => { + if (event.files.some((uri) => isConvertToProjectRefreshCandidate(uri))) { + void refreshMenuContext(); + } + }), + vscode.workspace.onDidRenameFiles((event) => { + if ( + event.files.some( + (file) => + isConvertToProjectRefreshCandidate(file.oldUri) || + isConvertToProjectRefreshCandidate(file.newUri) + ) + ) { + void refreshMenuContext(); + } }) ); + + void refreshMenuContext(); } /** @@ -43,6 +84,14 @@ async function convertToProject(uri: vscode.Uri): Promise { return; } + const kind = detectTextDocumentFileBasedAppKind(document); + if (!isLikelyFbaEntryPoint(uri.fsPath, kind, await getCsprojDirs())) { + vscode.window.showInformationMessage( + vscode.l10n.t('This file is not detected as a file-based app entry point.') + ); + return; + } + await runConvertCommand(uri.fsPath); } @@ -54,33 +103,19 @@ async function convertToProject(uri: vscode.Uri): Promise { * files are matched by `.cs` extension (VS Code only exposes language IDs for open files). */ async function pickAndConvertToProject(): Promise { - // Collect C# files from the workspace by extension, then augment with any already-open - // documents that VS Code treats as C# even if they lack a .cs extension. - const csFilesByExtension = await vscode.workspace.findFiles('**/*.cs', '**/obj/**'); - const csFileSet = new Set(csFilesByExtension.map((u) => u.fsPath)); - - const allCsUris: vscode.Uri[] = [...csFilesByExtension]; - for (const doc of vscode.workspace.textDocuments) { - if (doc.languageId === 'csharp' && !csFileSet.has(doc.uri.fsPath)) { - allCsUris.push(doc.uri); - } - } + const allCsUris = await findCandidateCsUris(); if (allCsUris.length === 0) { vscode.window.showInformationMessage(vscode.l10n.t('No C# files found in the workspace.')); return; } - // Build a set of directories that contain a .csproj so we can quickly check whether - // a given .cs file lives inside a project cone. - const csprojFiles = await vscode.workspace.findFiles('**/*.csproj'); - const csprojDirs = new Set(csprojFiles.map((u) => path.dirname(u.fsPath))); - const entryPoints: vscode.QuickPickItem[] = []; + const csprojDirs = await getCsprojDirs(); for (const fileUri of allCsUris) { + const kind = detectFileBasedAppKindForUri(fileUri); const filePath = fileUri.fsPath; - const kind = detectFileBasedAppKind(filePath); if (isLikelyFbaEntryPoint(filePath, kind, csprojDirs)) { const label = path.basename(filePath); @@ -112,6 +147,12 @@ async function pickAndConvertToProject(): Promise { await runConvertCommand(picked.detail); } +export async function refreshConvertToProjectMenuContext(): Promise { + const entryPoints = await findLikelyFbaEntryPointUris(); + const contextValue = Object.fromEntries(entryPoints.map((uri) => [uri.path, true])); + await vscode.commands.executeCommand('setContext', likelyFbaEntryPointsContextKey, contextValue); +} + /** * Returns `true` when `filePath` resides inside the directory cone of at least one * `.csproj` file -- i.e. when any directory in `csprojDirs` is an ancestor of (or the @@ -178,7 +219,19 @@ export enum FileBasedAppKind { * for `#!` or `#:` markers. Mirrors Roslyn's `FileBasedProgramsEntryPointDiscovery`. */ export function detectFileBasedAppKind(filePath: string): FileBasedAppKind { - const content = defaultReadFileHead(filePath); + return detectFileBasedAppKindFromContent(defaultReadFileHead(filePath)); +} + +function detectTextDocumentFileBasedAppKind(document: Pick): FileBasedAppKind { + return detectFileBasedAppKindFromContent(document.getText().slice(0, 1024)); +} + +function detectFileBasedAppKindForUri(uri: vscode.Uri): FileBasedAppKind { + const openDocument = vscode.workspace.textDocuments.find((document) => document.uri.fsPath === uri.fsPath); + return openDocument ? detectTextDocumentFileBasedAppKind(openDocument) : detectFileBasedAppKind(uri.fsPath); +} + +function detectFileBasedAppKindFromContent(content: string | null): FileBasedAppKind { if (content === null) { return FileBasedAppKind.None; } @@ -213,6 +266,43 @@ export function detectFileBasedAppKind(filePath: string): FileBasedAppKind { return FileBasedAppKind.None; } +async function findCandidateCsUris(): Promise { + // Collect C# files from the workspace by extension, then augment with any already-open + // documents that VS Code treats as C# even if they lack a .cs extension. + const csFilesByExtension = await vscode.workspace.findFiles('**/*.cs', '**/obj/**'); + const csFileSet = new Set(csFilesByExtension.map((u) => u.fsPath)); + + const allCsUris: vscode.Uri[] = [...csFilesByExtension]; + for (const doc of vscode.workspace.textDocuments) { + if (doc.languageId === 'csharp' && !csFileSet.has(doc.uri.fsPath)) { + allCsUris.push(doc.uri); + } + } + + return allCsUris; +} + +async function findLikelyFbaEntryPointUris(): Promise { + const allCsUris = await findCandidateCsUris(); + const csprojDirs = await getCsprojDirs(); + + return allCsUris.filter((fileUri) => + isLikelyFbaEntryPoint(fileUri.fsPath, detectFileBasedAppKindForUri(fileUri), csprojDirs) + ); +} + +async function getCsprojDirs(): Promise> { + // Build a set of directories that contain a .csproj so we can quickly check whether + // a given .cs file lives inside a project cone. + const csprojFiles = await vscode.workspace.findFiles('**/*.csproj'); + return new Set(csprojFiles.map((u) => path.dirname(u.fsPath))); +} + +function isConvertToProjectRefreshCandidate(uri: vscode.Uri): boolean { + const extension = path.extname(uri.fsPath); + return extension === '.cs' || extension === '.csproj'; +} + /** * Default implementation of the `readFileHead` parameter for `detectFileBasedAppKind`. * Reads the first 1 KB of the file -- sufficient to find `#!` / `#:` near the top diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts index 8ffc2aa15..8d61fb356 100644 --- a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { beforeEach, describe, expect, jest, test } from '@jest/globals'; +import * as path from 'path'; import * as vscode from 'vscode'; import { convertToProjectCommandName, @@ -13,12 +14,19 @@ import { jest.mock('vscode', () => ({ commands: { registerCommand: jest.fn((_name, _handler) => ({ dispose: jest.fn() })), + executeCommand: jest.fn(async () => undefined), }, workspace: { textDocuments: [], openTextDocument: jest.fn(), findFiles: jest.fn(), asRelativePath: jest.fn((uri: { fsPath: string }) => uri.fsPath), + onDidOpenTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidCloseTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidSaveTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidCreateFiles: jest.fn(() => ({ dispose: jest.fn() })), + onDidDeleteFiles: jest.fn(() => ({ dispose: jest.fn() })), + onDidRenameFiles: jest.fn(() => ({ dispose: jest.fn() })), }, window: { showErrorMessage: jest.fn(), @@ -32,7 +40,7 @@ jest.mock('vscode', () => ({ }, })); -type MockDocument = Pick; +type MockDocument = Pick; type MockTerminal = Pick; type WorkspaceMock = { @@ -68,6 +76,22 @@ function getRegisteredHandler(): (uri?: vscode.Uri) => Promise { return registerCommandMock.mock.calls[0][1] as (uri?: vscode.Uri) => Promise; } +function createDocument(uri: vscode.Uri, languageId: string, text = ''): MockDocument { + return { + uri, + languageId, + getText: jest.fn(() => text), + }; +} + +async function registerCommands(context: vscode.ExtensionContext): Promise { + registerConvertToProjectCommands(context); + await Promise.resolve(); + await Promise.resolve(); + workspaceMock.findFiles.mockReset().mockResolvedValue([] as vscode.Uri[]); + windowMock.showInformationMessage.mockReset(); +} + beforeEach(() => { jest.clearAllMocks(); workspaceMock.textDocuments = []; @@ -100,14 +124,14 @@ describe('registerConvertToProjectCommands', () => { describe('convertToProject command handler', () => { test('uses an already open C# document and creates a new terminal', async () => { const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; - const document: MockDocument = { uri, languageId: 'csharp' }; + const document = createDocument(uri, 'csharp'); const terminal = createTerminal(); const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; workspaceMock.textDocuments = [document]; windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); - registerConvertToProjectCommands(context); + await registerCommands(context); await getRegisteredHandler()(uri); expect(workspaceMock.openTextDocument).not.toHaveBeenCalled(); @@ -122,14 +146,14 @@ describe('convertToProject command handler', () => { test('opens a closed C# document and creates a new terminal', async () => { const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; - const document: MockDocument = { uri, languageId: 'csharp' }; + const document = createDocument(uri, 'csharp'); const terminal = createTerminal(); const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; workspaceMock.openTextDocument.mockResolvedValue(document as vscode.TextDocument); windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); - registerConvertToProjectCommands(context); + await registerCommands(context); await getRegisteredHandler()(uri); expect(workspaceMock.openTextDocument).toHaveBeenCalledWith(uri); @@ -144,7 +168,7 @@ describe('convertToProject command handler', () => { test('always creates a new terminal even when one with the same name already exists', async () => { const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; - const document: MockDocument = { uri, languageId: 'csharp' }; + const document = createDocument(uri, 'csharp'); const existingTerminal = createTerminal(); const newTerminal = createTerminal(); const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; @@ -153,7 +177,7 @@ describe('convertToProject command handler', () => { windowMock.terminals = [existingTerminal]; windowMock.createTerminal.mockReturnValue(newTerminal as vscode.Terminal); - registerConvertToProjectCommands(context); + await registerCommands(context); await getRegisteredHandler()(uri); expect(windowMock.createTerminal).toHaveBeenCalled(); @@ -164,14 +188,14 @@ describe('convertToProject command handler', () => { test('does not send any cd command regardless of platform', async () => { const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; - const document: MockDocument = { uri, languageId: 'csharp' }; + const document = createDocument(uri, 'csharp'); const terminal = createTerminal(); const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; workspaceMock.textDocuments = [document]; windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); - registerConvertToProjectCommands(context); + await registerCommands(context); await getRegisteredHandler()(uri); const sentTexts = (terminal.sendText as jest.Mock).mock.calls.map((c) => c[0] as string); @@ -182,14 +206,14 @@ describe('convertToProject command handler', () => { test('shows an error for a non-C# document and does not run the convert command', async () => { const uri = { fsPath: '/workspace/app.cs' } as vscode.Uri; - const document: MockDocument = { uri, languageId: 'plaintext' }; + const document = createDocument(uri, 'plaintext'); const terminal = createTerminal(); const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; workspaceMock.openTextDocument.mockResolvedValue(document as vscode.TextDocument); windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); - registerConvertToProjectCommands(context); + await registerCommands(context); await getRegisteredHandler()(uri); expect(windowMock.showErrorMessage).toHaveBeenCalledWith('Only C# files can be converted to a project.'); @@ -197,4 +221,44 @@ describe('convertToProject command handler', () => { expect(terminal.show).not.toHaveBeenCalled(); expect(terminal.sendText).not.toHaveBeenCalled(); }); + + test('shows an info message for a C# file in a csproj cone without FBA directives', async () => { + const uri = { fsPath: '/workspace/project/app.cs' } as vscode.Uri; + const document = createDocument(uri, 'csharp'); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + + await registerCommands(context); + workspaceMock.findFiles.mockResolvedValueOnce([{ fsPath: '/workspace/project/App.csproj' } as vscode.Uri]); + + await getRegisteredHandler()(uri); + + expect(windowMock.showInformationMessage).toHaveBeenCalledWith( + 'This file is not detected as a file-based app entry point.' + ); + expect(windowMock.createTerminal).not.toHaveBeenCalled(); + }); + + test('converts a shebang file even when it is in a csproj cone', async () => { + const filePath = path.join('/workspace', 'project', 'app.cs'); + const uri = { fsPath: filePath } as vscode.Uri; + const document = createDocument(uri, 'csharp', '#!/usr/bin/env dotnet\nConsole.WriteLine("hi");\n'); + const terminal = createTerminal(); + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + + workspaceMock.textDocuments = [document]; + windowMock.createTerminal.mockReturnValue(terminal as vscode.Terminal); + + await registerCommands(context); + workspaceMock.findFiles.mockResolvedValueOnce([{ fsPath: '/workspace/project/App.csproj' } as vscode.Uri]); + + await getRegisteredHandler()(uri); + + expect(windowMock.createTerminal).toHaveBeenCalledWith({ + name: 'dotnet project convert', + cwd: path.join('/workspace', 'project'), + }); + expect(terminal.sendText).toHaveBeenCalledWith('dotnet project convert "app.cs"'); + }); }); diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts index 793ae5d97..1d2e40fbc 100644 --- a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -4,14 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import { beforeEach, describe, expect, jest, test } from '@jest/globals'; +import * as fs from 'fs'; import * as path from 'path'; jest.mock('vscode', () => { const workspace = { - _textDocuments: [] as Array<{ languageId: string; uri: { fsPath: string } }>, - findFiles: jest.fn<(include: string, exclude: string) => Promise>>(), + _textDocuments: [] as Array<{ languageId: string; uri: { fsPath: string; path: string } }>, + findFiles: jest.fn<(include: string, exclude: string) => Promise>>(), openTextDocument: jest.fn(), asRelativePath: jest.fn<(uri: { fsPath: string }, includeWorkspace?: boolean) => string>(), + onDidOpenTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidCloseTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidSaveTextDocument: jest.fn(() => ({ dispose: jest.fn() })), + onDidCreateFiles: jest.fn(() => ({ dispose: jest.fn() })), + onDidDeleteFiles: jest.fn(() => ({ dispose: jest.fn() })), + onDidRenameFiles: jest.fn(() => ({ dispose: jest.fn() })), }; Object.defineProperty(workspace, 'textDocuments', { @@ -69,7 +76,7 @@ jest.mock('vscode', () => { t: (message: string) => message, }, Uri: { - file: (filePath: string) => ({ fsPath: filePath }), + file: (filePath: string) => ({ fsPath: filePath, path: filePath }), }, }; }); @@ -77,11 +84,13 @@ jest.mock('vscode', () => { import * as vscode from 'vscode'; import { convertToProjectCommandName, + likelyFbaEntryPointsContextKey, registerConvertToProjectCommands, + refreshConvertToProjectMenuContext, } from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; -type MockUri = { fsPath: string }; -type MockTextDocument = { languageId: string; uri: MockUri }; +type MockUri = { fsPath: string; path: string }; +type MockTextDocument = { languageId: string; uri: MockUri; getText: () => string }; type MockQuickPickItem = { label: string; description: string; detail: string }; type MockTerminal = { name: string; @@ -94,6 +103,12 @@ type WorkspaceMock = { openTextDocument: jest.Mock; findFiles: jest.Mock<(include: string, exclude: string) => Promise>; asRelativePath: jest.Mock<(uri: MockUri, includeWorkspace?: boolean) => string>; + onDidOpenTextDocument: jest.Mock; + onDidCloseTextDocument: jest.Mock; + onDidSaveTextDocument: jest.Mock; + onDidCreateFiles: jest.Mock; + onDidDeleteFiles: jest.Mock; + onDidRenameFiles: jest.Mock; }; type WindowMock = { @@ -121,17 +136,35 @@ const mockTerminal: MockTerminal = { sendText: jest.fn<(text: string) => void>(), }; const workspaceRoot = path.join(path.sep, 'workspace'); +const executeCommandMock = vscode.commands.executeCommand as unknown as jest.Mock; function uri(filePath: string): MockUri { return vscode.Uri.file(filePath) as MockUri; } +function createDocument(filePath: string, languageId = 'csharp', text = ''): MockTextDocument { + return { + languageId, + uri: uri(filePath), + getText: () => text, + }; +} + function getRegisteredHandler(): (uri?: vscode.Uri) => Promise { return registerCommandMock.mock.calls[0][1] as (uri?: vscode.Uri) => Promise; } -async function invokePickAndConvert(): Promise { +async function registerCommands(): Promise { registerConvertToProjectCommands({ subscriptions: [] } as unknown as vscode.ExtensionContext); + await Promise.resolve(); + await Promise.resolve(); + workspaceMock.findFiles.mockReset().mockResolvedValue([] as MockUri[]); + executeCommandMock.mockClear(); + windowMock.showInformationMessage.mockReset(); +} + +async function invokePickAndConvert(): Promise { + await registerCommands(); await getRegisteredHandler()(); } @@ -174,9 +207,10 @@ describe('pickAndConvertToProject', () => { const filePath = path.join(workspaceRoot, 'app', 'Program.cs'); const projectPath = path.join(workspaceRoot, 'app', 'App.csproj'); + await registerCommands(); workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]).mockResolvedValueOnce([uri(projectPath)]); - await invokePickAndConvert(); + await getRegisteredHandler()(); expect(windowMock.showInformationMessage).toHaveBeenCalledWith( 'No file-based C# apps were found in the workspace. ' + @@ -188,10 +222,11 @@ describe('pickAndConvertToProject', () => { test('shows a quick pick for file-based apps outside project cones', async () => { const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + await registerCommands(); workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); windowMock.showQuickPick.mockResolvedValue(undefined); - await invokePickAndConvert(); + await getRegisteredHandler()(); expect(windowMock.showQuickPick).toHaveBeenCalledWith( [{ label: 'Program.cs', description: path.join('scripts', 'Program.cs'), detail: filePath }], @@ -208,14 +243,15 @@ describe('pickAndConvertToProject', () => { const openPath = path.join(workspaceRoot, 'scripts', 'app'); workspaceMock.textDocuments = [ - { languageId: 'csharp', uri: uri(discoveredPath) }, - { languageId: 'csharp', uri: uri(openPath) }, - { languageId: 'plaintext', uri: uri(path.join(workspaceRoot, 'notes.txt')) }, + createDocument(discoveredPath), + createDocument(openPath), + createDocument(path.join(workspaceRoot, 'notes.txt'), 'plaintext'), ]; + await registerCommands(); workspaceMock.findFiles.mockResolvedValueOnce([uri(discoveredPath)]); windowMock.showQuickPick.mockResolvedValue(undefined); - await invokePickAndConvert(); + await getRegisteredHandler()(); expect(windowMock.showQuickPick).toHaveBeenCalledWith( expect.arrayContaining([{ label: 'app', description: path.join('scripts', 'app'), detail: openPath }]), @@ -226,10 +262,11 @@ describe('pickAndConvertToProject', () => { test('does not run conversion when the user dismisses the quick pick', async () => { const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + await registerCommands(); workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); windowMock.showQuickPick.mockResolvedValue(undefined); - await invokePickAndConvert(); + await getRegisteredHandler()(); expect(mockTerminal.sendText).not.toHaveBeenCalled(); }); @@ -237,6 +274,7 @@ describe('pickAndConvertToProject', () => { test('runs the convert command when the user picks a file', async () => { const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + await registerCommands(); workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); windowMock.showQuickPick.mockResolvedValue({ label: 'Program.cs', @@ -244,7 +282,7 @@ describe('pickAndConvertToProject', () => { detail: filePath, }); - await invokePickAndConvert(); + await getRegisteredHandler()(); expect(windowMock.createTerminal).toHaveBeenCalledWith({ name: 'dotnet project convert', @@ -257,6 +295,7 @@ describe('pickAndConvertToProject', () => { test('does not send any cd command when the user picks a file', async () => { const filePath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + await registerCommands(); workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); windowMock.showQuickPick.mockResolvedValue({ label: 'Program.cs', @@ -264,7 +303,7 @@ describe('pickAndConvertToProject', () => { detail: filePath, }); - await invokePickAndConvert(); + await getRegisteredHandler()(); const sentTexts = mockTerminal.sendText.mock.calls.map((c) => c[0] as string); for (const text of sentTexts) { @@ -280,6 +319,7 @@ describe('pickAndConvertToProject', () => { sendText: jest.fn<(text: string) => void>(), }; + await registerCommands(); workspaceMock.findFiles.mockResolvedValueOnce([uri(filePath)]); windowMock.terminals = [existingTerminal]; windowMock.showQuickPick.mockResolvedValue({ @@ -288,9 +328,46 @@ describe('pickAndConvertToProject', () => { detail: filePath, }); - await invokePickAndConvert(); + await getRegisteredHandler()(); expect(windowMock.createTerminal).toHaveBeenCalled(); expect(existingTerminal.sendText).not.toHaveBeenCalled(); }); }); + +describe('refreshConvertToProjectMenuContext', () => { + test('sets a resource-path membership map for likely FBA entry points', async () => { + const outsideProjectPath = path.join(workspaceRoot, 'scripts', 'Program.cs'); + const insideProjectPath = path.join(workspaceRoot, 'app', 'Program.cs'); + const openPath = path.join(workspaceRoot, 'scripts', 'app'); + const projectPath = path.join(workspaceRoot, 'app', 'App.csproj'); + + workspaceMock.textDocuments = [createDocument(openPath)]; + workspaceMock.findFiles.mockResolvedValueOnce([uri(outsideProjectPath), uri(insideProjectPath)]); + workspaceMock.findFiles.mockResolvedValueOnce([uri(projectPath)]); + + await refreshConvertToProjectMenuContext(); + + expect(executeCommandMock).toHaveBeenCalledWith('setContext', likelyFbaEntryPointsContextKey, { + [uri(outsideProjectPath).path]: true, + [uri(openPath).path]: true, + }); + }); +}); + +describe('package contributions', () => { + test('use the likely-FBA context key in editor and explorer menus', () => { + const packageJson = JSON.parse( + fs.readFileSync('/home/runner/work/vscode-csharp/vscode-csharp/package.json', 'utf8') + ); + const editorMenu = packageJson.contributes.menus['editor/context'].find( + (item: { command: string }) => item.command === convertToProjectCommandName + ); + const explorerMenu = packageJson.contributes.menus['explorer/context'].find( + (item: { command: string }) => item.command === convertToProjectCommandName + ); + + expect(editorMenu.when).toContain(`resourcePath in ${likelyFbaEntryPointsContextKey}`); + expect(explorerMenu.when).toContain(`resourcePath in ${likelyFbaEntryPointsContextKey}`); + }); +}); From d8bb81297de92e0396d676d698618a829d7e9f4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:57:26 +0000 Subject: [PATCH 27/28] test: replace blank promise awaits in convert-to-project tests Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- .../lsptoolshost/unitTests/convertToProjectCommands.test.ts | 6 ++++-- test/lsptoolshost/unitTests/convertToProjectPick.test.ts | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts index 8d61fb356..613812a6d 100644 --- a/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectCommands.test.ts @@ -9,6 +9,7 @@ import * as vscode from 'vscode'; import { convertToProjectCommandName, registerConvertToProjectCommands, + refreshConvertToProjectMenuContext, } from '../../../src/lsptoolshost/fileBasedApps/convertToProject'; jest.mock('vscode', () => ({ @@ -60,6 +61,7 @@ type WindowMock = { const workspaceMock = vscode.workspace as unknown as WorkspaceMock; const windowMock = vscode.window as unknown as WindowMock; +const executeCommandMock = vscode.commands.executeCommand as unknown as jest.Mock; const registerCommandMock = vscode.commands.registerCommand as unknown as jest.Mock< (name: string, handler: (uri?: vscode.Uri) => Promise) => vscode.Disposable >; @@ -86,9 +88,9 @@ function createDocument(uri: vscode.Uri, languageId: string, text = ''): MockDoc async function registerCommands(context: vscode.ExtensionContext): Promise { registerConvertToProjectCommands(context); - await Promise.resolve(); - await Promise.resolve(); + await refreshConvertToProjectMenuContext(); workspaceMock.findFiles.mockReset().mockResolvedValue([] as vscode.Uri[]); + executeCommandMock.mockClear(); windowMock.showInformationMessage.mockReset(); } diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts index 1d2e40fbc..811b9c639 100644 --- a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -156,8 +156,7 @@ function getRegisteredHandler(): (uri?: vscode.Uri) => Promise { async function registerCommands(): Promise { registerConvertToProjectCommands({ subscriptions: [] } as unknown as vscode.ExtensionContext); - await Promise.resolve(); - await Promise.resolve(); + await refreshConvertToProjectMenuContext(); workspaceMock.findFiles.mockReset().mockResolvedValue([] as MockUri[]); executeCommandMock.mockClear(); windowMock.showInformationMessage.mockReset(); From 6c0063a987f3a80176e6a6e73341015a80810f8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:01:21 +0000 Subject: [PATCH 28/28] test: fix hardcoded absolute path in convertToProjectPick test Co-authored-by: mwiemer-microsoft <80539004+mwiemer-microsoft@users.noreply.github.com> --- test/lsptoolshost/unitTests/convertToProjectPick.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts index 811b9c639..9e7d18daf 100644 --- a/test/lsptoolshost/unitTests/convertToProjectPick.test.ts +++ b/test/lsptoolshost/unitTests/convertToProjectPick.test.ts @@ -356,9 +356,7 @@ describe('refreshConvertToProjectMenuContext', () => { describe('package contributions', () => { test('use the likely-FBA context key in editor and explorer menus', () => { - const packageJson = JSON.parse( - fs.readFileSync('/home/runner/work/vscode-csharp/vscode-csharp/package.json', 'utf8') - ); + const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', '..', 'package.json'), 'utf8')); const editorMenu = packageJson.contributes.menus['editor/context'].find( (item: { command: string }) => item.command === convertToProjectCommandName );