Skip to content

Commit 96874c8

Browse files
authored
Merge pull request #3 from hbmartin/refinements
Support multiple API context providers
2 parents f564d1a + 1421df4 commit 96874c8

16 files changed

Lines changed: 165 additions & 145 deletions

src/lib/client/WebviewContext.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1-
import { createContext } from 'react';
21
import type { ClientCalls, HostCalls, VsCodeApi } from '../types';
32

43
/**
54
* Context value interface providing type-safe API access
65
*/
7-
export interface WebviewContextValue {
6+
export interface WebviewContextValue<T extends ClientCalls> {
87
api: {
9-
[K in keyof ClientCalls]: (...args: Parameters<ClientCalls[K]>) => ReturnType<ClientCalls[K]>;
8+
[K in keyof T]: (...args: Parameters<T[K]>) => ReturnType<T[K]>;
109
};
1110
addListener: <E extends keyof HostCalls>(key: E, callback: HostCalls[E]) => void;
1211
removeListener: <E extends keyof HostCalls>(key: E, callback: HostCalls[E]) => void;
1312
isReady: boolean;
1413
vscode: VsCodeApi;
1514
}
16-
17-
export const WebviewContext = createContext<WebviewContextValue | undefined>(undefined);

src/lib/client/WebviewProvider.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,39 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import { useEffect, useRef } from 'react';
2+
import { createContext, useEffect, useMemo, useRef } from 'react';
33
import {
44
isViewApiError,
55
isViewApiEvent,
66
isViewApiResponse,
77
type ClientCalls,
8+
type CtxKey,
89
type HostCalls,
910
type RequestContext,
1011
type ViewApiRequest,
1112
type VsCodeApi,
1213
} from '../types';
1314
import { generateId } from '../utils';
14-
import { DeferredPromise, type WebviewContextValue } from './types';
15+
import { DeferredPromise } from './types';
16+
import { TypedContexts } from './useWebviewApi';
17+
import type { WebviewContextValue } from './WebviewContext';
1518

1619
declare function acquireVsCodeApi(): VsCodeApi;
1720

18-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
1921
const vscodeApi = acquireVsCodeApi();
2022

21-
interface WebviewProviderProps {
23+
interface WebviewProviderProps<T extends ClientCalls> {
2224
viewType: string;
2325
children: React.ReactNode;
26+
contextKey: CtxKey<T>;
2427
}
2528

2629
/**
2730
* WebviewProvider provides type-safe API access to webview components
2831
*/
29-
export const WebviewProvider: React.FC<WebviewProviderProps> = ({ children, viewType }) => {
32+
export const WebviewProvider = <T extends ClientCalls, H extends HostCalls>({
33+
children,
34+
viewType,
35+
contextKey,
36+
}: WebviewProviderProps<T>) => {
3037
const pendingRequests = useRef<Map<string, DeferredPromise<any>>>(new Map());
3138
const listeners = useRef<Map<keyof HostCalls, Set<(...args: any[]) => void>>>(new Map());
3239

@@ -41,14 +48,14 @@ export const WebviewProvider: React.FC<WebviewProviderProps> = ({ children, view
4148
/**
4249
* Type-safe API caller with request/response matching
4350
*/
44-
const callApi = <K extends keyof ClientCalls>(
51+
const callApi = <K extends keyof T = keyof T>(
4552
key: K,
46-
...params: Parameters<ClientCalls[K]>
47-
): ReturnType<ClientCalls[K]> => {
53+
...params: Parameters<T[K]>
54+
): Promise<ReturnType<T[K]>> => {
4855
const id = generateId('req');
49-
const deferred = new DeferredPromise<Awaited<ReturnType<ClientCalls[K]>>>(key);
56+
const deferred = new DeferredPromise<Awaited<ReturnType<T[K]>>>(key as string);
5057

51-
const request: ViewApiRequest<K> = {
58+
const request: ViewApiRequest<T, K> = {
5259
type: 'request',
5360
id,
5461
key,
@@ -63,7 +70,7 @@ export const WebviewProvider: React.FC<WebviewProviderProps> = ({ children, view
6370
try {
6471
vscodeApi.postMessage(request);
6572
} catch (error) {
66-
console.error(`Failed to send API request ${key}:`, error);
73+
console.error(`Failed to send API request ${key as string}:`, error);
6774
deferred.clearTimeout();
6875
pendingRequests.current.delete(id);
6976
deferred.reject(error instanceof Error ? error : new Error(String(error)));
@@ -75,12 +82,12 @@ export const WebviewProvider: React.FC<WebviewProviderProps> = ({ children, view
7582
/**
7683
* Create typed API object using Proxy for dynamic method access
7784
*/
78-
const api = new Proxy({} as WebviewContextValue['api'], {
85+
const api = new Proxy({} as WebviewContextValue<T>['api'], {
7986
// eslint-disable-next-line code-complete/enforce-meaningful-names
8087
get: (_, key: string) => {
8188
return (...args: any[]) => {
8289
// Type assertion is safe here because the proxy ensures correct typing at usage
83-
return callApi(key, ...(args as Parameters<ClientCalls[keyof ClientCalls]>));
90+
return callApi(key, ...(args as Parameters<T[keyof T]>));
8491
};
8592
},
8693
});
@@ -107,6 +114,7 @@ export const WebviewProvider: React.FC<WebviewProviderProps> = ({ children, view
107114
* Handle messages from the extension host
108115
*/
109116
useEffect(() => {
117+
// eslint-disable-next-line sonarjs/cognitive-complexity
110118
const handleMessage = (event: MessageEvent<unknown>) => {
111119
const message = event.data;
112120

@@ -128,9 +136,9 @@ export const WebviewProvider: React.FC<WebviewProviderProps> = ({ children, view
128136
} else {
129137
console.warn(`No pending request found for error ID: ${message.id}`);
130138
}
131-
} else if (isViewApiEvent(message)) {
139+
} else if (isViewApiEvent<H>(message)) {
132140
// Handle event
133-
const callbacks = listeners.current.get(message.key);
141+
const callbacks = listeners.current.get(message.key as keyof HostCalls);
134142
if (callbacks && callbacks.size > 0) {
135143
for (const callback of callbacks) {
136144
try {
@@ -172,13 +180,19 @@ export const WebviewProvider: React.FC<WebviewProviderProps> = ({ children, view
172180
};
173181
}, []);
174182

175-
const contextValue: WebviewContextValue = {
183+
const context = useMemo(() => {
184+
const context = createContext<WebviewContextValue<T> | undefined>(undefined);
185+
TypedContexts.set(contextKey, context);
186+
return context;
187+
}, [contextKey]);
188+
189+
const contextValue: WebviewContextValue<T> = {
176190
api,
177191
addListener,
178192
removeListener,
179193
isReady: true,
180194
vscode: vscodeApi,
181195
};
182196

183-
return <WebviewContext.Provider value={contextValue}>{children}</WebviewContext.Provider>;
197+
return <context.Provider value={contextValue}>{children}</context.Provider>;
184198
};

src/lib/client/hooks.tsx

Lines changed: 0 additions & 32 deletions
This file was deleted.

src/lib/client/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { ClientCalls, HostCalls, VsCodeApi } from '../types';
2-
31
/**
42
* Deferred promise for handling async responses with timeout management
53
*/

src/lib/client/useLogger.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
/* eslint-disable no-console */
2-
import { useContext, useMemo } from 'react';
2+
import { useMemo } from 'react';
3+
import type { VsCodeApi } from '../types';
34
import { WebviewLogger } from '../host/WebviewLogger';
4-
import { WebviewContext } from './WebviewContext';
55
import type { ILogger } from '../host/ILogger';
66

77
/**
88
* React hook to get a logger instance for use in webview components.
99
* The logger automatically sends all log messages to the extension host
1010
* where they are written to the VS Code output channel.
1111
*/
12-
export function useLogger(tag: string): ILogger {
13-
const context = useContext(WebviewContext);
12+
export function useLogger(tag: string, vscode?: VsCodeApi): ILogger {
1413
return useMemo(
15-
() =>
16-
context?.vscode === undefined
17-
? createConsoleLogger(tag)
18-
: new WebviewLogger(context.vscode, tag),
19-
[context?.vscode, tag]
14+
() => (vscode === undefined ? createConsoleLogger(tag) : new WebviewLogger(vscode, tag)),
15+
[vscode, tag]
2016
);
2117
}
2218

src/lib/client/useVscodeState.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useMemo, useState } from 'react';
2+
import type { VsCodeApi } from '../types';
23
import {
34
ACT,
45
type Patch,
@@ -8,43 +9,42 @@ import {
89
type StateReducer,
910
isFnKey,
1011
} from '../types/ipcReducer';
11-
import { useWebviewApi } from './WebviewContext';
1212

1313
type PostAction<A extends object> = Pick<Action<A>, 'key' | 'params'>;
1414

15-
function isMyPatchMessage<A extends object>(msg: any, id: WebviewKey): msg is Patch<A> {
15+
function isMyPatchMessage<A extends object>(message: unknown, id: WebviewKey): message is Patch<A> {
1616
return (
17-
msg !== null &&
18-
msg !== undefined &&
19-
typeof msg === 'object' &&
20-
'providerId' in msg &&
21-
'type' in msg &&
22-
'key' in msg &&
23-
'patch' in msg &&
24-
msg.type === PATCH &&
25-
typeof msg.providerId === 'string' &&
26-
msg.providerId === id
17+
message !== null &&
18+
message !== undefined &&
19+
typeof message === 'object' &&
20+
'providerId' in message &&
21+
'type' in message &&
22+
'key' in message &&
23+
'patch' in message &&
24+
message.type === PATCH &&
25+
typeof message.providerId === 'string' &&
26+
message.providerId === id
2727
);
2828
}
2929

3030
const dangerousKeys = new Set(['__proto__', 'constructor', 'prototype']);
3131

3232
export function useVscodeState<S, A extends object>(
33+
vscode: VsCodeApi,
3334
providerId: WebviewKey,
3435
postReducer: StateReducer<S, A>,
3536
initialState: S | (() => S)
3637
): readonly [S, A] {
3738
const [state, setState] = useState<S>(
3839
typeof initialState === 'function' ? (initialState as () => S)() : initialState
3940
);
40-
const { vscode } = useWebviewApi();
4141
const validKeys = useMemo(
4242
() => new Set(Object.keys(postReducer).filter((k) => !dangerousKeys.has(k))),
4343
[postReducer]
4444
);
4545

4646
useEffect(() => {
47-
const handler = (event: MessageEvent) => {
47+
const handler = (event: MessageEvent<unknown>) => {
4848
const { data } = event;
4949
if (isMyPatchMessage<A>(data, providerId)) {
5050
if (
@@ -83,6 +83,7 @@ export function useVscodeState<S, A extends object>(
8383
);
8484

8585
const actor = new Proxy({} as A, {
86+
// eslint-disable-next-line code-complete/enforce-meaningful-names
8687
get(_, prop) {
8788
if (typeof prop !== 'string' && typeof prop !== 'symbol') {
8889
throw new TypeError(`Invalid action type: ${String(prop)}`);
@@ -94,6 +95,7 @@ export function useVscodeState<S, A extends object>(
9495
throw new Error(`Unknown or invalid action: ${String(prop)}`);
9596
}
9697
return (...args: unknown[]) => {
98+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9799
const params = args as A[typeof prop] extends (...args: unknown[]) => any
98100
? Parameters<A[typeof prop]>
99101
: never;

src/lib/client/useWebviewApi.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useContext, type Context } from 'react';
2+
import type { ClientCalls, CtxKey, WebviewContextValue } from '..';
3+
4+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5+
export const TypedContexts = new WeakMap<CtxKey<any>, Context<any>>();
6+
7+
export const useWebviewApi = <T extends ClientCalls>(
8+
contextKey: CtxKey<T>
9+
): WebviewContextValue<T> => {
10+
// eslint-disable-next-line sonarjs/no-empty-collection
11+
if (!TypedContexts.has(contextKey)) {
12+
throw new Error('useWebviewApi must be used within WebviewProvider with matching key');
13+
}
14+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-non-null-assertion, sonarjs/no-empty-collection
15+
const context = useContext(TypedContexts.get(contextKey)!);
16+
if (context === undefined) {
17+
throw new Error('useWebviewApi must be used within WebviewProvider with matching key');
18+
}
19+
return context as WebviewContextValue<T>;
20+
};
21+
22+
export function createCtxKey<T extends ClientCalls>(contextKey: string): CtxKey<T> {
23+
return { id: Symbol(contextKey) } as CtxKey<T>;
24+
}

src/lib/client/withWebviewApi.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { ComponentType, FC } from 'react';
2+
import { createCtxKey } from './useWebviewApi';
3+
import { WebviewProvider } from './WebviewProvider';
4+
5+
/**
6+
* Higher-order component to ensure WebviewProvider is available
7+
*/
8+
export function withWebviewApi<P extends object>(Component: ComponentType<P>): ComponentType<P> {
9+
const WrappedComponent: FC<P> = (props: P) => {
10+
return (
11+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
12+
<WebviewProvider viewType={Component.name} contextKey={createCtxKey(Component.name)}>
13+
<Component {...props} />
14+
</WebviewProvider>
15+
);
16+
};
17+
18+
WrappedComponent.displayName = `withWebviewApi(${Component.displayName ?? Component.name})`;
19+
20+
return WrappedComponent;
21+
}

0 commit comments

Comments
 (0)