Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0300dfa42f | ||
|
|
a01a94f1a4 | ||
|
|
e2ba32a647 |
@@ -1,20 +1,18 @@
|
|||||||
# .NET Solution Launcher
|
# .NET Solution Launcher
|
||||||
|
|
||||||
Startup project and solution configuration in the status bar, with build, debug, run and
|
Startup project and solution configuration in the status bar, with build and debug that
|
||||||
hot reload that actually use them. Built for [DotRush](https://github.com/JaneySprings/DotRush).
|
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) $(database) 4/4
|
$(project) Nerfed.Editor $(settings-gear) Test | x64 $(debug-alt)
|
||||||
```
|
```
|
||||||
|
|
||||||
- The **project item** shows which `.csproj` is the startup project, and picks another one
|
- 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).
|
from the executables in the solution (Runtime is a library, so it is not offered).
|
||||||
- The **configuration item** shows the *solution* configuration — `Debug | x64`,
|
- 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 **debug button** builds with that configuration and launches the startup project.
|
||||||
- The **flame** starts it under `dotnet watch` with hot reload instead.
|
|
||||||
- The **database counter** says how many projects of the solution DotRush's language server has actually loaded.
|
|
||||||
- The **database counter** says how many projects of the solution DotRush's language server has actually loaded.
|
|
||||||
|
|
||||||
## Why, when DotRush already has a status bar item
|
## Why, when DotRush already has a status bar item
|
||||||
|
|
||||||
@@ -27,13 +25,13 @@ DotRush shows `Debug | net10.0`. Two things are missing from that:
|
|||||||
conditioned on `'$(Configuration)|$(Platform)' == 'Debug|x64'` is skipped. In this
|
conditioned on `'$(Configuration)|$(Platform)' == 'Debug|x64'` is skipped. In this
|
||||||
solution that is not cosmetic:
|
solution that is not cosmetic:
|
||||||
|
|
||||||
| `MyGame.Runtime`, Configuration=Debug | `DefineConstants` |
|
| `Nerfed.Runtime`, Configuration=Debug | `DefineConstants` |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| without `-p:Platform` (what DotRush runs) | `TRACE;DEBUG` |
|
| without `-p:Platform` (what DotRush runs) | `TRACE;DEBUG` |
|
||||||
| with `-p:Platform=x64` | `TRACE;LOG_INFO;PROFILING;DEBUG` |
|
| with `-p:Platform=x64` | `TRACE;LOG_INFO;PROFILING;DEBUG` |
|
||||||
|
|
||||||
So logging and profiling silently vanish, `Optimize` is never set for Test/Release,
|
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
|
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
|
own workspace state — so this extension owns the build rather than wrapping
|
||||||
@@ -47,16 +45,16 @@ own workspace state — so this extension owns the build rather than wrapping
|
|||||||
|
|
||||||
| Scope | Command | Notes |
|
| 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. |
|
| `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 MyGame.Editor.csproj -c Test -p:Platform=x64` | Only the startup project and its `ProjectReference`s. Faster, but solution-only dependencies are ignored. |
|
| `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
|
Rebuild adds `--no-incremental`; Clean runs `dotnet clean`. Two more targets use the same
|
||||||
selection:
|
selection:
|
||||||
|
|
||||||
| Target | Command | Notes |
|
| 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 |
|
| `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 MyGame.sln -c Test -p:Platform=x64` | Follows `buildScope`; `dotnetSolution.test.args` adds filters or `--no-build` |
|
| `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
|
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
|
used as `preLaunchTask`. When one fails the Problems panel opens
|
||||||
@@ -81,8 +79,8 @@ Builder and Editor want different arguments, so these are layered, most specific
|
|||||||
1. `dotnetSolution.launch.projects`, keyed by project name:
|
1. `dotnetSolution.launch.projects`, keyed by project name:
|
||||||
```jsonc
|
```jsonc
|
||||||
"dotnetSolution.launch.projects": {
|
"dotnetSolution.launch.projects": {
|
||||||
"MyGame.Builder": { "args": ["-build", "-resourcePath", "Resources"], "cwd": "../MyGame.Editor" },
|
"Nerfed.Builder": { "args": ["-build", "-resourcePath", "Resources"], "cwd": "../Nerfed.Editor" },
|
||||||
"MyGame.Editor": { "console": "integratedTerminal" }
|
"Nerfed.Editor": { "console": "integratedTerminal" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`cwd` is relative to the project folder. `profile` names a launchSettings.json profile.
|
`cwd` is relative to the project folder. `profile` names a launchSettings.json profile.
|
||||||
@@ -95,77 +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 same layering applies to F5 through the launch.json entry: its `args` stay empty in
|
||||||
the file and are filled in at launch time.
|
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.
|
|
||||||
|
|
||||||
## Language server status
|
|
||||||
|
|
||||||
DotRush shows a spinner while it loads the workspace and nothing afterwards, so when Find
|
|
||||||
References comes back empty there is no way to tell "no references" from "not indexed".
|
|
||||||
DotRush does raise one `projectLoaded` event per project through its exports; the
|
|
||||||
`$(database) 3/4` item counts those against the solution in the status bar and turns
|
|
||||||
warning-coloured in two cases:
|
|
||||||
|
|
||||||
- the **startup project was never loaded**, so IntelliSense and references miss it;
|
|
||||||
- projects were loaded from **outside the selected solution** (`+1`), which is what a
|
|
||||||
`dotrush.roslyn.projectOrSolutionFiles` pointing at another checkout looks like.
|
|
||||||
DotRush's own picker writes absolute paths there; a workspace-relative one such as
|
|
||||||
`["MyGame.sln"]` works too (the server resolves it against the workspace root) and
|
|
||||||
is the one to commit.
|
|
||||||
|
|
||||||
The tooltip lists loaded and missing projects. Clicking offers *Reload Workspace* (which
|
|
||||||
also resets the count), DotRush's solution picker, and its output channel. The
|
|
||||||
`dotnetSolution.languageServerStatus` command returns the same data for other
|
|
||||||
extensions; *colored-references* uses it to say *why* a search found nothing.
|
|
||||||
|
|
||||||
The count only includes events raised while this extension was listening, so a window
|
|
||||||
where DotRush finished before this extension activated shows 0 until a reload.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
| Action | How |
|
| Action | How |
|
||||||
@@ -175,7 +102,6 @@ where DotRush finished before this extension activated shows 0 until a reload.
|
|||||||
| 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 |
|
| 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* |
|
| Publish / Test | *.NET Solution: Publish Startup Project*, *.NET Solution: Run Tests* |
|
||||||
| Debug | The `$(debug-alt)` button, *.NET Solution: Debug Startup Project*, or F5 |
|
| 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` |
|
| Run without debugging | *.NET Solution: Run Startup Project (without debugging)*, or `Ctrl+F5` |
|
||||||
| Several `.sln` files | *.NET Solution: Select Solution*, or `dotnetSolution.solution` |
|
| Several `.sln` files | *.NET Solution: Select Solution*, or `dotnetSolution.solution` |
|
||||||
|
|
||||||
@@ -231,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.publish.runtime` / `.args` | `""` / `[]` | `-r` and extra arguments for publish |
|
||||||
| `dotnetSolution.test.args` | `[]` | Extra arguments for test |
|
| `dotnetSolution.test.args` | `[]` | Extra arguments for test |
|
||||||
| `dotnetSolution.showProblemsOnFailure` | `true` | Open Problems when a task fails |
|
| `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.debugType` | `coreclr` | |
|
||||||
| `dotnetSolution.syncDotRush` | `true` | Push the startup project to DotRush and follow its changes |
|
| `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 |
|
| `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 |
|
||||||
@@ -250,20 +173,11 @@ honoured, so both extensions use the same SDK.
|
|||||||
terminal and the *Problems* panel (`$msCompile`).
|
terminal and the *Problems* panel (`$msCompile`).
|
||||||
- The *.NET Solution* output channel logs every `dotnet` invocation.
|
- The *.NET Solution* output channel logs every `dotnet` invocation.
|
||||||
|
|
||||||
## Platforms
|
|
||||||
|
|
||||||
Windows, Linux and macOS. The only platform-specific code is around processes: the
|
|
||||||
apphost is `MyGame.Editor.exe` on Windows and `MyGame.Editor` elsewhere; the process
|
|
||||||
list comes from `wmic` (or PowerShell's CIM query where wmic is gone) on Windows and
|
|
||||||
`ps -eo pid,ppid,args` elsewhere; and stopping hot reload uses `taskkill /T` on Windows
|
|
||||||
and a process-group signal on Unix, where `dotnet watch` is started detached for that
|
|
||||||
reason.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
npm install
|
||||||
npm run compile
|
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)
|
npm test # launches VS Code on that folder (SOLUTION_TEST_FOLDER overrides)
|
||||||
```
|
```
|
||||||
|
|||||||
+3
-142
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dotnet-solution-launcher",
|
"name": "dotnet-solution-launcher",
|
||||||
"displayName": ".NET 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",
|
"version": "0.1.0",
|
||||||
"publisher": "local",
|
"publisher": "local",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -18,9 +18,7 @@
|
|||||||
"solution",
|
"solution",
|
||||||
"configuration",
|
"configuration",
|
||||||
"dotrush",
|
"dotrush",
|
||||||
"startup project",
|
"startup project"
|
||||||
"hot reload",
|
|
||||||
"dotnet watch"
|
|
||||||
],
|
],
|
||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
"onLanguage:csharp",
|
"onLanguage:csharp",
|
||||||
@@ -85,66 +83,6 @@
|
|||||||
"category": ".NET Solution",
|
"category": ".NET Solution",
|
||||||
"icon": "$(play)"
|
"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.languageServerReload",
|
|
||||||
"title": "Reload Language Server Workspace (DotRush)",
|
|
||||||
"category": ".NET Solution",
|
|
||||||
"icon": "$(refresh)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "dotnetSolution.languageServerMenu",
|
|
||||||
"title": "Language Server Status",
|
|
||||||
"category": ".NET Solution"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "dotnetSolution.languageServerStatus",
|
|
||||||
"title": "Language Server Status (as data)",
|
|
||||||
"category": ".NET Solution"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"command": "dotnetSolution.generateLaunchConfig",
|
"command": "dotnetSolution.generateLaunchConfig",
|
||||||
"title": "Create launch.json and tasks.json entries",
|
"title": "Create launch.json and tasks.json entries",
|
||||||
@@ -178,50 +116,6 @@
|
|||||||
{
|
{
|
||||||
"command": "dotnetSolution.setStartupProject",
|
"command": "dotnetSolution.setStartupProject",
|
||||||
"when": "false"
|
"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"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "dotnetSolution.languageServerStatus",
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -344,7 +238,7 @@
|
|||||||
"dotnetSolution.launch.projects": {
|
"dotnetSolution.launch.projects": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"default": {},
|
"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": {
|
"additionalProperties": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -407,34 +301,6 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
"description": "Open the Problems panel when a build, publish or test run fails."
|
"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\"]."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -464,11 +330,6 @@
|
|||||||
"command": "dotnetSolution.selectStartupProject",
|
"command": "dotnetSolution.selectStartupProject",
|
||||||
"key": "ctrl+alt+p",
|
"key": "ctrl+alt+p",
|
||||||
"when": "dotnetSolution.active"
|
"when": "dotnetSolution.active"
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "dotnetSolution.hotReload.start",
|
|
||||||
"key": "ctrl+alt+f5",
|
|
||||||
"when": "dotnetSolution.active && !dotnetSolution.hotReload.running"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
-153
@@ -1,153 +0,0 @@
|
|||||||
import * as vscode from 'vscode';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { SolutionModel } from './model';
|
|
||||||
|
|
||||||
/** What DotRush sends per project in its `dotrush/projectLoaded` notification. */
|
|
||||||
interface LoadedProject {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
frameworks?: string[];
|
|
||||||
isTestProject?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DotRushExports {
|
|
||||||
onProjectLoaded?: { add(callback: (project: LoadedProject) => void): void };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Answer of the `dotnetSolution.languageServerStatus` command, for other extensions. */
|
|
||||||
export interface LanguageServerStatus {
|
|
||||||
/** Projects of the active solution the language server reported loaded. */
|
|
||||||
loaded: string[];
|
|
||||||
/** Projects of the active solution it has not reported. */
|
|
||||||
missing: string[];
|
|
||||||
/** Loaded projects that are not in the active solution at all. */
|
|
||||||
foreign: string[];
|
|
||||||
/** True when DotRush is not installed, so nothing can be known. */
|
|
||||||
unavailable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function samePath(a: string, b: string): boolean {
|
|
||||||
return path.normalize(a).toLowerCase() === path.normalize(b).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A status bar item saying how much of the solution DotRush's language server has
|
|
||||||
* actually loaded.
|
|
||||||
*
|
|
||||||
* DotRush shows a spinner while loading and nothing afterwards, so when Find References
|
|
||||||
* comes back empty there is no way to tell "no references" from "not indexed". It does
|
|
||||||
* raise one `projectLoaded` event per project through its exports; this collects them
|
|
||||||
* and compares against the solution in the status bar. Two mismatches get the warning
|
|
||||||
* colour: a startup project that was never loaded, and projects loaded from *outside*
|
|
||||||
* the selected solution — which is what a `dotrush.roslyn.projectOrSolutionFiles`
|
|
||||||
* pointing at another checkout looks like.
|
|
||||||
*/
|
|
||||||
export class LanguageServerIndicator implements vscode.Disposable {
|
|
||||||
private readonly item: vscode.StatusBarItem;
|
|
||||||
private readonly loaded = new Map<string, LoadedProject>();
|
|
||||||
private available = false;
|
|
||||||
private readonly subscriptions: vscode.Disposable[] = [];
|
|
||||||
|
|
||||||
constructor(private readonly model: SolutionModel, private readonly log: vscode.OutputChannel) {
|
|
||||||
this.item = vscode.window.createStatusBarItem('dotnetSolution.languageServer', vscode.StatusBarAlignment.Left, 100.2);
|
|
||||||
this.item.name = '.NET Solution: Language Server';
|
|
||||||
this.item.command = 'dotnetSolution.languageServerMenu';
|
|
||||||
this.subscriptions.push(this.item, model.onDidChange(() => this.render()));
|
|
||||||
|
|
||||||
const dotrush = vscode.extensions.getExtension<DotRushExports>('nromanov.dotrush');
|
|
||||||
if (dotrush) {
|
|
||||||
this.available = true;
|
|
||||||
void dotrush.activate().then(exports => {
|
|
||||||
exports?.onProjectLoaded?.add(project => {
|
|
||||||
if (!project?.path) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loaded.set(path.normalize(project.path).toLowerCase(), project);
|
|
||||||
this.log.appendLine(`DotRush loaded ${project.name} (${project.path})`);
|
|
||||||
this.render();
|
|
||||||
});
|
|
||||||
}, error => this.log.appendLine(`DotRush did not activate: ${error}`));
|
|
||||||
}
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
status(): LanguageServerStatus {
|
|
||||||
const solution = this.model.activeSolution;
|
|
||||||
const projects = solution?.projects ?? [];
|
|
||||||
const loaded: string[] = [];
|
|
||||||
const missing: string[] = [];
|
|
||||||
for (const project of projects) {
|
|
||||||
(this.loaded.has(path.normalize(project.fsPath).toLowerCase()) ? loaded : missing).push(project.name);
|
|
||||||
}
|
|
||||||
const foreign = [...this.loaded.values()]
|
|
||||||
.filter(entry => !projects.some(project => samePath(project.fsPath, entry.path)))
|
|
||||||
.map(entry => entry.name);
|
|
||||||
return { loaded, missing, foreign, unavailable: !this.available };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forgets what was loaded and asks DotRush to load again, so the count restarts from zero. */
|
|
||||||
async reload(): Promise<void> {
|
|
||||||
this.loaded.clear();
|
|
||||||
this.render();
|
|
||||||
await vscode.commands.executeCommand('dotrush.reloadWorkspace');
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): void {
|
|
||||||
if (!this.available || !this.model.activeSolution) {
|
|
||||||
this.item.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { loaded, missing, foreign } = this.status();
|
|
||||||
const total = loaded.length + missing.length;
|
|
||||||
const startup = this.model.startupProject?.info.name;
|
|
||||||
const startupMissing = startup !== undefined && missing.includes(startup);
|
|
||||||
const warn = startupMissing || foreign.length > 0;
|
|
||||||
|
|
||||||
const icon = total > 0 && missing.length === 0 ? 'database' : loaded.length === 0 ? 'loading~spin' : 'database';
|
|
||||||
this.item.text = `$(${icon}) ${loaded.length}/${total}${foreign.length ? ` +${foreign.length}` : ''}`;
|
|
||||||
this.item.backgroundColor = warn ? new vscode.ThemeColor('statusBarItem.warningBackground') : undefined;
|
|
||||||
|
|
||||||
const lines = [`**Language server (DotRush):** ${loaded.length} of ${total} projects of \`${this.model.activeSolution.name}\` loaded`];
|
|
||||||
if (loaded.length) {
|
|
||||||
lines.push('', 'Loaded: ' + loaded.map(name => `\`${name}\``).join(', '));
|
|
||||||
}
|
|
||||||
if (missing.length) {
|
|
||||||
lines.push('', 'Not loaded: ' + missing.map(name => `\`${name}\``).join(', '));
|
|
||||||
}
|
|
||||||
if (startupMissing) {
|
|
||||||
lines.push('', `$(warning) The startup project \`${startup}\` is not loaded — Find References and IntelliSense will miss it.`);
|
|
||||||
}
|
|
||||||
if (foreign.length) {
|
|
||||||
lines.push('', `$(warning) Loaded from outside this solution: ${foreign.map(name => `\`${name}\``).join(', ')}. ` +
|
|
||||||
'Check `dotrush.roslyn.projectOrSolutionFiles` — it may point at another checkout.');
|
|
||||||
}
|
|
||||||
if (loaded.length === 0 && missing.length > 0) {
|
|
||||||
lines.push('', 'Still loading, or the events fired before this extension was listening. Click → *Reload Workspace* to resync.');
|
|
||||||
}
|
|
||||||
lines.push('', 'Click for reload and solution picking.');
|
|
||||||
const tooltip = new vscode.MarkdownString(lines.join(' \n'));
|
|
||||||
tooltip.supportThemeIcons = true;
|
|
||||||
this.item.tooltip = tooltip;
|
|
||||||
this.item.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async menu(): Promise<void> {
|
|
||||||
type Item = vscode.QuickPickItem & { run: () => unknown };
|
|
||||||
const { missing, foreign } = this.status();
|
|
||||||
const items: Item[] = [
|
|
||||||
{ label: '$(refresh) Reload Workspace', description: 'DotRush: Reload Workspace, counting from zero', run: () => this.reload() },
|
|
||||||
{ label: '$(file-submodule) Pick solution for DotRush', description: 'dotrush.roslyn.projectOrSolutionFiles', run: () => vscode.commands.executeCommand('dotrush.pickProjectOrSolutionFiles') },
|
|
||||||
{ label: '$(output) Show DotRush output', run: () => vscode.commands.executeCommand('workbench.action.output.show.extension-output-nromanov.dotrush-#1-DotRush').then(undefined, () => vscode.commands.executeCommand('workbench.action.output.toggleOutput')) },
|
|
||||||
];
|
|
||||||
const picked = await vscode.window.showQuickPick(items, {
|
|
||||||
title: 'Language server' + (missing.length ? ` — ${missing.length} not loaded` : '') + (foreign.length ? ` — ${foreign.length} foreign` : ''),
|
|
||||||
});
|
|
||||||
await picked?.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
for (const subscription of this.subscriptions) {
|
|
||||||
subscription.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-19
@@ -6,14 +6,10 @@ import { configurationKey, projectConfigurationFor, SolutionConfiguration } from
|
|||||||
import { ALL_TARGETS, createTask, runTask, SolutionTaskProvider, TASK_TYPE, taskLabel, BuildTarget } from './tasks';
|
import { ALL_TARGETS, createTask, runTask, SolutionTaskProvider, TASK_TYPE, taskLabel, BuildTarget } from './tasks';
|
||||||
import { launchOptionsFor } from './launch';
|
import { launchOptionsFor } from './launch';
|
||||||
import { StatusBar } from './status';
|
import { StatusBar } from './status';
|
||||||
import { HotReloadController } from './hotreload/controller';
|
|
||||||
import { LanguageServerIndicator } from './dotrush';
|
|
||||||
|
|
||||||
let model: SolutionModel;
|
let model: SolutionModel;
|
||||||
let status: StatusBar;
|
let status: StatusBar;
|
||||||
let log: vscode.OutputChannel;
|
let log: vscode.OutputChannel;
|
||||||
let hotReload: HotReloadController;
|
|
||||||
let languageServer: LanguageServerIndicator;
|
|
||||||
|
|
||||||
function settings() {
|
function settings() {
|
||||||
return vscode.workspace.getConfiguration('dotnetSolution');
|
return vscode.workspace.getConfiguration('dotnetSolution');
|
||||||
@@ -307,15 +303,13 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
log = vscode.window.createOutputChannel('.NET Solution');
|
log = vscode.window.createOutputChannel('.NET Solution');
|
||||||
model = new SolutionModel(context.workspaceState, log);
|
model = new SolutionModel(context.workspaceState, log);
|
||||||
status = new StatusBar(model);
|
status = new StatusBar(model);
|
||||||
hotReload = new HotReloadController(model, log, () => build('build'));
|
|
||||||
languageServer = new LanguageServerIndicator(model, log);
|
|
||||||
|
|
||||||
const active = () => model.active;
|
const active = () => model.active;
|
||||||
const command = (id: string, handler: (...args: unknown[]) => unknown) =>
|
const command = (id: string, handler: (...args: unknown[]) => unknown) =>
|
||||||
vscode.commands.registerCommand(id, handler);
|
vscode.commands.registerCommand(id, handler);
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
log, model, status, hotReload, languageServer,
|
log, model, status,
|
||||||
|
|
||||||
vscode.tasks.registerTaskProvider(TASK_TYPE, new SolutionTaskProvider(model)),
|
vscode.tasks.registerTaskProvider(TASK_TYPE, new SolutionTaskProvider(model)),
|
||||||
vscode.debug.registerDebugConfigurationProvider(
|
vscode.debug.registerDebugConfigurationProvider(
|
||||||
@@ -334,18 +328,6 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||||||
command('dotnetSolution.debug', () => launch(false)),
|
command('dotnetSolution.debug', () => launch(false)),
|
||||||
command('dotnetSolution.run', () => launch(true)),
|
command('dotnetSolution.run', () => launch(true)),
|
||||||
command('dotnetSolution.generateLaunchConfig', generateLaunchConfig),
|
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.languageServerMenu', () => languageServer.menu()),
|
|
||||||
command('dotnetSolution.languageServerReload', () => languageServer.reload()),
|
|
||||||
command('dotnetSolution.languageServerStatus', () => languageServer.status()),
|
|
||||||
command('dotnetSolution.reload', () => model.reload()),
|
command('dotnetSolution.reload', () => model.reload()),
|
||||||
command('dotnetSolution.showOutput', () => log.show()),
|
command('dotnetSolution.showOutput', () => log.show()),
|
||||||
command('dotnetSolution.setStartupProject', async (resource?: unknown) => {
|
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,189 +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 {
|
|
||||||
// args rather than comm: comm is the kernel's 15-character name, which would
|
|
||||||
// cut "MyGame.Editor.Tools" short and never match the assembly name.
|
|
||||||
return parsePs(await capture('ps', ['-eo', 'pid=,ppid=,args=']));
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parses `ps -eo pid=,ppid=,args=`: the name is the basename of the command, without its arguments. */
|
|
||||||
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+)/.exec(line);
|
|
||||||
if (match) {
|
|
||||||
const command = match[3].replace(/^\[|\]$/g, ''); // kernel threads show as [name]
|
|
||||||
processes.push({
|
|
||||||
pid: Number(match[1]),
|
|
||||||
parentPid: Number(match[2]),
|
|
||||||
name: command.slice(command.lastIndexOf('/') + 1),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,216 +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,
|
|
||||||
// On Unix a detached child leads its own process group, which is what lets
|
|
||||||
// stop() kill the watcher *and* the application below it with one signal.
|
|
||||||
// Windows has taskkill /T for that and does not need it.
|
|
||||||
detached: process.platform !== 'win32',
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
executablePath: string | undefined;
|
||||||
/** The framework the evaluation used, when the project multi-targets. */
|
/** The framework the evaluation used, when the project multi-targets. */
|
||||||
targetFramework: string | undefined;
|
targetFramework: string | undefined;
|
||||||
/** True when the configuration optimises, which dotnet watch refuses to hot reload. */
|
|
||||||
optimize: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EvaluationRequest {
|
export interface EvaluationRequest {
|
||||||
@@ -24,7 +22,7 @@ export interface EvaluationRequest {
|
|||||||
targetFramework?: string;
|
targetFramework?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROPERTIES = ['TargetPath', 'TargetDir', 'AssemblyName', 'UseAppHost', 'OutputType', 'TargetExt', 'Optimize'];
|
const PROPERTIES = ['TargetPath', 'TargetDir', 'AssemblyName', 'UseAppHost', 'OutputType', 'TargetExt'];
|
||||||
|
|
||||||
export function dotnetPath(): string {
|
export function dotnetPath(): string {
|
||||||
// DotRush has its own SDK directory setting; honour it so both agree on the SDK.
|
// 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' : ''))
|
? path.join(targetDir, assemblyName + (process.platform === 'win32' ? '.exe' : ''))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const optimize = (properties.Optimize ?? 'false').trim().toLowerCase() === 'true';
|
return { targetPath, targetDir, assemblyName, executablePath, targetFramework: request.targetFramework };
|
||||||
return { targetPath, targetDir, assemblyName, executablePath, targetFramework: request.targetFramework, optimize };
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import { taskLabel } from './tasks';
|
|||||||
/**
|
/**
|
||||||
* Two status bar items, left side, next to DotRush's own:
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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.
|
* the mapped project configuration directly.
|
||||||
*/
|
*/
|
||||||
export function buildArguments(active: ActiveTarget, target: BuildTarget, scope: BuildScope, extra: string[] = []): string[] {
|
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 extensionDevelopmentPath = path.resolve(__dirname, '../../');
|
||||||
const extensionTestsPath = path.resolve(__dirname, './suite/index');
|
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)) {
|
if (!fs.existsSync(folder)) {
|
||||||
throw new Error(`Test folder does not exist: ${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');
|
throw new Error('timed out');
|
||||||
}
|
}
|
||||||
|
|
||||||
suite('.NET Solution Launcher on MyGame', () => {
|
suite('.NET Solution Launcher on Nerfed1', () => {
|
||||||
suiteSetup(async () => {
|
suiteSetup(async () => {
|
||||||
const extension = vscode.extensions.getExtension(EXTENSION_ID);
|
const extension = vscode.extensions.getExtension(EXTENSION_ID);
|
||||||
assert.ok(extension, `${EXTENSION_ID} is not installed in the test host`);
|
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 () => {
|
test('per-project launch options are honoured', async () => {
|
||||||
const name = await command<string>('dotnetSolution.activeProjectName');
|
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');
|
const settings = vscode.workspace.getConfiguration('dotnetSolution');
|
||||||
await settings.update('launch.projects', { [name]: { cwd: '..', args: ['--from-test'] } }, vscode.ConfigurationTarget.Global);
|
await settings.update('launch.projects', { [name]: { cwd: '..', args: ['--from-test'] } }, vscode.ConfigurationTarget.Global);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,53 +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 /usr/bin/dotnet watch run\n 103 100 /home/u/MyGame/Bin/MyGame.Editor --flag\n 2 0 [kthreadd]\n'), [
|
|
||||||
{ pid: 100, parentPid: 1, name: 'dotnet' }, { pid: 103, parentPid: 100, name: 'MyGame.Editor' },
|
|
||||||
{ pid: 2, parentPid: 0, name: 'kthreadd' }]);
|
|
||||||
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 { parseSln, parseSlnx, projectConfigurationFor, msbuildPlatform } from '../../sln';
|
||||||
import { parseProject } from '../../csproj';
|
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', () => {
|
suite('sln parser', () => {
|
||||||
const slnPath = path.join(nerfed, 'Nerfed.sln');
|
const slnPath = path.join(nerfed, 'Nerfed.sln');
|
||||||
|
|||||||
Reference in New Issue
Block a user