Skip to content

Commit ceaf1cf

Browse files
authored
Prepend the active air executable location to the PATH of integrated terminals (#504)
* Prefer located `air` binary in integrated terminals * CHANGELOG bullet * Manage via LSP state changes and config changes * Fix discriminated union * Consistency * Remove unused import * Use correct camel case
1 parent a05019d commit ceaf1cf

6 files changed

Lines changed: 158 additions & 2 deletions

File tree

‎editors/code/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
## Development version
99

10+
- New `air.addExecutableToPath` configuration option to control whether the `air` executable located by `air.executableStrategy` is also added to the `PATH` of all integrated terminals. This ensures that a call to `air` in an integrated terminal uses the same version of Air used by the editor, which is particularly useful when agents call Air on your behalf. It is on by default (#500).
11+
12+
1013
## 0.24.0
1114

1215
- [Air 0.9.0](https://github.com/posit-dev/air/blob/main/CHANGELOG.md) is now bundled with the extension.

‎editors/code/package.json‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
},
8484
"air.executableStrategy": {
8585
"default": "bundled",
86-
"markdownDescription": "Strategy used to locate the `air` executable to start the language server with.",
86+
"markdownDescription": "Strategy used to locate the `air` executable.",
8787
"enum": [
8888
"bundled",
8989
"environment",
@@ -102,6 +102,12 @@
102102
"markdownDescription": "The path to an `air` executable. Only utilized when `air.executableStrategy` is set to \"path\".",
103103
"scope": "window",
104104
"type": "string"
105+
},
106+
"air.addExecutableToPath": {
107+
"default": true,
108+
"markdownDescription": "Whether to add the directory containing the `air` executable resolved by `air.executableStrategy` to the `PATH` of integrated terminals. This ensures that `air` invoked in an integrated terminal matches `air` used by the editor.",
109+
"scope": "window",
110+
"type": "boolean"
105111
}
106112
}
107113
},

‎editors/code/src/environment.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import * as vscode from "vscode";
2+
import * as path from "path";
3+
4+
import * as output from "./output";
5+
import { getWorkspaceSettings } from "./settings";
6+
import { State, StateChange } from "./lsp";
7+
8+
// Manages the `PATH` environment variable contribution we make to integrated
9+
// terminals.
10+
export class PathEnvironmentVariableManager implements vscode.Disposable {
11+
private executableDirectory: string | null = null;
12+
private workspaceFolder: vscode.WorkspaceFolder | null = null;
13+
14+
private subscriptions: vscode.Disposable[] = [];
15+
16+
constructor(
17+
private collection: vscode.EnvironmentVariableCollection,
18+
onStateChange: vscode.Event<StateChange>,
19+
) {
20+
// When the LSP fully starts up, sync our shared state and update `PATH` contribution.
21+
// When the LSP shuts down (restart or deactivation), clear shared state and unset `PATH` contribution.
22+
this.subscriptions.push(
23+
onStateChange((change) => {
24+
switch (change.state) {
25+
case State.Started: {
26+
this.executableDirectory = path.dirname(
27+
change.binaryPath,
28+
);
29+
this.workspaceFolder = change.workspaceFolder;
30+
this.update();
31+
break;
32+
}
33+
case State.Stopped: {
34+
this.executableDirectory = null;
35+
this.workspaceFolder = null;
36+
this.delete();
37+
break;
38+
}
39+
}
40+
}),
41+
);
42+
43+
// When the user changes `air.addExecutableToPath`, reflect this immediately in new terminals
44+
this.subscriptions.push(
45+
vscode.workspace.onDidChangeConfiguration((event) => {
46+
if (event.affectsConfiguration("air.addExecutableToPath")) {
47+
this.update();
48+
}
49+
}),
50+
);
51+
}
52+
53+
private update(): void {
54+
if (!this.workspaceFolder) {
55+
return;
56+
}
57+
if (!this.executableDirectory) {
58+
return;
59+
}
60+
61+
const workspaceSettings = getWorkspaceSettings(
62+
"air",
63+
this.workspaceFolder,
64+
);
65+
66+
if (workspaceSettings.addExecutableToPath) {
67+
this.prepend(this.executableDirectory);
68+
} else {
69+
this.delete();
70+
}
71+
}
72+
73+
// Prepend a directory to the `PATH`
74+
//
75+
// `applyAtProcessCreation` and `applyAtShellIntegration` are really
76+
// mutually exclusive:
77+
//
78+
// - `applyAtProcessCreation` would modify the `PATH` as early as possible,
79+
// before any shell startup scripts run (like zshrc, for example). This
80+
// could allow shell scripts to overwrite our modifications.
81+
// - `applyAtShellIntegration` is the newer mechanism and applies the
82+
// modification via a VS Code managed shell integration script after any
83+
// shell startup scripts have run.
84+
private prepend(directory: string): void {
85+
this.collection.prepend("PATH", directory + path.delimiter, {
86+
applyAtProcessCreation: false,
87+
applyAtShellIntegration: true,
88+
});
89+
output.log(`Added \`${directory}\` to the terminal PATH.`);
90+
}
91+
92+
private delete(): void {
93+
this.collection.delete("PATH");
94+
output.log(`Removed terminal PATH mutation.`);
95+
}
96+
97+
dispose() {
98+
for (const subscription of this.subscriptions) {
99+
subscription.dispose();
100+
}
101+
}
102+
}

‎editors/code/src/extension.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,28 @@ import * as vscode from "vscode";
22
import { Ctx } from "./context";
33
import { Lsp } from "./lsp";
44
import { registerCommands } from "./commands";
5+
import { PathEnvironmentVariableManager } from "./environment";
56

67
let ctx: Ctx;
78

89
export function activate(context: vscode.ExtensionContext) {
10+
// Contributed environment variables should mirror the running server's
11+
// resolved binary, should be recomputed on each start, and don't cache
12+
// across IDE sessions. In particular, it would be bad if a collection was
13+
// restored after an Air extension update, in which case there would be a
14+
// short period where terminal `PATH`s point to an outdated location where
15+
// an `air` executable no longer exists.
16+
context.environmentVariableCollection.persistent = false;
17+
918
let lsp = new Lsp(context);
19+
20+
context.subscriptions.push(
21+
new PathEnvironmentVariableManager(
22+
context.environmentVariableCollection,
23+
lsp.onStateChange,
24+
),
25+
);
26+
1027
lsp.start();
1128

1229
ctx = new Ctx(context, lsp);

‎editors/code/src/lsp.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as vscode from "vscode";
22
import * as lc from "vscode-languageclient/node";
3+
import * as path from "path";
34
import { default as PQueue } from "p-queue";
45
import { getInitializationOptions, getWorkspaceSettings } from "./settings";
56
import {
@@ -16,16 +17,29 @@ import { getRootWorkspaceFolder } from "./workspace";
1617
// concurrently and either result in a started or stopped state. Starting when
1718
// started is a noop, same for stopping when stopped. On the other hand
1819
// restarting is always scheduled.
19-
enum State {
20+
export enum State {
2021
Started = "started",
2122
Stopped = "stopped",
2223
}
2324

25+
export interface StateChangeStarted {
26+
state: State.Started;
27+
binaryPath: string;
28+
workspaceFolder: vscode.WorkspaceFolder;
29+
}
30+
export interface StateChangeStopped {
31+
state: State.Stopped;
32+
}
33+
export type StateChange = StateChangeStarted | StateChangeStopped;
34+
2435
export class Lsp {
2536
private client: lc.LanguageClient | null = null;
2637

2738
private binaryPath: string | null = null;
2839

40+
public onStateChange: vscode.Event<StateChange>;
41+
private onStateChangeEmitter: vscode.EventEmitter<StateChange>;
42+
2943
// We've received and processed an `air.toml` settings synchronization
3044
// notification. Used to synchronize unit tests with the LSP.
3145
private onSettingsNotification: vscode.Event<SyncFileSettingsParams>;
@@ -48,6 +62,10 @@ export class Lsp {
4862
this.stateQueue = new PQueue({ concurrency: 1 });
4963
this.fileSettings = new FileSettingsState(context);
5064

65+
this.onStateChangeEmitter = new vscode.EventEmitter<StateChange>();
66+
context.subscriptions.push(this.onStateChangeEmitter);
67+
this.onStateChange = this.onStateChangeEmitter.event;
68+
5169
this.onSettingsNotificationEmitter =
5270
new vscode.EventEmitter<SyncFileSettingsParams>();
5371
context.subscriptions.push(this.onSettingsNotificationEmitter);
@@ -204,6 +222,11 @@ export class Lsp {
204222
this.client = client;
205223
this.binaryPath = binaryPath;
206224
this.state = State.Started;
225+
this.onStateChangeEmitter.fire({
226+
state: this.state,
227+
binaryPath: this.binaryPath,
228+
workspaceFolder: workspaceFolder,
229+
});
207230
}
208231

209232
private async stopImpl() {
@@ -221,6 +244,9 @@ export class Lsp {
221244
this.state = State.Stopped;
222245
this.client = null;
223246
this.binaryPath = null;
247+
this.onStateChangeEmitter.fire({
248+
state: this.state,
249+
});
224250
}
225251
}
226252

‎editors/code/src/settings.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type InitializationOptions = {
1515
export type WorkspaceSettings = {
1616
executableStrategy: ExecutableStrategy;
1717
executablePath?: string;
18+
addExecutableToPath: boolean;
1819
};
1920

2021
export function getInitializationOptions(
@@ -41,6 +42,7 @@ export function getWorkspaceSettings(
4142
executableStrategy:
4243
config.get<ExecutableStrategy>("executableStrategy") ?? "bundled",
4344
executablePath: config.get<string>("executablePath"),
45+
addExecutableToPath: config.get<boolean>("addExecutableToPath") ?? true,
4446
};
4547
}
4648

0 commit comments

Comments
 (0)