Compare commits
3
Commits
86b21c15d0
..
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0300dfa42f | ||
|
|
a01a94f1a4 | ||
|
|
e2ba32a647 |
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user