Skip to content

Commit a49d577

Browse files
committed
fix: configure extensions per workspace folder in multi-root workspaces (#280)
1 parent 3423c8b commit a49d577

25 files changed

Lines changed: 1389 additions & 133 deletions

.vscode-test.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
const { defineConfig } = require("@vscode/test-cli");
2+
const fs = require("node:fs");
23
const path = require("node:path");
34
const fixturesPath = path.join(__dirname, "src/e2e-tests/fixtures/");
45

6+
// The multi-root suites write workspace-level settings, which land in the
7+
// .code-workspace file itself. Those writes are part of what is under test and
8+
// cannot always be cleaned reliably, so the suite runs against a throwaway
9+
// gitignored copy of the committed workspace file, refreshed on every run.
10+
const multiRootWorkspaceFile = path.join(
11+
fixturesPath,
12+
"multi-root-workspace",
13+
"multi-root.generated.code-workspace",
14+
);
15+
fs.copyFileSync(
16+
path.join(fixturesPath, "multi-root-workspace", "multi-root.code-workspace"),
17+
multiRootWorkspaceFile,
18+
);
19+
520
// MISE_CEILING_PATHS stops mise from traversing above the fixtures directory,
621
// so the workspaces under test never inherit this repository's own mise config.
722
// Ceiling paths are exclusive: configs at the workspace root are still loaded.
@@ -175,6 +190,80 @@ module.exports = defineConfig([
175190
timeout: 60_000,
176191
},
177192
},
193+
{
194+
label: "multi-root",
195+
files: "src/e2e-tests/multi-root/*.e2e.ts",
196+
// a `.code-workspace` file, so vscode opens a multi-root workspace whose
197+
// folders are subdirectories of the directory holding the file
198+
workspaceFolder: multiRootWorkspaceFile,
199+
env: {
200+
MISE_CEILING_PATHS: fixturesPath,
201+
MISE_LOCKED: "0",
202+
MISE_TRUSTED_CONFIG_PATHS: fixturesPath,
203+
MISE_GLOBAL_CONFIG_FILE: path.join(
204+
fixturesPath,
205+
"multi-root-workspace",
206+
"global-config.toml",
207+
),
208+
// the go toolchains the suite installs stay inside the fixture
209+
MISE_DATA_DIR: path.join(
210+
fixturesPath,
211+
"multi-root-workspace",
212+
".mise-data",
213+
),
214+
MISE_CACHE_DIR: path.join(
215+
fixturesPath,
216+
"multi-root-workspace",
217+
".mise-cache",
218+
),
219+
},
220+
// golang.go is the extension under test: mise writes `go.goroot` and
221+
// `go.alternateTools` for it. shell-format has a window-scoped setting
222+
// (`shellformat.path`), which folder-scoped configuration cannot hold.
223+
installExtensions: ["golang.go", "foxundermoon.shell-format"],
224+
mocha: {
225+
require: ["tsx/cjs"],
226+
// the go suite installs two toolchains
227+
timeout: 600_000,
228+
},
229+
},
230+
{
231+
label: "go",
232+
files: "src/e2e-tests/go/*.e2e.ts",
233+
workspaceFolder: path.join(fixturesPath, "go-workspace"),
234+
env: {
235+
MISE_CEILING_PATHS: fixturesPath,
236+
MISE_LOCKED: "0",
237+
MISE_TRUSTED_CONFIG_PATHS: fixturesPath,
238+
MISE_GLOBAL_CONFIG_FILE: path.join(
239+
fixturesPath,
240+
"go-workspace",
241+
"global-config.toml",
242+
),
243+
// shares the toolchain store of the multi-root fixture, so each go
244+
// version is downloaded once per CI run
245+
MISE_DATA_DIR: path.join(
246+
fixturesPath,
247+
"multi-root-workspace",
248+
".mise-data",
249+
),
250+
MISE_CACHE_DIR: path.join(
251+
fixturesPath,
252+
"multi-root-workspace",
253+
".mise-cache",
254+
),
255+
// keep the build cache in the fixture, and never let go swap in
256+
// another toolchain than the one mise resolves
257+
GOCACHE: path.join(fixturesPath, "go-workspace", ".gocache"),
258+
GOTOOLCHAIN: "local",
259+
},
260+
installExtensions: ["golang.go"],
261+
mocha: {
262+
require: ["tsx/cjs"],
263+
// installs a toolchain and compiles the go stdlib on a cold cache
264+
timeout: 600_000,
265+
},
266+
},
178267
{
179268
label: "monorepo",
180269
files: "src/e2e-tests/monorepo/*.e2e.ts",

docs/src/content/docs/reference/Mutli-folders.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ If you are using a workspace with multiple root folders (see [multi-root workspa
1010
![select-folder.png](select-folder.png)
1111

1212
In this screenshot, the currently selected folder is `workspace-1` (indicated by the small `` icon).
13+
14+
The selected folder is the one shown in the sidebar views (tools, tasks, environment variables) and the one used to run mise commands.
15+
16+
## Extension settings
17+
18+
When [automatic extension configuration](/mise-vscode/reference/settings/#miseconfigureextensionsautomatically) runs in a multi-root workspace, each folder is configured with the tools of its own mise config. Settings are written at the folder level when the target extension supports it, so two folders pinning different versions of the same tool each get their own version. Settings that only exist per window are taken from the selected folder.

src/configuration.ts

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -340,22 +340,86 @@ export type VSCodeSettingValue =
340340
| Array<string | number | boolean>
341341
| Record<string, string | number | boolean>;
342342

343+
/**
344+
* A setting the extension stops managing: the value it wrote earlier is
345+
* removed instead of updated, so nothing is left pointing at a tool the
346+
* extension no longer chooses.
347+
*/
348+
export const REMOVE_SETTING = null;
349+
343350
export type VSCodeSetting = {
344351
key: string;
345-
value: VSCodeSettingValue;
352+
value: VSCodeSettingValue | typeof REMOVE_SETTING;
346353
};
347354

348355
const isObject = (value: unknown) =>
349356
typeof value === "object" && value !== null && !Array.isArray(value);
350357

358+
const valueAtTarget = (
359+
inspection: ReturnType<vscode.WorkspaceConfiguration["inspect"]>,
360+
target: vscode.ConfigurationTarget,
361+
) => {
362+
switch (target) {
363+
case vscode.ConfigurationTarget.Global:
364+
return inspection?.globalValue;
365+
case vscode.ConfigurationTarget.WorkspaceFolder:
366+
return inspection?.workspaceFolderValue;
367+
default:
368+
return inspection?.workspaceValue;
369+
}
370+
};
371+
351372
export async function updateVSCodeSettings(
352373
newSettings: VSCodeSetting[],
353374
target: vscode.ConfigurationTarget,
354-
): Promise<string[]> {
375+
{
376+
/** A value the extension is allowed to remove, defaults to none */
377+
canRemove = (_value: unknown) => false,
378+
resource,
379+
}: {
380+
canRemove?: (value: unknown) => boolean;
381+
/** The folder the settings apply to, for folder-scoped targets */
382+
resource?: vscode.Uri;
383+
} = {},
384+
): Promise<{ updatedKeys: string[]; unsupportedKeys: string[] }> {
355385
const updatedKeys: string[] = [];
356-
const configuration = vscode.workspace.getConfiguration();
386+
/** keys vscode refused to write per folder (the setting is window-scoped) */
387+
const unsupportedKeys: string[] = [];
388+
const configuration = vscode.workspace.getConfiguration(undefined, resource);
389+
390+
const update = async (key: string, value: unknown) => {
391+
try {
392+
await configuration.update(key, value, target);
393+
updatedKeys.push(key);
394+
} catch (error) {
395+
if (target === vscode.ConfigurationTarget.WorkspaceFolder) {
396+
logger.debug(`${key} cannot be written per folder: ${error}`);
397+
unsupportedKeys.push(key);
398+
return;
399+
}
400+
throw error;
401+
}
402+
};
357403

358404
for (const newSetting of newSettings) {
405+
if (newSetting.value === REMOVE_SETTING) {
406+
const previousValue = valueAtTarget(
407+
configuration.inspect(newSetting.key),
408+
target,
409+
);
410+
// what the user wrote by hand stays: only a leftover of a previous
411+
// run is removed
412+
if (previousValue === undefined || !canRemove(previousValue)) {
413+
continue;
414+
}
415+
416+
logger.info(
417+
`Removing ${newSetting.key}: the extension does not set it anymore`,
418+
);
419+
await update(newSetting.key, undefined);
420+
continue;
421+
}
422+
359423
const currentValue = configuration.get(newSetting.key);
360424

361425
if (isDeepStrictEqual(currentValue, newSetting.value)) {
@@ -371,36 +435,64 @@ export async function updateVSCodeSettings(
371435
continue;
372436
}
373437

374-
updatedKeys.push(newSetting.key);
375-
await configuration.update(newSetting.key, mergedValue, target);
438+
await update(newSetting.key, mergedValue);
376439
} else {
377-
updatedKeys.push(newSetting.key);
378-
await configuration.update(newSetting.key, newSetting.value, target);
440+
await update(newSetting.key, newSetting.value);
379441
}
380442
}
381-
return updatedKeys;
443+
return { updatedKeys, unsupportedKeys };
382444
}
383445

446+
const SELECTED_WORKSPACE_FOLDER_KEY = "selectedWorkspaceFolder";
447+
448+
/**
449+
* The workspace folder the extension works on. A multi-root workspace may hold
450+
* several folders with the same name (`services/api` and `apps/api` are both
451+
* called `api`), so the selection is stored as a path: stored by name, picking
452+
* the second one kept resolving to the first.
453+
*/
384454
export const getCurrentWorkspaceFolder = (context: vscode.ExtensionContext) => {
385455
const availableFolders = vscode.workspace.workspaceFolders;
386-
if (!availableFolders) {
456+
if (!availableFolders?.length) {
387457
return;
388458
}
389459

390-
const selectedWorkspaceFolder = context.workspaceState.get(
391-
"selectedWorkspaceFolder",
460+
const selectedWorkspaceFolder = context.workspaceState.get<string>(
461+
SELECTED_WORKSPACE_FOLDER_KEY,
392462
);
393463
if (!selectedWorkspaceFolder) {
394464
return availableFolders[0];
395465
}
396466

397-
const foundFolder = availableFolders.find(
398-
(folder) => folder.name === selectedWorkspaceFolder,
467+
return (
468+
availableFolders.find(
469+
(folder) => folder.uri.fsPath === selectedWorkspaceFolder,
470+
) ??
471+
// selections made before the key held a path
472+
availableFolders.find(
473+
(folder) => folder.name === selectedWorkspaceFolder,
474+
) ??
475+
availableFolders[0]
476+
);
477+
};
478+
479+
export const setCurrentWorkspaceFolder = (
480+
context: vscode.ExtensionContext,
481+
folder: vscode.WorkspaceFolder,
482+
) => {
483+
return context.workspaceState.update(
484+
SELECTED_WORKSPACE_FOLDER_KEY,
485+
folder.uri.fsPath,
486+
);
487+
};
488+
489+
export const clearCurrentWorkspaceFolder = (
490+
context: vscode.ExtensionContext,
491+
) => {
492+
return context.workspaceState.update(
493+
SELECTED_WORKSPACE_FOLDER_KEY,
494+
undefined,
399495
);
400-
if (foundFolder) {
401-
return foundFolder;
402-
}
403-
return availableFolders[0];
404496
};
405497

406498
export const getCurrentWorkspaceFolderPath = (
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Written at runtime by the go e2e suite
2+
.vscode/
3+
.gocache/
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package demo
2+
3+
import (
4+
"runtime"
5+
"testing"
6+
)
7+
8+
// Fails when built with any toolchain other than the one mise.toml pins
9+
func TestToolchainVersion(t *testing.T) {
10+
if runtime.Version() != "go1.25.0" {
11+
t.Fatalf("built with %s, mise.toml pins go1.25.0", runtime.Version())
12+
}
13+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Stands in for the machine global config. It pins another go on purpose: the
2+
# extension has to configure the project version, not this one.
3+
[tools]
4+
go = "1.24.0"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module example.com/fixture/demo
2+
3+
go 1.25.0
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[tools]
2+
# must win over the go of global-config.toml.
3+
# Same version as multi-root-workspace/wk2, the two fixtures share their
4+
# toolchain store.
5+
go = "1.25.0"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Written at runtime by the multi-root e2e suites
2+
.mise-data/
3+
.mise-cache/
4+
# throwaway copy of multi-root.code-workspace the suite runs against
5+
*.generated.code-workspace
6+
# folder-scoped settings written by per-folder extension configuration
7+
.vscode/
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Stands in for the machine global config: the version pinned here must never
2+
# win over the one a workspace folder declares.
3+
[tools]
4+
jq = "1.6"
5+
6+
[tasks.global-only-task]
7+
description = "Task defined in the global config"
8+
run = "echo 'global'"

0 commit comments

Comments
 (0)