Skip to content

Commit b818f6b

Browse files
committed
Fix mode state sharing when switching between open vscode windows
1 parent 2243036 commit b818f6b

File tree

2 files changed

+296
-13
lines changed

2 files changed

+296
-13
lines changed

src/core/__tests__/contextProxy.test.ts

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as vscode from "vscode"
2-
import { ContextProxy } from "../contextProxy"
2+
import { ContextProxy, WINDOW_SPECIFIC_KEYS } from "../contextProxy"
33
import { logger } from "../../utils/logging"
44
import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState"
55

@@ -19,6 +19,14 @@ jest.mock("vscode", () => ({
1919
Production: 2,
2020
Test: 3,
2121
},
22+
env: {
23+
sessionId: "test-session-id",
24+
machineId: "test-machine-id"
25+
},
26+
workspace: {
27+
name: "test-workspace-name",
28+
workspaceFolders: [{ uri: { toString: () => "test-workspace" } }],
29+
},
2230
}))
2331

2432
describe("ContextProxy", () => {
@@ -75,7 +83,14 @@ describe("ContextProxy", () => {
7583
it("should initialize state cache with all global state keys", () => {
7684
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length)
7785
for (const key of GLOBAL_STATE_KEYS) {
78-
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
86+
if (WINDOW_SPECIFIC_KEYS.includes(key as any)) {
87+
// For window-specific keys like 'mode', the key is prefixed
88+
expect(mockGlobalState.get).toHaveBeenCalledWith(
89+
expect.stringMatching(new RegExp(`^window:.+:${key}$`)),
90+
)
91+
} else {
92+
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
93+
}
7994
}
8095
})
8196

@@ -290,7 +305,15 @@ describe("ContextProxy", () => {
290305

291306
// Should have called update with undefined for each key
292307
for (const key of GLOBAL_STATE_KEYS) {
293-
expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined)
308+
if (WINDOW_SPECIFIC_KEYS.includes(key as any)) {
309+
// For window-specific keys like 'mode', the key is prefixed
310+
expect(mockGlobalState.update).toHaveBeenCalledWith(
311+
expect.stringMatching(new RegExp(`^window:.+:${key}$`)),
312+
undefined,
313+
)
314+
} else {
315+
expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined)
316+
}
294317
}
295318

296319
// Total calls should include initial setup + reset operations
@@ -328,4 +351,113 @@ describe("ContextProxy", () => {
328351
expect(initSecretCache).toHaveBeenCalledTimes(1)
329352
})
330353
})
354+
355+
describe("Window-specific state", () => {
356+
it("should use window-specific key for mode", async () => {
357+
// Ensure 'mode' is in window specific keys
358+
expect(WINDOW_SPECIFIC_KEYS).toContain("mode")
359+
360+
// Test update method with 'mode' key
361+
await proxy.updateGlobalState("mode", "debug")
362+
363+
// Verify it's called with window-specific key
364+
expect(mockGlobalState.update).toHaveBeenCalledWith(expect.stringMatching(/^window:.+:mode$/), "debug")
365+
})
366+
367+
it("should use regular key for non-window-specific state", async () => {
368+
// Test update method with a regular key
369+
await proxy.updateGlobalState("apiProvider", "test-provider")
370+
371+
// Verify it's called with regular key
372+
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "test-provider")
373+
})
374+
375+
it("should consistently use same key format for get/update operations", async () => {
376+
// Set mock values for testing
377+
const windowKeyPattern = /^window:.+:mode$/
378+
mockGlobalState.get.mockImplementation((key: string) => {
379+
if (windowKeyPattern.test(key)) return "window-debug-mode"
380+
if (key === "mode") return "global-debug-mode"
381+
return undefined
382+
})
383+
384+
// Update a window-specific value
385+
await proxy.updateGlobalState("mode", "test-mode")
386+
387+
// The key used in update should match pattern
388+
const updateCallArg = mockGlobalState.update.mock.calls[0][0]
389+
expect(updateCallArg).toMatch(windowKeyPattern)
390+
391+
// Re-init to load values
392+
proxy["initializeStateCache"]()
393+
394+
// Verify we get the window-specific value back
395+
const value = proxy.getGlobalState("mode")
396+
397+
// We should get the window-specific value, not the global one
398+
expect(mockGlobalState.get).toHaveBeenCalledWith(expect.stringMatching(windowKeyPattern))
399+
expect(value).not.toBe("global-debug-mode")
400+
})
401+
})
402+
403+
describe("Enhanced window ID generation", () => {
404+
it("should generate a window ID that includes workspace name", () => {
405+
// Access the private method using type assertion
406+
const generateWindowId = (proxy as any).generateWindowId.bind(proxy);
407+
const windowId = generateWindowId();
408+
409+
// Should include the workspace name from our mock
410+
expect(windowId).toContain("test-workspace-name");
411+
});
412+
413+
it("should generate a window ID that includes machine ID", () => {
414+
// Access the private method using type assertion
415+
const generateWindowId = (proxy as any).generateWindowId.bind(proxy);
416+
const windowId = generateWindowId();
417+
418+
// Should include the machine ID from our mock
419+
expect(windowId).toContain("test-machine-id");
420+
});
421+
422+
it("should use the fallback mechanism if generateWindowId fails", () => {
423+
// Create a proxy instance with a failing generateWindowId method
424+
const spyOnGenerate = jest.spyOn(ContextProxy.prototype as any, "generateWindowId")
425+
.mockImplementation(() => "");
426+
427+
// Create a new proxy to trigger the constructor with our mock
428+
const testProxy = new ContextProxy(mockContext);
429+
430+
// Should have called ensureUniqueWindowId with a fallback
431+
expect(spyOnGenerate).toHaveBeenCalled();
432+
433+
// The windowId should use the fallback format (random ID)
434+
// We can't test the exact value, but we can verify it's not empty
435+
expect((testProxy as any).windowId).not.toBe("");
436+
437+
// Restore original implementation
438+
spyOnGenerate.mockRestore();
439+
});
440+
441+
it("should create consistent session hash for same input", () => {
442+
// Access the private method using type assertion
443+
const createSessionHash = (proxy as any).createSessionHash.bind(proxy);
444+
445+
const hash1 = createSessionHash("test-input");
446+
const hash2 = createSessionHash("test-input");
447+
448+
// Same input should produce same hash within the same session
449+
expect(hash1).toBe(hash2);
450+
});
451+
452+
it("should create different session hashes for different inputs", () => {
453+
// Access the private method using type assertion
454+
const createSessionHash = (proxy as any).createSessionHash.bind(proxy);
455+
456+
const hash1 = createSessionHash("test-input-1");
457+
const hash2 = createSessionHash("test-input-2");
458+
459+
// Different inputs should produce different hashes
460+
expect(hash1).not.toBe(hash2);
461+
});
462+
});
331463
})

src/core/contextProxy.ts

Lines changed: 161 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,26 @@ import * as vscode from "vscode"
22
import { logger } from "../utils/logging"
33
import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../shared/globalState"
44

5+
// Keys that should be stored per-window rather than globally
6+
export const WINDOW_SPECIFIC_KEYS = ["mode"] as const
7+
export type WindowSpecificKey = (typeof WINDOW_SPECIFIC_KEYS)[number]
8+
59
export class ContextProxy {
610
private readonly originalContext: vscode.ExtensionContext
711
private stateCache: Map<string, any>
812
private secretCache: Map<string, string | undefined>
13+
private windowId: string
14+
private readonly instanceCreationTime: Date = new Date()
915

1016
constructor(context: vscode.ExtensionContext) {
1117
// Initialize properties first
1218
this.originalContext = context
1319
this.stateCache = new Map()
1420
this.secretCache = new Map()
21+
22+
// Generate a unique ID for this window instance
23+
this.windowId = this.ensureUniqueWindowId()
24+
logger.debug(`ContextProxy created with windowId: ${this.windowId}`)
1525

1626
// Initialize state cache with all defined global state keys
1727
this.initializeStateCache()
@@ -22,12 +32,125 @@ export class ContextProxy {
2232
logger.debug("ContextProxy created")
2333
}
2434

35+
/**
36+
* Ensures we have a unique window ID, with fallback mechanisms if primary generation fails
37+
* @returns A string ID unique to this VS Code window
38+
*/
39+
private ensureUniqueWindowId(): string {
40+
// Try to get a stable ID first
41+
let id = this.generateWindowId();
42+
43+
// If all else fails, use a purely random ID as ultimate fallback
44+
// This will not be stable across restarts but ensures uniqueness
45+
if (!id) {
46+
id = `random_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
47+
logger.warn("Failed to generate stable window ID, using random ID instead");
48+
}
49+
50+
return id;
51+
}
52+
53+
/**
54+
* Generates a unique identifier for the current VS Code window
55+
* This is used to namespace certain global state values to prevent
56+
* conflicts when using multiple VS Code windows.
57+
*
58+
* The ID generation uses multiple sources to ensure uniqueness even in
59+
* environments where workspace folders might be identical (like DevContainers).
60+
*
61+
* @returns A string ID unique to this VS Code window
62+
*/
63+
private generateWindowId(): string {
64+
try {
65+
// Get all available identifying information
66+
const folders = vscode.workspace.workspaceFolders || [];
67+
const workspaceName = vscode.workspace.name || "unknown";
68+
const folderPaths = folders.map(folder => folder.uri.toString()).join('|');
69+
70+
// Generate a stable, pseudorandom ID based on the workspace information
71+
// This will be consistent for the same workspace but different across workspaces
72+
const baseId = `${workspaceName}|${folderPaths}`;
73+
74+
// Add machine-specific information (will differ between host and containers)
75+
// env.machineId is stable across VS Code sessions on the same machine
76+
const machineSpecificId = vscode.env.machineId || "";
77+
78+
// Add a session component that distinguishes multiple windows with the same workspace
79+
// Creates a stable but reasonably unique hash
80+
const sessionHash = this.createSessionHash(baseId);
81+
82+
// Combine all components
83+
return `${baseId}|${machineSpecificId}|${sessionHash}`;
84+
} catch (error) {
85+
logger.error("Error generating window ID:", error);
86+
return ""; // Empty string triggers the fallback in ensureUniqueWindowId
87+
}
88+
}
89+
90+
/**
91+
* Creates a stable hash from input string and window-specific properties
92+
* that will be different for different VS Code windows even with identical projects
93+
*/
94+
private createSessionHash(input: string): string {
95+
try {
96+
// Use a combination of:
97+
// 1. The extension instance creation time
98+
const timestamp = this.instanceCreationTime.getTime();
99+
100+
// 2. VS Code window-specific info we can derive
101+
// Using vscode.env.sessionId which changes on each VS Code window startup
102+
const sessionInfo = vscode.env.sessionId || "";
103+
104+
// 3. Calculate a simple hash
105+
const hashStr = `${input}|${sessionInfo}|${timestamp}`;
106+
let hash = 0;
107+
for (let i = 0; i < hashStr.length; i++) {
108+
const char = hashStr.charCodeAt(i);
109+
hash = ((hash << 5) - hash) + char;
110+
hash = hash & hash; // Convert to 32bit integer
111+
}
112+
113+
// Return a hexadecimal representation
114+
return Math.abs(hash).toString(16).substring(0, 8);
115+
} catch (error) {
116+
logger.error("Error creating session hash:", error);
117+
return Math.random().toString(36).substring(2, 10); // Random fallback
118+
}
119+
}
120+
121+
/**
122+
* Checks if a key should be stored per-window
123+
* @param key The key to check
124+
* @returns True if the key should be stored per-window, false otherwise
125+
*/
126+
private isWindowSpecificKey(key: string): boolean {
127+
return WINDOW_SPECIFIC_KEYS.includes(key as WindowSpecificKey)
128+
}
129+
130+
/**
131+
* Converts a regular key to a window-specific key
132+
* @param key The original key
133+
* @returns The window-specific key with window ID prefix
134+
*/
135+
private getWindowSpecificKey(key: string): string {
136+
return `window:${this.windowId}:${key}`
137+
}
138+
25139
// Helper method to initialize state cache
26140
private initializeStateCache(): void {
27141
for (const key of GLOBAL_STATE_KEYS) {
28142
try {
29-
const value = this.originalContext.globalState.get(key)
30-
this.stateCache.set(key, value)
143+
if (this.isWindowSpecificKey(key)) {
144+
// For window-specific keys, get the value using the window-specific key
145+
const windowKey = this.getWindowSpecificKey(key)
146+
const value = this.originalContext.globalState.get(windowKey)
147+
this.stateCache.set(key, value)
148+
logger.debug(`Loaded window-specific key ${key} as ${windowKey} with value: ${value}`)
149+
} else {
150+
// For global keys, use the regular key
151+
const value = this.originalContext.globalState.get(key)
152+
this.stateCache.set(key, value)
153+
}
31154
} catch (error) {
32155
logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`)
33156
}
@@ -70,13 +193,30 @@ export class ContextProxy {
70193
getGlobalState<T>(key: string): T | undefined
71194
getGlobalState<T>(key: string, defaultValue: T): T
72195
getGlobalState<T>(key: string, defaultValue?: T): T | undefined {
73-
const value = this.stateCache.get(key) as T | undefined
74-
return value !== undefined ? value : (defaultValue as T | undefined)
196+
// Check for window-specific key
197+
if (this.isWindowSpecificKey(key)) {
198+
// Use the cached value if it exists (it would have been loaded with the window-specific key)
199+
const value = this.stateCache.get(key) as T | undefined
200+
return value !== undefined ? value : (defaultValue as T | undefined)
201+
} else {
202+
// For regular global keys, use the regular cached value
203+
const value = this.stateCache.get(key) as T | undefined
204+
return value !== undefined ? value : (defaultValue as T | undefined)
205+
}
75206
}
76207

77208
updateGlobalState<T>(key: string, value: T): Thenable<void> {
209+
// Update in-memory cache
78210
this.stateCache.set(key, value)
79-
return this.originalContext.globalState.update(key, value)
211+
212+
// Update in VSCode storage with appropriate key
213+
if (this.isWindowSpecificKey(key)) {
214+
const windowKey = this.getWindowSpecificKey(key)
215+
logger.debug(`Updating window-specific key ${key} as ${windowKey} with value: ${JSON.stringify(value)}`)
216+
return this.originalContext.globalState.update(windowKey, value)
217+
} else {
218+
return this.originalContext.globalState.update(key, value)
219+
}
80220
}
81221

82222
getSecret(key: string): string | undefined {
@@ -140,16 +280,27 @@ export class ContextProxy {
140280
this.stateCache.clear()
141281
this.secretCache.clear()
142282

283+
// Create an array for all reset promises
284+
const resetPromises: Thenable<void>[] = []
285+
143286
// Reset all global state values to undefined
144-
const stateResetPromises = GLOBAL_STATE_KEYS.map((key) =>
145-
this.originalContext.globalState.update(key, undefined),
146-
)
287+
for (const key of GLOBAL_STATE_KEYS) {
288+
if (this.isWindowSpecificKey(key)) {
289+
// For window-specific keys, reset using the window-specific key
290+
const windowKey = this.getWindowSpecificKey(key)
291+
resetPromises.push(this.originalContext.globalState.update(windowKey, undefined))
292+
} else {
293+
resetPromises.push(this.originalContext.globalState.update(key, undefined))
294+
}
295+
}
147296

148297
// Delete all secrets
149-
const secretResetPromises = SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key))
298+
for (const key of SECRET_KEYS) {
299+
resetPromises.push(this.originalContext.secrets.delete(key))
300+
}
150301

151302
// Wait for all reset operations to complete
152-
await Promise.all([...stateResetPromises, ...secretResetPromises])
303+
await Promise.all(resetPromises)
153304

154305
this.initializeStateCache()
155306
this.initializeSecretCache()

0 commit comments

Comments
 (0)