Compare commits

..
3 Commits
Author SHA1 Message Date
maxandClaude Fable 5.1 0300dfa42f Per-project launch options, publish/test tasks, explorer badge, keybindings
- dotnetSolution.launch.projects: args, env, cwd, console and profile
  per project name, layered over the global settings and
  Properties/launchSettings.json. Applied to the debug button and,
  through resolveDebugConfiguration, to the launch.json entry too.
- publish and test task targets with the same configuration/platform.
- Problems panel opens when a task fails.
- The startup csproj and its folder are marked in the explorer.
- F5 / Ctrl+F5 / Ctrl+Shift+B map to debug/run/build until a launch.json
  entry exists; Ctrl+Alt+C and Ctrl+Alt+P open the pickers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
2026-09-08 13:33:12 +02:00
maxandClaude Fable 5.1 a01a94f1a4 Offer launch.json generation from the startup project picker
The one-time prompt is easy to miss, so the picker now ends with the
same command, plus a solution picker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
2026-09-08 13:13:29 +02:00
maxandClaude Fable 5.1 e2ba32a647 Initial version of .NET Solution Launcher
Startup project and solution configuration (Debug|x64, Test|x64, ...) in
the status bar, with build and debug tasks that pass both configuration
and platform. Made for DotRush, whose build never passes -p:Platform and
whose status item shows the configuration but not the project.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
2026-09-08 13:10:55 +02:00
15 changed files with 26 additions and 1186 deletions
+14 -66
View File
@@ -1,18 +1,18 @@
# .NET Solution Launcher
Startup project and solution configuration in the status bar, with build, debug, run and
hot reload that actually use them. Built for [DotRush](https://github.com/JaneySprings/DotRush).
Startup project and solution configuration in the status bar, with build and debug that
actually use them. Built for [DotRush](https://github.com/JaneySprings/DotRush), next to
the `.NET Hot Reload` extension in this repo.
```
$(project) MyGame.Editor $(settings-gear) Test | x64 $(debug-alt) $(flame)
$(project) Nerfed.Editor $(settings-gear) Test | x64 $(debug-alt)
```
- The **project item** shows which `.csproj` is the startup project, and picks another one
from the executables in the solution (Runtime is a library, so it is not offered).
- The **configuration item** shows the *solution* configuration — `Debug | x64`,
`Test | x64`, `Release | Any CPU` — exactly as `MyGame.sln` lists them.
`Test | x64`, `Release | Any CPU` — exactly as `Nerfed.sln` lists them.
- The **debug button** builds with that configuration and launches the startup project.
- The **flame** starts it under `dotnet watch` with hot reload instead.
## Why, when DotRush already has a status bar item
@@ -25,13 +25,13 @@ DotRush shows `Debug | net10.0`. Two things are missing from that:
conditioned on `'$(Configuration)|$(Platform)' == 'Debug|x64'` is skipped. In this
solution that is not cosmetic:
| `MyGame.Runtime`, Configuration=Debug | `DefineConstants` |
| `Nerfed.Runtime`, Configuration=Debug | `DefineConstants` |
| --- | --- |
| without `-p:Platform` (what DotRush runs) | `TRACE;DEBUG` |
| with `-p:Platform=x64` | `TRACE;LOG_INFO;PROFILING;DEBUG` |
So logging and profiling silently vanish, `Optimize` is never set for Test/Release,
and `MyGame.Builder` loses `AllowUnsafeBlocks` and fails to compile.
and `Nerfed.Builder` loses `AllowUnsafeBlocks` and fails to compile.
DotRush also cannot be *told* a configuration from outside — its selection lives in its
own workspace state — so this extension owns the build rather than wrapping
@@ -45,16 +45,16 @@ own workspace state — so this extension owns the build rather than wrapping
| Scope | Command | Notes |
| --- | --- | --- |
| `solution` | `dotnet build MyGame.sln -c Test -p:Platform=x64` | Like Visual Studio's *Build Solution*. MSBuild maps every project through the `.sln`: MyGame.* build as `Test\|x64`, MoonWorks as `Debug\|Any CPU`, and the Editor → Builder `ProjectDependencies` entry is honoured, so the Builder exists before the Editor's post-build step needs it. |
| `project` | `dotnet build MyGame.Editor.csproj -c Test -p:Platform=x64` | Only the startup project and its `ProjectReference`s. Faster, but solution-only dependencies are ignored. |
| `solution` | `dotnet build Nerfed.sln -c Test -p:Platform=x64` | Like Visual Studio's *Build Solution*. MSBuild maps every project through the `.sln`: Nerfed.* build as `Test\|x64`, MoonWorks as `Debug\|Any CPU`, and the Editor → Builder `ProjectDependencies` entry is honoured, so the Builder exists before the Editor's post-build step needs it. |
| `project` | `dotnet build Nerfed.Editor.csproj -c Test -p:Platform=x64` | Only the startup project and its `ProjectReference`s. Faster, but solution-only dependencies are ignored. |
Rebuild adds `--no-incremental`; Clean runs `dotnet clean`. Two more targets use the same
selection:
| Target | Command | Notes |
| --- | --- | --- |
| `publish` | `dotnet publish MyGame.Editor.csproj -c Release -p:Platform=x64 [-r win-x64]` | Always the startup project. `dotnetSolution.publish.runtime` sets `-r`, which `PublishAot` needs; `publish.args` adds the rest |
| `test` | `dotnet test MyGame.sln -c Test -p:Platform=x64` | Follows `buildScope`; `dotnetSolution.test.args` adds filters or `--no-build` |
| `publish` | `dotnet publish Nerfed.Editor.csproj -c Release -p:Platform=x64 [-r win-x64]` | Always the startup project. `dotnetSolution.publish.runtime` sets `-r`, which `PublishAot` needs; `publish.args` adds the rest |
| `test` | `dotnet test Nerfed.sln -c Test -p:Platform=x64` | Follows `buildScope`; `dotnetSolution.test.args` adds filters or `--no-build` |
All five are tasks of type `dotnet-solution`, so they show up under *Run Task* and can be
used as `preLaunchTask`. When one fails the Problems panel opens
@@ -79,8 +79,8 @@ Builder and Editor want different arguments, so these are layered, most specific
1. `dotnetSolution.launch.projects`, keyed by project name:
```jsonc
"dotnetSolution.launch.projects": {
"MyGame.Builder": { "args": ["-build", "-resourcePath", "Resources"], "cwd": "../MyGame.Editor" },
"MyGame.Editor": { "console": "integratedTerminal" }
"Nerfed.Builder": { "args": ["-build", "-resourcePath", "Resources"], "cwd": "../Nerfed.Editor" },
"Nerfed.Editor": { "console": "integratedTerminal" }
}
```
`cwd` is relative to the project folder. `profile` names a launchSettings.json profile.
@@ -93,54 +93,6 @@ Builder and Editor want different arguments, so these are layered, most specific
The same layering applies to F5 through the launch.json entry: its `args` stay empty in
the file and are filled in at launch time.
## Hot reload
The third way to start the startup project, next to Debug and Run, and a rewrite of the
earlier `dotnet-hot-reload` extension now that the selection exists to build on:
```
dotnet watch run --project MyGame.Editor.csproj -c Debug --property:Platform=x64 -- <args>
```
Same project, configuration, platform, arguments, environment and working directory as
F5. It runs in an integrated terminal named after the project, and the status bar item
next to the debug button follows the watcher's output: *watching*, *applied*, *failed*,
*restart needed*. Save a file and the change is applied; the flame in the terminal's
title is the same session.
| Action | How |
| --- | --- |
| Start | The `$(flame)` status bar item (icon only; it gains a word — *failed*, *restart needed* — only when something needs you), *.NET Solution: Run Startup Project with Hot Reload*, or `Ctrl+Alt+F5` |
| Apply, restart, attach, stop | Click the status bar item while it runs — one menu for all of them |
| Restart the application | Also `Ctrl+R` inside the watch terminal |
**The debugger is not attached by default.** That is the biggest change from the old
extension, and deliberate. `dotnet watch` replaces the application process on every
rude edit, restart or crash, and each of those ends an attached debug session; keeping a
debugger attached across that meant guessing whether an ended session was a stop or a
swap, racing the outgoing process, and giving up on crash loops. Now *Attach Debugger*
finds the application below the watcher (by assembly name, so MSBuild is never picked)
and attaches when you ask. If the process is later replaced while the watcher still
runs, one notification says so and offers to re-attach. While attached, the flame and
restart buttons also appear in the debug toolbar.
**Rude edits** — changes hot reload cannot apply — follow `dotnetSolution.hotReload.rudeEdit`:
| Value | Behaviour |
| --- | --- |
| `restart` (default) | dotnet watch restarts the application on its own |
| `ask` | A notification offers *Restart* / *Keep running*; the answer goes to dotnet watch's console question |
| `warn` | Keeps the old code running and shows a warning with a *Restart* button |
**Optimised builds cannot hot reload.** SDK 10's `dotnet watch` refuses when `Optimize`
is true and restarts on every change instead. Test and Release set it here, so starting
hot reload under those asks whether to switch to Debug first.
**Solution build first.** `dotnet watch` only builds the project it runs. With
`dotnetSolution.hotReload.buildSolutionFirst` (default on) the normal solution build runs
before the watcher starts, so the Editor's post-build step finds the Builder even on a
clean checkout; the watcher's own build is then incremental.
## Usage
| Action | How |
@@ -150,7 +102,6 @@ clean checkout; the watcher's own build is then incremental.
| Build / Rebuild / Clean | *.NET Solution: Build* etc., or the `dotnet-solution` tasks. `Ctrl+Shift+B` until a launch.json entry exists, after that VS Code's default build task |
| Publish / Test | *.NET Solution: Publish Startup Project*, *.NET Solution: Run Tests* |
| Debug | The `$(debug-alt)` button, *.NET Solution: Debug Startup Project*, or F5 |
| Hot reload | The `$(flame)` item, or `Ctrl+Alt+F5`. See above |
| Run without debugging | *.NET Solution: Run Startup Project (without debugging)*, or `Ctrl+F5` |
| Several `.sln` files | *.NET Solution: Select Solution*, or `dotnetSolution.solution` |
@@ -206,9 +157,6 @@ The same entry is offered dynamically in the *Run and Debug* dropdown even witho
| `dotnetSolution.publish.runtime` / `.args` | `""` / `[]` | `-r` and extra arguments for publish |
| `dotnetSolution.test.args` | `[]` | Extra arguments for test |
| `dotnetSolution.showProblemsOnFailure` | `true` | Open Problems when a task fails |
| `dotnetSolution.hotReload.rudeEdit` | `restart` | `restart`, `ask` or `warn` |
| `dotnetSolution.hotReload.buildSolutionFirst` | `true` | Solution build before `dotnet watch` |
| `dotnetSolution.hotReload.watchArgs` | `[]` | Extra arguments for `dotnet watch` itself |
| `dotnetSolution.debugType` | `coreclr` | |
| `dotnetSolution.syncDotRush` | `true` | Push the startup project to DotRush and follow its changes |
| `dotnetSolution.syncDotRushWorkspaceProperties` | `false` | Write `Configuration=…;Platform=…` into `dotrush.roslyn.workspaceProperties` so IntelliSense sees the same `DefineConstants`. Off because DotRush reloads its workspace on every change |
@@ -230,6 +178,6 @@ honoured, so both extensions use the same SDK.
```
npm install
npm run compile
npm run test:unit # parser tests, against D:\Projects\MyGame when present
npm run test:unit # parser tests, against D:\Downloads\Nerfed\Nerfed1 when present
npm test # launches VS Code on that folder (SOLUTION_TEST_FOLDER overrides)
```
+3 -122
View File
@@ -1,7 +1,7 @@
{
"name": "dotnet-solution-launcher",
"displayName": ".NET Solution Launcher",
"description": "Startup project and solution configuration (Debug|x64, Test|x64, ...) in the status bar, with build, debug, run and hot reload that honour them. Made for DotRush, which shows the configuration but not the project and never passes the platform.",
"description": "Startup project and solution configuration (Debug|x64, Test|x64, ...) in the status bar, with build and debug that honour them. Made for DotRush, which shows the configuration but not the project and never passes the platform.",
"version": "0.1.0",
"publisher": "local",
"license": "MIT",
@@ -18,9 +18,7 @@
"solution",
"configuration",
"dotrush",
"startup project",
"hot reload",
"dotnet watch"
"startup project"
],
"activationEvents": [
"onLanguage:csharp",
@@ -85,50 +83,6 @@
"category": ".NET Solution",
"icon": "$(play)"
},
{
"command": "dotnetSolution.hotReload.start",
"title": "Run Startup Project with Hot Reload",
"category": ".NET Solution",
"icon": "$(flame)"
},
{
"command": "dotnetSolution.hotReload.apply",
"title": "Hot Reload: Apply Changes",
"category": ".NET Solution",
"icon": "$(flame)"
},
{
"command": "dotnetSolution.hotReload.restart",
"title": "Hot Reload: Restart Application",
"category": ".NET Solution",
"icon": "$(debug-restart)"
},
{
"command": "dotnetSolution.hotReload.stop",
"title": "Hot Reload: Stop",
"category": ".NET Solution",
"icon": "$(debug-stop)"
},
{
"command": "dotnetSolution.hotReload.attach",
"title": "Hot Reload: Attach Debugger",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.hotReload.detach",
"title": "Hot Reload: Detach Debugger",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.hotReload.showTerminal",
"title": "Hot Reload: Show Terminal",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.hotReload.menu",
"title": "Hot Reload: Actions",
"category": ".NET Solution"
},
{
"command": "dotnetSolution.generateLaunchConfig",
"title": "Create launch.json and tasks.json entries",
@@ -162,46 +116,6 @@
{
"command": "dotnetSolution.setStartupProject",
"when": "false"
},
{
"command": "dotnetSolution.hotReload.apply",
"when": "dotnetSolution.hotReload.running"
},
{
"command": "dotnetSolution.hotReload.restart",
"when": "dotnetSolution.hotReload.running"
},
{
"command": "dotnetSolution.hotReload.stop",
"when": "dotnetSolution.hotReload.running"
},
{
"command": "dotnetSolution.hotReload.attach",
"when": "dotnetSolution.hotReload.running && !dotnetSolution.hotReload.attached"
},
{
"command": "dotnetSolution.hotReload.detach",
"when": "dotnetSolution.hotReload.attached"
},
{
"command": "dotnetSolution.hotReload.showTerminal",
"when": "dotnetSolution.hotReload.running"
},
{
"command": "dotnetSolution.hotReload.menu",
"when": "false"
}
],
"debug/toolBar": [
{
"command": "dotnetSolution.hotReload.apply",
"when": "dotnetSolution.hotReload.attached",
"group": "navigation@10"
},
{
"command": "dotnetSolution.hotReload.restart",
"when": "dotnetSolution.hotReload.attached",
"group": "navigation@11"
}
]
},
@@ -324,7 +238,7 @@
"dotnetSolution.launch.projects": {
"type": "object",
"default": {},
"markdownDescription": "Per-project launch options, keyed by project name. Each entry may set `args`, `env`, `cwd` (relative to the project folder), `console` and `profile` (a launchSettings.json profile name). These override the global `dotnetSolution.launch.*` settings and `Properties/launchSettings.json`.\n\nExample: `{ \"MyGame.Builder\": { \"args\": [\"-build\"], \"cwd\": \"../Bin/MyGame.Builder\" } }`",
"markdownDescription": "Per-project launch options, keyed by project name. Each entry may set `args`, `env`, `cwd` (relative to the project folder), `console` and `profile` (a launchSettings.json profile name). These override the global `dotnetSolution.launch.*` settings and `Properties/launchSettings.json`.\n\nExample: `{ \"Nerfed.Builder\": { \"args\": [\"-build\"], \"cwd\": \"../Bin/Nerfed.Builder\" } }`",
"additionalProperties": {
"type": "object",
"properties": {
@@ -387,34 +301,6 @@
"type": "boolean",
"default": true,
"description": "Open the Problems panel when a build, publish or test run fails."
},
"dotnetSolution.hotReload.rudeEdit": {
"type": "string",
"enum": [
"restart",
"ask",
"warn"
],
"default": "restart",
"enumDescriptions": [
"Restart the application automatically (DOTNET_WATCH_RESTART_ON_RUDE_EDIT).",
"Show a prompt with Restart / Keep running.",
"Keep running the old code and show a warning with a Restart button."
],
"description": "What happens when an edit cannot be hot reloaded (a rude edit)."
},
"dotnetSolution.hotReload.buildSolutionFirst": {
"type": "boolean",
"default": true,
"markdownDescription": "Run the solution build before starting `dotnet watch`. dotnet watch builds only the startup project, so solution-level dependencies (the Editor's post-build step needs the Builder) would otherwise be missing on a clean checkout."
},
"dotnetSolution.hotReload.watchArgs": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Extra arguments for dotnet watch itself, e.g. [\"--verbose\"]."
}
}
},
@@ -444,11 +330,6 @@
"command": "dotnetSolution.selectStartupProject",
"key": "ctrl+alt+p",
"when": "dotnetSolution.active"
},
{
"command": "dotnetSolution.hotReload.start",
"key": "ctrl+alt+f5",
"when": "dotnetSolution.active && !dotnetSolution.hotReload.running"
}
]
},
+1 -13
View File
@@ -6,12 +6,10 @@ import { configurationKey, projectConfigurationFor, SolutionConfiguration } from
import { ALL_TARGETS, createTask, runTask, SolutionTaskProvider, TASK_TYPE, taskLabel, BuildTarget } from './tasks';
import { launchOptionsFor } from './launch';
import { StatusBar } from './status';
import { HotReloadController } from './hotreload/controller';
let model: SolutionModel;
let status: StatusBar;
let log: vscode.OutputChannel;
let hotReload: HotReloadController;
function settings() {
return vscode.workspace.getConfiguration('dotnetSolution');
@@ -305,14 +303,13 @@ export function activate(context: vscode.ExtensionContext): void {
log = vscode.window.createOutputChannel('.NET Solution');
model = new SolutionModel(context.workspaceState, log);
status = new StatusBar(model);
hotReload = new HotReloadController(model, log, () => build('build'));
const active = () => model.active;
const command = (id: string, handler: (...args: unknown[]) => unknown) =>
vscode.commands.registerCommand(id, handler);
context.subscriptions.push(
log, model, status, hotReload,
log, model, status,
vscode.tasks.registerTaskProvider(TASK_TYPE, new SolutionTaskProvider(model)),
vscode.debug.registerDebugConfigurationProvider(
@@ -331,15 +328,6 @@ export function activate(context: vscode.ExtensionContext): void {
command('dotnetSolution.debug', () => launch(false)),
command('dotnetSolution.run', () => launch(true)),
command('dotnetSolution.generateLaunchConfig', generateLaunchConfig),
command('dotnetSolution.hotReload.start', () => hotReload.start()),
command('dotnetSolution.hotReload.apply', () => hotReload.apply()),
command('dotnetSolution.hotReload.restart', () => hotReload.restart()),
command('dotnetSolution.hotReload.stop', () => hotReload.stop()),
command('dotnetSolution.hotReload.attach', () => hotReload.attach()),
command('dotnetSolution.hotReload.detach', () => hotReload.detach()),
command('dotnetSolution.hotReload.showTerminal', () => hotReload.showTerminal()),
command('dotnetSolution.hotReload.menu', () => hotReload.menu()),
command('dotnetSolution.reload', () => model.reload()),
command('dotnetSolution.showOutput', () => log.show()),
command('dotnetSolution.setStartupProject', async (resource?: unknown) => {
-136
View File
@@ -1,136 +0,0 @@
import * as vscode from 'vscode';
import { isAlive, waitForApp } from './processes';
import { HotReloadSession } from './session';
/**
* A debugger attached, on request, to the application `dotnet watch` runs.
*
* Deliberately not automatic. The old approach — attach at start and re-attach after
* every restart — meant guessing whether an ended session was a stop or a swap, racing
* the outgoing process, and giving up after crash loops. Here the debugger is attached
* when asked for, and when the process it was on disappears while the watcher is still
* running, that is reported once with an offer to re-attach.
*/
export class DebugLink implements vscode.Disposable {
private session: vscode.DebugSession | undefined;
private attachedPid: number | undefined;
private attaching = false;
private stopping = false;
private readonly subscriptions: vscode.Disposable[] = [];
private readonly changed = new vscode.EventEmitter<void>();
readonly onDidChange = this.changed.event;
constructor(
private readonly watch: HotReloadSession,
private readonly debugType: string,
private readonly log: vscode.OutputChannel,
) {
this.subscriptions.push(
vscode.debug.onDidStartDebugSession(session => this.adopt(session)),
vscode.debug.onDidTerminateDebugSession(session => void this.onTerminated(session)),
);
}
get attached(): boolean { return this.session !== undefined; }
get pid(): number | undefined { return this.attachedPid; }
get busy(): boolean { return this.attaching; }
private get sessionName(): string {
return `Hot Reload: ${this.watch.spec.projectName}`;
}
/** Finds the application below the watcher and attaches to it. */
async attach(): Promise<boolean> {
if (this.attaching || this.session) {
return false;
}
const rootPid = this.watch.pid;
if (rootPid === undefined) {
this.log.appendLine('no pid for dotnet watch, cannot attach');
return false;
}
this.attaching = true;
this.changed.fire();
try {
const target = await waitForApp(rootPid, this.watch.spec.assemblyName, {
cancelled: () => !this.watch.running,
});
if (!target) {
this.log.appendLine(`no process named ${this.watch.spec.assemblyName} found below pid ${rootPid}`);
return false;
}
this.log.appendLine(`attaching ${this.debugType} to ${target.name} (pid ${target.pid})`);
this.attachedPid = target.pid;
const started = await vscode.debug.startDebugging(
vscode.workspace.getWorkspaceFolder(vscode.Uri.file(this.watch.spec.projectPath)),
{ type: this.debugType, request: 'attach', name: this.sessionName, processId: target.pid },
{ suppressSaveBeforeStart: true });
if (!started) {
this.log.appendLine(`the ${this.debugType} adapter refused to attach`);
this.attachedPid = undefined;
}
return started;
} finally {
this.attaching = false;
this.changed.fire();
}
}
async detach(): Promise<void> {
const session = this.session;
if (!session) {
return;
}
this.stopping = true;
try {
await vscode.debug.stopDebugging(session);
} finally {
this.stopping = false;
}
}
/** Call before tearing the watcher down, so the process dying is not reported. */
expectShutdown(): void {
this.stopping = true;
}
private adopt(session: vscode.DebugSession): void {
if (session.name !== this.sessionName || session.configuration?.processId !== this.attachedPid) {
return;
}
this.session = session;
this.changed.fire();
}
private async onTerminated(session: vscode.DebugSession): Promise<void> {
if (session !== this.session) {
return;
}
const previousPid = this.attachedPid;
this.session = undefined;
this.attachedPid = undefined;
this.changed.fire();
// The adapter reports the session gone slightly before the OS reaps the process.
await new Promise(resolve => setTimeout(resolve, 300));
if (this.stopping || !this.watch.running || isAlive(previousPid)) {
// A stop, a shutdown, or a deliberate detach: nothing to say.
return;
}
const choice = await vscode.window.showInformationMessage(
`${this.watch.spec.projectName} was restarted by dotnet watch, so the debugger is no longer attached.`,
'Re-attach');
if (choice === 'Re-attach' && this.watch.running) {
await this.attach();
}
}
dispose(): void {
this.stopping = true;
for (const subscription of this.subscriptions) {
subscription.dispose();
}
this.changed.dispose();
}
}
-285
View File
@@ -1,285 +0,0 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { launchOptionsFor } from '../launch';
import { SolutionModel } from '../model';
import { DebugLink } from './attach';
import { HotReloadSession, RudeEditPolicy, SessionSpec } from './session';
import { LABELS } from './state';
function settings() {
return vscode.workspace.getConfiguration('dotnetSolution');
}
/**
* Hot reload as a third way to start the startup project, next to Debug and Run.
*
* Same project, configuration, platform, arguments and environment as the other two;
* only the host differs: `dotnet watch run` owns the process, applies saved edits, and
* restarts on rude edits according to `dotnetSolution.hotReload.rudeEdit`.
*/
export class HotReloadController implements vscode.Disposable {
private session: HotReloadSession | undefined;
private link: DebugLink | undefined;
private readonly item: vscode.StatusBarItem;
private readonly subscriptions: vscode.Disposable[] = [];
constructor(
private readonly model: SolutionModel,
private readonly log: vscode.OutputChannel,
/** Runs the solution build; resolves with the exit code. */
private readonly buildFirst: () => Promise<number>,
) {
this.item = vscode.window.createStatusBarItem('dotnetSolution.hotReload', vscode.StatusBarAlignment.Left, 100.3);
this.item.name = '.NET Solution: Hot Reload';
this.subscriptions.push(this.item, model.onDidChange(() => this.render()));
this.render();
}
get running(): boolean { return this.session?.running === true; }
private setContexts(): void {
void vscode.commands.executeCommand('setContext', 'dotnetSolution.hotReload.running', this.running);
void vscode.commands.executeCommand('setContext', 'dotnetSolution.hotReload.attached', this.link?.attached === true);
}
render(): void {
const state = this.session?.state ?? 'idle';
const label = LABELS[state];
const active = this.model.active;
if (!active && !this.session) {
this.item.hide();
this.setContexts();
return;
}
// Icon only while things are fine; a word only when something needs attention.
// "watching" and "applied" are the normal course of a session and would just be
// a wider flame, but "failed" and "restart needed" are the moments you would
// otherwise wonder why the app did not change.
const debuggerMark = this.link?.busy ? '$(loading~spin)' : this.link?.attached ? '$(debug)' : '';
const word = label.warn ? ` ${label.text.replace(/^Hot Reload:\s*/, '')}` : '';
this.item.text = `$(${label.icon})${word}${debuggerMark}`;
this.item.backgroundColor = label.warn ? new vscode.ThemeColor('statusBarItem.warningBackground') : undefined;
this.item.command = this.session?.running ? 'dotnetSolution.hotReload.menu' : 'dotnetSolution.hotReload.start';
const lines = [`**${label.text}**`, label.tooltip];
if (this.session) {
const { spec } = this.session;
lines.push('', `Project: \`${spec.projectName}\` (${spec.configuration}|${spec.platform})`);
lines.push(this.link?.attached
? `Debugger: attached (pid ${this.link.pid})`
: this.link?.busy ? 'Debugger: attaching…' : 'Debugger: not attached — click for *Attach Debugger*');
if (this.session.lastMessage) {
lines.push('', `Last: ${this.session.lastMessage}`);
}
lines.push('', 'Click for apply, restart, attach, stop.');
} else if (active) {
lines.push('', `Runs \`${active.project.info.name}\` as ${active.projectConfiguration}|${active.projectPlatform} under dotnet watch.`);
}
this.item.tooltip = new vscode.MarkdownString(lines.join(' \n'));
this.item.show();
this.setContexts();
}
private spec(): SessionSpec | undefined {
const active = this.model.active;
if (!active) {
return undefined;
}
const info = active.project.info;
const launch = launchOptionsFor(info.name, info.fsPath, path.dirname(info.fsPath));
const hot = settings();
return {
projectPath: info.fsPath,
projectName: info.name,
assemblyName: info.assemblyName,
configuration: active.projectConfiguration,
platform: active.projectPlatform,
targetFramework: active.targetFramework,
args: launch.args,
env: launch.env,
// dotnet watch runs the app from the project directory unless told otherwise;
// the launch cwd (per-project setting or launchSettings) is what Debug uses too.
cwd: launch.cwd ?? path.dirname(info.fsPath),
watchArgs: hot.get<string[]>('hotReload.watchArgs', []),
rudeEdit: hot.get<RudeEditPolicy>('hotReload.rudeEdit', 'restart'),
};
}
async start(): Promise<void> {
if (this.session?.running) {
void vscode.window.showInformationMessage(
`Hot reload is already running for ${this.session.spec.projectName}.`);
this.session.showTerminal();
return;
}
const spec = this.spec();
if (!spec) {
void vscode.window.showWarningMessage('No startup project selected.');
return;
}
// dotnet watch (SDK 10) declines to hot reload an optimised build and restarts the
// application on every change instead. Test and Release here set Optimize, so say
// so before a long session of wondering why nothing applies.
if (!await this.checkOptimize(spec)) {
return;
}
// dotnet watch only builds the project it runs. Solution-level dependencies
// (Editor's post-build step needs the Builder) are satisfied by a solution build
// first; dotnet watch's own build is then incremental and fast.
if (settings().get<boolean>('hotReload.buildSolutionFirst', true)) {
if (await this.buildFirst() !== 0) {
return;
}
}
this.teardown();
this.session = new HotReloadSession(spec, this.log);
this.session.onDidChangeState(() => this.render());
this.session.onRestartPrompt(() => void this.onRestartPrompt());
this.link = new DebugLink(this.session, settings().get<string>('debugType', 'coreclr'), this.log);
this.link.onDidChange(() => this.render());
this.session.start();
this.render();
}
/** False when the user chose not to continue with an optimised configuration. */
private async checkOptimize(spec: SessionSpec): Promise<boolean> {
let optimize = false;
try {
optimize = (await this.model.resolveTarget()).optimize;
} catch (error) {
this.log.appendLine(`could not evaluate Optimize: ${error instanceof Error ? error.message : error}`);
}
if (!optimize) {
return true;
}
const debug = this.model.configurations.find(config =>
/^debug$/i.test(config.configuration) && this.model.isNaturalConfiguration(config));
const choice = await vscode.window.showWarningMessage(
`${spec.configuration}|${spec.platform} builds with Optimize=true, which dotnet watch cannot hot reload — ` +
'it will restart the application on every change instead.',
...(debug ? [`Switch to ${debug.configuration} | ${debug.platform}`] : []), 'Start anyway');
if (choice === undefined) {
return false;
}
if (choice.startsWith('Switch') && debug) {
await this.model.selectConfiguration(debug);
const next = this.spec();
if (next) {
Object.assign(spec, next);
}
}
return true;
}
/** What to do when dotnet watch asks whether to restart after a rude edit. */
private async onRestartPrompt(): Promise<void> {
const session = this.session;
if (!session) {
return;
}
const policy = session.spec.rudeEdit;
if (policy === 'warn') {
session.answerRestartPrompt(false);
const choice = await vscode.window.showWarningMessage(
`${session.spec.projectName}: the last edit cannot be hot reloaded. It keeps running the old code until restarted.`,
'Restart');
if (choice === 'Restart') {
session.restart();
}
return;
}
if (policy === 'ask') {
const choice = await vscode.window.showWarningMessage(
`${session.spec.projectName}: the last edit cannot be hot reloaded. Restart the application?`,
{ modal: false }, 'Restart', 'Keep running');
session.answerRestartPrompt(choice === 'Restart');
}
// 'restart' never gets here: DOTNET_WATCH_RESTART_ON_RUDE_EDIT answers for us.
}
async apply(): Promise<void> {
if (!this.session?.running) {
return this.start();
}
await this.session.apply();
this.render();
}
restart(): void {
this.session?.restart();
this.render();
}
async stop(): Promise<void> {
this.teardown();
this.session?.dispose();
this.session = undefined;
this.render();
}
async attach(): Promise<void> {
if (!this.session?.running || !this.link) {
void vscode.window.showInformationMessage('No hot reload session is running.');
return;
}
if (this.link.attached) {
void vscode.window.showInformationMessage(`The debugger is already attached to ${this.session.spec.projectName}.`);
return;
}
const ok = await this.link.attach();
if (!ok && this.session.running) {
void vscode.window.showWarningMessage(
`Could not attach to ${this.session.spec.assemblyName}. Is it running yet? See the log.`,
'Show Log').then(choice => choice && this.log.show());
}
}
async detach(): Promise<void> {
await this.link?.detach();
}
showTerminal(): void {
this.session?.showTerminal();
}
/** The click target while a session runs: one place for every action. */
async menu(): Promise<void> {
if (!this.session?.running) {
return this.start();
}
const attached = this.link?.attached === true;
type Item = vscode.QuickPickItem & { run: () => unknown };
const items: Item[] = [
{ label: '$(flame) Apply changes', description: 'save all files; dotnet watch picks them up', run: () => this.apply() },
{ label: '$(debug-restart) Restart application', description: 'Ctrl+R in the watch terminal', run: () => this.restart() },
attached
? { label: '$(debug-disconnect) Detach debugger', run: () => this.detach() }
: { label: '$(debug) Attach debugger', description: 'breakpoints on demand', run: () => this.attach() },
{ label: '$(terminal) Show terminal', run: () => this.showTerminal() },
{ label: '$(debug-stop) Stop hot reload', run: () => this.stop() },
];
const picked = await vscode.window.showQuickPick(items, {
title: `Hot Reload: ${this.session.spec.projectName}${LABELS[this.session.state].text}`,
});
await picked?.run();
}
private teardown(): void {
this.link?.expectShutdown();
this.link?.dispose();
this.link = undefined;
}
dispose(): void {
this.teardown();
this.session?.dispose();
for (const subscription of this.subscriptions) {
subscription.dispose();
}
}
}
-181
View File
@@ -1,181 +0,0 @@
import { spawn } from 'child_process';
/** A process, as far as attaching needs it. */
export interface ProcessInfo {
pid: number;
parentPid: number;
name: string;
}
/**
* Every process on the machine, or an empty list if none of the tools work.
*
* `wmic` is fast and present on Windows 10, but removed from recent Windows 11 builds,
* so PowerShell's CIM query is the fallback there.
*/
export async function listProcesses(): Promise<ProcessInfo[]> {
if (process.platform !== 'win32') {
try {
return parsePs(await capture('ps', ['-eo', 'pid=,ppid=,comm=']));
} catch {
return [];
}
}
try {
const out = await capture('wmic', ['process', 'get', 'ProcessId,ParentProcessId,Name', '/format:csv']);
const parsed = parseWmicCsv(out);
if (parsed.length > 0) {
return parsed;
}
} catch {
// fall through
}
try {
const out = await capture('powershell', [
'-NoProfile', '-NonInteractive', '-Command',
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation',
]);
return parseWmicCsv(out.replace(/"/g, ''));
} catch {
return [];
}
}
export function parseWmicCsv(text: string): ProcessInfo[] {
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
const header = lines.findIndex(line => /(^|,)Name(,|$)/i.test(line));
if (header < 0) {
return [];
}
const columns = lines[header].split(',').map(column => column.trim().toLowerCase());
const nameAt = columns.indexOf('name');
const parentAt = columns.indexOf('parentprocessid');
const pidAt = columns.indexOf('processid');
if (nameAt < 0 || parentAt < 0 || pidAt < 0) {
return [];
}
const processes: ProcessInfo[] = [];
for (const line of lines.slice(header + 1)) {
const cells = line.split(',');
const pid = Number(cells[pidAt]);
const parentPid = Number(cells[parentAt]);
if (Number.isFinite(pid) && Number.isFinite(parentPid)) {
processes.push({ pid, parentPid, name: (cells[nameAt] ?? '').trim() });
}
}
return processes;
}
export function parsePs(text: string): ProcessInfo[] {
const processes: ProcessInfo[] = [];
for (const line of text.split(/\r?\n/)) {
const match = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line);
if (match) {
processes.push({ pid: Number(match[1]), parentPid: Number(match[2]), name: match[3] });
}
}
return processes;
}
/**
* The application process below `rootPid`, identified by assembly name.
*
* `dotnet watch` sits in the middle: it spawns a build and then the application, so the
* target is a descendant rather than a direct child. Matching on the assembly name keeps
* us from attaching to MSBuild. The most recently listed match is the newest one, which
* matters right after a restart when the old process is still shutting down.
*/
export function findAppProcess(
processes: readonly ProcessInfo[], rootPid: number, assembly: string, avoidPid?: number,
): ProcessInfo | undefined {
const byParent = new Map<number, ProcessInfo[]>();
for (const info of processes) {
const siblings = byParent.get(info.parentPid) ?? [];
siblings.push(info);
byParent.set(info.parentPid, siblings);
}
const wanted = assembly.toLowerCase();
const candidates: ProcessInfo[] = [];
const queue = [rootPid];
const seen = new Set<number>([rootPid]);
while (queue.length > 0) {
const pid = queue.shift()!;
for (const child of byParent.get(pid) ?? []) {
if (seen.has(child.pid)) {
continue;
}
seen.add(child.pid);
queue.push(child.pid);
const name = child.name.toLowerCase().replace(/\.exe$/, '');
if (name === wanted && child.pid !== avoidPid) {
candidates.push(child);
}
}
}
return candidates[candidates.length - 1];
}
/** Whether a pid is still running. EPERM means it exists but is not ours, which counts. */
export function isAlive(pid: number | undefined): boolean {
if (pid === undefined) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
/** Polls for the application process below the watcher; the build has to finish first. */
export async function waitForApp(
rootPid: number, assembly: string,
options: { timeoutMs?: number; intervalMs?: number; avoidPid?: number; cancelled?: () => boolean } = {},
): Promise<ProcessInfo | undefined> {
const deadline = Date.now() + (options.timeoutMs ?? 90_000);
while (Date.now() < deadline && !options.cancelled?.()) {
const found = findAppProcess(await listProcesses(), rootPid, assembly, options.avoidPid);
if (found) {
return found;
}
await new Promise(resolve => setTimeout(resolve, options.intervalMs ?? 500));
}
return undefined;
}
/**
* Kills the watcher and the application it launched.
*
* `child.kill()` only signals `dotnet watch` itself, leaving the application running and
* holding its output files — so the whole tree has to go.
*/
export async function killTree(pid: number): Promise<void> {
try {
if (process.platform === 'win32') {
await capture('taskkill', ['/PID', String(pid), '/T', '/F']);
} else {
process.kill(-pid, 'SIGTERM');
}
} catch {
try {
process.kill(pid, 'SIGKILL');
} catch {
// already gone
}
}
}
function capture(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { windowsHide: true });
let out = '';
child.stdout?.on('data', chunk => (out += String(chunk)));
child.on('error', reject);
child.on('exit', () => resolve(out));
});
}
-212
View File
@@ -1,212 +0,0 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { ChildProcess, spawn } from 'child_process';
import { dotnetPath } from '../msbuild';
import { killTree } from './processes';
import { classify, isRestartPrompt, summarize, WatchState } from './state';
export type RudeEditPolicy = 'restart' | 'ask' | 'warn';
/** Everything a session needs, resolved by the controller from the status bar selection. */
export interface SessionSpec {
projectPath: string;
projectName: string;
/** The process to look for when attaching; usually the project name. */
assemblyName: string;
configuration: string;
/** MSBuild spelling (AnyCPU). */
platform: string;
targetFramework?: string;
args: string[];
env: Record<string, string>;
cwd: string;
watchArgs: string[];
rudeEdit: RudeEditPolicy;
}
/**
* A `dotnet watch run` session hosted in a VS Code pseudoterminal.
*
* A pseudoterminal rather than a task because the same stream has to be shown, parsed
* for state, and fed with keystrokes so `dotnet watch`'s own keys (Ctrl+R restart) keep
* working. The application runs *without* a debugger; attaching one is a separate,
* explicit step handled by DebugLink.
*/
export class HotReloadSession implements vscode.Disposable {
private child: ChildProcess | undefined;
private terminal: vscode.Terminal | undefined;
private readonly write = new vscode.EventEmitter<string>();
private readonly closed = new vscode.EventEmitter<number | void>();
private readonly stateChanged = new vscode.EventEmitter<WatchState>();
private readonly promptSeen = new vscode.EventEmitter<void>();
readonly onDidChangeState = this.stateChanged.event;
/** Fires when dotnet watch asks whether to restart after a rude edit. */
readonly onRestartPrompt = this.promptSeen.event;
private currentState: WatchState = 'idle';
private pending = '';
private message = '';
constructor(readonly spec: SessionSpec, private readonly log: vscode.OutputChannel) { }
get state(): WatchState { return this.currentState; }
get lastMessage(): string { return this.message; }
get pid(): number | undefined { return this.child?.pid; }
get running(): boolean { return this.child !== undefined && this.child.exitCode === null; }
start(): void {
const { spec } = this;
const args = [
'watch', 'run',
'--project', spec.projectPath,
'-c', spec.configuration,
// Not -p: — dotnet watch takes -p as its own --project alias.
`--property:Platform=${spec.platform}`,
...(spec.targetFramework ? ['-f', spec.targetFramework] : []),
...spec.watchArgs,
...(spec.args.length > 0 ? ['--', ...spec.args] : []),
];
const pty: vscode.Pseudoterminal = {
onDidWrite: this.write.event,
onDidClose: this.closed.event,
open: () => this.spawn(args),
close: () => void this.stop(),
handleInput: data => this.child?.stdin?.write(data),
};
this.terminal = vscode.window.createTerminal({
name: `Hot Reload: ${spec.projectName} (${spec.configuration}|${spec.platform})`,
pty,
iconPath: new vscode.ThemeIcon('flame'),
});
this.terminal.show(true);
}
showTerminal(): void {
this.terminal?.show(false);
}
private spawn(args: string[]): void {
const { spec } = this;
this.setState('starting');
const command = dotnetPath();
this.log.appendLine(`> ${command} ${args.join(' ')} (cwd ${spec.cwd})`);
const env: Record<string, string | undefined> = {
...process.env,
...spec.env,
// Colour and emoji make the terminal readable; the classifier strips them.
DOTNET_WATCH_SUPPRESS_EMOJIS: process.env.DOTNET_WATCH_SUPPRESS_EMOJIS ?? '0',
};
// With 'restart' dotnet watch restarts on its own and never asks. The other two
// policies leave the question to us: the prompt line is intercepted in consume().
if (spec.rudeEdit === 'restart') {
env.DOTNET_WATCH_RESTART_ON_RUDE_EDIT = 'true';
} else {
delete env.DOTNET_WATCH_RESTART_ON_RUDE_EDIT;
}
this.child = spawn(command, args, {
cwd: spec.cwd,
env,
windowsHide: true,
});
this.child.stdout?.on('data', chunk => this.consume(String(chunk)));
this.child.stderr?.on('data', chunk => this.consume(String(chunk)));
this.child.on('error', error => {
this.log.appendLine(`failed to start: ${error.message}`);
this.write.fire(`\r\n\x1b[31mFailed to start dotnet watch: ${error.message}\x1b[0m\r\n`);
this.setState('exited');
});
this.child.on('exit', code => {
this.log.appendLine(`dotnet watch exited with code ${code ?? 0}`);
this.setState('exited');
this.closed.fire(code ?? 0);
});
}
/** Mirrors output to the terminal and reads state out of the same stream. */
private consume(chunk: string): void {
this.write.fire(chunk.replace(/\r?\n/g, '\r\n'));
this.pending += chunk;
const lines = this.pending.split(/\r?\n/);
this.pending = lines.pop() ?? '';
// The restart question has no newline after it; look at the partial line too.
if (this.pending && isRestartPrompt(this.pending)) {
lines.push(this.pending);
this.pending = '';
}
for (const line of lines) {
if (line.trim().length === 0) {
continue;
}
this.log.appendLine(line);
const state = classify(line);
if (state) {
this.message = summarize(line);
this.setState(state);
}
if (isRestartPrompt(line)) {
this.promptSeen.fire();
}
}
}
private setState(state: WatchState): void {
this.currentState = state;
this.stateChanged.fire(state);
}
/**
* Applies pending edits by saving them: `dotnet watch` watches the file system, so an
* unsaved buffer is invisible to it. That is the honest meaning of an "apply" button.
*/
async apply(): Promise<boolean> {
const dirty = vscode.workspace.textDocuments.filter(doc => doc.isDirty);
if (dirty.length === 0) {
this.message = 'nothing to apply — no unsaved changes';
this.stateChanged.fire(this.currentState);
return false;
}
await vscode.workspace.saveAll(false);
this.log.appendLine(`saved ${dirty.length} file(s) to trigger hot reload`);
return true;
}
/** Answers the rude-edit question: yes restarts, no keeps the old code running. */
answerRestartPrompt(restart: boolean): void {
this.child?.stdin?.write(restart ? 'y' : 'n');
this.log.appendLine(`answered the restart prompt with ${restart ? 'yes' : 'no'}`);
}
/** Restarts the watched application without restarting the watcher (Ctrl+R). */
restart(): void {
this.child?.stdin?.write('\x12');
this.log.appendLine('requested a restart (Ctrl+R)');
}
async stop(): Promise<void> {
const child = this.child;
this.child = undefined;
if (child?.pid && child.exitCode === null) {
await killTree(child.pid);
}
this.setState('idle');
}
dispose(): void {
void this.stop();
this.terminal?.dispose();
this.write.dispose();
this.closed.dispose();
this.stateChanged.dispose();
this.promptSeen.dispose();
}
}
export function defaultCwd(projectPath: string): string {
return path.dirname(projectPath);
}
-110
View File
@@ -1,110 +0,0 @@
/**
* What `dotnet watch` is currently doing, as far as its output tells us.
*
* The output format is not a contract — the emoji and wording have changed between SDK
* releases — so the parser matches on keywords rather than exact strings, and everything
* it cannot classify is still written to the log for diagnosis.
*/
export type WatchState =
| 'idle'
| 'starting'
| 'running'
| 'applied'
| 'failed'
| 'restartRequired'
| 'exited';
export interface StateLabel {
/** Codicon id for the status bar. */
icon: string;
text: string;
tooltip: string;
/** True when the state deserves the warning colour. */
warn?: boolean;
}
export const LABELS: Record<WatchState, StateLabel> = {
idle: {
icon: 'flame',
text: 'Hot Reload',
tooltip: 'Run the startup project under dotnet watch with hot reload',
},
starting: {
icon: 'loading~spin',
text: 'Hot Reload: starting',
tooltip: 'dotnet watch is building and launching the application',
},
running: {
icon: 'flame',
text: 'Hot Reload: watching',
tooltip: 'dotnet watch is watching for changes. Save a file to apply it.',
},
applied: {
icon: 'flame',
text: 'Hot Reload: applied',
tooltip: 'The last change was applied to the running application',
},
failed: {
icon: 'warning',
text: 'Hot Reload: failed',
tooltip: 'The last change could not be applied. See the terminal.',
warn: true,
},
restartRequired: {
icon: 'debug-restart',
text: 'Hot Reload: restart needed',
tooltip: 'The change cannot be hot reloaded — a restart is needed to apply it',
warn: true,
},
exited: {
icon: 'circle-slash',
text: 'Hot Reload: exited',
tooltip: 'The watched application exited',
},
};
/** True for the console question dotnet watch asks after a rude edit. */
export function isRestartPrompt(line: string): boolean {
return /do you want to restart/i.test(line);
}
/**
* Classifies one line of `dotnet watch` output.
*
* Returns undefined for lines that say nothing about state, which is most of them —
* the application's own stdout flows through here too.
*/
export function classify(line: string): WatchState | undefined {
const text = line.toLowerCase();
// Order matters: a failure mentioning "hot reload" must not read as a success.
if (isRestartPrompt(text) || /restart(?:\s+is)?\s+(?:needed|required)|rude edit/.test(text)) {
return 'restartRequired';
}
if (/hot reload/.test(text) && /fail|error|unable|could not/.test(text)) {
return 'failed';
}
if (/hot reload/.test(text) && /succeed|applied|handled/.test(text)) {
return 'applied';
}
if (/waiting for (?:a )?file(?: to change)?|waiting for changes|no hot reload changes to apply/.test(text)) {
return 'running';
}
if (/started|now listening on|hot reload enabled/.test(text)) {
return 'running';
}
if (/exited|shutdown requested|process terminated/.test(text)) {
return 'exited';
}
return undefined;
}
/** Strips the `dotnet watch` prefix and its emoji, for a one-line status summary. */
export function summarize(line: string): string {
return line
.replace(/^\s*dotnet watch\s*/i, '')
// dotnet watch decorates its messages with emoji (🔥 ⌚ ❌ ⏳).
.replace(/[\p{Extended_Pictographic}]/gu, '')
.replace(/\s+/g, ' ')
.trim();
}
+2 -5
View File
@@ -12,8 +12,6 @@ export interface TargetInfo {
executablePath: string | undefined;
/** The framework the evaluation used, when the project multi-targets. */
targetFramework: string | undefined;
/** True when the configuration optimises, which dotnet watch refuses to hot reload. */
optimize: boolean;
}
export interface EvaluationRequest {
@@ -24,7 +22,7 @@ export interface EvaluationRequest {
targetFramework?: string;
}
const PROPERTIES = ['TargetPath', 'TargetDir', 'AssemblyName', 'UseAppHost', 'OutputType', 'TargetExt', 'Optimize'];
const PROPERTIES = ['TargetPath', 'TargetDir', 'AssemblyName', 'UseAppHost', 'OutputType', 'TargetExt'];
export function dotnetPath(): string {
// DotRush has its own SDK directory setting; honour it so both agree on the SDK.
@@ -96,6 +94,5 @@ export async function evaluateTarget(request: EvaluationRequest, log: vscode.Out
? path.join(targetDir, assemblyName + (process.platform === 'win32' ? '.exe' : ''))
: undefined;
const optimize = (properties.Optimize ?? 'false').trim().toLowerCase() === 'true';
return { targetPath, targetDir, assemblyName, executablePath, targetFramework: request.targetFramework, optimize };
return { targetPath, targetDir, assemblyName, executablePath, targetFramework: request.targetFramework };
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { taskLabel } from './tasks';
/**
* Two status bar items, left side, next to DotRush's own:
*
* $(project) MyGame.Editor $(settings-gear) Debug | x64 $(debug-alt)
* $(project) Nerfed.Editor $(settings-gear) Debug | x64 $(debug-alt)
*
* The first picks the startup project, the second the solution configuration, the third
* launches. The project name is the point: DotRush shows only the configuration, so with
+1 -1
View File
@@ -28,7 +28,7 @@ function configuredScope(): BuildScope {
*
* A solution build gets the *solution* configuration and platform (`Any CPU`, with the
* space); MSBuild then maps each project through the .sln, so MoonWorks builds as
* `Debug|Any CPU` while the MyGame projects build as `Debug|x64`. A project build gets
* `Debug|Any CPU` while the Nerfed projects build as `Debug|x64`. A project build gets
* the mapped project configuration directly.
*/
export function buildArguments(active: ActiveTarget, target: BuildTarget, scope: BuildScope, extra: string[] = []): string[] {
+1 -1
View File
@@ -12,7 +12,7 @@ async function main(): Promise<void> {
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
const extensionTestsPath = path.resolve(__dirname, './suite/index');
const folder = path.normalize(process.env.SOLUTION_TEST_FOLDER ?? 'D:/Projects/MyGame');
const folder = path.normalize(process.env.SOLUTION_TEST_FOLDER ?? 'D:/Downloads/Nerfed/Nerfed1');
if (!fs.existsSync(folder)) {
throw new Error(`Test folder does not exist: ${folder}`);
}
+2 -2
View File
@@ -19,7 +19,7 @@ async function waitFor(predicate: () => Promise<boolean>, ms = 30_000): Promise<
throw new Error('timed out');
}
suite('.NET Solution Launcher on MyGame', () => {
suite('.NET Solution Launcher on Nerfed1', () => {
suiteSetup(async () => {
const extension = vscode.extensions.getExtension(EXTENSION_ID);
assert.ok(extension, `${EXTENSION_ID} is not installed in the test host`);
@@ -82,7 +82,7 @@ suite('.NET Solution Launcher additions', () => {
test('per-project launch options are honoured', async () => {
const name = await command<string>('dotnetSolution.activeProjectName');
// Global, so the test host's own user-data dir takes the write, not MyGame's .vscode/settings.json.
// Global, so the test host's own user-data dir takes the write, not Nerfed1's .vscode/settings.json.
const settings = vscode.workspace.getConfiguration('dotnetSolution');
await settings.update('launch.projects', { [name]: { cwd: '..', args: ['--from-test'] } }, vscode.ConfigurationTarget.Global);
try {
-50
View File
@@ -1,50 +0,0 @@
import * as assert from 'assert';
import { classify, isRestartPrompt, summarize } from '../../hotreload/state';
import { findAppProcess, parsePs, parseWmicCsv } from '../../hotreload/processes';
suite('hot reload output classifier', () => {
test('maps dotnet watch lines to states', () => {
assert.strictEqual(classify('dotnet watch ⌚ Waiting for a file to change before restarting dotnet...'), 'running');
assert.strictEqual(classify('dotnet watch 🔥 Hot reload of changes succeeded.'), 'applied');
assert.strictEqual(classify('dotnet watch ❌ Unable to apply hot reload because of a rude edit.'), 'restartRequired');
assert.strictEqual(classify('dotnet watch ❌ Hot reload failed: compilation errors'), 'failed');
assert.strictEqual(classify('dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see ...'), 'running');
assert.strictEqual(classify(' Do you want to restart your app - Yes (y) / No (n) / Always (a) / Never (v)?'), 'restartRequired');
assert.strictEqual(classify('dotnet watch ⌚ Waiting for changes'), 'running');
assert.strictEqual(classify('info: Program[0] Frame 1234'), undefined);
});
test('recognises the restart prompt without a newline', () => {
assert.ok(isRestartPrompt('Do you want to restart your app - Yes (y) / No (n)'));
assert.ok(!isRestartPrompt('restarting dotnet...'));
});
test('summarize strips prefix and emoji', () => {
assert.strictEqual(summarize('dotnet watch 🔥 Hot reload of changes succeeded.'), 'Hot reload of changes succeeded.');
});
});
suite('process lookup', () => {
const processes = [
{ pid: 1, parentPid: 0, name: 'System' },
{ pid: 100, parentPid: 1, name: 'dotnet.exe' }, // dotnet watch
{ pid: 101, parentPid: 100, name: 'MSBuild.exe' },
{ pid: 102, parentPid: 100, name: 'MyGame.Editor.exe' }, // old, exiting
{ pid: 103, parentPid: 100, name: 'MyGame.Editor.exe' }, // new
{ pid: 200, parentPid: 1, name: 'MyGame.Editor.exe' }, // unrelated instance
];
test('finds the newest matching descendant of the watcher', () => {
assert.strictEqual(findAppProcess(processes, 100, 'MyGame.Editor')?.pid, 103);
assert.strictEqual(findAppProcess(processes, 100, 'MyGame.Editor', 103)?.pid, 102);
assert.strictEqual(findAppProcess(processes, 100, 'MyGame.Builder'), undefined);
});
test('parses wmic csv and ps output', () => {
const csv = '\r\nNode,Name,ParentProcessId,ProcessId\r\nPC,dotnet.exe,1,100\r\nPC,MyGame.Editor.exe,100,103\r\n';
assert.deepStrictEqual(parseWmicCsv(csv), [
{ pid: 100, parentPid: 1, name: 'dotnet.exe' }, { pid: 103, parentPid: 100, name: 'MyGame.Editor.exe' }]);
assert.deepStrictEqual(parsePs(' 100 1 dotnet\n 103 100 MyGame.Editor\n'), [
{ pid: 100, parentPid: 1, name: 'dotnet' }, { pid: 103, parentPid: 100, name: 'MyGame.Editor' }]);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import * as path from 'path';
import { parseSln, parseSlnx, projectConfigurationFor, msbuildPlatform } from '../../sln';
import { parseProject } from '../../csproj';
const nerfed = process.env.SOLUTION_TEST_FOLDER ?? 'D:/Projects/MyGame';
const nerfed = process.env.SOLUTION_TEST_FOLDER ?? 'D:/Downloads/Nerfed/Nerfed1';
suite('sln parser', () => {
const slnPath = path.join(nerfed, 'Nerfed.sln');