diff --git a/src/webview/lib/actions/clipboard.ts b/src/webview/lib/actions/clipboard.ts index c71976de..02da6499 100644 --- a/src/webview/lib/actions/clipboard.ts +++ b/src/webview/lib/actions/clipboard.ts @@ -1,9 +1,9 @@ import { openErrorDialog } from "@/webview/lib/actions"; -import { rpc } from "@/webview/lib/rpc/rpc-client"; +import { rpcClient } from "@/webview/lib/rpc/rpc-client"; export async function copyToClipboard(type: string, data: string) { try { - const success = await rpc.call("clipboard.copy", data); + const success = await rpcClient.request("clipboard.copy", data); if (!success) { openErrorDialog(window.l10n.unableToCopyToClipboard.replace("{0}", type)); diff --git a/src/webview/lib/dispatcher.ts b/src/webview/lib/dispatcher.ts index 28624220..9c81e0e3 100644 --- a/src/webview/lib/dispatcher.ts +++ b/src/webview/lib/dispatcher.ts @@ -1,6 +1,4 @@ import type { ResponseMessage } from "@/types"; -import { handleRpcResponse } from "@/webview/lib/rpc/rpc-client"; -import { handleRpcNotification } from "@/webview/lib/rpc/rpc-notify"; import { handleActionResult } from "./handler/action-result"; import { handleCommitDetails } from "./handler/commit-details"; @@ -38,16 +36,22 @@ const handlers: Handlers = { export function initDispatcher() { window.addEventListener("message", (e: MessageEvent) => { - if (handleRpcResponse(e.data)) { + if (!isResponseMessage(e.data)) { return; } - if (handleRpcNotification(e.data)) { - return; - } - dispatch(e.data as ResponseMessage); + dispatch(e.data); }); } +function isResponseMessage(message: unknown): message is ResponseMessage { + return ( + typeof message === "object" && + message !== null && + "command" in message && + typeof message.command === "string" + ); +} + function dispatch(msg: ResponseMessage): void { const handle = handlers[msg.command] as ((m: ResponseMessage) => void) | undefined; diff --git a/src/webview/lib/rpc/rpc-client.ts b/src/webview/lib/rpc/rpc-client.ts index 518a367d..cd3f16bf 100644 --- a/src/webview/lib/rpc/rpc-client.ts +++ b/src/webview/lib/rpc/rpc-client.ts @@ -1,17 +1,22 @@ -import type { RpcMethod, RpcMethodMap, RpcRequest, RpcResponse } from "@/types"; +import type { RpcMethod, RpcMethodMap, RpcRequest } from "@/types"; +import { initRpcHandler, type PendingRpcRequest } from "@/webview/lib/rpc/rpc-handler"; import { vscode } from "@/webview/lib/vscode"; const RPC_TIMEOUT_MS = 30_000; -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (value: unknown) => void; - timeout: ReturnType; -}; -const requests = new Map(); +const requests = new Map(); +let initialized = false; + +export const rpcClient = { + init(): void { + if (initialized) { + return; + } -export const rpc = { - call( + initialized = true; + initRpcHandler(requests); + }, + request( method: M, params: RpcMethodMap[M]["params"] ): Promise { @@ -47,45 +52,3 @@ export const rpc = { }); } }; - -export function handleRpcResponse(message: unknown): boolean { - if (!isRpcResponse(message)) { - return false; - } - - const request = requests.get(message.id); - if (request === undefined) { - return true; - } - requests.delete(message.id); - clearTimeout(request.timeout); - - if (message.success) { - request.resolve(message.result); - } else { - request.reject(new Error(message.error)); - } - - return true; -} - -function isRpcResponse(message: unknown): message is RpcResponse { - if ( - typeof message !== "object" || - message === null || - !("kind" in message) || - message.kind !== "rpc.response" || - !("id" in message) || - typeof message.id !== "string" || - !("success" in message) || - typeof message.success !== "boolean" - ) { - return false; - } - - if (message.success) { - return "result" in message; - } - - return "error" in message && typeof message.error === "string"; -} diff --git a/src/webview/lib/rpc/rpc-handler.ts b/src/webview/lib/rpc/rpc-handler.ts new file mode 100644 index 00000000..da3d8f55 --- /dev/null +++ b/src/webview/lib/rpc/rpc-handler.ts @@ -0,0 +1,89 @@ +import type { RpcNotification, RpcResponse } from "@/types"; +import { refresh } from "@/webview/lib/actions"; +import { loadRepoList } from "@/webview/lib/load-repos"; +import { selectedRepo } from "@/webview/lib/stores"; +import { repoListStore } from "@/webview/lib/stores/repo-list.store"; + +export type PendingRpcRequest = { + resolve: (value: unknown) => void; + reject: (value: unknown) => void; + timeout: ReturnType; +}; + +export function initRpcHandler(requests: Map): void { + window.addEventListener("message", (event: MessageEvent) => { + if (isRpcResponse(event.data)) { + handleRpcResponse(event.data, requests); + return; + } + + if (isRpcNotification(event.data)) { + handleRpcNotification(event.data); + } + }); +} + +function handleRpcResponse(message: RpcResponse, requests: Map): void { + const request = requests.get(message.id); + if (request === undefined) { + return; + } + requests.delete(message.id); + clearTimeout(request.timeout); + + if (message.success) { + request.resolve(message.result); + } else { + request.reject(new Error(message.error)); + } +} + +function handleRpcNotification(message: RpcNotification): void { + switch (message.name) { + case "repo.changed": + repoListStore.apply(message.message); + return; + case "repo.rescan": + void loadRepoList(); + return; + case "repo.updated": + if (message.message.path === selectedRepo.value) { + refresh(); + } + } +} + +function isRpcResponse(message: unknown): message is RpcResponse { + if ( + typeof message !== "object" || + message === null || + !("kind" in message) || + message.kind !== "rpc.response" || + !("id" in message) || + typeof message.id !== "string" || + !("success" in message) || + typeof message.success !== "boolean" + ) { + return false; + } + + if (message.success) { + return "result" in message; + } + + return "error" in message && typeof message.error === "string"; +} + +function isRpcNotification(message: unknown): message is RpcNotification { + return ( + typeof message === "object" && + message !== null && + "kind" in message && + message.kind === "rpc.notify" && + "id" in message && + typeof message.id === "string" && + "name" in message && + typeof message.name === "string" && + "message" in message + ); +} diff --git a/src/webview/lib/rpc/rpc-notify.ts b/src/webview/lib/rpc/rpc-notify.ts deleted file mode 100644 index be15cd2c..00000000 --- a/src/webview/lib/rpc/rpc-notify.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { RpcNotification } from "@/types"; -import { refresh } from "@/webview/lib/actions"; -import { loadRepoList } from "@/webview/lib/load-repos"; -import { selectedRepo } from "@/webview/lib/stores"; -import { repoListStore } from "@/webview/lib/stores/repo-list.store"; - -export function handleRpcNotification(message: unknown): boolean { - if (!isRpcNotification(message)) { - return false; - } - - switch (message.name) { - case "repo.changed": - repoListStore.apply(message.message); - return true; - case "repo.rescan": - void loadRepoList(); - return true; - case "repo.updated": - if (message.message.path === selectedRepo.value) { - refresh(); - } - return true; - default: - return false; - } -} - -function isRpcNotification(message: unknown): message is RpcNotification { - return ( - typeof message === "object" && - message !== null && - "kind" in message && - message.kind === "rpc.notify" && - "id" in message && - typeof message.id === "string" && - "name" in message && - typeof message.name === "string" && - "message" in message - ); -} diff --git a/src/webview/lib/stores/repo-list.store.ts b/src/webview/lib/stores/repo-list.store.ts index 1fa1cdc2..5a89a2ed 100644 --- a/src/webview/lib/stores/repo-list.store.ts +++ b/src/webview/lib/stores/repo-list.store.ts @@ -1,7 +1,7 @@ import { signal } from "@preact/signals"; import type { GitRepo, RepoChange } from "@/types"; -import { rpc } from "@/webview/lib/rpc/rpc-client"; +import { rpcClient } from "@/webview/lib/rpc/rpc-client"; const repoList = signal | undefined>(undefined); @@ -11,7 +11,7 @@ export const repoListStore = { }, load: async (): Promise> => { repoList.value = undefined; - const result = await rpc.call("repo.scan", null); + const result = await rpcClient.request("repo.scan", null); repoList.value = result.repos; return result.repos; }, diff --git a/src/webview/main.tsx b/src/webview/main.tsx index 01b4f36b..27cc65b1 100644 --- a/src/webview/main.tsx +++ b/src/webview/main.tsx @@ -8,7 +8,7 @@ import { Button } from "./components/ui/Button"; import { selectRepo } from "./lib/actions"; import { initDispatcher } from "./lib/dispatcher"; import { loadRepoList, repoListError } from "./lib/load-repos"; -import { rpc } from "./lib/rpc/rpc-client"; +import { rpcClient } from "./lib/rpc/rpc-client"; import { initializeStores, selectedRepo } from "./lib/stores"; import { repoListStore } from "./lib/stores/repo-list.store"; import { initializeWebviewConfig } from "./lib/webview-config"; @@ -17,6 +17,7 @@ import { NoRepoPage } from "./pages/NoRepoPage"; const root = document.getElementById("app")!; +rpcClient.init(); initDispatcher(); render(, root); @@ -30,7 +31,7 @@ void main().catch((error: unknown) => { }); async function main() { - const { l10n, config } = await rpc.call("webview.initialize", null); + const { l10n, config } = await rpcClient.request("webview.initialize", null); window.l10n = l10n; initializeWebviewConfig(config); initializeStores(config.initialLoadCommits); diff --git a/src/webview/pages/NoRepoPage.tsx b/src/webview/pages/NoRepoPage.tsx index aaf73a5b..0aafbcdc 100644 --- a/src/webview/pages/NoRepoPage.tsx +++ b/src/webview/pages/NoRepoPage.tsx @@ -2,7 +2,7 @@ import { useState } from "preact/hooks"; import { Button } from "@/webview/components/ui/Button"; import { Icon } from "@/webview/components/ui/Icons"; -import { rpc } from "@/webview/lib/rpc/rpc-client"; +import { rpcClient } from "@/webview/lib/rpc/rpc-client"; export function NoRepoPage() { const [initializing, setInitializing] = useState(false); @@ -13,7 +13,7 @@ export function NoRepoPage() { setError(undefined); try { - await rpc.call("git.init", null); + await rpcClient.request("git.init", null); } catch (reason: unknown) { const message = reason instanceof Error ? reason.message : String(reason); setError(window.l10n.unableToInitializeRepo.replace("{0}", message)); diff --git a/tests/webview/lib/rpc-client.test.ts b/tests/webview/lib/rpc-client.test.ts index 33465f4f..17f96244 100644 --- a/tests/webview/lib/rpc-client.test.ts +++ b/tests/webview/lib/rpc-client.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { rpc } from "@/webview/lib/rpc/rpc-client"; +import { rpcClient } from "@/webview/lib/rpc/rpc-client"; import { vscodeApi } from "@tests/webview/setup"; @@ -14,7 +14,7 @@ afterEach(() => { it("rejects a request that times out", async () => { vi.useFakeTimers(); - const result = rpc.call("clipboard.copy", "commit"); + const result = rpcClient.request("clipboard.copy", "commit"); const rejection = expect(result).rejects.toThrow("RPC request timed out: clipboard.copy"); await vi.runAllTimersAsync(); diff --git a/tests/webview/test-utils.ts b/tests/webview/test-utils.ts index 61294f6a..1c048ebf 100644 --- a/tests/webview/test-utils.ts +++ b/tests/webview/test-utils.ts @@ -1,6 +1,7 @@ import type { LocalizedStrings } from "@/old-extension/l10n/webviewL10n"; import type { WebviewConfig } from "@/types"; import { initDispatcher } from "@/webview/lib/dispatcher"; +import { rpcClient } from "@/webview/lib/rpc/rpc-client"; import { initializeWebviewConfig } from "@/webview/lib/webview-config"; const config: WebviewConfig = { @@ -23,6 +24,7 @@ export function setupWebviewTest({ dispatchMessages = false } = {}) { }); if (dispatchMessages) { + rpcClient.init(); initDispatcher(); } }