Skip to content

Commit 2ed3077

Browse files
authored
feat: per-stack restart commands and a hover-only status bar (#4)
* feat: per-stack restart commands and hover-only status bar Restart is now a shell concern with two levels: rstack.restart ("Relaunch Extension") tears down every controller, re-runs detection and registers again from scratch; the new rstack.<stack>.restart commands do the same for a single stack. The Rslint stack's own LSP-only restart is removed — bouncing just the server kept the controller's stale package resolution and version check, which is exactly what these commands exist to clear. Its settings listener now routes through the shell command instead. The status bar QuickPick is gone: the hover card is the single surface. It renders all six rows in one table (aligned state/label/action columns), colours state icons with theme variables, moves state text into the icon's native tooltip, and stacks the three global actions under a divider. Clicking the item opens the extension log directly. Unit tests cover the per-stack restart scope and the manifest contract (rstack.restart stays palette-unconditional; per-stack restarts gate on their context keys). * refactor: derive stack command ids and give stacks a restart seam Three follow-ups from a cleanup pass over the restart work: - `stackCommand(stack, verb)` in types.ts is now the single place a per-stack command id is spelled. The shell registers through it and the status bar links through it, so the two OUTPUT_COMMANDS/RESTART_COMMANDS tables are gone — they had become hand-copied templates whose drift from the registration site would not be a type error. - `StackContext.requestRestart(reason)` replaces the Rslint stack reaching back through the command registry. Restart was already a shell service; it is now injected like every other one, and the reason reaches the log so a settings-triggered restart says what moved. - `dispose()` tears controllers down in parallel, matching the restart path, and drops `clearProjectModuleCache` which had no callers. Also folds the status bar's two anchor builders into one so escaping is a property of the markup rather than a per-call-site obligation, inlines `#canRestart`, and trims the rejected-alternative journal from render(). * docs: state what a restart cannot recover A restart re-resolves binaries and package versions and respawns every tool process, but the config-loader and eslint-plugin modules imported from the project stay in Node's ESM registry for the lifetime of the window. Verified: clearing the local memo in projectModules.ts hands back the identical module object, and a cache-busting query reloads only the entry module — relative specifiers inside it do not inherit the query, so the result is a fresh entry over stale dependencies. Records the limit in the README and the reasoning in AGENTS.md so nobody adds an invalidation hook that cannot work. * fix: run shell teardown on the shared queue `retire` drops a controller from the shell's map before awaiting its teardown, so a restart in flight leaves a window where the map is already empty and the Rslint client is still shutting down. `dispose()` walked that map, found nothing, and went on to dispose the channels out from under the running teardown — letting deactivate() resolve while the child processes were still alive. Putting the teardown pass on the same queue as reconciles and restarts makes "whatever was in flight has finished" something dispose can wait for; the `#disposed` flag it sets first still stops that pass from rebuilding anything on its way out. The regression test asserts on the output channels rather than on deactivate's promise: "has not resolved yet" races the microtask queue, whereas "the channel this teardown still logs to is alive" is a fact. Confirmed to fail against the previous dispose(). * docs: add a roadmap to the README States, per config source, what the extension supports today and what is still coming, plus the cross-cutting items (re-detection command, bounded version ranges, retiring the standalone extensions). * fix: run the activation reconcile on the shared queue `registerCommands()` runs before activation's first await, so the palette can reach a restart while activation is still bringing stacks up. The activation reconcile called `reconcile()` directly rather than through `enqueue`, so that restart ran straight through it and could retire a controller whose `register()` had not returned — the retired controller then went on to publish its exports and set `active` to true. Every pass now rides the one queue, which is what AGENTS.md already says. The regression test blocks the Rslint controller's `register()`, invokes the restart, and asserts no teardown begins for a stack whose register is still in flight. Confirmed to fail against the direct call. * refactor(vscode): make the shell's queue discipline structural The two concurrency fixes in this PR both came from a pass that bypassed the shell's single queue, so encode the rule instead of restating it: - `reconcile()` is now the queued wrapper and `runReconcile()` the body, matching the existing `restart()`/`runRestart()` pair. The `run*` prefix marks "already owns the queue" at every call site. - `retireAll()` replaces the two hand-rolled `Promise.allSettled` loops in `runRestart` and `dispose`. - `dispose()` drops the detection service before waiting on the queue, so a file touched during shutdown cannot arm a fresh pass behind it. The reconcileStack comments still described the pre-fix model where a teardown could run beside a reconcile; they now describe what actually happens. On the test side: drop the dead `blockDetection` field, rebuild harness state from a factory so a new field cannot leak between tests, and use `Promise.withResolvers`.
1 parent 3a9e224 commit 2ed3077

14 files changed

Lines changed: 994 additions & 190 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ Rstack Editor provides unified editor support for [Rstack](https://rstack.rs), t
88
| --- | --- |
99
| [`packages/vscode`](./packages/vscode) | The VS Code extension (`rstack.rstack`) |
1010

11+
## Roadmap
12+
13+
The extension takes its configuration from five sources. The tool-native configs are fully supported today; support for driving a stack from `rstack.config.*` is landing one stack at a time.
14+
15+
| Config source | Status |
16+
| --- | --- |
17+
| `rslint.config.*` | **Supported.** Diagnostics, quick fixes and the language server, all resolved from the `@rslint/core` installed in your project. |
18+
| `rstest.config.*` | **Supported.** Test discovery, run and debug, watch mode, coverage and snapshot updates in the Test Explorer. |
19+
| `define.test()` in `rstack.config.*` | **Supported.** Tests run through the same config shim `rs test` uses, so the editor and the CLI resolve the config identically. |
20+
| `define.fmt()` in `rstack.config.*` | **Planned.** Detected and reported in the status bar; formatting itself arrives next, first over `rs fmt --stdin-filepath` and later over an `rs fmt` language server. |
21+
| `define.lint()` in `rstack.config.*` | **Planned.** Linting a project configured only through `rstack.config.*` needs upstream changes in Rslint and rstack-cli before the editor can evaluate it correctly. `rs lint` on the command line is unaffected. |
22+
1123
## License
1224

1325
Rstack Editor is licensed under the [MIT License](./LICENSE).

packages/vscode/AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
1919

2020
- One stack failing to register or crashing must never take another stack (or the shell) down.
2121
- The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. Enable-settings are coarse kill switches only.
22+
- Reconciles and restarts share one serialized queue (`enqueue`); a reconcile leaves a live stack alone, so the restart commands are the only path that rebuilds one. Do not add a second queue.
23+
- Restart is a shell concern, not a stack one: `rstack.restart` rebuilds every controller, `rstack.<stack>.restart` rebuilds one. A stack must never register its own restart command — a shallower "bounce the tool's process" restart keeps that controller's stale package resolution and version check, which is the bug the command exists to clear.
2224
- Deprecated `rslint.json` / `rslint.jsonc` are unsupported by decision, not omission — never make them detection signals.
2325
- Never share a child process across stacks: the tools have incompatible cwd semantics (lint LSP anchors on spawn cwd; test worker pins to project root; `rs fmt` resolves config from spawn cwd with no upward walk).
2426
- In Restricted Mode (workspace trust), only the status bar runs — no process spawns, no project code loaded.
@@ -30,6 +32,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
3032
- The lint × `rstack.config.*` bridge was built and deliberately removed: a partial editor-side bridge gave wrong results, and a correct one needs upstream work first. `TODO(rstack-bridge)` markers carry the plan. Do not reintroduce a partial bridge.
3133
- The test × `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Never re-implement rstack config semantics in the extension.
3234
- The fmt stack is a stub on purpose. The MVP will spawn `rs fmt --stdin-filepath` with cwd = the config directory (forced by rs fmt's cwd-only config resolution); the endgame is an upstream LSP, so do not add a warm-process middle tier or "fix" the stub into an error state.
35+
- `projectModules.ts` has no cache-invalidation hook and restart must not grow one. Node's ESM registry is keyed by resolved URL and process-lifetime, so clearing the local memo hands back the identical module object (verified); a `?epoch=` query does reload the entry but relative specifiers inside it do not inherit the query, yielding a fresh entry over stale dependencies. In-place reinstalls under an unchanged path need a window reload — say so, don't fake it.
3336
- The VSIX is platform-targeted for exactly one reason: the test stack's AST collection loads a native parser binding. Do not add another native dependency — it multiplies the release matrix.
3437

3538
## Testing

packages/vscode/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ The extension activates on startup, then decides **per workspace folder** which
2626
| Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` |
2727
| rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` |
2828

29-
Config files and lockfiles are watched, so detection re-runs without a window reload. Deprecated `rslint.json` / `rslint.jsonc` configs are **not** detection signals — migrate them with `rslint --init`.
29+
Config files and lockfiles are watched, so detection re-runs without a window reload. When something changes that none of those files record — a reinstall that leaves the lockfile untouched, or a `node_modules` that ends up broken — run **Rstack: Relaunch Extension** from the Command Palette (also on the status bar hover) to tear every tool down and start over. To rebuild a single tool, use **Rstack: Restart Rslint** / **Restart Rstest** / **Restart rs fmt**.
30+
31+
A restart re-resolves every binary and package version and respawns every tool process, but it cannot reload JavaScript the editor has already imported from your project — Node keeps those modules for the lifetime of the window. If a reinstall replaced `@rslint/core` in place and lint still behaves like the old version, reload the window. Deprecated `rslint.json` / `rslint.jsonc` configs are **not** detection signals — migrate them with `rslint --init`.
3032

3133
## Supported package versions
3234

packages/vscode/package.json

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,17 @@
4343
},
4444
"contributes": {
4545
"commands": [
46-
{
47-
"command": "rstack.showMenu",
48-
"title": "Show Menu",
49-
"category": "Rstack"
50-
},
5146
{
5247
"command": "rstack.showOutput",
5348
"title": "Show Extension Log",
5449
"category": "Rstack"
5550
},
51+
{
52+
"command": "rstack.restart",
53+
"title": "Relaunch Extension",
54+
"category": "Rstack",
55+
"icon": "$(debug-restart)"
56+
},
5657
{
5758
"command": "rstack.migrateSettings",
5859
"title": "Migrate Rslint/Rstest Settings",
@@ -65,7 +66,7 @@
6566
},
6667
{
6768
"command": "rstack.rslint.restart",
68-
"title": "Restart Rslint Language Server",
69+
"title": "Restart Rslint",
6970
"category": "Rstack",
7071
"icon": "$(refresh)"
7172
},
@@ -74,6 +75,12 @@
7475
"title": "Show Rstest Log",
7576
"category": "Rstack"
7677
},
78+
{
79+
"command": "rstack.rstest.restart",
80+
"title": "Restart Rstest",
81+
"category": "Rstack",
82+
"icon": "$(refresh)"
83+
},
7784
{
7885
"command": "rstack.rstest.updateSnapshot",
7986
"title": "Update Snapshot",
@@ -107,6 +114,12 @@
107114
"command": "rstack.fmt.output.focus",
108115
"title": "Show rs fmt Log",
109116
"category": "Rstack"
117+
},
118+
{
119+
"command": "rstack.fmt.restart",
120+
"title": "Restart rs fmt",
121+
"category": "Rstack",
122+
"icon": "$(refresh)"
110123
}
111124
],
112125
"configuration": [
@@ -347,10 +360,18 @@
347360
"command": "rstack.rstest.output.focus",
348361
"when": "rstack.rstest.active"
349362
},
363+
{
364+
"command": "rstack.rstest.restart",
365+
"when": "rstack.rstest.active"
366+
},
350367
{
351368
"command": "rstack.fmt.output.focus",
352369
"when": "rstack.fmt.active"
353370
},
371+
{
372+
"command": "rstack.fmt.restart",
373+
"when": "rstack.fmt.active"
374+
},
354375
{
355376
"command": "rstack.rstest.updateSnapshot",
356377
"when": "false"

packages/vscode/rstest.config.mts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,13 @@ export default defineConfig({
1010
externals: {
1111
vscode: 'commonjs vscode',
1212
},
13+
// An externalized dependency is imported by the chunk itself, so it loads
14+
// before any `rs.mock` can intervene — and `vscode-languageclient/node`
15+
// does a bare `require('vscode')` at load time, which no mock can serve
16+
// from plain Node. Bundling it routes that require through the bundler,
17+
// where the `vscode` external (and therefore the mock) applies. This is
18+
// what lets a test import the shell, whose module graph reaches the Rslint
19+
// stack.
20+
bundleDependencies: ['vscode-languageclient'],
1321
},
1422
});

packages/vscode/src/detection.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,51 @@
11
import { describe, expect, it, rs } from '@rstest/core';
2+
import type vscode from 'vscode';
3+
import type { DetectionSnapshot } from './types';
24

35
// `detection.ts` imports the `vscode` namespace for the watcher/`findFiles`
46
// paths. `detectionWatchPatterns` is pure, but the module still has to load, so
57
// the namespace is stubbed away: unit tests run in plain Node, with no
6-
// extension host (unit tests are Rstest, E2E is Electron).
8+
// extension host (unit tests are Rstest, E2E is Electron). The stub carries
9+
// exactly what `DetectionService` touches with no workspace folder open — its
10+
// event plumbing — so the notification rules can be exercised here too.
711
rs.mock('vscode', () => {
8-
const vscode = {};
12+
class EventEmitter {
13+
readonly #listeners = new Set<(value: unknown) => void>();
14+
readonly event = (listener: (value: unknown) => void) => {
15+
this.#listeners.add(listener);
16+
return {
17+
dispose: () => {
18+
this.#listeners.delete(listener);
19+
},
20+
};
21+
};
22+
fire(value: unknown): void {
23+
for (const listener of [...this.#listeners]) {
24+
listener(value);
25+
}
26+
}
27+
dispose(): void {
28+
this.#listeners.clear();
29+
}
30+
}
31+
const disposable = { dispose: () => undefined };
32+
const vscode = {
33+
EventEmitter,
34+
workspace: {
35+
// No folder is open: every pass produces the empty snapshot, so the
36+
// detection signature is unchanged by construction.
37+
workspaceFolders: undefined,
38+
onDidChangeWorkspaceFolders: () => disposable,
39+
onDidChangeConfiguration: () => disposable,
40+
},
41+
};
942
return { ...vscode, default: vscode };
1043
});
1144

1245
import {
1346
DEFAULT_RSTEST_CONFIG_GLOBS,
1447
DETECTION_WATCH_NAMES,
48+
DetectionService,
1549
detectionWatchPatterns,
1650
} from './detection';
1751

@@ -160,3 +194,42 @@ describe('detectionWatchPatterns', () => {
160194
}
161195
});
162196
});
197+
198+
/**
199+
* With no workspace folder open every pass yields the empty snapshot, so the
200+
* detection signature is identical across passes by construction — exactly the
201+
* shape a lockfile write or a `rstack.restart` produces in a real workspace
202+
* whose `node_modules` was replaced without touching a watched file.
203+
*/
204+
describe('DetectionService — notification rules', () => {
205+
const fakeOutput = () =>
206+
({
207+
info: () => undefined,
208+
warn: () => undefined,
209+
error: () => undefined,
210+
}) as unknown as vscode.LogOutputChannel;
211+
212+
const listen = (service: DetectionService) => {
213+
const seen: DetectionSnapshot[] = [];
214+
service.onDidChange((snapshot) => seen.push(snapshot));
215+
return seen;
216+
};
217+
218+
it('stays quiet when the signature did not change', async () => {
219+
const service = new DetectionService(fakeOutput());
220+
const seen = listen(service);
221+
await service.initialize();
222+
await service.refresh();
223+
expect(seen).toHaveLength(0);
224+
service.dispose();
225+
});
226+
227+
it('does not notify after disposal', async () => {
228+
const service = new DetectionService(fakeOutput());
229+
const seen = listen(service);
230+
await service.initialize();
231+
service.dispose();
232+
await service.refresh();
233+
expect(seen).toHaveLength(0);
234+
});
235+
});

packages/vscode/src/detection.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,9 @@ export class DetectionService implements vscode.Disposable {
240240
// out identical while every project-resolved package (Rslint binary, Rstest
241241
// core, the rstack shim) may now resolve differently. Such a pass must
242242
// notify subscribers even when the signature is unchanged, or failed
243-
// resolutions are never retried until a window reload.
243+
// resolutions are never retried until a window reload. Set by the lockfile
244+
// watcher only — a caller that drives the rebuild itself does not need the
245+
// event, it already has the fresh snapshot.
244246
#notifyUnchanged = false;
245247
#watchers: vscode.Disposable[] = [];
246248
#debounce: ReturnType<typeof setTimeout> | undefined;
@@ -301,12 +303,15 @@ export class DetectionService implements vscode.Disposable {
301303
// Virtual filesystems cannot host a project-local toolchain.
302304
(folder) => folder.uri.scheme === 'file',
303305
);
306+
// Consumed before the first `await`: a pass that rejects (a folder removed
307+
// mid-scan, a filesystem provider erroring) must not leave the flag set for
308+
// an unrelated later pass to act on.
309+
const notifyUnchanged = this.#notifyUnchanged;
310+
this.#notifyUnchanged = false;
304311
const detections = await Promise.all(folders.map(detectFolder));
305312
const snapshot = new Snapshot(detections);
306313
const signature = signatureOf(snapshot);
307314
this.#snapshot = snapshot;
308-
const notifyUnchanged = this.#notifyUnchanged;
309-
this.#notifyUnchanged = false;
310315
if (signature !== this.#signature || notifyUnchanged) {
311316
this.#signature = signature;
312317
this.log(snapshot);

0 commit comments

Comments
 (0)