Skip to content

Commit 5073b43

Browse files
committed
refactor: simplify shared workspaces provider and tests
- collapse per-query branches into a readonly WORKSPACE_QUERY_CONFIG table - drop the unreachable re-entry branch in the workspace refresh loop - remove test-only getCurrentUserId; tests read getSnapshot via a helper - extract the repeated tree-view wiring in extension.ts into a helper - drive metadata tests through the real watcher over MockEventStream - move shared test doubles (session, workspaces client, flush) into testHelpers - replace describe-scoped state + beforeEach/afterEach with a setup() helper
1 parent adfc6d0 commit 5073b43

7 files changed

Lines changed: 502 additions & 572 deletions

File tree

src/deployment/deploymentManager.ts

Lines changed: 38 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import * as vscode from "vscode";
2-
31
import { CoderApi } from "../api/coderApi";
42
import {
53
CONFIG_CHANGE_DEBOUNCE_MS,
@@ -14,32 +12,21 @@ import { type OAuthSessionManager } from "../oauth/sessionManager";
1412
import { getAuthConfigWatchSettings } from "../settings/authConfig";
1513
import { type TelemetryService } from "../telemetry/service";
1614

15+
import { SessionStore, type SessionData } from "./sessionStore";
1716
import {
1817
DeploymentSchema,
1918
type Deployment,
2019
type DeploymentWithAuth,
2120
} from "./types";
2221

2322
import type { User } from "coder/site/src/api/typesGenerated";
23+
import type * as vscode from "vscode";
2424

2525
import type {
2626
WorkspaceSessionSnapshot,
2727
WorkspaceSessionState,
2828
} from "../workspace/session";
2929

30-
type DeploymentSessionSnapshot =
31-
| {
32-
readonly kind: "signedOut";
33-
readonly revision: number;
34-
readonly deployment: Deployment | null;
35-
}
36-
| {
37-
readonly kind: "signedIn";
38-
readonly revision: number;
39-
readonly deployment: Deployment;
40-
readonly user: User;
41-
};
42-
4330
/**
4431
* Manages deployment state for the extension.
4532
*
@@ -60,14 +47,8 @@ export class DeploymentManager
6047
private readonly logger: Logger;
6148
private readonly telemetryService: TelemetryService;
6249

63-
#session: DeploymentSessionSnapshot = {
64-
kind: "signedOut",
65-
revision: 0,
66-
deployment: null,
67-
};
68-
readonly #onDidChangeWorkspaceSession =
69-
new vscode.EventEmitter<WorkspaceSessionSnapshot>();
70-
public readonly onDidChange = this.#onDidChangeWorkspaceSession.event;
50+
readonly #sessionStore = new SessionStore();
51+
public readonly onDidChange = this.#sessionStore.onDidChange;
7152
#disposed = false;
7253
#authListenerDisposable: vscode.Disposable | undefined;
7354
#authConfigDisposable: vscode.Disposable | undefined;
@@ -106,31 +87,18 @@ export class DeploymentManager
10687
* Get the current deployment state.
10788
*/
10889
public getCurrentDeployment(): Deployment | null {
109-
return this.#session.deployment;
110-
}
111-
112-
public getCurrentUserId(): string | undefined {
113-
return this.#session.kind === "signedIn"
114-
? this.#session.user.id
115-
: undefined;
90+
return this.#sessionStore.current.deployment;
11691
}
11792

11893
public getSnapshot(): WorkspaceSessionSnapshot {
119-
if (this.#session.kind === "signedIn") {
120-
return {
121-
kind: "signedIn",
122-
revision: this.#session.revision,
123-
userId: this.#session.user.id,
124-
};
125-
}
126-
return { kind: "signedOut", revision: this.#session.revision };
94+
return this.#sessionStore.getSnapshot();
12795
}
12896

12997
/**
13098
* Check if we have an authenticated deployment (does not guarantee that the current auth data is valid).
13199
*/
132100
public isAuthenticated(): boolean {
133-
return this.#session.kind === "signedIn";
101+
return this.#sessionStore.current.kind === "signedIn";
134102
}
135103

136104
/**
@@ -139,10 +107,10 @@ export class DeploymentManager
139107
* a token become valid again. Bails if state moved during the verify
140108
* (logout, another login, dispose), so callers don't need a race guard.
141109
*/
142-
public async verifyAndApplyDeployment(
110+
public async verifyAndApply(
143111
deployment: Deployment & { token?: string },
144112
): Promise<boolean> {
145-
const sessionBefore = this.#session;
113+
const sessionBefore = this.#sessionStore.current;
146114
const token =
147115
deployment.token ??
148116
(await this.secretsManager.getSessionAuth(deployment.safeHostname))
@@ -165,11 +133,11 @@ export class DeploymentManager
165133
}
166134

167135
/** True if disposal, login, or a deployment switch raced our await. */
168-
#hasStateChangedSince(sessionBefore: DeploymentSessionSnapshot): boolean {
136+
#hasStateChangedSince(sessionBefore: SessionData): boolean {
169137
return (
170138
this.#disposed ||
171139
this.isAuthenticated() ||
172-
this.#session !== sessionBefore
140+
this.#sessionStore.current !== sessionBefore
173141
);
174142
}
175143

@@ -192,14 +160,17 @@ export class DeploymentManager
192160
this.client.setCredentials(deployment.url, deployment.token);
193161
}
194162

195-
const ourRef = this.setSignedIn(deploymentWithoutAuth, deployment.user);
163+
const ourRef = this.#sessionStore.signIn(
164+
deploymentWithoutAuth,
165+
deployment.user,
166+
);
196167
// Register before OAuth setup so background token refresh can update client credentials.
197168
this.registerAuthListener();
198169
this.updateAuthContexts(deployment.user);
199170

200171
await this.oauthSessionManager.setDeployment(deploymentWithoutAuth);
201172
// Bail if a concurrent write took over during the await.
202-
if (this.#session !== ourRef) {
173+
if (this.#sessionStore.current !== ourRef) {
203174
return;
204175
}
205176
await this.persistDeployment(deploymentWithoutAuth);
@@ -211,12 +182,12 @@ export class DeploymentManager
211182
public async clearDeployment(): Promise<void> {
212183
this.logger.debug(
213184
"Clearing deployment",
214-
this.#session.deployment?.safeHostname,
185+
this.#sessionStore.current.deployment?.safeHostname,
215186
);
216187
this.#authListenerDisposable?.dispose();
217188
this.#authListenerDisposable = undefined;
218-
this.setSignedOut(null);
219-
this.clearSessionSideEffects();
189+
this.#sessionStore.signOut(null);
190+
this.clearSideEffects();
220191
this.telemetryService.setDeploymentUrl("");
221192

222193
await this.secretsManager.setCurrentDeployment(undefined);
@@ -227,11 +198,11 @@ export class DeploymentManager
227198
* Auth listener remains active so recovery can happen automatically if tokens update.
228199
*/
229200
public suspendSession(): void {
230-
this.setSignedOut(this.#session.deployment);
231-
this.clearSessionSideEffects();
201+
this.#sessionStore.signOut(this.#sessionStore.current.deployment);
202+
this.clearSideEffects();
232203
}
233204

234-
private clearSessionSideEffects(): void {
205+
private clearSideEffects(): void {
235206
this.oauthSessionManager.clearDeployment();
236207
this.client.setCredentials(undefined, undefined);
237208
this.updateAuthContexts(undefined);
@@ -242,7 +213,7 @@ export class DeploymentManager
242213
this.#authListenerDisposable?.dispose();
243214
this.#authConfigDisposable?.dispose();
244215
this.#crossWindowSyncDisposable?.dispose();
245-
this.#onDidChangeWorkspaceSession.dispose();
216+
this.#sessionStore.dispose();
246217
}
247218

248219
/**
@@ -251,25 +222,28 @@ export class DeploymentManager
251222
* Also handles recovery from suspended session state.
252223
*/
253224
private registerAuthListener(): void {
254-
if (!this.#session.deployment) {
225+
const deployment = this.#sessionStore.current.deployment;
226+
if (!deployment) {
255227
return;
256228
}
257229

258230
// Capture hostname at registration time for the guard clause
259-
const safeHostname = this.#session.deployment.safeHostname;
231+
const safeHostname = deployment.safeHostname;
260232

261233
this.#authListenerDisposable?.dispose();
262234
this.logger.debug("Registering auth listener for hostname", safeHostname);
263235
this.#authListenerDisposable = this.secretsManager.onDidChangeSessionAuth(
264236
safeHostname,
265237
async (auth) => {
266-
if (this.#session.deployment?.safeHostname !== safeHostname) {
238+
if (
239+
this.#sessionStore.current.deployment?.safeHostname !== safeHostname
240+
) {
267241
return;
268242
}
269243

270244
if (auth) {
271245
if (this.isAuthenticated()) {
272-
await this.verifyAndUpdateAuthenticatedSession({
246+
await this.verifyAndUpdateSession({
273247
url: auth.url,
274248
safeHostname,
275249
token: auth.token,
@@ -278,7 +252,7 @@ export class DeploymentManager
278252
this.logger.debug(
279253
"Token updated after session suspended, recovering",
280254
);
281-
await this.verifyAndApplyDeployment({
255+
await this.verifyAndApply({
282256
url: auth.url,
283257
safeHostname,
284258
token: auth.token,
@@ -291,10 +265,10 @@ export class DeploymentManager
291265
);
292266
}
293267

294-
private async verifyAndUpdateAuthenticatedSession(
268+
private async verifyAndUpdateSession(
295269
deployment: Deployment & { token: string },
296270
): Promise<void> {
297-
const sessionBefore = this.#session;
271+
const sessionBefore = this.#sessionStore.current;
298272
const tempClient = CoderApi.create(
299273
deployment.url,
300274
deployment.token,
@@ -303,7 +277,7 @@ export class DeploymentManager
303277

304278
try {
305279
const user = await tempClient.getAuthenticatedUser();
306-
if (this.#disposed || this.#session !== sessionBefore) {
280+
if (this.#disposed || this.#sessionStore.current !== sessionBefore) {
307281
return;
308282
}
309283
await this.setDeployment({ ...deployment, user });
@@ -314,32 +288,6 @@ export class DeploymentManager
314288
}
315289
}
316290

317-
private setSignedIn(
318-
deployment: Deployment,
319-
user: User,
320-
): DeploymentSessionSnapshot {
321-
this.#session = {
322-
kind: "signedIn",
323-
revision: this.#session.revision + 1,
324-
deployment,
325-
user,
326-
};
327-
this.#onDidChangeWorkspaceSession.fire(this.getSnapshot());
328-
return this.#session;
329-
}
330-
331-
private setSignedOut(
332-
deployment: Deployment | null,
333-
): DeploymentSessionSnapshot {
334-
this.#session = {
335-
kind: "signedOut",
336-
revision: this.#session.revision + 1,
337-
deployment,
338-
};
339-
this.#onDidChangeWorkspaceSession.fire(this.getSnapshot());
340-
return this.#session;
341-
}
342-
343291
private subscribeToAuthConfigChanges(): void {
344292
this.#authConfigDisposable = watchConfigurationChanges(
345293
getAuthConfigWatchSettings(),
@@ -363,14 +311,14 @@ export class DeploymentManager
363311
try {
364312
do {
365313
this.#recoveryPending = false;
366-
const snapshot = this.#session.deployment;
367-
if (this.#disposed || !snapshot || this.isAuthenticated()) {
314+
const deployment = this.#sessionStore.current.deployment;
315+
if (this.#disposed || !deployment || this.isAuthenticated()) {
368316
return;
369317
}
370318
this.logger.debug(
371319
"Authentication settings changed after session suspended, recovering",
372320
);
373-
await this.verifyAndApplyDeployment(snapshot);
321+
await this.verifyAndApply(deployment);
374322
} while (this.#recoveryPending);
375323
} catch (err) {
376324
this.logger.warn(
@@ -392,7 +340,7 @@ export class DeploymentManager
392340

393341
if (deployment) {
394342
this.logger.info("Deployment changed from another window");
395-
await this.verifyAndApplyDeployment(deployment);
343+
await this.verifyAndApply(deployment);
396344
}
397345
},
398346
);

src/deployment/sessionStore.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as vscode from "vscode";
2+
3+
import type { User } from "coder/site/src/api/typesGenerated";
4+
5+
import type {
6+
WorkspaceSessionSnapshot,
7+
WorkspaceSessionState,
8+
} from "../workspace/session";
9+
10+
import type { Deployment } from "./types";
11+
12+
/**
13+
* The deployment session: signed out (optionally keeping the last deployment
14+
* for re-login) or signed in with an authenticated user.
15+
*
16+
* Every transition makes a new object, so callers can spot a change by
17+
* comparing identity against an earlier value.
18+
*/
19+
export type SessionData =
20+
| { readonly kind: "signedOut"; readonly deployment: Deployment | null }
21+
| {
22+
readonly kind: "signedIn";
23+
readonly deployment: Deployment;
24+
readonly user: User;
25+
};
26+
27+
/**
28+
* Owns the deployment session. State changes only through signIn()/signOut(),
29+
* each of which bumps the revision and notifies listeners.
30+
*
31+
* Consumers that only need auth status (like the workspace tree) take the lean
32+
* WorkspaceSessionState projection instead of the full session.
33+
*/
34+
export class SessionStore implements WorkspaceSessionState {
35+
#data: SessionData = { kind: "signedOut", deployment: null };
36+
#revision = 0;
37+
readonly #onDidChange = new vscode.EventEmitter<WorkspaceSessionSnapshot>();
38+
39+
public readonly onDidChange = this.#onDidChange.event;
40+
41+
/** The full current session, including deployment and user. */
42+
public get current(): SessionData {
43+
return this.#data;
44+
}
45+
46+
/** The lean projection exposed to session-state consumers. */
47+
public getSnapshot(): WorkspaceSessionSnapshot {
48+
if (this.#data.kind === "signedIn") {
49+
return {
50+
kind: "signedIn",
51+
revision: this.#revision,
52+
userId: this.#data.user.id,
53+
};
54+
}
55+
return { kind: "signedOut", revision: this.#revision };
56+
}
57+
58+
public signIn(deployment: Deployment, user: User): SessionData {
59+
return this.transition({ kind: "signedIn", deployment, user });
60+
}
61+
62+
public signOut(deployment: Deployment | null): SessionData {
63+
return this.transition({ kind: "signedOut", deployment });
64+
}
65+
66+
private transition(data: SessionData): SessionData {
67+
this.#data = data;
68+
this.#revision++;
69+
this.#onDidChange.fire(this.getSnapshot());
70+
return data;
71+
}
72+
73+
public dispose(): void {
74+
this.#onDidChange.dispose();
75+
}
76+
}

0 commit comments

Comments
 (0)