Skip to content

Commit fcf0926

Browse files
authored
Merge pull request #1235 from samhvw8/feat/context-proxy
Feat ContextProxy to improve state management
2 parents 2e41376 + 86401fa commit fcf0926

File tree

7 files changed

+818
-530
lines changed

7 files changed

+818
-530
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import * as vscode from "vscode"
2+
import { ContextProxy } from "../contextProxy"
3+
import { logger } from "../../utils/logging"
4+
import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState"
5+
import { ApiConfiguration } from "../../shared/api"
6+
7+
// Mock shared/globalState
8+
jest.mock("../../shared/globalState", () => ({
9+
GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"],
10+
SECRET_KEYS: ["apiKey", "openAiApiKey"],
11+
GlobalStateKey: {},
12+
SecretKey: {},
13+
}))
14+
15+
// Mock shared/api
16+
jest.mock("../../shared/api", () => ({
17+
API_CONFIG_KEYS: ["apiProvider", "apiModelId"],
18+
ApiConfiguration: {},
19+
}))
20+
21+
// Mock VSCode API
22+
jest.mock("vscode", () => ({
23+
Uri: {
24+
file: jest.fn((path) => ({ path })),
25+
},
26+
ExtensionMode: {
27+
Development: 1,
28+
Production: 2,
29+
Test: 3,
30+
},
31+
}))
32+
33+
describe("ContextProxy", () => {
34+
let proxy: ContextProxy
35+
let mockContext: any
36+
let mockGlobalState: any
37+
let mockSecrets: any
38+
39+
beforeEach(() => {
40+
// Reset mocks
41+
jest.clearAllMocks()
42+
43+
// Mock globalState
44+
mockGlobalState = {
45+
get: jest.fn(),
46+
update: jest.fn().mockResolvedValue(undefined),
47+
}
48+
49+
// Mock secrets
50+
mockSecrets = {
51+
get: jest.fn().mockResolvedValue("test-secret"),
52+
store: jest.fn().mockResolvedValue(undefined),
53+
delete: jest.fn().mockResolvedValue(undefined),
54+
}
55+
56+
// Mock the extension context
57+
mockContext = {
58+
globalState: mockGlobalState,
59+
secrets: mockSecrets,
60+
extensionUri: { path: "/test/extension" },
61+
extensionPath: "/test/extension",
62+
globalStorageUri: { path: "/test/storage" },
63+
logUri: { path: "/test/logs" },
64+
extension: { packageJSON: { version: "1.0.0" } },
65+
extensionMode: vscode.ExtensionMode.Development,
66+
}
67+
68+
// Create proxy instance
69+
proxy = new ContextProxy(mockContext)
70+
})
71+
72+
describe("read-only pass-through properties", () => {
73+
it("should return extension properties from the original context", () => {
74+
expect(proxy.extensionUri).toBe(mockContext.extensionUri)
75+
expect(proxy.extensionPath).toBe(mockContext.extensionPath)
76+
expect(proxy.globalStorageUri).toBe(mockContext.globalStorageUri)
77+
expect(proxy.logUri).toBe(mockContext.logUri)
78+
expect(proxy.extension).toBe(mockContext.extension)
79+
expect(proxy.extensionMode).toBe(mockContext.extensionMode)
80+
})
81+
})
82+
83+
describe("constructor", () => {
84+
it("should initialize state cache with all global state keys", () => {
85+
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length)
86+
for (const key of GLOBAL_STATE_KEYS) {
87+
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
88+
}
89+
})
90+
91+
it("should initialize secret cache with all secret keys", () => {
92+
expect(mockSecrets.get).toHaveBeenCalledTimes(SECRET_KEYS.length)
93+
for (const key of SECRET_KEYS) {
94+
expect(mockSecrets.get).toHaveBeenCalledWith(key)
95+
}
96+
})
97+
})
98+
99+
describe("getGlobalState", () => {
100+
it("should return value from cache when it exists", async () => {
101+
// Manually set a value in the cache
102+
await proxy.updateGlobalState("test-key", "cached-value")
103+
104+
// Should return the cached value
105+
const result = proxy.getGlobalState("test-key")
106+
expect(result).toBe("cached-value")
107+
108+
// Original context should be called once during updateGlobalState
109+
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length) // Only from initialization
110+
})
111+
112+
it("should handle default values correctly", async () => {
113+
// No value in cache
114+
const result = proxy.getGlobalState("unknown-key", "default-value")
115+
expect(result).toBe("default-value")
116+
})
117+
})
118+
119+
describe("updateGlobalState", () => {
120+
it("should update state directly in original context", async () => {
121+
await proxy.updateGlobalState("test-key", "new-value")
122+
123+
// Should have called original context
124+
expect(mockGlobalState.update).toHaveBeenCalledWith("test-key", "new-value")
125+
126+
// Should have stored the value in cache
127+
const storedValue = await proxy.getGlobalState("test-key")
128+
expect(storedValue).toBe("new-value")
129+
})
130+
})
131+
132+
describe("getSecret", () => {
133+
it("should return value from cache when it exists", async () => {
134+
// Manually set a value in the cache
135+
await proxy.storeSecret("api-key", "cached-secret")
136+
137+
// Should return the cached value
138+
const result = proxy.getSecret("api-key")
139+
expect(result).toBe("cached-secret")
140+
})
141+
})
142+
143+
describe("storeSecret", () => {
144+
it("should store secret directly in original context", async () => {
145+
await proxy.storeSecret("api-key", "new-secret")
146+
147+
// Should have called original context
148+
expect(mockSecrets.store).toHaveBeenCalledWith("api-key", "new-secret")
149+
150+
// Should have stored the value in cache
151+
const storedValue = await proxy.getSecret("api-key")
152+
expect(storedValue).toBe("new-secret")
153+
})
154+
155+
it("should handle undefined value for secret deletion", async () => {
156+
await proxy.storeSecret("api-key", undefined)
157+
158+
// Should have called delete on original context
159+
expect(mockSecrets.delete).toHaveBeenCalledWith("api-key")
160+
161+
// Should have stored undefined in cache
162+
const storedValue = await proxy.getSecret("api-key")
163+
expect(storedValue).toBeUndefined()
164+
})
165+
166+
describe("getApiConfiguration", () => {
167+
it("should combine global state and secrets into a single ApiConfiguration object", async () => {
168+
// Mock data in state cache
169+
await proxy.updateGlobalState("apiProvider", "anthropic")
170+
await proxy.updateGlobalState("apiModelId", "test-model")
171+
// Mock data in secrets cache
172+
await proxy.storeSecret("apiKey", "test-api-key")
173+
174+
const config = proxy.getApiConfiguration()
175+
176+
// Should contain values from global state
177+
expect(config.apiProvider).toBe("anthropic")
178+
expect(config.apiModelId).toBe("test-model")
179+
// Should contain values from secrets
180+
expect(config.apiKey).toBe("test-api-key")
181+
})
182+
183+
it("should handle special case for apiProvider defaulting", async () => {
184+
// Clear apiProvider but set apiKey
185+
await proxy.updateGlobalState("apiProvider", undefined)
186+
await proxy.storeSecret("apiKey", "test-api-key")
187+
188+
const config = proxy.getApiConfiguration()
189+
190+
// Should default to anthropic when apiKey exists
191+
expect(config.apiProvider).toBe("anthropic")
192+
193+
// Clear both apiProvider and apiKey
194+
await proxy.updateGlobalState("apiProvider", undefined)
195+
await proxy.storeSecret("apiKey", undefined)
196+
197+
const configWithoutKey = proxy.getApiConfiguration()
198+
199+
// Should default to openrouter when no apiKey exists
200+
expect(configWithoutKey.apiProvider).toBe("openrouter")
201+
})
202+
})
203+
204+
describe("updateApiConfiguration", () => {
205+
it("should update both global state and secrets", async () => {
206+
const apiConfig: ApiConfiguration = {
207+
apiProvider: "anthropic",
208+
apiModelId: "claude-latest",
209+
apiKey: "test-api-key",
210+
}
211+
212+
await proxy.updateApiConfiguration(apiConfig)
213+
214+
// Should update global state
215+
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "anthropic")
216+
expect(mockGlobalState.update).toHaveBeenCalledWith("apiModelId", "claude-latest")
217+
// Should update secrets
218+
expect(mockSecrets.store).toHaveBeenCalledWith("apiKey", "test-api-key")
219+
220+
// Check that values are in cache
221+
expect(proxy.getGlobalState("apiProvider")).toBe("anthropic")
222+
expect(proxy.getGlobalState("apiModelId")).toBe("claude-latest")
223+
expect(proxy.getSecret("apiKey")).toBe("test-api-key")
224+
})
225+
226+
it("should ignore keys that aren't in either GLOBAL_STATE_KEYS or SECRET_KEYS", async () => {
227+
// Use type assertion to add an invalid key
228+
const apiConfig = {
229+
apiProvider: "anthropic",
230+
invalidKey: "should be ignored",
231+
} as ApiConfiguration & { invalidKey: string }
232+
233+
await proxy.updateApiConfiguration(apiConfig)
234+
235+
// Should update keys in GLOBAL_STATE_KEYS
236+
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "anthropic")
237+
// Should not call update/store for invalid keys
238+
expect(mockGlobalState.update).not.toHaveBeenCalledWith("invalidKey", expect.anything())
239+
expect(mockSecrets.store).not.toHaveBeenCalledWith("invalidKey", expect.anything())
240+
})
241+
})
242+
})
243+
})

src/core/contextProxy.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import * as vscode from "vscode"
2+
import { logger } from "../utils/logging"
3+
import { ApiConfiguration, API_CONFIG_KEYS } from "../shared/api"
4+
import { GLOBAL_STATE_KEYS, SECRET_KEYS, GlobalStateKey, SecretKey } from "../shared/globalState"
5+
6+
export class ContextProxy {
7+
private readonly originalContext: vscode.ExtensionContext
8+
private stateCache: Map<string, any>
9+
private secretCache: Map<string, string | undefined>
10+
11+
constructor(context: vscode.ExtensionContext) {
12+
// Initialize properties first
13+
this.originalContext = context
14+
this.stateCache = new Map()
15+
this.secretCache = new Map()
16+
17+
// Initialize state cache with all defined global state keys
18+
this.initializeStateCache()
19+
20+
// Initialize secret cache with all defined secret keys
21+
this.initializeSecretCache()
22+
23+
logger.debug("ContextProxy created")
24+
}
25+
26+
// Helper method to initialize state cache
27+
private initializeStateCache(): void {
28+
for (const key of GLOBAL_STATE_KEYS) {
29+
try {
30+
const value = this.originalContext.globalState.get(key)
31+
this.stateCache.set(key, value)
32+
} catch (error) {
33+
logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`)
34+
}
35+
}
36+
}
37+
38+
// Helper method to initialize secret cache
39+
private initializeSecretCache(): void {
40+
for (const key of SECRET_KEYS) {
41+
// Get actual value and update cache when promise resolves
42+
;(this.originalContext.secrets.get(key) as Promise<string | undefined>)
43+
.then((value) => {
44+
this.secretCache.set(key, value)
45+
})
46+
.catch((error: Error) => {
47+
logger.error(`Error loading secret ${key}: ${error.message}`)
48+
})
49+
}
50+
}
51+
52+
get extensionUri(): vscode.Uri {
53+
return this.originalContext.extensionUri
54+
}
55+
get extensionPath(): string {
56+
return this.originalContext.extensionPath
57+
}
58+
get globalStorageUri(): vscode.Uri {
59+
return this.originalContext.globalStorageUri
60+
}
61+
get logUri(): vscode.Uri {
62+
return this.originalContext.logUri
63+
}
64+
get extension(): vscode.Extension<any> | undefined {
65+
return this.originalContext.extension
66+
}
67+
get extensionMode(): vscode.ExtensionMode {
68+
return this.originalContext.extensionMode
69+
}
70+
71+
getGlobalState<T>(key: string): T | undefined
72+
getGlobalState<T>(key: string, defaultValue: T): T
73+
getGlobalState<T>(key: string, defaultValue?: T): T | undefined {
74+
const value = this.stateCache.get(key) as T | undefined
75+
return value !== undefined ? value : (defaultValue as T | undefined)
76+
}
77+
78+
updateGlobalState<T>(key: string, value: T): Thenable<void> {
79+
this.stateCache.set(key, value)
80+
return this.originalContext.globalState.update(key, value)
81+
}
82+
83+
getSecret(key: string): string | undefined {
84+
return this.secretCache.get(key)
85+
}
86+
storeSecret(key: string, value?: string): Thenable<void> {
87+
// Update cache
88+
this.secretCache.set(key, value)
89+
// Write directly to context
90+
if (value === undefined) {
91+
return this.originalContext.secrets.delete(key)
92+
} else {
93+
return this.originalContext.secrets.store(key, value)
94+
}
95+
}
96+
97+
/**
98+
* Gets a complete ApiConfiguration object by fetching values
99+
* from both global state and secrets storage
100+
*/
101+
getApiConfiguration(): ApiConfiguration {
102+
// Create an empty ApiConfiguration object
103+
const config: ApiConfiguration = {}
104+
105+
// Add all API-related keys from global state
106+
for (const key of API_CONFIG_KEYS) {
107+
const value = this.getGlobalState(key)
108+
if (value !== undefined) {
109+
// Use type assertion to avoid TypeScript error
110+
;(config as any)[key] = value
111+
}
112+
}
113+
114+
// Add all secret values
115+
for (const key of SECRET_KEYS) {
116+
const value = this.getSecret(key)
117+
if (value !== undefined) {
118+
// Use type assertion to avoid TypeScript error
119+
;(config as any)[key] = value
120+
}
121+
}
122+
123+
// Handle special case for apiProvider if needed (same logic as current implementation)
124+
if (!config.apiProvider) {
125+
if (config.apiKey) {
126+
config.apiProvider = "anthropic"
127+
} else {
128+
config.apiProvider = "openrouter"
129+
}
130+
}
131+
132+
return config
133+
}
134+
135+
/**
136+
* Updates an ApiConfiguration by persisting each property
137+
* to the appropriate storage (global state or secrets)
138+
*/
139+
async updateApiConfiguration(apiConfiguration: ApiConfiguration): Promise<void> {
140+
const promises: Array<Thenable<void>> = []
141+
142+
// For each property, update the appropriate storage
143+
Object.entries(apiConfiguration).forEach(([key, value]) => {
144+
if (SECRET_KEYS.includes(key as SecretKey)) {
145+
promises.push(this.storeSecret(key, value))
146+
} else if (API_CONFIG_KEYS.includes(key as GlobalStateKey)) {
147+
promises.push(this.updateGlobalState(key, value))
148+
}
149+
// Ignore keys that aren't in either list
150+
})
151+
152+
await Promise.all(promises)
153+
}
154+
}

0 commit comments

Comments
 (0)