Skip to content

Commit 5202de5

Browse files
authored
feat: bootstrap code lens (#272)
1 parent f58770a commit 5202de5

13 files changed

Lines changed: 549 additions & 55 deletions

File tree

docs/src/content/docs/reference/Bootstrap.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,24 @@ Selecting an entry jumps to its declaration.
5454
Secrets are listed by name and by the environment variable they are read from.
5555
Their values are never read by the extension.
5656

57+
## Section code lens
58+
59+
Each `[bootstrap.*]` table gets a code lens on its header reporting how many of
60+
its entries are not in their desired state, for example
61+
`⚠ Bootstrap · 2/5 pending`. A section whose entries are all converged reads
62+
`✓ Bootstrap · 5 ok`, and one mise could not inspect here (a Linux-only section
63+
on macOS, Docker not running) reads `⊘ Bootstrap · not applicable here`.
64+
Hovering lists the entries behind the count, and clicking opens the bootstrap
65+
status view.
66+
67+
Counts cover only the entries of the file you are looking at. `mise bootstrap
68+
status` merges every config file, so a workspace `mise.toml` does not report the
69+
packages or files declared in your global config.
70+
71+
Reading the status inspects the machine, so it is only read for files that
72+
declare a bootstrap section. Set `mise.enableBootstrapCodeLens` to `false` to
73+
turn the lens off, or `mise.enableCodeLens` to turn off every code lens.
74+
5775
## Bootstrap plan
5876

5977
`Mise: Show mise bootstrap plan` (or the diff icon in the `Bootstrap` panel)
@@ -63,6 +81,13 @@ by a summary of the create/update/remove/unchanged counts.
6381

6482
This requires mise `2026.8.2` or later.
6583

84+
A plan only covers the sections that have moved to mise's declarative resource
85+
model. The others are still applied by `mise bootstrap`, they just never show up
86+
in a plan, so a configuration made only of those plans nothing at all. mise
87+
grows this coverage as more sections adopt the model, so
88+
[the mise bootstrap docs](https://mise.jdx.dev/bootstrap.html) are the reference
89+
for what a plan reports today.
90+
6691
## Bootstrap status view
6792

6893
Use the `Mise: Show bootstrap status` command (or the list icon in the

docs/src/content/docs/reference/Settings.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,16 @@ Show the tools and environment variables of each [monorepo](https://mise.jdx.dev
269269

270270
---
271271

272+
##### `mise.enableBootstrapCodeLens`
273+
- **Type:** `boolean`
274+
- **Default:** `true`
275+
276+
Show a code lens on each `[bootstrap.*]` section reporting how many of its entries are not in their desired state.
277+
278+
Requires `#mise.enableCodeLens#` and mise 2026.7.16+. Reading the status runs `mise bootstrap status`, which inspects the machine (installed packages, cloned repos, macOS defaults, Docker Compose projects); the result is cached and only read for files that declare a bootstrap section.
279+
280+
---
281+
272282
##### `mise.showNotificationIfMissingTools`
273283
- **Type:** `boolean`
274284
- **Default:** `true`

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,13 @@
384384
"default": true,
385385
"markdownDescription": "Show run/add tool code lens indicators in the editor."
386386
},
387+
"mise.enableBootstrapCodeLens": {
388+
"order": 11,
389+
"type": "boolean",
390+
"title": "Enable bootstrap code lens",
391+
"default": true,
392+
"markdownDescription": "Show a code lens on each `[bootstrap.*]` section reporting how many of its entries are not in their desired state.\n\nRequires `#mise.enableCodeLens#` and mise 2026.7.16+. Reading the status runs `mise bootstrap status`, which inspects the machine (installed packages, cloned repos, macOS defaults, Docker Compose projects); the result is cached and only read for files that declare a bootstrap section."
393+
},
387394
"mise.enableToolLinks": {
388395
"order": 10.5,
389396
"type": "boolean",

src/configuration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const CONFIGURATION_FLAGS = {
1919
configureExtensionsAutomaticallyIncludeList:
2020
"configureExtensionsAutomaticallyIncludeList",
2121
enableCodeLens: "enableCodeLens",
22+
enableBootstrapCodeLens: "enableBootstrapCodeLens",
2223
enableToolLinks: "enableToolLinks",
2324
showToolVersionsDecorations: "showToolVersionsDecorations",
2425
showToolEnvVarsDecorations: "showToolEnvVarsDecorations",
@@ -212,6 +213,10 @@ export const isCodeLensEnabled = () => {
212213
return getConfOrElse(CONFIGURATION_FLAGS.enableCodeLens, true);
213214
};
214215

216+
export const isBootstrapCodeLensEnabled = () => {
217+
return getConfOrElse(CONFIGURATION_FLAGS.enableBootstrapCodeLens, true);
218+
};
219+
215220
export const isToolLinksEnabled = () => {
216221
return getConfOrElse(CONFIGURATION_FLAGS.enableToolLinks, true);
217222
};

src/e2e-tests/bootstrap/bootstrap.e2e.ts

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,18 @@ suite("Bootstrap Test Suite", function () {
195195
[
196196
fixturePath(".mise-vscode-e2e-dir"),
197197
fixturePath(".mise-vscode-e2e-dir-two"),
198+
// [bootstrap.files] and [bootstrap.directories] merge across config
199+
// files, so the global config contributes to the same section
200+
fixturePath(".mise-vscode-e2e-global-dir"),
198201
fixturePath(".mise-vscode-e2e-file.conf"),
202+
fixturePath(".mise-vscode-e2e-global.conf"),
199203
fixturePath(".mise-vscode-e2e-secret.conf"),
200204
],
201205
"directories and files share one section, in mise's order",
202206
);
203-
assert.equal(files.entries[2]?.state, "create");
207+
assert.equal(files.entries[3]?.state, "create");
204208
// the secret this file templates is never set, so mise cannot inspect it
205-
assert.equal(files.entries[3]?.state, "unknown");
209+
assert.equal(files.entries[5]?.state, "unknown");
206210

207211
const secrets = byLabel("Secrets");
208212
assert.ok(secrets, "Secrets section should be present");
@@ -235,12 +239,14 @@ suite("Bootstrap Test Suite", function () {
235239
[
236240
{ kind: "directory", name: fixturePath(".mise-vscode-e2e-dir") },
237241
{ kind: "directory", name: fixturePath(".mise-vscode-e2e-dir-two") },
242+
{ kind: "directory", name: fixturePath(".mise-vscode-e2e-global-dir") },
238243
{ kind: "file", name: fixturePath(".mise-vscode-e2e-file.conf") },
244+
{ kind: "file", name: fixturePath(".mise-vscode-e2e-global.conf") },
239245
{ kind: "file", name: fixturePath(".mise-vscode-e2e-secret.conf") },
240246
],
241247
);
242248
assert.deepEqual(plan.summary, {
243-
create: 3,
249+
create: 5,
244250
update: 0,
245251
remove: 0,
246252
unchanged: 0,
@@ -299,4 +305,115 @@ suite("Bootstrap Test Suite", function () {
299305
"Selection should be on the secret declaration line",
300306
);
301307
});
308+
309+
test("shows a code lens on every bootstrap section of the document", async () => {
310+
const lenses = (
311+
(await vscode.commands.executeCommand<vscode.CodeLens[]>(
312+
"vscode.executeCodeLensProvider",
313+
miseTomlDocument.uri,
314+
)) ?? []
315+
).filter((lens) => lens.command?.command === "mise.showBootstrap");
316+
317+
// one lens per table declared in the fixture, anchored on its header
318+
const byTable = new Map(
319+
lenses.map((lens) => [
320+
lens.command?.arguments?.[0] as string,
321+
{ title: lens.command?.title ?? "", line: lens.range.start.line },
322+
]),
323+
);
324+
325+
assert.deepEqual(
326+
[...byTable.keys()].sort(),
327+
[
328+
"bootstrap.directories",
329+
"bootstrap.files",
330+
// the friendly shorthands are reported as com.apple.* defaults, but
331+
// they are written here, so their lenses belong on these tables
332+
"bootstrap.macos.dock",
333+
"bootstrap.macos.finder",
334+
"bootstrap.macos.defaults.com.example.mise-vscode-e2e",
335+
"bootstrap.repos",
336+
"bootstrap.secrets",
337+
"dotfiles",
338+
].sort(),
339+
"every bootstrap table of the fixture should get a lens",
340+
);
341+
342+
assert.equal(
343+
byTable.get("bootstrap.macos.finder")?.line,
344+
lineOf("[bootstrap.macos.finder]"),
345+
"the shorthand lens should sit on the shorthand table, not on defaults",
346+
);
347+
// `dock.autohide` is a dotted key inside [bootstrap.macos]: the lens has
348+
// to anchor on that header, not on the line the key happens to be written
349+
// on, or it renders in the middle of the table
350+
assert.equal(
351+
byTable.get("bootstrap.macos.dock")?.line,
352+
lineOf("[bootstrap.macos]"),
353+
"a dotted-key shorthand should anchor on its enclosing table header",
354+
);
355+
356+
assert.equal(
357+
byTable.get("bootstrap.repos")?.line,
358+
lineOf("[bootstrap.repos]"),
359+
"the lens should sit on the table header",
360+
);
361+
362+
// the fixture entries all point at resources that do not exist
363+
assert.match(
364+
byTable.get("bootstrap.repos")?.title ?? "",
365+
/^\$\(warning\) Bootstrap · 1\/1 pending$/,
366+
);
367+
assert.match(
368+
byTable.get("bootstrap.secrets")?.title ?? "",
369+
/^\$\(warning\) Bootstrap · 1\/1 pending$/,
370+
);
371+
// two files here, one of which mise cannot inspect without the secret: an
372+
// uninspectable entry is not actionable, so it must not be reported. The
373+
// global config declares a third file, which merges into the same status
374+
// section and must not be counted by this document's lens
375+
assert.match(
376+
byTable.get("bootstrap.files")?.title ?? "",
377+
/^\$\(warning\) Bootstrap · 1\/2 pending$/,
378+
);
379+
// likewise: two directories here, a third in the global config
380+
assert.match(
381+
byTable.get("bootstrap.directories")?.title ?? "",
382+
/^\$\(warning\) Bootstrap · 2\/2 pending$/,
383+
);
384+
});
385+
386+
test("each config file gets a lens for its own entries only", async () => {
387+
// the global config declares one file and one directory of its own, which
388+
// merge into the same status sections as the workspace ones: its lenses
389+
// must report only its two entries, not all six
390+
const globalConfig = await vscode.workspace.openTextDocument(
391+
path.join(workspaceRoot, "global-config.toml"),
392+
);
393+
394+
const lenses = (
395+
(await vscode.commands.executeCommand<vscode.CodeLens[]>(
396+
"vscode.executeCodeLensProvider",
397+
globalConfig.uri,
398+
)) ?? []
399+
).filter((lens) => lens.command?.command === "mise.showBootstrap");
400+
401+
assert.deepEqual(
402+
lenses.map((lens) => lens.command?.arguments?.[0]).sort(),
403+
["bootstrap.directories", "bootstrap.files"],
404+
"only the sections the global config declares should get a lens",
405+
);
406+
for (const lens of lenses) {
407+
assert.match(
408+
lens.command?.title ?? "",
409+
/^\$\(warning\) Bootstrap · 1\/1 pending$/,
410+
`${lens.command?.arguments?.[0]} should count one entry`,
411+
);
412+
assert.match(
413+
lens.command?.tooltip ?? "",
414+
/mise-vscode-e2e-global/,
415+
"the tooltip should list the entry of this document",
416+
);
417+
}
418+
});
302419
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,13 @@
11
# Used as MISE_GLOBAL_CONFIG_FILE for the bootstrap e2e suite so the machine's
22
# real global config (tools, bootstrap sections) never leaks into the tests.
3+
4+
# `bootstrap status` merges [bootstrap.files] and [bootstrap.directories]
5+
# across config files (unlike [bootstrap.repos], which the closest config wins
6+
# outright). These entries are what proves each code lens counts only the
7+
# entries of its own document.
8+
[bootstrap.directories."~/.mise-vscode-e2e-global-dir"]
9+
mode = "0755"
10+
11+
[bootstrap.files."~/.mise-vscode-e2e-global.conf"]
12+
content = "global = true\n"
13+
mode = "0644"

src/e2e-tests/fixtures/bootstrap-workspace/mise.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
[bootstrap.repos]
66
"vendor/mise-e2e-repo" = { url = "https://example.invalid/mise-e2e-repo.git" }
77

8+
# a shorthand written as a dotted key rather than as its own table. It parses
9+
# to the identical table, but it is not written on a header line, so the code
10+
# lens has to anchor on the enclosing header rather than on the key
11+
[bootstrap.macos]
12+
dock.autohide = true
13+
814
[bootstrap.macos.defaults]
915
"com.example.mise-vscode-e2e" = { ExampleKey = true }
1016

src/miseService.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,26 @@ export class MiseService {
288288
storage: { type: "memory" },
289289
}).define("getProjects", () => this.loadProjectEntries());
290290

291+
/**
292+
* `mise bootstrap status` inspects the machine: it shells out to the package
293+
* managers, `defaults`, docker and git, which costs far more than the default
294+
* command ttl is meant for. The code lens re-renders on every keystroke, so
295+
* the result is kept long enough that typing never spawns a subprocess.
296+
*
297+
* Everything the extension can observe invalidates this eagerly through
298+
* `invalidateCache`: running bootstrap from the extension reloads on task
299+
* end, and editing a config file reloads through the file watcher. What
300+
* stays unobservable is the machine changing on its own (`brew install` in a
301+
* terminal, `docker compose up`, `defaults write`), and the ttl is the only
302+
* thing that recovers from that.
303+
*/
304+
private bootstrapCache = createCache({
305+
ttl: 30,
306+
storage: { type: "memory" },
307+
}).define("execCmd", ({ args, setMiseEnv } = {}) =>
308+
this.execMiseCommand(args, { setMiseEnv }),
309+
);
310+
291311
async invalidateCache() {
292312
await Promise.all([
293313
this.dedupeCache.clear(),
@@ -296,6 +316,7 @@ export class MiseService {
296316
this.longTTLCache.clear(),
297317
this.projectsCache.clear(),
298318
this.taskCacheCache.clear(),
319+
this.bootstrapCache.clear(),
299320
]);
300321
this.eventEmitter.fire();
301322
}
@@ -1976,7 +1997,7 @@ export class MiseService {
19761997
}
19771998

19781999
try {
1979-
const { stdout } = await this.cache.execCmd({
2000+
const { stdout } = await this.bootstrapCache.execCmd({
19802001
args: ["bootstrap", "status", "--json"],
19812002
});
19822003
return JSON.parse(stdout) as MiseBootstrapStatus;
@@ -2019,7 +2040,7 @@ export class MiseService {
20192040
}
20202041

20212042
try {
2022-
const { stdout } = await this.cache.execCmd({
2043+
const { stdout } = await this.bootstrapCache.execCmd({
20232044
args: ["bootstrap", "plan", "--json"],
20242045
});
20252046
return JSON.parse(stdout) as MiseBootstrapPlan;

0 commit comments

Comments
 (0)