Hot reload as a third launch mode, rewritten from dotnet-hot-reload

dotnet watch run with the startup project, configuration, platform and
per-project launch options from the status bar. The debugger is no
longer attached automatically: attach on demand, one notification with
Re-attach when the watcher replaces the process. Rude edits follow a
setting (restart / ask / warn). Warns before starting under an optimised
configuration, which SDK 10's dotnet watch cannot hot reload. Solution
build runs first so the Editor's post-build step finds the Builder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169iPWwKHZoBTNN9qwXiwqk
This commit is contained in:
max
2026-09-08 13:47:37 +02:00
co-authored by Claude Fable 5.1
parent 63de8a31f6
commit 0cae24a1f0
10 changed files with 1164 additions and 9 deletions
+136
View File
@@ -0,0 +1,136 @@
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();
}
}
+280
View File
@@ -0,0 +1,280 @@
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;
}
const debuggerMark = this.link?.busy ? ' $(loading~spin)' : this.link?.attached ? ' $(debug)' : '';
this.item.text = `$(${label.icon}) ${label.text}${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.tooltip];
if (this.session) {
const { spec } = this.session;
lines.push('', `Project: \`${spec.projectName}\` (${spec.configuration}|${spec.platform})`);
lines.push(this.link?.attached
? `Debugger: attached (pid ${this.link.pid})`
: this.link?.busy ? 'Debugger: attaching…' : 'Debugger: not attached — click for *Attach Debugger*');
if (this.session.lastMessage) {
lines.push('', `Last: ${this.session.lastMessage}`);
}
lines.push('', 'Click for apply, restart, attach, stop.');
} else if (active) {
lines.push('', `Runs \`${active.project.info.name}\` as ${active.projectConfiguration}|${active.projectPlatform} under dotnet watch.`);
}
this.item.tooltip = new vscode.MarkdownString(lines.join(' \n'));
this.item.show();
this.setContexts();
}
private spec(): SessionSpec | undefined {
const active = this.model.active;
if (!active) {
return undefined;
}
const info = active.project.info;
const launch = launchOptionsFor(info.name, info.fsPath, path.dirname(info.fsPath));
const hot = settings();
return {
projectPath: info.fsPath,
projectName: info.name,
assemblyName: info.assemblyName,
configuration: active.projectConfiguration,
platform: active.projectPlatform,
targetFramework: active.targetFramework,
args: launch.args,
env: launch.env,
// dotnet watch runs the app from the project directory unless told otherwise;
// the launch cwd (per-project setting or launchSettings) is what Debug uses too.
cwd: launch.cwd ?? path.dirname(info.fsPath),
watchArgs: hot.get<string[]>('hotReload.watchArgs', []),
rudeEdit: hot.get<RudeEditPolicy>('hotReload.rudeEdit', 'restart'),
};
}
async start(): Promise<void> {
if (this.session?.running) {
void vscode.window.showInformationMessage(
`Hot reload is already running for ${this.session.spec.projectName}.`);
this.session.showTerminal();
return;
}
const spec = this.spec();
if (!spec) {
void vscode.window.showWarningMessage('No startup project selected.');
return;
}
// dotnet watch (SDK 10) declines to hot reload an optimised build and restarts the
// application on every change instead. Test and Release here set Optimize, so say
// so before a long session of wondering why nothing applies.
if (!await this.checkOptimize(spec)) {
return;
}
// dotnet watch only builds the project it runs. Solution-level dependencies
// (Editor's post-build step needs the Builder) are satisfied by a solution build
// first; dotnet watch's own build is then incremental and fast.
if (settings().get<boolean>('hotReload.buildSolutionFirst', true)) {
if (await this.buildFirst() !== 0) {
return;
}
}
this.teardown();
this.session = new HotReloadSession(spec, this.log);
this.session.onDidChangeState(() => this.render());
this.session.onRestartPrompt(() => void this.onRestartPrompt());
this.link = new DebugLink(this.session, settings().get<string>('debugType', 'coreclr'), this.log);
this.link.onDidChange(() => this.render());
this.session.start();
this.render();
}
/** False when the user chose not to continue with an optimised configuration. */
private async checkOptimize(spec: SessionSpec): Promise<boolean> {
let optimize = false;
try {
optimize = (await this.model.resolveTarget()).optimize;
} catch (error) {
this.log.appendLine(`could not evaluate Optimize: ${error instanceof Error ? error.message : error}`);
}
if (!optimize) {
return true;
}
const debug = this.model.configurations.find(config =>
/^debug$/i.test(config.configuration) && this.model.isNaturalConfiguration(config));
const choice = await vscode.window.showWarningMessage(
`${spec.configuration}|${spec.platform} builds with Optimize=true, which dotnet watch cannot hot reload — ` +
'it will restart the application on every change instead.',
...(debug ? [`Switch to ${debug.configuration} | ${debug.platform}`] : []), 'Start anyway');
if (choice === undefined) {
return false;
}
if (choice.startsWith('Switch') && debug) {
await this.model.selectConfiguration(debug);
const next = this.spec();
if (next) {
Object.assign(spec, next);
}
}
return true;
}
/** What to do when dotnet watch asks whether to restart after a rude edit. */
private async onRestartPrompt(): Promise<void> {
const session = this.session;
if (!session) {
return;
}
const policy = session.spec.rudeEdit;
if (policy === 'warn') {
session.answerRestartPrompt(false);
const choice = await vscode.window.showWarningMessage(
`${session.spec.projectName}: the last edit cannot be hot reloaded. It keeps running the old code until restarted.`,
'Restart');
if (choice === 'Restart') {
session.restart();
}
return;
}
if (policy === 'ask') {
const choice = await vscode.window.showWarningMessage(
`${session.spec.projectName}: the last edit cannot be hot reloaded. Restart the application?`,
{ modal: false }, 'Restart', 'Keep running');
session.answerRestartPrompt(choice === 'Restart');
}
// 'restart' never gets here: DOTNET_WATCH_RESTART_ON_RUDE_EDIT answers for us.
}
async apply(): Promise<void> {
if (!this.session?.running) {
return this.start();
}
await this.session.apply();
this.render();
}
restart(): void {
this.session?.restart();
this.render();
}
async stop(): Promise<void> {
this.teardown();
this.session?.dispose();
this.session = undefined;
this.render();
}
async attach(): Promise<void> {
if (!this.session?.running || !this.link) {
void vscode.window.showInformationMessage('No hot reload session is running.');
return;
}
if (this.link.attached) {
void vscode.window.showInformationMessage(`The debugger is already attached to ${this.session.spec.projectName}.`);
return;
}
const ok = await this.link.attach();
if (!ok && this.session.running) {
void vscode.window.showWarningMessage(
`Could not attach to ${this.session.spec.assemblyName}. Is it running yet? See the log.`,
'Show Log').then(choice => choice && this.log.show());
}
}
async detach(): Promise<void> {
await this.link?.detach();
}
showTerminal(): void {
this.session?.showTerminal();
}
/** The click target while a session runs: one place for every action. */
async menu(): Promise<void> {
if (!this.session?.running) {
return this.start();
}
const attached = this.link?.attached === true;
type Item = vscode.QuickPickItem & { run: () => unknown };
const items: Item[] = [
{ label: '$(flame) Apply changes', description: 'save all files; dotnet watch picks them up', run: () => this.apply() },
{ label: '$(debug-restart) Restart application', description: 'Ctrl+R in the watch terminal', run: () => this.restart() },
attached
? { label: '$(debug-disconnect) Detach debugger', run: () => this.detach() }
: { label: '$(debug) Attach debugger', description: 'breakpoints on demand', run: () => this.attach() },
{ label: '$(terminal) Show terminal', run: () => this.showTerminal() },
{ label: '$(debug-stop) Stop hot reload', run: () => this.stop() },
];
const picked = await vscode.window.showQuickPick(items, {
title: `Hot Reload: ${this.session.spec.projectName}${LABELS[this.session.state].text}`,
});
await picked?.run();
}
private teardown(): void {
this.link?.expectShutdown();
this.link?.dispose();
this.link = undefined;
}
dispose(): void {
this.teardown();
this.session?.dispose();
for (const subscription of this.subscriptions) {
subscription.dispose();
}
}
}
+181
View File
@@ -0,0 +1,181 @@
import { spawn } from 'child_process';
/** A process, as far as attaching needs it. */
export interface ProcessInfo {
pid: number;
parentPid: number;
name: string;
}
/**
* Every process on the machine, or an empty list if none of the tools work.
*
* `wmic` is fast and present on Windows 10, but removed from recent Windows 11 builds,
* so PowerShell's CIM query is the fallback there.
*/
export async function listProcesses(): Promise<ProcessInfo[]> {
if (process.platform !== 'win32') {
try {
return parsePs(await capture('ps', ['-eo', 'pid=,ppid=,comm=']));
} catch {
return [];
}
}
try {
const out = await capture('wmic', ['process', 'get', 'ProcessId,ParentProcessId,Name', '/format:csv']);
const parsed = parseWmicCsv(out);
if (parsed.length > 0) {
return parsed;
}
} catch {
// fall through
}
try {
const out = await capture('powershell', [
'-NoProfile', '-NonInteractive', '-Command',
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation',
]);
return parseWmicCsv(out.replace(/"/g, ''));
} catch {
return [];
}
}
export function parseWmicCsv(text: string): ProcessInfo[] {
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
const header = lines.findIndex(line => /(^|,)Name(,|$)/i.test(line));
if (header < 0) {
return [];
}
const columns = lines[header].split(',').map(column => column.trim().toLowerCase());
const nameAt = columns.indexOf('name');
const parentAt = columns.indexOf('parentprocessid');
const pidAt = columns.indexOf('processid');
if (nameAt < 0 || parentAt < 0 || pidAt < 0) {
return [];
}
const processes: ProcessInfo[] = [];
for (const line of lines.slice(header + 1)) {
const cells = line.split(',');
const pid = Number(cells[pidAt]);
const parentPid = Number(cells[parentAt]);
if (Number.isFinite(pid) && Number.isFinite(parentPid)) {
processes.push({ pid, parentPid, name: (cells[nameAt] ?? '').trim() });
}
}
return processes;
}
export function parsePs(text: string): ProcessInfo[] {
const processes: ProcessInfo[] = [];
for (const line of text.split(/\r?\n/)) {
const match = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line);
if (match) {
processes.push({ pid: Number(match[1]), parentPid: Number(match[2]), name: match[3] });
}
}
return processes;
}
/**
* The application process below `rootPid`, identified by assembly name.
*
* `dotnet watch` sits in the middle: it spawns a build and then the application, so the
* target is a descendant rather than a direct child. Matching on the assembly name keeps
* us from attaching to MSBuild. The most recently listed match is the newest one, which
* matters right after a restart when the old process is still shutting down.
*/
export function findAppProcess(
processes: readonly ProcessInfo[], rootPid: number, assembly: string, avoidPid?: number,
): ProcessInfo | undefined {
const byParent = new Map<number, ProcessInfo[]>();
for (const info of processes) {
const siblings = byParent.get(info.parentPid) ?? [];
siblings.push(info);
byParent.set(info.parentPid, siblings);
}
const wanted = assembly.toLowerCase();
const candidates: ProcessInfo[] = [];
const queue = [rootPid];
const seen = new Set<number>([rootPid]);
while (queue.length > 0) {
const pid = queue.shift()!;
for (const child of byParent.get(pid) ?? []) {
if (seen.has(child.pid)) {
continue;
}
seen.add(child.pid);
queue.push(child.pid);
const name = child.name.toLowerCase().replace(/\.exe$/, '');
if (name === wanted && child.pid !== avoidPid) {
candidates.push(child);
}
}
}
return candidates[candidates.length - 1];
}
/** Whether a pid is still running. EPERM means it exists but is not ours, which counts. */
export function isAlive(pid: number | undefined): boolean {
if (pid === undefined) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}
/** Polls for the application process below the watcher; the build has to finish first. */
export async function waitForApp(
rootPid: number, assembly: string,
options: { timeoutMs?: number; intervalMs?: number; avoidPid?: number; cancelled?: () => boolean } = {},
): Promise<ProcessInfo | undefined> {
const deadline = Date.now() + (options.timeoutMs ?? 90_000);
while (Date.now() < deadline && !options.cancelled?.()) {
const found = findAppProcess(await listProcesses(), rootPid, assembly, options.avoidPid);
if (found) {
return found;
}
await new Promise(resolve => setTimeout(resolve, options.intervalMs ?? 500));
}
return undefined;
}
/**
* Kills the watcher and the application it launched.
*
* `child.kill()` only signals `dotnet watch` itself, leaving the application running and
* holding its output files — so the whole tree has to go.
*/
export async function killTree(pid: number): Promise<void> {
try {
if (process.platform === 'win32') {
await capture('taskkill', ['/PID', String(pid), '/T', '/F']);
} else {
process.kill(-pid, 'SIGTERM');
}
} catch {
try {
process.kill(pid, 'SIGKILL');
} catch {
// already gone
}
}
}
function capture(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { windowsHide: true });
let out = '';
child.stdout?.on('data', chunk => (out += String(chunk)));
child.on('error', reject);
child.on('exit', () => resolve(out));
});
}
+212
View File
@@ -0,0 +1,212 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { ChildProcess, spawn } from 'child_process';
import { dotnetPath } from '../msbuild';
import { killTree } from './processes';
import { classify, isRestartPrompt, summarize, WatchState } from './state';
export type RudeEditPolicy = 'restart' | 'ask' | 'warn';
/** Everything a session needs, resolved by the controller from the status bar selection. */
export interface SessionSpec {
projectPath: string;
projectName: string;
/** The process to look for when attaching; usually the project name. */
assemblyName: string;
configuration: string;
/** MSBuild spelling (AnyCPU). */
platform: string;
targetFramework?: string;
args: string[];
env: Record<string, string>;
cwd: string;
watchArgs: string[];
rudeEdit: RudeEditPolicy;
}
/**
* A `dotnet watch run` session hosted in a VS Code pseudoterminal.
*
* A pseudoterminal rather than a task because the same stream has to be shown, parsed
* for state, and fed with keystrokes so `dotnet watch`'s own keys (Ctrl+R restart) keep
* working. The application runs *without* a debugger; attaching one is a separate,
* explicit step handled by DebugLink.
*/
export class HotReloadSession implements vscode.Disposable {
private child: ChildProcess | undefined;
private terminal: vscode.Terminal | undefined;
private readonly write = new vscode.EventEmitter<string>();
private readonly closed = new vscode.EventEmitter<number | void>();
private readonly stateChanged = new vscode.EventEmitter<WatchState>();
private readonly promptSeen = new vscode.EventEmitter<void>();
readonly onDidChangeState = this.stateChanged.event;
/** Fires when dotnet watch asks whether to restart after a rude edit. */
readonly onRestartPrompt = this.promptSeen.event;
private currentState: WatchState = 'idle';
private pending = '';
private message = '';
constructor(readonly spec: SessionSpec, private readonly log: vscode.OutputChannel) { }
get state(): WatchState { return this.currentState; }
get lastMessage(): string { return this.message; }
get pid(): number | undefined { return this.child?.pid; }
get running(): boolean { return this.child !== undefined && this.child.exitCode === null; }
start(): void {
const { spec } = this;
const args = [
'watch', 'run',
'--project', spec.projectPath,
'-c', spec.configuration,
// Not -p: — dotnet watch takes -p as its own --project alias.
`--property:Platform=${spec.platform}`,
...(spec.targetFramework ? ['-f', spec.targetFramework] : []),
...spec.watchArgs,
...(spec.args.length > 0 ? ['--', ...spec.args] : []),
];
const pty: vscode.Pseudoterminal = {
onDidWrite: this.write.event,
onDidClose: this.closed.event,
open: () => this.spawn(args),
close: () => void this.stop(),
handleInput: data => this.child?.stdin?.write(data),
};
this.terminal = vscode.window.createTerminal({
name: `Hot Reload: ${spec.projectName} (${spec.configuration}|${spec.platform})`,
pty,
iconPath: new vscode.ThemeIcon('flame'),
});
this.terminal.show(true);
}
showTerminal(): void {
this.terminal?.show(false);
}
private spawn(args: string[]): void {
const { spec } = this;
this.setState('starting');
const command = dotnetPath();
this.log.appendLine(`> ${command} ${args.join(' ')} (cwd ${spec.cwd})`);
const env: Record<string, string | undefined> = {
...process.env,
...spec.env,
// Colour and emoji make the terminal readable; the classifier strips them.
DOTNET_WATCH_SUPPRESS_EMOJIS: process.env.DOTNET_WATCH_SUPPRESS_EMOJIS ?? '0',
};
// With 'restart' dotnet watch restarts on its own and never asks. The other two
// policies leave the question to us: the prompt line is intercepted in consume().
if (spec.rudeEdit === 'restart') {
env.DOTNET_WATCH_RESTART_ON_RUDE_EDIT = 'true';
} else {
delete env.DOTNET_WATCH_RESTART_ON_RUDE_EDIT;
}
this.child = spawn(command, args, {
cwd: spec.cwd,
env,
windowsHide: true,
});
this.child.stdout?.on('data', chunk => this.consume(String(chunk)));
this.child.stderr?.on('data', chunk => this.consume(String(chunk)));
this.child.on('error', error => {
this.log.appendLine(`failed to start: ${error.message}`);
this.write.fire(`\r\n\x1b[31mFailed to start dotnet watch: ${error.message}\x1b[0m\r\n`);
this.setState('exited');
});
this.child.on('exit', code => {
this.log.appendLine(`dotnet watch exited with code ${code ?? 0}`);
this.setState('exited');
this.closed.fire(code ?? 0);
});
}
/** Mirrors output to the terminal and reads state out of the same stream. */
private consume(chunk: string): void {
this.write.fire(chunk.replace(/\r?\n/g, '\r\n'));
this.pending += chunk;
const lines = this.pending.split(/\r?\n/);
this.pending = lines.pop() ?? '';
// The restart question has no newline after it; look at the partial line too.
if (this.pending && isRestartPrompt(this.pending)) {
lines.push(this.pending);
this.pending = '';
}
for (const line of lines) {
if (line.trim().length === 0) {
continue;
}
this.log.appendLine(line);
const state = classify(line);
if (state) {
this.message = summarize(line);
this.setState(state);
}
if (isRestartPrompt(line)) {
this.promptSeen.fire();
}
}
}
private setState(state: WatchState): void {
this.currentState = state;
this.stateChanged.fire(state);
}
/**
* Applies pending edits by saving them: `dotnet watch` watches the file system, so an
* unsaved buffer is invisible to it. That is the honest meaning of an "apply" button.
*/
async apply(): Promise<boolean> {
const dirty = vscode.workspace.textDocuments.filter(doc => doc.isDirty);
if (dirty.length === 0) {
this.message = 'nothing to apply — no unsaved changes';
this.stateChanged.fire(this.currentState);
return false;
}
await vscode.workspace.saveAll(false);
this.log.appendLine(`saved ${dirty.length} file(s) to trigger hot reload`);
return true;
}
/** Answers the rude-edit question: yes restarts, no keeps the old code running. */
answerRestartPrompt(restart: boolean): void {
this.child?.stdin?.write(restart ? 'y' : 'n');
this.log.appendLine(`answered the restart prompt with ${restart ? 'yes' : 'no'}`);
}
/** Restarts the watched application without restarting the watcher (Ctrl+R). */
restart(): void {
this.child?.stdin?.write('\x12');
this.log.appendLine('requested a restart (Ctrl+R)');
}
async stop(): Promise<void> {
const child = this.child;
this.child = undefined;
if (child?.pid && child.exitCode === null) {
await killTree(child.pid);
}
this.setState('idle');
}
dispose(): void {
void this.stop();
this.terminal?.dispose();
this.write.dispose();
this.closed.dispose();
this.stateChanged.dispose();
this.promptSeen.dispose();
}
}
export function defaultCwd(projectPath: string): string {
return path.dirname(projectPath);
}
+110
View File
@@ -0,0 +1,110 @@
/**
* 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();
}