snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import { useSearch } from "@tanstack/react-router";
import type { CookieJar } from "@yaakapp-internal/models";
import { cookieJarsAtom } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { useEffect } from "react";
import { jotaiStore } from "../lib/jotai";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
export const activeCookieJarAtom = atom<CookieJar | null>(null);
export function useActiveCookieJar() {
return useAtomValue(activeCookieJarAtom);
}
export function useSubscribeActiveCookieJarId() {
const search = useSearch({ strict: false });
const cookieJarId = search.cookie_jar_id;
const cookieJars = useAtomValue(cookieJarsAtom);
useEffect(() => {
if (search == null) return; // Happens during Vite hot reload
const activeCookieJar = cookieJars?.find((j) => j.id === cookieJarId) ?? null;
jotaiStore.set(activeCookieJarAtom, activeCookieJar);
}, [cookieJarId, cookieJars, search]);
}
export function getActiveCookieJar() {
return jotaiStore.get(activeCookieJarAtom);
}
export function useEnsureActiveCookieJar() {
const cookieJars = useAtomValue(cookieJarsAtom);
const { cookie_jar_id: activeCookieJarId } = useSearch({ from: "/workspaces/$workspaceId/" });
// Set the active cookie jar to the first one, if none set
// NOTE: We only run this on cookieJars to prevent data races when switching workspaces since a lot of
// things change when switching workspaces, and we don't currently have a good way to ensure that all
// stores have updated.
// TODO: Create a global data store that can handle this case
// oxlint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (cookieJars == null) return; // Hasn't loaded yet
if (cookieJars.find((j) => j.id === activeCookieJarId)) {
return; // There's an active jar
}
const firstJar = cookieJars[0];
if (firstJar == null) {
console.log(`Workspace doesn't have any cookie jars to activate`);
return;
}
// There's no active jar, so set it to the first one
console.log("Defaulting active cookie jar to first jar", firstJar);
setWorkspaceSearchParams({ cookie_jar_id: firstJar.id });
}, [cookieJars]);
}

View File

@@ -0,0 +1,29 @@
import { useSearch } from "@tanstack/react-router";
import type { Environment } from "@yaakapp-internal/models";
import { environmentsAtom } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { useEffect } from "react";
import { jotaiStore } from "../lib/jotai";
export const activeEnvironmentIdAtom = atom<string>();
export const activeEnvironmentAtom = atom<Environment | null>((get) => {
const activeEnvironmentId = get(activeEnvironmentIdAtom);
return get(environmentsAtom).find((e) => e.id === activeEnvironmentId) ?? null;
});
export function useActiveEnvironment() {
return useAtomValue(activeEnvironmentAtom);
}
export function getActiveEnvironment() {
return jotaiStore.get(activeEnvironmentAtom);
}
export function useSubscribeActiveEnvironmentId() {
const { environment_id } = useSearch({ strict: false });
useEffect(
() => jotaiStore.set(activeEnvironmentIdAtom, environment_id ?? undefined),
[environment_id],
);
}

View File

@@ -0,0 +1,8 @@
import { useAtomValue } from "jotai";
import { activeEnvironmentAtom } from "./useActiveEnvironment";
import { useEnvironmentVariables } from "./useEnvironmentVariables";
export function useActiveEnvironmentVariables() {
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
return useEnvironmentVariables(activeEnvironment?.id ?? null).map((v) => v.variable);
}

View File

@@ -0,0 +1,9 @@
import { foldersAtom } from "@yaakapp-internal/models";
import { atom } from "jotai";
import { activeFolderIdAtom } from "./useActiveFolderId";
export const activeFolderAtom = atom((get) => {
const activeFolderId = get(activeFolderIdAtom);
const folders = get(foldersAtom);
return folders.find((r) => r.id === activeFolderId) ?? null;
});

View File

@@ -0,0 +1,11 @@
import { useSearch } from "@tanstack/react-router";
import { atom } from "jotai";
import { useEffect } from "react";
import { jotaiStore } from "../lib/jotai";
export const activeFolderIdAtom = atom<string | null>(null);
export function useSubscribeActiveFolderId() {
const { folder_id } = useSearch({ strict: false });
useEffect(() => jotaiStore.set(activeFolderIdAtom, folder_id ?? null), [folder_id]);
}

View File

@@ -0,0 +1,23 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { activeRequestIdAtom } from "./useActiveRequestId";
import { allRequestsAtom } from "./useAllRequests";
export const activeRequestAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests.find((r) => r.id === activeRequestId) ?? null;
});
interface TypeMap {
http_request: HttpRequest;
grpc_request: GrpcRequest;
websocket_request: WebsocketRequest;
}
export function useActiveRequest<T extends keyof TypeMap>(model?: T): TypeMap[T] | null {
const activeRequest = useAtomValue(activeRequestAtom);
if (model == null) return activeRequest as TypeMap[T];
if (activeRequest?.model === model) return activeRequest as TypeMap[T];
return null;
}

View File

@@ -0,0 +1,11 @@
import { useSearch } from "@tanstack/react-router";
import { atom } from "jotai";
import { useEffect } from "react";
import { jotaiStore } from "../lib/jotai";
export const activeRequestIdAtom = atom<string | null>(null);
export function useSubscribeActiveRequestId() {
const { request_id } = useSearch({ strict: false });
useEffect(() => jotaiStore.set(activeRequestIdAtom, request_id ?? null), [request_id]);
}

View File

@@ -0,0 +1,24 @@
import { useParams } from "@tanstack/react-router";
import { workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import { atom } from "jotai";
import { useEffect } from "react";
import { jotaiStore } from "../lib/jotai";
export const activeWorkspaceIdAtom = atom<string | null>(null);
export const activeWorkspaceAtom = atom((get) => {
const activeWorkspaceId = get(activeWorkspaceIdAtom);
const workspaces = get(workspacesAtom);
return workspaces.find((w) => w.id === activeWorkspaceId) ?? null;
});
export const activeWorkspaceMetaAtom = atom((get) => {
const activeWorkspaceId = get(activeWorkspaceIdAtom);
const workspaceMetas = get(workspaceMetasAtom);
return workspaceMetas.find((m) => m.workspaceId === activeWorkspaceId) ?? null;
});
export function useSubscribeActiveWorkspaceId() {
const { workspaceId } = useParams({ strict: false });
useEffect(() => jotaiStore.set(activeWorkspaceIdAtom, workspaceId ?? null), [workspaceId]);
}

View File

@@ -0,0 +1,31 @@
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { InlineCode } from "@yaakapp-internal/ui";
import { showToast } from "../lib/toast";
import { activeWorkspaceAtom } from "./useActiveWorkspace";
export function useActiveWorkspaceChangedToast() {
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const [id, setId] = useState<string | null>(activeWorkspace?.id ?? null);
useEffect(() => {
// Early return if same or invalid active workspace
if (id === activeWorkspace?.id || activeWorkspace == null) return;
setId(activeWorkspace?.id ?? null);
// Don't notify on the first load
if (id === null) return;
showToast({
id: `workspace-changed-${activeWorkspace.id}`,
timeout: 3000,
message: (
<>
Activated workspace{" "}
<InlineCode className="whitespace-nowrap">{activeWorkspace.name}</InlineCode>
</>
),
});
}, [activeWorkspace, id]);
}

View File

@@ -0,0 +1,40 @@
import {
grpcRequestsAtom,
httpRequestsAtom,
websocketRequestsAtom,
} from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
export const allRequestsAtom = atom((get) => [
...get(httpRequestsAtom),
...get(grpcRequestsAtom),
...get(websocketRequestsAtom),
]);
export function useAllRequests() {
return useAtomValue(allRequestsAtom);
}
const stringArrayEqual = (a: string[], b: string[]) =>
a.length === b.length && a.every((v, i) => v === b[i]);
// Identity-stable derivations so subscribers don't recompute or re-render when
// unrelated request fields change (eg. every debounced edit of a request)
export const allRequestIdsAtom = selectAtom(
allRequestsAtom,
(requests) => requests.map((r) => r.id),
stringArrayEqual,
);
export const allRequestUrlsAtom = selectAtom(
allRequestsAtom,
(requests) => {
const urls = new Set<string>();
for (const r of requests) {
if (r.url) urls.add(r.url);
}
return Array.from(urls);
},
stringArrayEqual,
);

View File

@@ -0,0 +1,206 @@
import type { Folder } from "@yaakapp-internal/models";
import { modelTypeLabel, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { IconTooltip } from "../components/core/IconTooltip";
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
import type { TabItem } from "../components/core/Tabs/Tabs";
import { capitalize } from "../lib/capitalize";
import { showConfirm } from "../lib/confirm";
import { resolvedModelName } from "../lib/resolvedModelName";
import { useHttpAuthenticationSummaries } from "./useHttpAuthentication";
import type { AuthenticatedModel } from "./useInheritedAuthentication";
import { useInheritedAuthentication } from "./useInheritedAuthentication";
import { useModelAncestors } from "./useModelAncestors";
export function useAuthTab<T extends string>(
tabValue: T,
model: AuthenticatedModel | null,
) {
const options = useAuthDropdownOptions(model);
return useMemo<TabItem[]>(() => {
if (model == null || options == null) return [];
const tab: TabItem = {
value: tabValue,
label: "Auth",
options,
};
return [tab];
}, [model, options, tabValue]);
}
export function useAuthDropdownOptions(
model: AuthenticatedModel | null,
): Omit<RadioDropdownProps, "children"> | null {
const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null;
return useMemo(() => {
if (model == null) return null;
return {
value: model.authenticationType,
items: [
...authentication.map((a) => ({
label: a.label || "UNKNOWN",
shortLabel: a.shortLabel,
value: a.name,
})),
{ type: "separator" },
{
label: "Inherit from Parent",
shortLabel:
inheritedAuth != null &&
inheritedAuth.authenticationType !== "none" ? (
<HStack space={1.5}>
{authentication.find(
(a) => a.name === inheritedAuth.authenticationType,
)?.shortLabel ?? "UNKNOWN"}
<IconTooltip
icon="zap_off"
iconSize="xs"
content="Authentication was inherited from an ancestor"
/>
</HStack>
) : (
"Auth"
),
value: null,
},
{ label: "No Auth", shortLabel: "No Auth", value: "none" },
],
itemsAfter: (() => {
const actions: (
| { type: "separator"; label: string }
| {
label: string;
leftSlot: React.ReactNode;
onSelect: () => Promise<void>;
}
)[] = [];
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== "none" &&
(parentModel.authenticationType == null ||
parentModel.authenticationType === "none")
) {
actions.push(
{ type: "separator", label: "Actions" },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: (
<Icon
icon={
parentModel.model === "workspace"
? "corner_right_up"
: "folder_up"
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "promote-auth-confirm",
title: "Promote Authentication",
confirmText: "Promote",
description: (
<>
Move authentication config to{" "}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: {},
authenticationType: null,
});
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
}
},
},
);
}
// Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find(
(a) =>
a.authenticationType != null && a.authenticationType !== "none",
);
if (ancestorWithAuth) {
if (actions.length === 0) {
actions.push({ type: "separator", label: "Actions" });
}
actions.push({
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
leftSlot: (
<Icon
icon={
ancestorWithAuth.model === "workspace"
? "corner_right_down"
: "folder_down"
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "copy-auth-confirm",
title: "Copy Authentication",
confirmText: "Copy",
description: (
<>
Copy{" "}
{authentication.find(
(a) => a.name === ancestorWithAuth.authenticationType,
)?.label ?? "authentication"}{" "}
config from{" "}
<InlineCode>
{resolvedModelName(ancestorWithAuth)}
</InlineCode>
? This will override the current authentication but will not
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication },
authenticationType: ancestorWithAuth.authenticationType,
});
}
},
});
}
return actions.length > 0 ? actions : undefined;
})(),
onChange: async (authenticationType) => {
let authentication: Folder["authentication"] = model.authentication;
if (model.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
await patchModel(model, { authentication, authenticationType });
},
};
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
}

View File

@@ -0,0 +1,9 @@
import { platform } from "@yaakapp-internal/platform";
import { useFastMutation } from "./useFastMutation";
export function useCancelHttpResponse(id: string | null) {
return useFastMutation<void>({
mutationKey: ["cancel_http_response", id],
mutationFn: () => platform.emit(`cancel_http_response_${id}`),
});
}

View File

@@ -0,0 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { InlineCode } from "@yaakapp-internal/ui";
import { showAlert } from "../lib/alert";
import { appInfo } from "../lib/appInfo";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { rpc } from "../lib/rpc";
export function useCheckForUpdates() {
return useMutation({
mutationKey: ["check_for_updates"],
mutationFn: async () => {
const hasUpdate: boolean = await minPromiseMillis(rpc("cmd_check_for_updates"), 500);
if (!hasUpdate) {
showAlert({
id: "no-updates",
title: "No Update Available",
body: (
<>
You are currently on the latest version <InlineCode>{appInfo.version}</InlineCode>
</>
),
});
}
},
});
}

View File

@@ -0,0 +1,41 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
/**
* Get notified when a mouse click happens outside the target ref
* @param ref The element to be notified when a mouse click happens outside it
* @param onClickAway
* @param ignored Optional outside element to ignore (useful for dropdown triggers)
*/
export function useClickOutside(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: MouseEvent) => void,
ignored?: RefObject<HTMLElement | null>,
) {
const savedCallback = useRef(onClickAway);
useEffect(() => {
savedCallback.current = onClickAway;
}, [onClickAway]);
useEffect(() => {
const handler = (event: MouseEvent) => {
if (ref.current == null || !(event.target instanceof HTMLElement)) {
return;
}
const isIgnored = ignored?.current?.contains(event.target);
const clickedOutside = !ref.current.contains(event.target);
if (!isIgnored && clickedOutside) {
savedCallback.current(event);
}
};
// NOTE: We're using mousedown instead of click to handle some edge cases like when a context
// menu is open with the ctrl key.
document.addEventListener("mousedown", handler, { capture: true });
document.addEventListener("contextmenu", handler, { capture: true });
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("contextmenu", handler);
};
}, [ignored, ref]);
}

View File

@@ -0,0 +1,16 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { copyToClipboard } from "../lib/copy";
import { getResponseBodyText } from "../lib/responseBody";
import { useFastMutation } from "./useFastMutation";
export function useCopyHttpResponse(response: HttpResponse | null) {
return useFastMutation({
mutationKey: ["copy_http_response", response?.id],
async mutationFn() {
if (response == null) return;
const body = await getResponseBodyText({ response, filter: null });
copyToClipboard(body);
},
});
}

View File

@@ -0,0 +1,33 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { showPrompt } from "../lib/prompt";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
export function useCreateCookieJar() {
return useFastMutation({
mutationKey: ["create_cookie_jar"],
mutationFn: async () => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) {
throw new Error("Cannot create cookie jar when there's no active workspace");
}
const name = await showPrompt({
id: "new-cookie-jar",
title: "New CookieJar",
placeholder: "My Jar",
confirmText: "Create",
label: "Name",
defaultValue: "My Jar",
});
if (name == null) return null;
return createWorkspaceModel({ model: "cookie_jar", workspaceId, name });
},
onSuccess: async (cookieJarId) => {
setWorkspaceSearchParams({ cookie_jar_id: cookieJarId });
},
});
}

View File

@@ -0,0 +1,118 @@
import type { HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import type { GrpcRequest } from "@yaakapp-internal/sync";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { createFolder } from "../commands/commands";
import type { DropdownItem } from "../components/core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { generateId } from "../lib/generateId";
import { BODY_TYPE_GRAPHQL } from "../lib/model_util";
import { activeRequestAtom } from "./useActiveRequest";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
export function useCreateDropdownItems({
hideFolder,
hideIcons,
folderId,
}: {
hideFolder?: boolean;
hideIcons?: boolean;
folderId?: string | null;
} = {}): DropdownItem[] {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const activeRequest = useAtomValue(activeRequestAtom);
const items = useMemo((): DropdownItem[] => {
return getCreateDropdownItems({ hideFolder, hideIcons, folderId, activeRequest, workspaceId });
}, [activeRequest, folderId, hideFolder, hideIcons, workspaceId]);
return items;
}
export function getCreateDropdownItems({
hideFolder,
hideIcons,
folderId: folderIdOption,
workspaceId,
activeRequest,
onCreate,
}: {
hideFolder?: boolean;
hideIcons?: boolean;
folderId?: string | null;
workspaceId: string | null;
activeRequest: HttpRequest | GrpcRequest | WebsocketRequest | null;
onCreate?: (
model: "http_request" | "grpc_request" | "websocket_request" | "folder",
id: string,
) => void;
}): DropdownItem[] {
const folderId =
(folderIdOption === "active-folder" ? activeRequest?.folderId : folderIdOption) ?? null;
if (workspaceId == null) {
return [];
}
return [
{
label: "HTTP",
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: async () => {
const id = await createRequestAndNavigate({ model: "http_request", workspaceId, folderId });
onCreate?.("http_request", id);
},
},
{
label: "GraphQL",
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: async () => {
const id = await createRequestAndNavigate({
model: "http_request",
workspaceId,
folderId,
bodyType: BODY_TYPE_GRAPHQL,
method: "POST",
headers: [{ name: "Content-Type", value: "application/json", id: generateId() }],
});
onCreate?.("http_request", id);
},
},
{
label: "gRPC",
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: async () => {
const id = await createRequestAndNavigate({ model: "grpc_request", workspaceId, folderId });
onCreate?.("grpc_request", id);
},
},
{
label: "WebSocket",
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: async () => {
const id = await createRequestAndNavigate({
model: "websocket_request",
workspaceId,
folderId,
});
onCreate?.("websocket_request", id);
},
},
...((hideFolder
? []
: [
{ type: "separator" },
{
label: "Folder",
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: async () => {
const id = await createFolder.mutateAsync({ folderId });
if (id != null) {
onCreate?.("folder", id);
}
},
},
]) as DropdownItem[]),
];
}

View File

@@ -0,0 +1,14 @@
import { useCallback } from "react";
import { CreateWorkspaceDialog } from "../components/CreateWorkspaceDialog";
import { showDialog } from "../lib/dialog";
export function useCreateWorkspace() {
return useCallback(() => {
showDialog({
id: "create-workspace",
title: "Create Workspace",
size: "sm",
render: ({ hide }) => <CreateWorkspaceDialog hide={hide} />,
});
}, []);
}

View File

@@ -0,0 +1,12 @@
import { rpc } from "../lib/rpc";
import { useFastMutation } from "./useFastMutation";
export function useDeleteGrpcConnections(requestId?: string) {
return useFastMutation({
mutationKey: ["delete_grpc_connections", requestId],
mutationFn: async () => {
if (requestId === undefined) return;
await rpc("cmd_delete_all_grpc_connections", { requestId });
},
});
}

View File

@@ -0,0 +1,12 @@
import { rpc } from "../lib/rpc";
import { useFastMutation } from "./useFastMutation";
export function useDeleteHttpResponses(requestId?: string) {
return useFastMutation({
mutationKey: ["delete_http_responses", requestId],
mutationFn: async () => {
if (requestId === undefined) return;
await rpc("cmd_delete_all_http_responses", { requestId });
},
});
}

View File

@@ -0,0 +1,52 @@
import {
grpcConnectionsAtom,
httpResponsesAtom,
websocketConnectionsAtom,
} from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { showAlert } from "../lib/alert";
import { showConfirmDelete } from "../lib/confirm";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
export function useDeleteSendHistory() {
const httpResponses = useAtomValue(httpResponsesAtom);
const grpcConnections = useAtomValue(grpcConnectionsAtom);
const websocketConnections = useAtomValue(websocketConnectionsAtom);
const labels = [
httpResponses.length > 0 ? pluralizeCount("Http Response", httpResponses.length) : null,
grpcConnections.length > 0 ? pluralizeCount("Grpc Connection", grpcConnections.length) : null,
websocketConnections.length > 0
? pluralizeCount("WebSocket Connection", websocketConnections.length)
: null,
].filter((l) => l != null);
return useFastMutation({
mutationKey: ["delete_send_history", labels],
mutationFn: async () => {
if (labels.length === 0) {
showAlert({
id: "no-responses",
title: "Nothing to Delete",
body: "There is no Http, Grpc, or Websocket history",
});
return;
}
const confirmed = await showConfirmDelete({
id: "delete-send-history",
title: "Clear Send History",
description: <>Delete {labels.join(" and ")}?</>,
});
if (!confirmed) return false;
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
await rpc("cmd_delete_send_history", { workspaceId });
return true;
},
});
}

View File

@@ -0,0 +1,10 @@
import type { Environment } from "@yaakapp-internal/models";
import { useKeyValue } from "./useKeyValue";
export function useEnvironmentValueVisibility(environment: Environment) {
return useKeyValue<boolean>({
namespace: "global",
key: ["environmentValueVisibility", environment.workspaceId],
fallback: false,
});
}

View File

@@ -0,0 +1,66 @@
import type { Environment, EnvironmentVariable } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { jotaiStore } from "../lib/jotai";
import { isBaseEnvironment, isFolderEnvironment } from "../lib/model_util";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { useActiveRequest } from "./useActiveRequest";
import { useEnvironmentsBreakdown } from "./useEnvironmentsBreakdown";
import { useParentFolders } from "./useParentFolders";
export function useEnvironmentVariables(targetEnvironmentId: string | null) {
const { baseEnvironment, folderEnvironments, allEnvironments } = useEnvironmentsBreakdown();
const activeEnvironment = useActiveEnvironment();
const targetEnvironment = allEnvironments.find((e) => e.id === targetEnvironmentId) ?? null;
const activeRequest = useActiveRequest();
const folders = useAtomValue(foldersAtom);
const activeFolder = folders.find((f) => f.id === targetEnvironment?.parentId) ?? null;
const parentFolders = useParentFolders(activeFolder ?? activeRequest);
return useMemo(() => {
const varMap: Record<string, WrappedEnvironmentVariable> = {};
const folderVariables = parentFolders.flatMap((f) =>
wrapVariables(folderEnvironments.find((fe) => fe.parentId === f.id) ?? null),
);
// Add active environment variables to everything except sub environments
const activeEnvironmentVariables =
targetEnvironment == null || // Editing request
isFolderEnvironment(targetEnvironment) || // Editing folder variables
isBaseEnvironment(targetEnvironment) // Editing global variables
? wrapVariables(activeEnvironment)
: wrapVariables(targetEnvironment); // Add own variables for sub environments
const allVariables = [
...folderVariables,
...activeEnvironmentVariables,
...wrapVariables(baseEnvironment),
];
for (const v of allVariables) {
if (!v.variable.enabled || !v.variable.name || v.variable.name in varMap) {
continue;
}
varMap[v.variable.name] = v;
}
return Object.values(varMap);
}, [activeEnvironment, baseEnvironment, folderEnvironments, parentFolders, targetEnvironment]);
}
export interface WrappedEnvironmentVariable {
variable: EnvironmentVariable;
environment: Environment;
source: string;
}
function wrapVariables(e: Environment | null): WrappedEnvironmentVariable[] {
if (e == null) return [];
const folders = jotaiStore.get(foldersAtom);
return e.variables.map((v) => {
const folder = e.parentModel === "folder" ? folders.find((f) => f.id === e.parentId) : null;
const source = folder?.name ?? e.name;
return { variable: v, environment: e, source };
});
}

View File

@@ -0,0 +1,35 @@
import { environmentsAtom } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
export const environmentsBreakdownAtom = atom((get) => {
const allEnvironments = get(environmentsAtom);
const baseEnvironments = allEnvironments.filter((e) => e.parentModel === "workspace") ?? [];
const subEnvironments =
allEnvironments
.filter((e) => e.parentModel === "environment")
?.sort((a, b) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
}
return a.sortPriority - b.sortPriority;
}) ?? [];
const folderEnvironments =
allEnvironments.filter((e) => e.parentModel === "folder" && e.parentId != null) ?? [];
const baseEnvironment = baseEnvironments[0] ?? null;
const otherBaseEnvironments = baseEnvironments.filter((e) => e.id !== baseEnvironment?.id) ?? [];
return {
allEnvironments,
baseEnvironment,
subEnvironments,
folderEnvironments,
otherBaseEnvironments,
baseEnvironments,
};
});
export function useEnvironmentsBreakdown() {
return useAtomValue(environmentsBreakdownAtom);
}

View File

@@ -0,0 +1,85 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { useCallback } from "react";
import { useKey } from "react-use";
interface UseEventViewerKeyboardProps {
totalCount: number;
activeIndex: number | null;
setActiveIndex: (index: number | null) => void;
virtualizer?: Virtualizer<HTMLDivElement, Element> | null;
isContainerFocused: () => boolean;
enabled?: boolean;
closePanel?: () => void;
openPanel?: () => void;
}
export function useEventViewerKeyboard({
totalCount,
activeIndex,
setActiveIndex,
virtualizer,
isContainerFocused,
enabled = true,
closePanel,
openPanel,
}: UseEventViewerKeyboardProps) {
const selectPrev = useCallback(() => {
if (totalCount === 0) return;
const newIndex = activeIndex == null ? 0 : Math.max(0, activeIndex - 1);
setActiveIndex(newIndex);
virtualizer?.scrollToIndex(newIndex, { align: "auto" });
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
const selectNext = useCallback(() => {
if (totalCount === 0) return;
const newIndex = activeIndex == null ? 0 : Math.min(totalCount - 1, activeIndex + 1);
setActiveIndex(newIndex);
virtualizer?.scrollToIndex(newIndex, { align: "auto" });
}, [activeIndex, setActiveIndex, totalCount, virtualizer]);
useKey(
(e) => e.key === "ArrowUp" || e.key === "k",
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
selectPrev();
},
undefined,
[enabled, isContainerFocused, selectPrev],
);
useKey(
(e) => e.key === "ArrowDown" || e.key === "j",
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
selectNext();
},
undefined,
[enabled, isContainerFocused, selectNext],
);
useKey(
(e) => e.key === "Escape",
(e) => {
if (!enabled || !isContainerFocused()) return;
e.preventDefault();
closePanel?.();
},
undefined,
[enabled, isContainerFocused, closePanel],
);
useKey(
(e) => e.key === "Enter" || e.key === " ",
(e) => {
if (!enabled || !isContainerFocused() || activeIndex == null) return;
e.preventDefault();
openPanel?.();
},
undefined,
[enabled, isContainerFocused, activeIndex, openPanel],
);
}

View File

@@ -0,0 +1,41 @@
import { workspacesAtom } from "@yaakapp-internal/models";
import { ExportDataDialog } from "../components/ExportDataDialog";
import { showAlert } from "../lib/alert";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { showToast } from "../lib/toast";
import { activeWorkspaceAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
export function useExportData() {
return useFastMutation({
mutationKey: ["export_data"],
onError: (err: string) => {
showAlert({ id: "export-failed", title: "Export Failed", body: err });
},
mutationFn: async () => {
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
if (activeWorkspace == null || workspaces.length === 0) return;
showDialog({
id: "export-data",
title: "Export Data",
size: "md",
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
onSuccess={() => {
showToast({
color: "success",
message: "Data export successful",
});
}}
/>
),
});
},
});
}

View File

@@ -0,0 +1,76 @@
import type { MutationKey } from "@tanstack/react-query";
import { useMemo } from "react";
import { showToast } from "../lib/toast";
interface MutationOptions<TData, TError, TVariables> {
mutationKey: MutationKey;
mutationFn: (vars: TVariables) => Promise<TData>;
onSettled?: () => void;
onError?: (err: TError) => void;
onSuccess?: (data: TData) => void;
disableToastError?: boolean;
}
type CallbackMutationOptions<TData, TError, TVariables> = Omit<
MutationOptions<TData, TError, TVariables>,
"mutationKey" | "mutationFn"
>;
export function createFastMutation<TData = unknown, TError = unknown, TVariables = void>(
defaultArgs: MutationOptions<TData, TError, TVariables>,
) {
const mutateAsync = async (
variables: TVariables,
args?: CallbackMutationOptions<TData, TError, TVariables>,
) => {
const { mutationKey, mutationFn, disableToastError } = {
...defaultArgs,
...args,
};
try {
const data = await mutationFn(variables);
// Run both default and custom onSuccess callbacks
defaultArgs.onSuccess?.(data);
args?.onSuccess?.(data);
defaultArgs.onSettled?.();
args?.onSettled?.();
return data;
} catch (err: unknown) {
const stringKey = mutationKey.join(".");
const e = err as TError;
console.log("mutation error", stringKey, e);
if (!disableToastError) {
showToast({
id: stringKey,
message: err instanceof Error ? err.message : String(err),
color: "danger",
timeout: 5000,
});
}
// Run both default and custom onError callbacks
defaultArgs.onError?.(e);
args?.onError?.(e);
defaultArgs.onSettled?.();
args?.onSettled?.();
throw e;
}
};
const mutate = (
variables: TVariables,
args?: CallbackMutationOptions<TData, TError, TVariables>,
) => {
setTimeout(() => mutateAsync(variables, args));
};
return { mutateAsync, mutate };
}
export function useFastMutation<TData = unknown, TError = unknown, TVariables = void>(
defaultArgs: MutationOptions<TData, TError, TVariables>,
) {
return useMemo(() => {
return createFastMutation(defaultArgs);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Force it!
}, defaultArgs.mutationKey);
}

View File

@@ -0,0 +1,14 @@
import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "./useActiveWorkspace";
import { useKeyValue } from "./useKeyValue";
export function useFloatingSidebarHidden() {
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const { set, value } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["floating_sidebar_hidden", activeWorkspace?.id ?? "n/a"],
fallback: false,
});
return [value, set] as const;
}

View File

@@ -0,0 +1,50 @@
import { useQuery } from "@tanstack/react-query";
import type { Folder } from "@yaakapp-internal/models";
import type {
CallFolderActionRequest,
FolderAction,
GetFolderActionsResponse,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableFolderAction = Pick<FolderAction, "label" | "icon"> & {
call: (folder: Folder) => Promise<void>;
};
export function useFolderActions() {
const pluginsKey = usePluginsKey();
const actionsResult = useQuery<CallableFolderAction[]>({
queryKey: ["folder_actions", pluginsKey],
queryFn: () => getFolderActions(),
});
// oxlint-disable-next-line react-hooks/exhaustive-deps
const actions = useMemo(() => {
return actionsResult.data ?? [];
}, [JSON.stringify(actionsResult.data)]);
return actions;
}
export async function getFolderActions() {
const responses = await rpc<GetFolderActionsResponse[]>("cmd_folder_actions");
const actions = responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
icon: a.icon,
call: async (folder: Folder) => {
const payload: CallFolderActionRequest = {
index: i,
pluginRefId: r.pluginRefId,
args: { folder },
};
await rpc("cmd_call_folder_action", { req: payload });
},
})),
);
return actions;
}

View File

@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import type { EditorProps } from "../components/core/Editor/Editor";
import { tryFormatJson, tryFormatXml } from "../lib/formatters";
export function useFormatText({
text,
language,
pretty,
}: {
text: string;
language: EditorProps["language"];
pretty: boolean;
}) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [text, language, pretty],
queryFn: async () => {
if (text === "" || !pretty) {
return text;
}
if (language === "json") {
return tryFormatJson(text);
}
if (language === "xml" || language === "html") {
return tryFormatXml(text);
}
return text;
},
}).data;
}

View File

@@ -0,0 +1,18 @@
import { useKeyValue } from "./useKeyValue";
// The file a request's GraphQL schema is loaded from, or null when the schema
// comes from an introspection request.
//
// This is the *source*, not the schema. The introspection row it produces is a
// cache that expires on its own; this outlives it and regenerates it, the same
// way gRPC keeps its proto file list separate from a reflection result.
export function graphqlSchemaFileArgs(requestId: string | null) {
return {
namespace: "global" as const,
key: ["graphql_schema_file", requestId ?? "n/a"],
};
}
export function useGraphQLSchemaFile(requestId: string | null) {
return useKeyValue<string | null>({ ...graphqlSchemaFileArgs(requestId), fallback: null });
}

View File

@@ -0,0 +1,78 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { rpc } from "../lib/rpc";
import { activeEnvironmentIdAtom, useActiveEnvironment } from "./useActiveEnvironment";
import { useDebouncedValue } from "@yaakapp-internal/ui";
export interface ReflectResponseService {
name: string;
methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[];
}
export function useGrpc(
req: GrpcRequest | null,
conn: GrpcConnection | null,
protoFiles: string[],
) {
const requestId = req?.id ?? "n/a";
const environment = useActiveEnvironment();
const go = useMutation<void, string>({
mutationKey: ["grpc_go", conn?.id],
mutationFn: async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
return rpc<void>("cmd_grpc_go", {
requestId,
environmentId: environment?.id,
protoFiles,
});
},
});
const send = useMutation({
mutationKey: ["grpc_send", conn?.id],
mutationFn: ({ message }: { message: string }) =>
platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, { Message: message }),
});
const cancel = useMutation({
mutationKey: ["grpc_cancel", conn?.id ?? "n/a"],
mutationFn: () => platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Cancel"),
});
const commit = useMutation({
mutationKey: ["grpc_commit", conn?.id ?? "n/a"],
mutationFn: () => platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Commit"),
});
const debouncedUrl = useDebouncedValue<string>(req?.url ?? "", 1000);
const reflect = useQuery<ReflectResponseService[], string>({
enabled: req != null,
queryKey: ["grpc_reflect", req?.id ?? "n/a", debouncedUrl, protoFiles],
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
queryFn: () => {
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
return minPromiseMillis<ReflectResponseService[]>(
rpc("cmd_grpc_reflect", { requestId, protoFiles, environmentId }),
300,
);
},
});
return {
go,
reflect,
cancel,
commit,
isStreaming: conn != null && conn.state !== "closed",
send,
};
}

View File

@@ -0,0 +1,17 @@
import { getKeyValue } from "../lib/keyValueStore";
import { useKeyValue } from "./useKeyValue";
export function protoFilesArgs(requestId: string | null) {
return {
namespace: "global" as const,
key: ["proto_files", requestId ?? "n/a"],
};
}
export function useGrpcProtoFiles(activeRequestId: string | null) {
return useKeyValue<string[]>({ ...protoFilesArgs(activeRequestId), fallback: [] });
}
export async function getGrpcProtoFiles(activeRequestId: string | null) {
return getKeyValue<string[]>({ ...protoFilesArgs(activeRequestId), fallback: [] });
}

View File

@@ -0,0 +1,53 @@
import { useQuery } from "@tanstack/react-query";
import type { GrpcRequest } from "@yaakapp-internal/models";
import type {
CallGrpcRequestActionRequest,
GetGrpcRequestActionsResponse,
GrpcRequestAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { rpc } from "../lib/rpc";
import { getGrpcProtoFiles } from "./useGrpcProtoFiles";
import { usePluginsKey } from "./usePlugins";
export type CallableGrpcRequestAction = Pick<GrpcRequestAction, "label" | "icon"> & {
call: (grpcRequest: GrpcRequest) => Promise<void>;
};
export function useGrpcRequestActions() {
const pluginsKey = usePluginsKey();
const actionsResult = useQuery<CallableGrpcRequestAction[]>({
queryKey: ["grpc_request_actions", pluginsKey],
queryFn: async () => {
return getGrpcRequestActions();
},
});
// oxlint-disable-next-line react-hooks/exhaustive-deps
const actions = useMemo(() => {
return actionsResult.data ?? [];
}, [JSON.stringify(actionsResult.data)]);
return actions;
}
export async function getGrpcRequestActions() {
const responses = await rpc<GetGrpcRequestActionsResponse[]>("cmd_grpc_request_actions");
return responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
icon: a.icon,
call: async (grpcRequest: GrpcRequest) => {
const protoFiles = await getGrpcProtoFiles(grpcRequest.id);
const payload: CallGrpcRequestActionRequest = {
index: i,
pluginRefId: r.pluginRefId,
args: { grpcRequest, protoFiles },
};
await rpc("cmd_call_grpc_request_action", { req: payload });
},
})),
);
}

View File

@@ -0,0 +1,31 @@
import { useMemo } from "react";
import { CountBadge } from "../components/core/CountBadge";
import type { TabItem } from "../components/core/Tabs/Tabs";
import type { HeaderModel } from "./useInheritedHeaders";
import { useInheritedHeaders } from "./useInheritedHeaders";
export function useHeadersTab<T extends string>(
tabValue: T,
model: HeaderModel | null,
label?: string,
) {
const inheritedHeaders = useInheritedHeaders(model);
return useMemo<TabItem[]>(() => {
if (model == null) return [];
const allHeaders = [
...inheritedHeaders,
...(model.model === "grpc_request" ? model.metadata : model.headers),
];
const numHeaders = allHeaders.filter((h) => h.name).length;
const tab: TabItem = {
value: tabValue,
label: label ?? "Headers",
rightSlot: <CountBadge count={numHeaders} />,
};
return [tab];
}, [inheritedHeaders, label, model, tabValue]);
}

View File

@@ -0,0 +1,442 @@
import { platform } from "@yaakapp-internal/platform";
import { debounce } from "@yaakapp-internal/lib";
import { settingsAtom } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { useEffect } from "react";
import { capitalize } from "../lib/capitalize";
import { jotaiStore } from "../lib/jotai";
const HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"];
const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"];
export type HotkeyAction =
| "app.zoom_in"
| "app.zoom_out"
| "app.zoom_reset"
| "command_palette.toggle"
| "cookies_editor.show"
| "editor.autocomplete"
| "environment_editor.toggle"
| "hotkeys.showHelp"
| "model.create"
| "model.duplicate"
| "request.send"
| "request.rename"
| "switcher.next"
| "switcher.prev"
| "switcher.toggle"
| "settings.show"
| "sidebar.filter"
| "sidebar.selected.delete"
| "sidebar.selected.duplicate"
| "sidebar.selected.move"
| "sidebar.selected.rename"
| "sidebar.expand_all"
| "sidebar.collapse_all"
| "sidebar.focus"
| "sidebar.context_menu"
| "url_bar.focus"
| "workspace_settings.show";
/** Default hotkeys for macOS (uses Meta for Cmd) */
const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
"app.zoom_in": ["Meta+Equal"],
"app.zoom_out": ["Meta+Minus"],
"app.zoom_reset": ["Meta+0"],
"command_palette.toggle": ["Meta+k"],
"cookies_editor.show": ["Meta+Shift+k"],
"editor.autocomplete": ["Control+Space"],
"environment_editor.toggle": ["Meta+Shift+e"],
"request.rename": ["Control+Shift+r"],
"request.send": ["Meta+Enter", "Meta+r"],
"hotkeys.showHelp": ["Meta+Shift+/"],
"model.create": ["Meta+n"],
"model.duplicate": ["Meta+d"],
"switcher.next": ["Control+Shift+Tab"],
"switcher.prev": ["Control+Tab"],
"switcher.toggle": ["Meta+p"],
"settings.show": ["Meta+,"],
"sidebar.filter": ["Meta+f"],
"sidebar.expand_all": ["Meta+Shift+Equal"],
"sidebar.collapse_all": ["Meta+Shift+Minus"],
"sidebar.selected.delete": ["Delete", "Meta+Backspace"],
"sidebar.selected.duplicate": ["Meta+d"],
"sidebar.selected.move": [],
"sidebar.selected.rename": ["Enter"],
"sidebar.focus": ["Meta+b"],
"sidebar.context_menu": ["Control+Enter"],
"url_bar.focus": ["Meta+l"],
"workspace_settings.show": ["Meta+;"],
};
/** Default hotkeys for Windows/Linux (uses Control for Ctrl) */
const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
"app.zoom_in": ["Control+Equal"],
"app.zoom_out": ["Control+Minus"],
"app.zoom_reset": ["Control+0"],
"command_palette.toggle": ["Control+k"],
"cookies_editor.show": ["Control+Shift+k"],
"editor.autocomplete": ["Control+Space"],
"environment_editor.toggle": ["Control+Shift+e"],
"request.rename": ["F2"],
"request.send": ["Control+Enter", "Control+r"],
"hotkeys.showHelp": ["Control+Shift+/"],
"model.create": ["Control+n"],
"model.duplicate": ["Control+d"],
"switcher.next": ["Control+Shift+Tab"],
"switcher.prev": ["Control+Tab"],
"switcher.toggle": ["Control+p"],
"settings.show": ["Control+,"],
"sidebar.filter": ["Control+f"],
"sidebar.expand_all": ["Control+Shift+Equal"],
"sidebar.collapse_all": ["Control+Shift+Minus"],
"sidebar.selected.delete": ["Delete", "Control+Backspace"],
"sidebar.selected.duplicate": ["Control+d"],
"sidebar.selected.move": [],
"sidebar.selected.rename": ["Enter"],
"sidebar.focus": ["Control+b"],
"sidebar.context_menu": ["Alt+Insert"],
"url_bar.focus": ["Control+l"],
"workspace_settings.show": ["Control+;"],
};
/** Get the default hotkeys for the current platform */
export const defaultHotkeys: Record<HotkeyAction, string[]> =
platform.osType() === "macos" ? defaultHotkeysMac : defaultHotkeysOther;
/** Atom that provides the effective hotkeys by merging defaults with user settings */
export const hotkeysAtom = atom((get) => {
const settings = get(settingsAtom);
const customHotkeys = settings?.hotkeys ?? {};
// Merge default hotkeys with custom hotkeys from settings
// Custom hotkeys override defaults for the same action
// An empty array means the hotkey is intentionally disabled
const merged: Partial<Record<HotkeyAction, string[]>> = {};
for (const action of hotkeyActions) {
merged[action] = defaultHotkeys[action];
}
for (const [action, keys] of Object.entries(customHotkeys)) {
if (action in merged && Array.isArray(keys)) {
merged[action as HotkeyAction] = keys;
}
}
return merged;
});
/** Helper function to get current hotkeys from the store */
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
return jotaiStore.get(hotkeysAtom);
}
const hotkeyLabels: Record<HotkeyAction, string> = {
"app.zoom_in": "Zoom In",
"app.zoom_out": "Zoom Out",
"app.zoom_reset": "Zoom to Actual Size",
"command_palette.toggle": "Toggle Command Palette",
"cookies_editor.show": "Show Cookies",
"editor.autocomplete": "Trigger Autocomplete",
"environment_editor.toggle": "Edit Environments",
"hotkeys.showHelp": "Show Keyboard Shortcuts",
"model.create": "New Request",
"model.duplicate": "Duplicate Request",
"request.rename": "Rename Active Request",
"request.send": "Send Active Request",
"switcher.next": "Go To Previous Request",
"switcher.prev": "Go To Next Request",
"switcher.toggle": "Toggle Request Switcher",
"settings.show": "Open Settings",
"sidebar.filter": "Filter Sidebar",
"sidebar.expand_all": "Expand All Folders",
"sidebar.collapse_all": "Collapse All Folders",
"sidebar.selected.delete": "Delete Selected Sidebar Item",
"sidebar.selected.duplicate": "Duplicate Selected Sidebar Item",
"sidebar.selected.move": "Move Selected to Workspace",
"sidebar.selected.rename": "Rename Selected Sidebar Item",
"sidebar.focus": "Focus or Toggle Sidebar",
"sidebar.context_menu": "Show Context Menu",
"url_bar.focus": "Focus URL",
"workspace_settings.show": "Open Workspace Settings",
};
const layoutInsensitiveKeys = [
"Equal",
"Minus",
"BracketLeft",
"BracketRight",
"Backquote",
"Space",
];
/** Zoom is the browser's own on these keys, so the app has no such action there. */
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
/**
* The actions this host actually has. An action left out of here has no keys in
* `hotkeysAtom`, so it never matches and never claims the keystroke.
*/
export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
)
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
.sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
priority?: number;
allowDefault?: boolean;
};
interface Callback {
action: HotkeyAction;
callback: (e: KeyboardEvent) => void;
options: HotKeyOptions;
}
const callbacksAtom = atom<Callback[]>([]);
const currentKeysAtom = atom<Set<string>>(new Set([]));
export const sortedCallbacksAtom = atom((get) =>
[...get(callbacksAtom)].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)),
);
const clearCurrentKeysDebounced = debounce(() => {
jotaiStore.set(currentKeysAtom, new Set([]));
}, 5000);
export function useHotKey(
action: HotkeyAction | null,
callback: (e: KeyboardEvent) => void,
options: HotKeyOptions = {},
) {
useEffect(() => {
if (action == null) return;
jotaiStore.set(callbacksAtom, (prev) => {
const without = prev.filter((cb) => {
const isTheSame = cb.action === action && cb.options.priority === options.priority;
return !isTheSame;
});
const newCb: Callback = { action, callback, options };
return [...without, newCb];
});
return () => {
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
};
}, [action, callback, options]);
}
export function useSubscribeHotKeys() {
useEffect(() => {
document.addEventListener("keyup", handleKeyUp, { capture: true });
document.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
document.removeEventListener("keydown", handleKeyDown, { capture: true });
document.removeEventListener("keyup", handleKeyUp, { capture: true });
};
}, []);
}
function handleKeyUp(e: KeyboardEvent) {
const keyToRemove = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
const currentKeys = new Set(jotaiStore.get(currentKeysAtom));
currentKeys.delete(keyToRemove);
// Clear all keys if no longer holding modifier
// HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;
// As you see, the ":" is not removed because it turned into ";" when shift was released
const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
if (!isHoldingModifier) {
currentKeys.clear();
}
jotaiStore.set(currentKeysAtom, currentKeys);
}
function handleKeyDown(e: KeyboardEvent) {
// Don't add key if not holding modifier
const isValidKeymapKey =
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || SINGLE_WHITELIST.includes(e.key);
if (!isValidKeymapKey) {
return;
}
// Don't add hold keys
if (HOLD_KEYS.includes(e.key)) {
return;
}
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
const currentKeys = new Set(jotaiStore.get(currentKeysAtom));
currentKeys.add(keyToAdd);
const currentKeysWithModifiers = new Set(currentKeys);
if (e.altKey) currentKeysWithModifiers.add("Alt");
if (e.ctrlKey) currentKeysWithModifiers.add("Control");
if (e.metaKey) currentKeysWithModifiers.add("Meta");
if (e.shiftKey) currentKeysWithModifiers.add("Shift");
// Don't trigger if the user is focused within an element that explicitly disableds hotkeys
if (document.activeElement?.closest("[data-disable-hotkey]")) {
return;
}
// Don't support certain single-key combinations within inputs
if (
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
currentKeysWithModifiers.size === 1 &&
(currentKeysWithModifiers.has("Backspace") || currentKeysWithModifiers.has("Delete"))
) {
return;
}
const executed: string[] = [];
for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
const enable = typeof options.enable === "function" ? options.enable() : options.enable;
if (enable === false) {
continue;
}
if (keysMatchAction(Array.from(currentKeysWithModifiers), action)) {
if (!options.allowDefault) {
e.preventDefault();
e.stopPropagation();
}
callback(e);
executed.push(`${action} ${options.priority ?? 0}`);
break;
}
}
if (executed.length > 0) {
console.log("Executed hotkey", executed.join(", "));
jotaiStore.set(currentKeysAtom, new Set([]));
}
clearCurrentKeysDebounced();
}
export function useHotkeyLabel(action: HotkeyAction): string {
return hotkeyLabels[action];
}
export function getHotkeyScope(action: HotkeyAction): string {
const scope = action.split(".")[0];
return scope || "";
}
export function formatHotkeyString(trigger: string): string[] {
const os = platform.osType();
const parts = trigger.split("+");
const labelParts: string[] = [];
for (const p of parts) {
if (os === "macos") {
if (p === "Meta") {
labelParts.push("⌘");
} else if (p === "Shift") {
labelParts.push("⇧");
} else if (p === "Control") {
labelParts.push("⌃");
} else if (p === "Alt") {
labelParts.push("⌥");
} else if (p === "Enter") {
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
labelParts.push("↩︎");
} else if (p === "Tab") {
labelParts.push("⇥");
} else if (p === "Backspace") {
labelParts.push("⌫");
} else if (p === "Delete") {
labelParts.push("⌦");
} else if (p === "Minus") {
labelParts.push("-");
} else if (p === "Plus") {
labelParts.push("+");
} else if (p === "Equal") {
labelParts.push("=");
} else if (p === "Space") {
labelParts.push("Space");
} else {
labelParts.push(capitalize(p));
}
} else {
if (p === "Control") {
labelParts.push("Ctrl");
} else if (p === "Space") {
labelParts.push("Space");
} else {
labelParts.push(capitalize(p));
}
}
}
if (os === "macos") {
return labelParts;
}
return [labelParts.join("+")];
}
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const hotkeys = useAtomValue(hotkeysAtom);
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
if (trigger == null) {
return null;
}
return formatHotkeyString(trigger);
}
function compareKeys(keysA: string[], keysB: string[]) {
if (keysA.length !== keysB.length) return false;
const sortedA = keysA
.map((k) => k.toLowerCase())
.sort()
.join("::");
const sortedB = keysB
.map((k) => k.toLowerCase())
.sort()
.join("::");
return sortedA === sortedB;
}
/** Build the full key combination from a KeyboardEvent including modifiers */
function getKeysFromEvent(e: KeyboardEvent): string[] {
const keys: string[] = [];
if (e.altKey) keys.push("Alt");
if (e.ctrlKey) keys.push("Control");
if (e.metaKey) keys.push("Meta");
if (e.shiftKey) keys.push("Shift");
// Add the actual key (use code for layout-insensitive keys)
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
keys.push(keyToAdd);
return keys;
}
/** Check if a set of pressed keys matches any hotkey for the given action */
function keysMatchAction(keys: string[], action: HotkeyAction): boolean {
const hotkeys = getHotkeys();
const hkKeys = hotkeys[action];
if (!hkKeys || hkKeys.length === 0) return false;
for (const hkKey of hkKeys) {
const hotkeyParts = hkKey.split("+");
if (compareKeys(hotkeyParts, keys)) {
return true;
}
}
return false;
}
/** Check if a KeyboardEvent matches a hotkey action */
export function eventMatchesHotkey(e: KeyboardEvent, action: HotkeyAction): boolean {
const keys = getKeysFromEvent(e);
return keysMatchAction(keys, action);
}

View File

@@ -0,0 +1,49 @@
import { useQuery } from "@tanstack/react-query";
import type { GetHttpAuthenticationSummaryResponse } from "@yaakapp-internal/plugins";
import { atom, useAtomValue } from "jotai";
import { useState } from "react";
import { jotaiStore } from "../lib/jotai";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { usePluginsKey } from "./usePlugins";
const httpAuthenticationSummariesAtom = atom<GetHttpAuthenticationSummaryResponse[]>([]);
const orderedHttpAuthenticationAtom = atom((get) =>
get(httpAuthenticationSummariesAtom)?.sort((a, b) => a.name.localeCompare(b.name)),
);
export function useHttpAuthenticationSummaries() {
return useAtomValue(orderedHttpAuthenticationAtom);
}
export function useSubscribeHttpAuthentication() {
const [numResults, setNumResults] = useState<number>(0);
const pluginsKey = usePluginsKey();
useQuery({
queryKey: ["http_authentication_summaries", pluginsKey],
// Fetch periodically until functions are returned
// NOTE: visibilitychange (refetchOnWindowFocus) does not work on Windows, so we'll rely on this logic
// to refetch things until that's working again
// TODO: Update plugin system to wait for plugins to initialize before sending the first event to them
refetchInterval: numResults > 0 ? Number.POSITIVE_INFINITY : 1000,
refetchOnMount: true,
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
try {
const result = await rpc<GetHttpAuthenticationSummaryResponse[]>(
"cmd_get_http_authentication_summaries",
);
setNumResults(result.length);
jotaiStore.set(httpAuthenticationSummariesAtom, result);
return result;
} catch (err) {
showErrorToast({
id: "http-authentication-error",
title: "HTTP Authentication Error",
message: err,
});
}
},
});
}

View File

@@ -0,0 +1,84 @@
import { useQuery } from "@tanstack/react-query";
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { httpResponsesAtom } from "@yaakapp-internal/models";
import type { GetHttpAuthenticationConfigResponse, JsonPrimitive } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import { useState } from "react";
import { rpc } from "../lib/rpc";
import { activeEnvironmentIdAtom } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
export function useHttpAuthenticationConfig(
authName: string | null,
values: Record<string, JsonPrimitive>,
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
) {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const environmentId = useAtomValue(activeEnvironmentIdAtom);
const responses = useAtomValue(httpResponsesAtom);
const [forceRefreshCounter, setForceRefreshCounter] = useState<number>(0);
// Some auth handlers like OAuth 2.0 show the current token after a successful request. To
// handle that, we'll force the auth to re-fetch after each new response closes
const responseKey = md5(
responses
.filter((r) => r.state === "closed")
.map((r) => r.id)
.join(":"),
);
return useQuery({
queryKey: [
"http_authentication_config",
model,
authName,
values,
responseKey,
forceRefreshCounter,
workspaceId,
environmentId,
],
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
if (authName == null || authName === "inherit") return null;
const config = await rpc<GetHttpAuthenticationConfigResponse>(
"cmd_get_http_authentication_config",
{
authName,
values,
model,
environmentId,
},
);
return {
...config,
actions: config.actions?.map((a, i) => ({
...a,
call: async (
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
) => {
await rpc("cmd_call_http_authentication_action", {
pluginRefId: config.pluginRefId,
actionIndex: i,
authName,
values,
model,
environmentId,
});
// Ensure the config is refreshed after the action is done
setForceRefreshCounter((c) => c + 1);
},
})),
};
},
});
}

View File

@@ -0,0 +1,50 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpRequest } from "@yaakapp-internal/models";
import type {
CallHttpRequestActionRequest,
GetHttpRequestActionsResponse,
HttpRequestAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableHttpRequestAction = Pick<HttpRequestAction, "label" | "icon"> & {
call: (httpRequest: HttpRequest) => Promise<void>;
};
export function useHttpRequestActions() {
const pluginsKey = usePluginsKey();
const actionsResult = useQuery<CallableHttpRequestAction[]>({
queryKey: ["http_request_actions", pluginsKey],
queryFn: () => getHttpRequestActions(),
});
// oxlint-disable-next-line react-hooks/exhaustive-deps
const actions = useMemo(() => {
return actionsResult.data ?? [];
}, [JSON.stringify(actionsResult.data)]);
return actions;
}
export async function getHttpRequestActions() {
const responses = await rpc<GetHttpRequestActionsResponse[]>("cmd_http_request_actions");
const actions = responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
icon: a.icon,
call: async (httpRequest: HttpRequest) => {
const payload: CallHttpRequestActionRequest = {
index: i,
pluginRefId: r.pluginRefId,
args: { httpRequest },
};
await rpc("cmd_call_http_request_action", { req: payload });
},
})),
);
return actions;
}

View File

@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { rpc } from "../lib/rpc";
export function useHttpRequestBody(response: HttpResponse | null) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ["request_body", response?.id, response?.state, response?.requestContentLength],
enabled: (response?.requestContentLength ?? 0) > 0,
queryFn: async () => {
return getRequestBodyText(response);
},
});
}
export async function getRequestBodyText(response: HttpResponse | null) {
if (response?.id == null) {
return null;
}
const data = await rpc<number[] | null>("cmd_http_request_body", {
responseId: response.id,
});
if (data == null) {
return null;
}
const body = new Uint8Array(data);
const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(body);
return { body, bodyText };
}

View File

@@ -0,0 +1,32 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import {
httpResponseEventsAtom,
mergeModelsInStore,
replaceModelsInStore,
} from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { fireAndForget } from "../lib/fireAndForget";
import { rpc } from "../lib/rpc";
export function useHttpResponseEvents(response: HttpResponse | null) {
const allEvents = useAtomValue(httpResponseEventsAtom);
useEffect(() => {
if (response?.id == null) {
replaceModelsInStore("http_response_event", []);
return;
}
// Fetch events from database, filtering out events from other responses and merging atomically
fireAndForget(
rpc<HttpResponseEvent[]>("cmd_get_http_response_events", { responseId: response.id }).then(
(events) =>
mergeModelsInStore("http_response_event", events, (e) => e.responseId === response.id),
),
);
}, [response?.id]);
const events = allEvents.filter((e) => e.responseId === response?.id);
return { data: events, error: null, isLoading: false };
}

View File

@@ -0,0 +1,46 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
const ancestorsAtom = atom((get) => [...get(foldersAtom), ...get(workspacesAtom)]);
export type AuthenticatedModel = HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
export function useInheritedAuthentication(baseModel: AuthenticatedModel | null) {
const parents = useAtomValue(ancestorsAtom);
if (baseModel == null) return null;
const next = (child: AuthenticatedModel) => {
// We hit the top
if (child.model === "workspace") {
return child.authenticationType == null ? null : child;
}
// Has valid auth
if (child.authenticationType !== null) {
return child;
}
// Recurse up the tree
const parent = parents.find((p) => {
if (child.folderId) return p.id === child.folderId;
return p.id === child.workspaceId;
});
// Failed to find parent (should never happen)
if (parent == null) {
return null;
}
return next(parent);
};
return next(baseModel);
}

View File

@@ -0,0 +1,53 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
HttpRequestHeader,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { defaultHeaders } from "../lib/defaultHeaders";
const ancestorsAtom = atom((get) => [...get(foldersAtom), ...get(workspacesAtom)]);
export type HeaderModel = HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
export function useInheritedHeaders(baseModel: HeaderModel | null) {
const parents = useAtomValue(ancestorsAtom);
if (baseModel == null) return [];
if (baseModel.model === "workspace") return defaultHeaders;
const next = (child: HeaderModel): HttpRequestHeader[] => {
// Short-circuit at workspace level - return global defaults + workspace headers
if (child.model === "workspace") {
return [...defaultHeaders, ...child.headers];
}
// Recurse up the tree
const parent = parents.find((p) => {
if (child.folderId) return p.id === child.folderId;
return p.id === child.workspaceId;
});
// Failed to find parent (should never happen)
if (parent == null) {
return [];
}
const headers = next(parent);
return [...headers, ...parent.headers];
};
const allHeaders = next(baseModel);
// Deduplicate by header name (case-insensitive), keeping the latest (most specific) value
const headersByName = new Map<string, HttpRequestHeader>();
for (const header of allHeaders) {
headersByName.set(header.name.toLowerCase(), header);
}
return Array.from(headersByName.values());
}

View File

@@ -0,0 +1,11 @@
import { installPluginFromDirectory } from "@yaakapp-internal/plugins";
import { useFastMutation } from "./useFastMutation";
export function useInstallPlugin() {
return useFastMutation<void, unknown, string>({
mutationKey: ["install_plugin"],
mutationFn: async (directory: string) => {
await installPluginFromDirectory(directory);
},
});
}

View File

@@ -0,0 +1,268 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, getIntrospectionQuery } from "graphql";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { rpc } from "../lib/rpc";
const introspectionRequestBody = JSON.stringify({
query: getIntrospectionQuery(),
operationName: "IntrospectionQuery",
});
export function useIntrospectGraphQL(
baseRequest: HttpRequest,
options: { disabled?: boolean } = {},
) {
// Debounce the request because it can change rapidly, and we don't
// want to send so too many requests.
const debouncedRequest = useDebouncedValue(baseRequest);
const activeEnvironment = useActiveEnvironment();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>();
const [schema, setSchema] = useState<GraphQLSchema | null>(null);
const queryClient = useQueryClient();
const introspection = useIntrospectionResult(baseRequest);
// The schema's source. Outlives the introspection row it produces, so a
// request configured with a file keeps working after the row is swept.
const schemaFile = useGraphQLSchemaFile(baseRequest.id);
const filePath = schemaFile.value ?? null;
const upsertIntrospection = useCallback(
async (content: string | null) => {
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
requestId: baseRequest.id,
workspaceId: baseRequest.workspaceId,
content: content ?? "",
});
// Update local introspection
queryClient.setQueryData(["introspection", baseRequest.id], v);
},
[baseRequest.id, baseRequest.workspaceId, queryClient],
);
const refetch = useCallback(async () => {
try {
setIsLoading(true);
setError(undefined);
const args = {
...baseRequest,
bodyType: "application/json",
body: { text: introspectionRequestBody },
};
const { response, body } = await minPromiseMillis(
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
700,
);
if (response.error) {
return setError(response.error);
}
// The send hands back the only copy of the body — an unsaved response has
// nothing on disk and no row to read it back from
const bodyText = new TextDecoder("utf-8").decode(new Uint8Array(body));
if (response.status < 200 || response.status >= 300) {
return setError(
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
);
}
if (bodyText === "") {
return setError("Empty body returned in response");
}
console.log(`Got introspection response for ${baseRequest.url}`, bodyText);
await upsertIntrospection(bodyText);
} catch (err) {
setError(String(err));
} finally {
setIsLoading(false);
}
}, [activeEnvironment?.id, baseRequest, upsertIntrospection]);
// oxlint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
// Skip introspection if automatic is disabled and we already have one
if (options.disabled) {
return;
}
// A request pointed at a file gets its schema from that file. Introspecting
// here would overwrite it on the next URL edit.
if (filePath != null) {
return;
}
refetch().catch(console.error);
}, [
baseRequest.id,
debouncedRequest.url,
debouncedRequest.method,
activeEnvironment?.id,
filePath,
]);
// Clears the schema, not the source. Removing a file source is a separate
// action, because the source is what would rebuild this a moment later.
const clear = useCallback(async () => {
setError("");
setSchema(null);
await upsertIntrospection(null);
}, [upsertIntrospection]);
// Reads a schema file and produces an introspection row from it, the same way
// `refetch` produces one from a server. Does not touch the stored source.
const introspectFromFile = useCallback(
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
try {
setIsLoading(true);
setError(undefined);
const fileContent = await platform.files.readText(path);
const result = tryBuildIntrospectionFromFile(fileContent);
if ("error" in result) {
setError(result.error);
return { ok: false, error: result.error };
}
await upsertIntrospection(result.content);
return { ok: true };
} catch (err) {
// The host rejects with a bare string for a missing or unreadable path,
// so this can't assume an Error.
const message = err instanceof Error ? err.message : String(err);
setError(message);
return { ok: false, error: message };
} finally {
setIsLoading(false);
}
},
[upsertIntrospection],
);
// Points the request at a file and immediately builds its schema from it.
const loadFromFile = useCallback(
async (path: string) => {
const result = await introspectFromFile(path);
if (result.ok) await schemaFile.set(path);
return result;
},
[introspectFromFile, schemaFile],
);
const reloadFromFile = useCallback(async () => {
if (filePath == null) return { ok: false as const, error: "No schema file to reload" };
return introspectFromFile(filePath);
}, [filePath, introspectFromFile]);
// The file-source counterpart of automatic introspection: re-read the file
// when the request is opened, so an edited schema is picked up without asking.
//
// A missing row is repaired even with the setting off — that is recovering
// from the 7-day sweep, not keeping the schema fresh, and skipping it would
// make the schema disappear with no visible cause.
const reloadedFor = useRef<string | null>(null);
useEffect(() => {
if (filePath == null || introspection.isLoading) return;
// Only attempt once per path, so an unreadable file doesn't spin.
if (reloadedFor.current === filePath) return;
const hasContent = (introspection.data?.content ?? "") !== "";
if (hasContent && options.disabled) return;
reloadedFor.current = filePath;
introspectFromFile(filePath).catch(console.error);
}, [
filePath,
introspection.data?.content,
introspection.isLoading,
introspectFromFile,
options.disabled,
]);
// Stops using the file. The schema goes with it, since the file is what
// produced it; introspection repopulates if it's set to run automatically.
const removeSchemaFile = useCallback(async () => {
setError("");
setSchema(null);
await schemaFile.set(null);
await upsertIntrospection(null);
}, [schemaFile, upsertIntrospection]);
useEffect(() => {
if (introspection.data?.content == null || introspection.data.content === "") {
return;
}
const parseResult = tryParseIntrospectionToSchema(introspection.data.content);
if ("error" in parseResult) {
setError(parseResult.error);
} else {
setSchema(parseResult.schema);
}
}, [introspection.data?.content]);
return {
schema,
isLoading,
error,
refetch,
clear,
loadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
};
}
function useIntrospectionResult(request: HttpRequest) {
return useQuery({
queryKey: ["introspection", request.id],
queryFn: async () =>
rpc<GraphQlIntrospection | null>("models_get_graphql_introspection", {
requestId: request.id,
}),
});
}
export function useCurrentGraphQLSchema(request: HttpRequest) {
const result = useIntrospectionResult(request);
return useMemo(() => {
if (result.data == null) return null;
if (result.data.content == null || result.data.content === "") return null;
const r = tryParseIntrospectionToSchema(result.data.content);
return "error" in r ? null : r.schema;
}, [result.data]);
}
function tryParseIntrospectionToSchema(
content: string,
): { schema: GraphQLSchema } | { error: string } {
let parsedResponse: IntrospectionQuery;
try {
parsedResponse = JSON.parse(content).data;
// oxlint-disable-next-line no-explicit-any
} catch (e: any) {
return { error: String("message" in e ? e.message : e) };
}
try {
return { schema: buildClientSchema(parsedResponse, {}) };
// oxlint-disable-next-line no-explicit-any
} catch (e: any) {
return { error: String("message" in e ? e.message : e) };
}
}

View File

@@ -0,0 +1,7 @@
import { useAtomValue } from "jotai";
import { activeWorkspaceMetaAtom } from "./useActiveWorkspace";
export function useIsEncryptionEnabled() {
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
return workspaceMeta?.encryptionKey != null;
}

View File

@@ -0,0 +1,90 @@
import deepEqual from "@gilbarbara/deep-equal";
import { useMutation } from "@tanstack/react-query";
import { keyValuesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
import { useCallback, useMemo } from "react";
import { jotaiStore } from "../lib/jotai";
import { buildKeyValueKey, extractKeyValueOrFallback, setKeyValue } from "../lib/keyValueStore";
const DEFAULT_NAMESPACE = "global";
export function useKeyValue<T extends object | boolean | number | string | null>({
namespace = DEFAULT_NAMESPACE,
key,
fallback,
}: {
namespace?: "global" | "no_sync" | "license";
key: string | string[];
fallback: T;
}) {
const { value, isLoading } = useAtomValue(
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only create a new atom when the key changes. Fallback might not be a stable reference, so we don't want to refresh on that.
useMemo(
() =>
selectAtom(
keyValuesAtom,
(keyValues) => {
const keyValue =
keyValues?.find((kv) => buildKeyValueKey(kv.key) === buildKeyValueKey(key)) ?? null;
const value = keyValues == null ? null : extractKeyValueOrFallback(keyValue, fallback);
const isLoading = keyValues == null;
return { value, isLoading };
},
(a, b) => deepEqual(a, b),
),
[buildKeyValueKey(key)],
),
);
const { mutateAsync } = useMutation<void, unknown, T>({
mutationKey: ["set_key_value", namespace, key],
mutationFn: (value) => setKeyValue<T>({ namespace, key, value }),
});
// oxlint-disable-next-line react-hooks/exhaustive-deps
const set = useCallback(
async (valueOrUpdate: ((v: T) => T) | T) => {
if (typeof valueOrUpdate === "function") {
const newV = valueOrUpdate(value ?? fallback);
if (newV === value) return;
await mutateAsync(newV);
} else {
// TODO: Make this only update if the value is different. I tried this but it seems query.data
// is stale.
await mutateAsync(valueOrUpdate);
}
},
[typeof key === "string" ? key : key.join("::"), namespace, value],
);
const reset = useCallback(async () => mutateAsync(fallback), [fallback, mutateAsync]);
return useMemo(
() => ({
value,
isLoading,
set,
reset,
}),
[isLoading, reset, set, value],
);
}
export function getKeyValue<T extends object | boolean | number | string | null>({
namespace,
key,
fallback,
}: {
namespace?: "global" | "no_sync" | "license";
key: string | string[];
fallback: T;
}) {
const keyValues = jotaiStore.get(keyValuesAtom);
const keyValue =
keyValues?.find(
(kv) => kv.namespace === namespace && buildKeyValueKey(kv.key) === buildKeyValueKey(key),
) ?? null;
const value = extractKeyValueOrFallback(keyValue, fallback);
return value;
}

View File

@@ -0,0 +1,16 @@
import { useEffect } from "react";
export function useKeyboardEvent(
event: "keyup" | "keydown",
key: KeyboardEvent["key"],
cb: () => void,
) {
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Don't have `cb` as a dep for caller convenience
useEffect(() => {
const fn = (e: KeyboardEvent) => {
if (e.key === key) cb();
};
document.addEventListener(event, fn);
return () => document.removeEventListener(event, fn);
}, [event]);
}

View File

@@ -0,0 +1,7 @@
import type { GrpcConnection } from "@yaakapp-internal/models";
import { grpcConnectionsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
export function useLatestGrpcConnection(requestId: string | null): GrpcConnection | null {
return useAtomValue(grpcConnectionsAtom).find((c) => c.requestId === requestId) ?? null;
}

View File

@@ -0,0 +1,7 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { httpResponsesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
export function useLatestHttpResponse(requestId: string | null): HttpResponse | null {
return useAtomValue(httpResponsesAtom).find((r) => r.requestId === requestId) ?? null;
}

View File

@@ -0,0 +1,41 @@
import type { AnyModel, Folder, Workspace } from "@yaakapp-internal/models";
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
type ModelAncestor = Folder | Workspace;
export function useModelAncestors(m: AnyModel | null) {
const folders = useAtomValue(foldersAtom);
const workspaces = useAtomValue(workspacesAtom);
return useMemo(() => getModelAncestors(folders, workspaces, m), [folders, workspaces, m]);
}
export function getModelAncestors(
folders: Folder[],
workspaces: Workspace[],
currentModel: AnyModel | null,
): ModelAncestor[] {
if (currentModel == null) return [];
const parentFolder =
"folderId" in currentModel && currentModel.folderId
? folders.find((f) => f.id === currentModel.folderId)
: null;
if (parentFolder != null) {
return [parentFolder, ...getModelAncestors(folders, workspaces, parentFolder)];
}
const parentWorkspace =
"workspaceId" in currentModel && currentModel.workspaceId
? workspaces.find((w) => w.id === currentModel.workspaceId)
: null;
if (parentWorkspace != null) {
return [parentWorkspace, ...getModelAncestors(folders, workspaces, parentWorkspace)];
}
return [];
}

View File

@@ -0,0 +1,24 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
const folders = useAtomValue(foldersAtom);
// Key on folderId, not the model itself, so edits to the model (eg. every URL
// keystroke replacing the active request) don't produce a new array identity
const folderId = m?.folderId ?? null;
return useMemo(() => getParentFolders(folders, folderId), [folders, folderId]);
}
function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
if (folderId == null) return [];
const parentFolder = folders.find((f) => f.id === folderId);
if (parentFolder == null) {
return [];
}
return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
}

View File

@@ -0,0 +1,84 @@
import type { GrpcConnection, GrpcEvent } from "@yaakapp-internal/models";
import {
grpcConnectionsAtom,
grpcEventsAtom,
mergeModelsInStore,
replaceModelsInStore,
} from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { fireAndForget } from "../lib/fireAndForget";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { activeRequestIdAtom } from "./useActiveRequestId";
import { rpc } from "../lib/rpc";
const pinnedGrpcConnectionIdsAtom = atomWithKVStorage<Record<string, string | null>>(
"pinned-grpc-connection-ids",
{},
);
export const pinnedGrpcConnectionIdAtom = atom(
(get) => {
const activeRequestId = get(activeRequestIdAtom);
const activeConnections = get(activeGrpcConnections);
const latestConnection = activeConnections[0] ?? null;
if (!activeRequestId) return null;
const key = recordKey(activeRequestId, latestConnection);
return get(pinnedGrpcConnectionIdsAtom)[key] ?? null;
},
(get, set, id: string | null) => {
const activeRequestId = get(activeRequestIdAtom);
const activeConnections = get(activeGrpcConnections);
const latestConnection = activeConnections[0] ?? null;
if (!activeRequestId) return;
const key = recordKey(activeRequestId, latestConnection);
set(pinnedGrpcConnectionIdsAtom, (prev) => ({
...prev,
[key]: id,
}));
},
);
function recordKey(activeRequestId: string | null, latestConnection: GrpcConnection | null) {
return `${activeRequestId}-${latestConnection?.id ?? "none"}`;
}
export const activeGrpcConnections = atom<GrpcConnection[]>((get) => {
const activeRequestId = get(activeRequestIdAtom) ?? "n/a";
return get(grpcConnectionsAtom).filter((c) => c.requestId === activeRequestId) ?? [];
});
export const activeGrpcConnectionAtom = atom<GrpcConnection | null>((get) => {
const activeRequestId = get(activeRequestIdAtom) ?? "n/a";
const activeConnections = get(activeGrpcConnections);
const latestConnection = activeConnections[0] ?? null;
const pinnedConnectionId = get(pinnedGrpcConnectionIdsAtom)[
recordKey(activeRequestId, latestConnection)
];
return activeConnections.find((c) => c.id === pinnedConnectionId) ?? activeConnections[0] ?? null;
});
export function useGrpcEvents(connectionId: string | null) {
const allEvents = useAtomValue(grpcEventsAtom);
useEffect(() => {
if (connectionId == null) {
replaceModelsInStore("grpc_event", []);
return;
}
// Fetch events from database, filtering out events from other connections and merging atomically
fireAndForget(
rpc<GrpcEvent[]>("models_grpc_events", { connectionId }).then((events) =>
mergeModelsInStore("grpc_event", events, (e) => e.connectionId === connectionId),
),
);
}, [connectionId]);
return useMemo(
() => allEvents.filter((e) => e.connectionId === connectionId),
[allEvents, connectionId],
);
}

View File

@@ -0,0 +1,29 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { httpResponsesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useKeyValue } from "./useKeyValue";
import { useLatestHttpResponse } from "./useLatestHttpResponse";
export function usePinnedHttpResponse(activeRequestId: string) {
const latestResponse = useLatestHttpResponse(activeRequestId);
const { set, value: pinnedResponseId } = useKeyValue<string | null>({
// Key on the latest response instead of activeRequest because responses change out of band of active request
key: ["pinned_http_response_id", latestResponse?.id ?? "n/a"],
fallback: null,
namespace: "global",
});
const allResponses = useAtomValue(httpResponsesAtom);
const responses = allResponses.filter((r) => r.requestId === activeRequestId);
const activeResponse: HttpResponse | null =
responses.find((r) => r.id === pinnedResponseId) ?? latestResponse;
const setPinnedResponseId = async (id: string) => {
if (pinnedResponseId === id) {
await set(null);
} else {
await set(id);
}
};
return { activeResponse, setPinnedResponseId, pinnedResponseId, responses } as const;
}

View File

@@ -0,0 +1,71 @@
import type { WebsocketConnection, WebsocketEvent } from "@yaakapp-internal/models";
import {
mergeModelsInStore,
replaceModelsInStore,
websocketConnectionsAtom,
websocketEventsAtom,
} from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { fireAndForget } from "../lib/fireAndForget";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { jotaiStore } from "../lib/jotai";
import { activeRequestIdAtom } from "./useActiveRequestId";
import { rpc } from "../lib/rpc";
const pinnedWebsocketConnectionIdAtom = atomWithKVStorage<Record<string, string | null>>(
"pinned-websocket-connection-ids",
{},
);
function recordKey(activeRequestId: string | null, latestConnection: WebsocketConnection | null) {
return `${activeRequestId}-${latestConnection?.id ?? "none"}`;
}
export const activeWebsocketConnectionsAtom = atom<WebsocketConnection[]>((get) => {
const activeRequestId = get(activeRequestIdAtom) ?? "n/a";
return get(websocketConnectionsAtom).filter((c) => c.requestId === activeRequestId) ?? [];
});
export const activeWebsocketConnectionAtom = atom<WebsocketConnection | null>((get) => {
const activeRequestId = get(activeRequestIdAtom) ?? "n/a";
const activeConnections = get(activeWebsocketConnectionsAtom);
const latestConnection = activeConnections[0] ?? null;
const pinnedConnectionId = get(pinnedWebsocketConnectionIdAtom)[
recordKey(activeRequestId, latestConnection)
];
return activeConnections.find((c) => c.id === pinnedConnectionId) ?? activeConnections[0] ?? null;
});
export function setPinnedWebsocketConnectionId(id: string | null) {
const activeRequestId = jotaiStore.get(activeRequestIdAtom);
const activeConnections = jotaiStore.get(activeWebsocketConnectionsAtom);
const latestConnection = activeConnections[0] ?? null;
if (activeRequestId == null) return;
jotaiStore.set(pinnedWebsocketConnectionIdAtom, (prev) => {
return { ...prev, [recordKey(activeRequestId, latestConnection)]: id };
});
}
export function useWebsocketEvents(connectionId: string | null) {
const allEvents = useAtomValue(websocketEventsAtom);
useEffect(() => {
if (connectionId == null) {
replaceModelsInStore("websocket_event", []);
return;
}
// Fetch events from database, filtering out events from other connections and merging atomically
fireAndForget(
rpc<WebsocketEvent[]>("models_websocket_events", { connectionId }).then((events) =>
mergeModelsInStore("websocket_event", events, (e) => e.connectionId === connectionId),
),
);
}, [connectionId]);
return useMemo(
() => allEvents.filter((e) => e.connectionId === connectionId),
[allEvents, connectionId],
);
}

View File

@@ -0,0 +1,12 @@
import { platform } from "@yaakapp-internal/platform";
import { useEffect, useRef } from "react";
/** Subscribe to a backend event for the lifetime of the component. */
export function usePlatformEvent<T>(event: string, fn: (payload: T) => void) {
const handlerRef = useRef(fn);
useEffect(() => {
handlerRef.current = fn;
}, [fn]);
useEffect(() => platform.listen<T>(event, (payload) => handlerRef.current(payload)), [event]);
}

View File

@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import type { Plugin } from "@yaakapp-internal/models";
import { pluginsAtom } from "@yaakapp-internal/models";
import type { PluginMetadata } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai";
import { queryClient } from "../lib/queryClient";
import { rpc } from "../lib/rpc";
function pluginInfoKey(id: string | null, plugin: Plugin | null) {
return ["plugin_info", id ?? "n/a", plugin?.updatedAt ?? "n/a"];
}
export function usePluginInfo(id: string | null) {
const plugins = useAtomValue(pluginsAtom);
// Get the plugin so we can refetch whenever it's updated
const plugin = plugins.find((p) => p.id === id);
return useQuery({
queryKey: pluginInfoKey(id, plugin ?? null),
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: () => {
if (id == null) return null;
return rpc<PluginMetadata>("cmd_plugin_info", { id });
},
});
}
export function invalidateAllPluginInfo() {
queryClient.invalidateQueries({ queryKey: ["plugin_info"] }).catch(console.error);
}

View File

@@ -0,0 +1,37 @@
import { useMutation } from "@tanstack/react-query";
import { changeModelStoreWorkspace, pluginsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { rpc } from "../lib/rpc";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { invalidateAllPluginInfo } from "./usePluginInfo";
export function usePluginsKey() {
const pluginKey = useAtomValue(pluginsAtom)
.map((p) => p.id + p.updatedAt)
.join(",");
// Debounce plugins both for efficiency and to give plugins a chance to reload after the DB updates
return useDebouncedValue(pluginKey, 1000);
}
/**
* Reload all plugins and refresh the list of plugins
*/
export function useRefreshPlugins() {
return useMutation({
mutationKey: ["refresh_plugins"],
mutationFn: async () => {
await minPromiseMillis(
(async () => {
await rpc("cmd_reload_plugins");
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
await changeModelStoreWorkspace(workspaceId); // Force refresh models
invalidateAllPluginInfo();
})(),
);
},
});
}

View File

@@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
import type { Appearance } from "@yaakapp-internal/theme";
import {
getCSSAppearance,
getSystemAppearance,
subscribeToPreferredAppearance,
} from "@yaakapp-internal/theme";
export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(
getSystemAppearance() ?? getCSSAppearance(),
);
useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []);
return preferredAppearance;
}

View File

@@ -0,0 +1,8 @@
import { useCallback, useState } from "react";
import { generateId } from "../lib/generateId";
export function useRandomKey(initialValue?: string) {
const [value, setValue] = useState<string>(initialValue ?? generateId());
const regenerate = useCallback(() => setValue(generateId()), []);
return [value, regenerate] as const;
}

View File

@@ -0,0 +1,53 @@
import { cookieJarsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { jotaiStore } from "../lib/jotai";
import { getKeyValue, setKeyValue } from "../lib/keyValueStore";
import { activeCookieJarAtom } from "./useActiveCookieJar";
import { useKeyValue } from "./useKeyValue";
const kvKey = (workspaceId: string) => `recent_cookie_jars::${workspaceId}`;
const namespace = "global";
const fallback: string[] = [];
export function useRecentCookieJars() {
const cookieJars = useAtomValue(cookieJarsAtom);
const kv = useKeyValue<string[]>({
key: kvKey(cookieJars[0]?.workspaceId ?? "n/a"),
namespace,
fallback,
});
const onlyValidIds = useMemo(
() => kv.value?.filter((id) => cookieJars?.some((e) => e.id === id)) ?? [],
[kv.value, cookieJars],
);
return onlyValidIds;
}
export function useSubscribeRecentCookieJars() {
useEffect(() => {
return jotaiStore.sub(activeCookieJarAtom, async () => {
const activeCookieJar = jotaiStore.get(activeCookieJarAtom);
if (activeCookieJar == null) return;
const key = kvKey(activeCookieJar.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeCookieJar.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeCookieJar.id);
const value = [activeCookieJar.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value });
});
}, []);
}
export async function getRecentCookieJars(workspaceId: string) {
return getKeyValue<string[]>({
namespace,
key: kvKey(workspaceId),
fallback,
});
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useMemo } from "react";
import { jotaiStore } from "../lib/jotai";
import { getKeyValue, setKeyValue } from "../lib/keyValueStore";
import { activeEnvironmentAtom } from "./useActiveEnvironment";
import { useEnvironmentsBreakdown } from "./useEnvironmentsBreakdown";
import { useKeyValue } from "./useKeyValue";
const kvKey = (workspaceId: string) => `recent_environments::${workspaceId}`;
const namespace = "global";
const fallback: string[] = [];
export function useRecentEnvironments() {
const { subEnvironments, allEnvironments } = useEnvironmentsBreakdown();
const kv = useKeyValue<string[]>({
key: kvKey(allEnvironments[0]?.workspaceId ?? "n/a"),
namespace,
fallback,
});
const onlyValidIds = useMemo(
() => kv.value?.filter((id) => subEnvironments.some((e) => e.id === id)) ?? [],
[kv.value, subEnvironments],
);
return onlyValidIds;
}
export function useSubscribeRecentEnvironments() {
useEffect(() => {
return jotaiStore.sub(activeEnvironmentAtom, async () => {
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
if (activeEnvironment == null) return;
const key = kvKey(activeEnvironment.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeEnvironment.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeEnvironment.id);
const value = [activeEnvironment.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value });
});
}, []);
}
export async function getRecentEnvironments(workspaceId: string) {
return getKeyValue<string[]>({
namespace,
key: kvKey(workspaceId),
fallback,
});
}

View File

@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { useKeyValue } from "./useKeyValue";
export interface RecentFilter {
value: string;
pinned?: boolean;
}
const MAX_RECENT_FILTERS = 20;
const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`;
const namespace = "global";
const fallback: RecentFilter[] = [];
export function useRecentFilters(filterStateKey: string | null) {
const { value, set } = useKeyValue<RecentFilter[]>({
key: kvKey(filterStateKey ?? "n/a"),
namespace,
fallback,
});
const addFilter = useCallback(
async (rawValue: string) => {
const value = rawValue.trim();
if (filterStateKey == null || value === "") return;
await set((prev) => {
// Returning the same reference skips the write, so re-committing the
// expression already at the top (on every blur) costs nothing
if (prev[0]?.value === value) return prev;
const existing = prev.find((f) => f.value === value);
const rest = prev.filter((f) => f.value !== value);
return trim([{ value, pinned: existing?.pinned }, ...rest]);
});
},
[filterStateKey, set],
);
const removeFilter = useCallback(
async (value: string) => set((prev) => prev.filter((f) => f.value !== value)),
[set],
);
const togglePin = useCallback(
async (value: string) =>
set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))),
[set],
);
const clearFilters = useCallback(async () => set([]), [set]);
return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters };
}
/** Bound the list, evicting the oldest unpinned entries before any pinned ones */
function trim(filters: RecentFilter[]): RecentFilter[] {
const excess = filters.length - MAX_RECENT_FILTERS;
if (excess <= 0) return filters;
const evicted = new Set<number>();
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
if (!filters[i]?.pinned) evicted.add(i);
}
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
evicted.add(i);
}
return filters.filter((_, i) => !evicted.has(i));
}

View File

@@ -0,0 +1,53 @@
import { useEffect, useMemo } from "react";
import { jotaiStore } from "../lib/jotai";
import { getKeyValue, setKeyValue } from "../lib/keyValueStore";
import { activeRequestAtom } from "./useActiveRequest";
import { useAllRequests } from "./useAllRequests";
import { useKeyValue } from "./useKeyValue";
const kvKey = (workspaceId: string) => `recent_requests::${workspaceId}`;
const namespace = "global";
const fallback: string[] = [];
export function useRecentRequests() {
const requests = useAllRequests();
const { set: setRecentRequests, value: recentRequests } = useKeyValue<string[]>({
key: kvKey(requests[0]?.workspaceId ?? "n/a"),
namespace,
fallback,
});
const onlyValidIds = useMemo(
() => recentRequests?.filter((id) => requests.some((r) => r.id === id)) ?? [],
[recentRequests, requests],
);
return [onlyValidIds, setRecentRequests] as const;
}
export function useSubscribeRecentRequests() {
useEffect(() => {
return jotaiStore.sub(activeRequestAtom, async () => {
const activeRequest = jotaiStore.get(activeRequestAtom);
if (activeRequest == null) return;
const key = kvKey(activeRequest.workspaceId);
const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeRequest.id) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeRequest.id);
const value = [activeRequest.id, ...withoutActiveId];
await setKeyValue({ namespace, key, value });
});
}, []);
}
export async function getRecentRequests(workspaceId: string) {
return getKeyValue<string[]>({
namespace,
key: kvKey(workspaceId),
fallback,
});
}

View File

@@ -0,0 +1,48 @@
import { workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { jotaiStore } from "../lib/jotai";
import { getKeyValue, setKeyValue } from "../lib/keyValueStore";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useKeyValue } from "./useKeyValue";
const kvKey = () => "recent_workspaces";
const namespace = "global";
const fallback: string[] = [];
export function useRecentWorkspaces() {
const workspaces = useAtomValue(workspacesAtom);
const { value, isLoading } = useKeyValue<string[]>({ key: kvKey(), namespace, fallback });
const onlyValidIds = useMemo(
() => value?.filter((id) => workspaces.some((w) => w.id === id)) ?? [],
[value, workspaces],
);
if (isLoading) return null;
return onlyValidIds;
}
export function useSubscribeRecentWorkspaces() {
useEffect(() => {
const unsub = jotaiStore.sub(activeWorkspaceIdAtom, updateRecentWorkspaces);
updateRecentWorkspaces().catch(console.error); // Update when opened in a new window
return unsub;
}, []);
}
async function updateRecentWorkspaces() {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (activeWorkspaceId == null) return;
const key = kvKey();
const recentIds = getKeyValue<string[]>({ namespace, key, fallback });
if (recentIds[0] === activeWorkspaceId) return; // Short-circuit
const withoutActiveId = recentIds.filter((id) => id !== activeWorkspaceId);
const value = [activeWorkspaceId, ...withoutActiveId];
console.log("Recent workspaces update", activeWorkspaceId);
await setKeyValue({ namespace, key, value });
}

View File

@@ -0,0 +1,71 @@
import { useQuery } from "@tanstack/react-query";
import type { RenderPurpose } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { rpc } from "../lib/rpc";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
export function useRenderTemplate({
template,
enabled,
purpose,
refreshKey,
ignoreError,
preservePreviousValue,
}: {
template: string;
enabled: boolean;
purpose: RenderPurpose;
refreshKey?: string | null;
ignoreError?: boolean;
preservePreviousValue?: boolean;
}) {
const workspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "n/a";
const environmentId = useActiveEnvironment()?.id ?? null;
return useQuery<string>({
refetchOnWindowFocus: false,
enabled,
placeholderData: preservePreviousValue ? (prev) => prev : undefined,
queryKey: ["render_template", workspaceId, environmentId, refreshKey, purpose, ignoreError],
queryFn: () =>
minPromiseMillis(
renderTemplate({ template, workspaceId, environmentId, purpose, ignoreError }),
300,
),
});
}
export async function renderTemplate({
template,
workspaceId,
environmentId,
purpose,
ignoreError,
}: {
template: string;
workspaceId: string;
environmentId: string | null;
purpose: RenderPurpose;
ignoreError?: boolean;
}): Promise<string> {
return rpc("cmd_render_template", {
template,
workspaceId,
environmentId,
purpose,
ignoreError,
});
}
export async function decryptTemplate({
template,
workspaceId,
environmentId,
}: {
template: string;
workspaceId: string;
environmentId: string | null;
}): Promise<string> {
return rpc("cmd_decrypt_template", { template, workspaceId, environmentId });
}

View File

@@ -0,0 +1,85 @@
import EventEmitter from "eventemitter3";
import { atom, useAtom } from "jotai";
import type { DependencyList } from "react";
import { useCallback, useEffect } from "react";
type EventDataMap = {
"request_params.focus_value": string;
"request_pane.focus_tab": undefined;
};
export function useRequestEditorEvent<
Event extends keyof EventDataMap,
Data extends EventDataMap[Event],
>(event: Event, fn: (data: Data) => void, deps?: DependencyList) {
useEffect(() => {
emitter.on(event, fn);
return () => {
emitter.off(event, fn);
};
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We're handing deps manually
}, deps);
}
export const urlKeyAtom = atom<string>(Math.random().toString());
export const urlParamsKeyAtom = atom<string>(Math.random().toString());
export function useRequestEditor() {
const [urlParametersKey, setUrlParametersKey] = useAtom(urlParamsKeyAtom);
const [urlKey, setUrlKey] = useAtom(urlKeyAtom);
const focusParamsTab = useCallback(() => {
emitter.emit("request_pane.focus_tab", undefined);
}, []);
const focusParamValue = useCallback(
(name: string) => {
focusParamsTab();
requestAnimationFrame(() => emitter.emit("request_params.focus_value", name));
},
[focusParamsTab],
);
const forceUrlRefresh = useCallback(() => setUrlKey(Math.random().toString()), [setUrlKey]);
const forceParamsRefresh = useCallback(
() => setUrlParametersKey(Math.random().toString()),
[setUrlParametersKey],
);
return [
{
urlParametersKey,
urlKey,
},
{
focusParamValue,
focusParamsTab,
forceParamsRefresh,
forceUrlRefresh,
},
] as const;
}
const emitter = new (class RequestEditorEventEmitter {
#emitter: EventEmitter = new EventEmitter();
emit<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
data: Data,
) {
this.#emitter.emit(event, data);
}
on<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
fn: (data: Data) => void,
) {
this.#emitter.on(event, fn);
}
off<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
fn: (data: Data) => void,
) {
this.#emitter.off(event, fn);
}
})();

View File

@@ -0,0 +1,41 @@
import { platform } from "@yaakapp-internal/platform";
import type { ModelPayload } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { generateId } from "../lib/generateId";
import { jotaiStore } from "../lib/jotai";
const requestUpdateKeyAtom = atom<Record<string, string>>({});
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
const changedIds: string[] = [];
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (
(payload.model.model === "http_request" ||
payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" &&
payload.updateSource.label !== platform.window.label) ||
payload.updateSource.type !== "window")
) {
changedIds.push(payload.model.id);
}
}
if (changedIds.length > 0) wasUpdatedExternally(changedIds);
});
export function wasUpdatedExternally(changedRequestIds: string | string[]) {
const ids = Array.isArray(changedRequestIds) ? changedRequestIds : [changedRequestIds];
jotaiStore.set(requestUpdateKeyAtom, (m) => {
const next = { ...m };
for (const id of ids) next[id] = generateId();
return next;
});
}
export function useRequestUpdateKey(requestId: string | null) {
const keys = useAtomValue(requestUpdateKeyAtom);
const key = keys[requestId ?? "n/a"];
return `${requestId}::${key ?? "default"}`;
}

View File

@@ -0,0 +1,10 @@
import { settingsAtom } from "@yaakapp-internal/models";
import { resolveAppearance } from "@yaakapp-internal/theme";
import { useAtomValue } from "jotai";
import { usePreferredAppearance } from "./usePreferredAppearance";
export function useResolvedAppearance() {
const preferredAppearance = usePreferredAppearance();
const settings = useAtomValue(settingsAtom);
return resolveAppearance(preferredAppearance, settings.appearance);
}

View File

@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { getResolvedTheme, getThemes } from "../lib/themes";
import { usePluginsKey } from "./usePlugins";
import { usePreferredAppearance } from "./usePreferredAppearance";
export function useResolvedTheme() {
const preferredAppearance = usePreferredAppearance();
const settings = useAtomValue(settingsAtom);
const pluginKey = usePluginsKey();
return useQuery({
placeholderData: (prev) => prev,
queryKey: ["resolved_theme", preferredAppearance, settings.updatedAt, pluginKey],
queryFn: async () => {
const data = await getResolvedTheme(
preferredAppearance,
settings.appearance,
settings.themeLight,
settings.themeDark,
);
return { ...data, ...(await getThemes()) };
},
});
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { ServerSentEvent } from "@yaakapp-internal/sse";
import { getResponseBodyEventSource } from "../lib/responseBody";
export function useResponseBodyEventSource(response: HttpResponse) {
return useQuery<ServerSentEvent[]>({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [
"response-body-event-source",
response.id,
response.updatedAt,
response.contentLength,
],
queryFn: () => getResponseBodyEventSource(response),
});
}

View File

@@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { SseSummary } from "@yaakapp-internal/sse";
import { getResponseBodySseSummary } from "../lib/responseBody";
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
return useQuery<SseSummary>({
enabled: resultKeyPath != null,
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [
"response-body-sse-summary",
response.id,
response.updatedAt,
response.contentLength,
resultKeyPath,
],
queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""),
});
}

View File

@@ -0,0 +1,43 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
export function responseBodyTextQuery({
response,
filter,
}: {
response: HttpResponse;
filter: string | null;
}) {
return {
queryKey: [
"response_body_text",
response.id,
response.updatedAt,
response.contentLength,
filter ?? "",
],
queryFn: () => getResponseBodyText({ response, filter }),
};
}
export function useResponseBodyText({
response,
filter,
}: {
response: HttpResponse;
filter: string | null;
}) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
...responseBodyTextQuery({ response, filter }),
});
}
export function useResponseBodyBytes({ response }: { response: HttpResponse }) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ["response_body_bytes", response.id, response.updatedAt, response.contentLength],
queryFn: () => getResponseBodyBytes(response),
});
}

View File

@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
/**
* A URL for a stored response body, for the viewers that hand one to an element
* instead of reading the bytes themselves.
*
* Resolved once here rather than inside each viewer: the host may have to ask
* the backend where the body is, and a viewer that computes its source during
* render (the PDF one, deliberately) needs it settled before it mounts.
*
* Null data means the response has no stored body.
*/
export function useResponseBodyUrl(response: HttpResponse | null) {
const responseId = response?.id ?? null;
return useQuery({
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
enabled: responseId != null,
// A response body is stored under the response's own id
queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
});
}

View File

@@ -0,0 +1,129 @@
import { useCallback, useState } from "react";
import { createGlobalState } from "react-use";
import type { RecentFilter } from "./useRecentFilters";
import { useRecentFilters } from "./useRecentFilters";
/** What's typed in the filter box. `null` means the filter box is closed */
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
/** What's actually applied to the response. Only changes on an explicit apply */
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
export interface ResponseFilterApi {
stateKey: string | null;
/** Draft text in the filter box, or `null` when the box is closed */
filterText: string | null;
/** The expression currently filtering the response */
appliedFilter: string | null;
isSearching: boolean;
/** The box holds an expression that isn't the one currently applied */
isDirty: boolean;
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
filterUpdateKey: number;
setFilterText: (value: string | null) => void;
/** Apply the expression to the response, recording it if the filter accepts it */
applyFilter: (value: string) => void;
/** Like applyFilter, but also replaces what's shown in the filter box */
replaceFilter: (value: string) => void;
toggleSearch: () => void;
recentFilters: RecentFilter[];
removeRecentFilter: (value: string) => void;
togglePinRecentFilter: (value: string) => void;
clearRecentFilters: () => void;
}
/**
* Draft/applied state and history for a response filter (JSONPath/XPath).
*
* History records at most one entry per apply gesture, and only after `runFilter`
* confirms the plugin accepts the expression — the plugin is the sole judge of
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
* deleted entries and a gesture can't record into another request's history.
*/
export function useResponseFilter({
stateKey,
runFilter,
}: {
stateKey: string | null;
/** Evaluate an expression, rejecting if the filter plugin reports an error */
runFilter: (filter: string) => Promise<unknown>;
}): ResponseFilterApi {
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
const setFilterText = useCallback(
(v: string | null) => {
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setFilterTextMap],
);
const setAppliedFilter = useCallback(
(v: string | null) => {
if (!stateKey) return;
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setAppliedFilterMap],
);
const {
recentFilters,
addFilter,
removeFilter: removeRecentFilter,
togglePin: togglePinRecentFilter,
clearFilters: clearRecentFilters,
} = useRecentFilters(stateKey);
const applyFilter = useCallback(
(value: string) => {
setFilterText(value);
const applied = value.trim() === "" ? null : value.trim();
setAppliedFilter(applied);
if (applied == null) return;
runFilter(applied).then(
() => addFilter(applied),
() => {}, // Rejected by the filter plugin — don't record
);
},
[setFilterText, setAppliedFilter, runFilter, addFilter],
);
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const replaceFilter = useCallback(
(value: string) => {
applyFilter(value);
setFilterUpdateKey((k) => k + 1);
},
[applyFilter],
);
const isSearching = filterText != null;
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
setAppliedFilter(null);
} else {
setFilterText("");
}
}, [isSearching, setFilterText, setAppliedFilter]);
return {
stateKey,
filterText,
appliedFilter,
isSearching,
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
filterUpdateKey,
setFilterText,
applyFilter,
replaceFilter,
toggleSearch,
recentFilters,
removeRecentFilter,
togglePinRecentFilter,
clearRecentFilters,
};
}

View File

@@ -0,0 +1,8 @@
import { useLocalStorage } from "react-use";
const DEFAULT_VIEW_MODE = "pretty";
export function useResponseViewMode(requestId?: string): [string, (m: "pretty" | "raw") => void] {
const [value, setValue] = useLocalStorage<"pretty" | "raw">(`response_view_mode::${requestId}`);
return [value ?? DEFAULT_VIEW_MODE, setValue];
}

View File

@@ -0,0 +1,42 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { getModel } from "@yaakapp-internal/models";
import mime from "mime";
import slugify from "slugify";
import { InlineCode } from "@yaakapp-internal/ui";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { rpc } from "../lib/rpc";
import { showToast } from "../lib/toast";
import { useFastMutation } from "./useFastMutation";
import { platform } from "@yaakapp-internal/platform";
export function useSaveResponse(response: HttpResponse | null) {
return useFastMutation({
mutationKey: ["save_response", response?.id],
mutationFn: async () => {
if (response == null) return null;
const request = getModel("http_request", response.requestId);
if (request == null) return null;
const contentType = getContentTypeFromHeaders(response.headers) ?? "unknown";
const ext = mime.getExtension(contentType);
const slug = slugify(request.name || "response", { lower: true });
const filepath = await platform.dialog.save({
defaultPath: ext ? `${slug}.${ext}` : slug,
title: "Save Response",
});
if (filepath == null) {
return; // Cancelled
}
await rpc("cmd_save_response", { responseId: response.id, filepath });
showToast({
message: (
<>
Response saved to <InlineCode>{filepath}</InlineCode>
</>
),
});
},
});
}

View File

@@ -0,0 +1,9 @@
import { useEffect } from "react";
export function useScrollIntoView<T extends HTMLElement>(node: T | null, enabled: boolean) {
useEffect(() => {
if (enabled) {
node?.scrollIntoView({ block: "nearest" });
}
}, [enabled, node]);
}

View File

@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.test" as string | null },
showConfirm: vi.fn<() => Promise<boolean>>(),
flushAllModelWrites: vi.fn(),
rpc: vi.fn(),
}));
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
vi.mock("@yaakapp-internal/models", () => ({ flushAllModelWrites: mocks.flushAllModelWrites }));
vi.mock("../lib/confirm", () => ({ showConfirm: mocks.showConfirm }));
vi.mock("../lib/appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
vi.mock("../lib/toast", () => ({ showToast: vi.fn() }));
vi.mock("./useActiveCookieJar", () => ({ getActiveCookieJar: () => ({ id: "cj_test" }) }));
vi.mock("./useActiveEnvironment", () => ({ getActiveEnvironment: () => ({ id: "ev_test" }) }));
async function sender() {
return (await import("./useSendAnyHttpRequest")).sendAnyHttpRequest;
}
describe("web proxy confirmation before sending", () => {
let stored: Map<string, string>;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
mocks.platform.httpProxyUrl = "https://web.yaak.test";
mocks.rpc.mockResolvedValue({ id: "rs_test" });
stored = new Map();
vi.stubGlobal("localStorage", {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => stored.set(key, value),
});
});
afterEach(() => vi.unstubAllGlobals());
test("blocks all pending sends until one shared confirmation is accepted", async () => {
let decide!: (accepted: boolean) => void;
mocks.showConfirm.mockReturnValue(
new Promise((resolve) => {
decide = resolve;
}),
);
const send = await sender();
const first = send.mutateAsync("rq_one");
const second = send.mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
decide(true);
await Promise.all([first, second]);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
expect(mocks.rpc).toHaveBeenCalledWith("cmd_send_http_request", {
requestId: "rq_one",
environmentId: "ev_test",
cookieJarId: "cj_test",
});
expect(stored.size).toBe(1);
});
test("cancel sends nothing, remembers nothing, and asks again next time", async () => {
mocks.showConfirm.mockResolvedValue(false);
const send = await sender();
expect(await send.mutateAsync("rq_one")).toBeNull();
expect(await send.mutateAsync("rq_one")).toBeNull();
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
expect(stored.size).toBe(0);
});
test("cancel applies to every send waiting on the dialog", async () => {
mocks.showConfirm.mockResolvedValue(false);
const send = await sender();
expect(await Promise.all([send.mutateAsync("rq_one"), send.mutateAsync("rq_two")])).toEqual([
null,
null,
]);
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).not.toHaveBeenCalled();
});
test("remembers acceptance across module reloads for the same proxy", async () => {
mocks.showConfirm.mockResolvedValue(true);
await (await sender()).mutateAsync("rq_one");
vi.resetModules();
await (await sender()).mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
});
test("requires a new decision when the proxy changes", async () => {
mocks.showConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const send = await sender();
await send.mutateAsync("rq_one");
mocks.platform.httpProxyUrl = "https://another-proxy.test";
expect(await send.mutateAsync("rq_two")).toBeNull();
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
expect(mocks.rpc).toHaveBeenCalledTimes(1);
});
test("still asks and remembers in this tab if browser storage is unavailable", async () => {
const unavailable = () => {
throw new Error("Storage unavailable");
};
vi.stubGlobal("localStorage", { getItem: unavailable, setItem: unavailable });
mocks.showConfirm.mockResolvedValue(true);
const send = await sender();
await send.mutateAsync("rq_one");
await send.mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
});
test("desktop sends directly without confirmation", async () => {
mocks.platform.httpProxyUrl = null;
await (await sender()).mutateAsync("rq_one");
expect(mocks.showConfirm).not.toHaveBeenCalled();
expect(mocks.rpc).toHaveBeenCalledTimes(1);
expect(stored.size).toBe(0);
});
test("an empty selection does not prompt or send", async () => {
expect(await (await sender()).mutateAsync(null)).toBeNull();
expect(mocks.showConfirm).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,37 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { confirmWebProxy } from "../lib/confirmWebProxy";
import { rpc } from "../lib/rpc";
import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment";
import { createFastMutation, useFastMutation } from "./useFastMutation";
async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
if (id == null) {
return null;
}
if (!(await confirmWebProxy())) {
return null;
}
await flushAllModelWrites();
return rpc("cmd_send_http_request", {
requestId: id,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
}
export function useSendAnyHttpRequest() {
return useFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"],
mutationFn: sendAnyHttpRequestById,
});
}
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"],
mutationFn: sendAnyHttpRequestById,
});

View File

@@ -0,0 +1,14 @@
import { useFastMutation } from "./useFastMutation";
import { useSendAnyHttpRequest } from "./useSendAnyHttpRequest";
export function useSendManyRequests() {
const sendAnyRequest = useSendAnyHttpRequest();
return useFastMutation<void, string, string[]>({
mutationKey: ["send_many_requests"],
mutationFn: async (requestIds: string[]) => {
for (const id of requestIds) {
sendAnyRequest.mutate(id);
}
},
});
}

View File

@@ -0,0 +1,14 @@
import { useAtomValue } from "jotai";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useKeyValue } from "./useKeyValue";
export function useSidebarHidden() {
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const { set, value } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["sidebar_hidden", activeWorkspaceId ?? "n/a"],
fallback: false,
});
return [value, set] as const;
}

View File

@@ -0,0 +1,12 @@
import { atom } from "jotai";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
function kvKey(workspaceId: string | null) {
return ["sidebar_collapsed", workspaceId ?? "n/a"];
}
export const sidebarCollapsedAtom = atom((get) => {
const workspaceId = get(activeWorkspaceIdAtom);
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
});

View File

@@ -0,0 +1,14 @@
import { useAtomValue } from "jotai";
import { useCallback } from "react";
import { useLocalStorage } from "react-use";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
export function useSidebarWidth() {
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);
const [width, setWidth] = useLocalStorage<number>(
`sidebar_width::${activeWorkspaceId ?? "n/a"}`,
250,
);
const resetWidth = useCallback(() => setWidth(250), [setWidth]);
return [width ?? null, setWidth, resetWidth] as const;
}

View File

@@ -0,0 +1,98 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import type { GenericCompletionConfig } from "../components/core/Editor/genericCompletion";
import { useKeyValue } from "./useKeyValue";
const OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH = "$.choices[0].delta.content";
const OPENAI_RESPONSES_RESULT_KEY_PATH = "$.delta";
const ANTHROPIC_RESULT_KEY_PATH = "$.delta.text";
const GOOGLE_RESULT_KEY_PATH = "$.candidates[0].content.parts[0].text";
const sseSummaryResultKeyPathOptions: GenericCompletionOption[] = [
{
label: OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH,
detail: "ChatGPT (OpenAI)",
type: "constant",
boost: 1,
},
{
label: OPENAI_RESPONSES_RESULT_KEY_PATH,
detail: "Responses (OpenAI)",
type: "constant",
boost: 1,
},
{
label: ANTHROPIC_RESULT_KEY_PATH,
detail: "Claude (Anthropic)",
type: "constant",
boost: 1,
},
{
label: GOOGLE_RESULT_KEY_PATH,
detail: "Gemini (Google)",
type: "constant",
boost: 1,
},
];
export const sseSummaryResultKeyPathAutocomplete: GenericCompletionConfig = {
minMatch: 0,
options: sseSummaryResultKeyPathOptions,
};
export function useSseSummaryResultKeyPath({ response }: { response: HttpResponse }) {
const storedResultKeyPath = useKeyValue<string | null>({
namespace: "no_sync",
key: ["sse_summary_result_key_path", response.requestId],
fallback: null,
});
const enabled = useKeyValue<boolean | null>({
namespace: "no_sync",
key: ["sse_summary_result_key_path_enabled", response.requestId],
fallback: null,
});
const inferredResultKeyPath = useMemo(() => inferSseSummaryResultKeyPath(response), [response.url]);
const resultKeyPath = storedResultKeyPath.value ?? inferredResultKeyPath;
const trimmedResultKeyPath = resultKeyPath?.trim() ?? "";
const isEnabled = enabled.value ?? inferredResultKeyPath != null;
return {
enabled: isEnabled,
inferredResultKeyPath,
resultKeyPath: isEnabled && trimmedResultKeyPath.length > 0 ? trimmedResultKeyPath : null,
resultKeyPathInputValue: resultKeyPath ?? "",
setEnabled: enabled.set,
setResultKeyPath: storedResultKeyPath.set,
};
}
function inferSseSummaryResultKeyPath(response: HttpResponse): string | null {
let url: URL;
try {
url = new URL(response.url);
} catch {
return null;
}
const hostname = url.hostname.toLowerCase();
const pathname = url.pathname.toLowerCase();
if (hostname === "api.openai.com" && pathname === "/v1/chat/completions") {
return OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH;
}
if (hostname === "api.openai.com" && pathname === "/v1/responses") {
return OPENAI_RESPONSES_RESULT_KEY_PATH;
}
if (hostname === "api.anthropic.com" && pathname === "/v1/messages") {
return ANTHROPIC_RESULT_KEY_PATH;
}
if (
hostname === "generativelanguage.googleapis.com" &&
pathname.includes(":streamgeneratecontent")
) {
return GOOGLE_RESULT_KEY_PATH;
}
return null;
}

View File

@@ -0,0 +1,14 @@
import type { DependencyList } from "react";
import { useEffect, useState } from "react";
/**
* Like useState, except it will update the value when the default value changes
*/
export function useStateWithDeps<T>(defaultValue: T | (() => T), deps: DependencyList) {
const [value, setValue] = useState(defaultValue);
// oxlint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
setValue(defaultValue);
}, [...deps]);
return [value, setValue] as const;
}

View File

@@ -0,0 +1,8 @@
import { platform } from "@yaakapp-internal/platform";
import { useIsFullscreen } from "@yaakapp-internal/ui";
export function useStoplightsVisible() {
const fullscreen = useIsFullscreen();
const stoplightsVisible = platform.osType() === "macos" && !fullscreen;
return stoplightsVisible;
}

View File

@@ -0,0 +1,17 @@
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect } from "react";
export function useSyncFontSizeSetting() {
const settings = useAtomValue(settingsAtom);
useEffect(() => {
if (settings == null) {
return;
}
const { interfaceScale, editorFontSize } = settings;
platform.window.setZoom(interfaceScale).catch(console.error);
document.documentElement.style.setProperty("--editor-font-size", `${editorFontSize}px`);
}, [settings]);
}

View File

@@ -0,0 +1,17 @@
import { changeModelStoreWorkspace } from "@yaakapp-internal/models";
import { useEffect } from "react";
import { jotaiStore } from "../lib/jotai";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
export function useSyncWorkspaceChildModels() {
useEffect(() => {
const unsub = jotaiStore.sub(activeWorkspaceIdAtom, sync);
sync().catch(console.error);
return unsub;
}, []);
}
async function sync() {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom) ?? null;
changeModelStoreWorkspace(workspaceId).catch(console.error);
}

View File

@@ -0,0 +1,34 @@
import { setWindowTitle } from "@yaakapp-internal/mac-window";
import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { appInfo } from "../lib/appInfo";
import { jotaiStore } from "../lib/jotai";
import { resolvedModelName } from "../lib/resolvedModelName";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { activeRequestAtom } from "./useActiveRequest";
import { activeWorkspaceAtom } from "./useActiveWorkspace";
export function useSyncWorkspaceRequestTitle() {
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const activeEnvironment = useActiveEnvironment();
const activeRequest = useAtomValue(activeRequestAtom);
useEffect(() => {
const settings = jotaiStore.get(settingsAtom);
let newTitle = activeWorkspace ? activeWorkspace.name : "Yaak";
if (activeEnvironment) {
newTitle += ` (${activeEnvironment.name})`;
}
if (!settings.useNativeTitlebar && activeRequest) {
newTitle += ` › ${resolvedModelName(activeRequest)}`;
}
if (appInfo.isDev) {
newTitle = `[DEV] ${newTitle}`;
}
setWindowTitle(newTitle);
}, [activeEnvironment, activeRequest, activeWorkspace]);
}

View File

@@ -0,0 +1,16 @@
import { useHotKey } from "./useHotKey";
import { usePlatformEvent } from "./usePlatformEvent";
import { useZoom } from "./useZoom";
export function useSyncZoomSetting() {
// Handle Zoom.
// Note, Mac handles it in the app menu, so need to also handle keyboard
// shortcuts for Windows/Linux
const zoom = useZoom();
useHotKey("app.zoom_in", zoom.zoomIn);
usePlatformEvent("zoom_in", zoom.zoomIn);
useHotKey("app.zoom_out", zoom.zoomOut);
usePlatformEvent("zoom_out", zoom.zoomOut);
useHotKey("app.zoom_reset", zoom.zoomReset);
usePlatformEvent("zoom_reset", zoom.zoomReset);
}

View File

@@ -0,0 +1,76 @@
import { useQuery } from "@tanstack/react-query";
import {
environmentsAtom,
type Folder,
type GrpcRequest,
type HttpRequest,
httpResponsesAtom,
pluginsAtom,
type WebsocketRequest,
type Workspace,
} from "@yaakapp-internal/models";
import type { GetTemplateFunctionConfigResponse, JsonPrimitive } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import { rpc } from "../lib/rpc";
import { activeEnvironmentIdAtom } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
export function useTemplateFunctionConfig(
functionName: string | null,
values: Record<string, JsonPrimitive>,
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
) {
const pluginsKey = useAtomValue(pluginsAtom);
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const environmentId = useAtomValue(activeEnvironmentIdAtom);
const responses = useAtomValue(httpResponsesAtom);
const environments = useAtomValue(environmentsAtom);
const environmentsKey = environments.map((e) => e.id + e.updatedAt).join(":");
// Some auth handlers like OAuth 2.0 show the current token after a successful request. To
// handle that, we'll force the auth to re-fetch after each new response closes
const responseKey = md5(
responses
.filter((r) => r.state === "closed")
.map((r) => r.id)
.join(":"),
);
return useQuery({
queryKey: [
"template_function_config",
model,
functionName,
values,
workspaceId, // Refresh when the active workspace changes
environmentId, // Refresh when the active environment changes
environmentsKey, // Refresh when environments change
responseKey, // Refresh when responses change
pluginsKey, // Refresh when plugins reload
],
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
if (functionName == null) return null;
return getTemplateFunctionConfig(functionName, values, model, environmentId);
},
});
}
export async function getTemplateFunctionConfig(
functionName: string,
values: Record<string, JsonPrimitive>,
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
environmentId: string | undefined,
) {
const config = await rpc<GetTemplateFunctionConfigResponse>(
"cmd_template_function_config",
{
functionName,
values,
model,
environmentId,
},
);
return config.function;
}

View File

@@ -0,0 +1,61 @@
import { useQuery } from "@tanstack/react-query";
import type {
GetTemplateFunctionSummaryResponse,
TemplateFunction,
} from "@yaakapp-internal/plugins";
import { atom, useAtomValue, useSetAtom } from "jotai";
import { useMemo, useState } from "react";
import type { TwigCompletionOption } from "../components/core/Editor/twig/completion";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
const templateFunctionsAtom = atom<TemplateFunction[]>([]);
export function useTemplateFunctionCompletionOptions(
onClick: (fn: TemplateFunction, ragTag: string, pos: number) => void,
enabled: boolean,
) {
const templateFunctions = useAtomValue(templateFunctionsAtom);
return useMemo<TwigCompletionOption[]>(() => {
if (!enabled) {
return [];
}
return templateFunctions.map((fn) => {
const argsLabel = fn.args.length > 0 ? "…" : "";
const fn2: TwigCompletionOption = {
type: "function",
onClick: (rawTag: string, startPos: number) => onClick(fn, rawTag, startPos),
label: `${fn.name}(${argsLabel})`,
invalid: false,
value: null,
...fn,
};
return fn2;
});
}, [enabled, onClick, templateFunctions]);
}
export function useSubscribeTemplateFunctions() {
const pluginsKey = usePluginsKey();
const [numFns, setNumFns] = useState<number>(0);
const setAtom = useSetAtom(templateFunctionsAtom);
useQuery({
queryKey: ["template_functions", pluginsKey],
// Fetch periodically until functions are returned
// NOTE: visibilitychange (refetchOnWindowFocus) does not work on Windows, so we'll rely on this logic
// to refetch things until that's working again
// TODO: Update plugin system to wait for plugins to initialize before sending the first event to them
refetchInterval: numFns > 0 ? Number.POSITIVE_INFINITY : 1000,
refetchOnMount: true,
queryFn: async () => {
const result = await rpc<GetTemplateFunctionSummaryResponse[]>(
"cmd_template_function_summaries",
);
setNumFns(result.length);
const functions = result.flatMap((r) => r.functions) ?? [];
setAtom(functions);
return functions;
},
});
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import type { Tokens } from "@yaakapp-internal/templates";
import { rpc } from "../lib/rpc";
export function useTemplateTokensToString(tokens: Tokens) {
return useQuery<string>({
refetchOnWindowFocus: false,
queryKey: ["template_tokens_to_string", tokens],
queryFn: () => templateTokensToString(tokens),
});
}
export async function templateTokensToString(tokens: Tokens): Promise<string> {
return rpc("cmd_template_tokens_to_string", { tokens });
}

View File

@@ -0,0 +1,14 @@
import type { TimelineViewMode } from "../components/HttpResponsePane";
import { useKeyValue } from "./useKeyValue";
const DEFAULT_VIEW_MODE: TimelineViewMode = "timeline";
export function useTimelineViewMode() {
const { set, value } = useKeyValue<TimelineViewMode>({
namespace: "no_sync",
key: "timeline_view_mode",
fallback: DEFAULT_VIEW_MODE,
});
return [value ?? DEFAULT_VIEW_MODE, set] as const;
}

View File

@@ -0,0 +1,7 @@
import { useCallback, useState } from "react";
export function useToggle(initialValue = false) {
const [value, setValue] = useState<boolean>(initialValue);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle, setValue] as const;
}

View File

@@ -0,0 +1,20 @@
import { useCallback } from "react";
import { CommandPaletteDialog } from "../components/CommandPaletteDialog";
import { toggleDialog } from "../lib/dialog";
export function useToggleCommandPalette() {
const togglePalette = useCallback(() => {
toggleDialog({
id: "command_palette",
size: "dynamic",
hideX: true,
className: "mb-auto mt-16 max-h-[min(30rem,calc(100vh-4rem))]!",
vAlign: "top",
noPadding: true,
noScroll: true,
render: ({ hide }) => <CommandPaletteDialog onClose={hide} />,
});
}, []);
return togglePalette;
}

View File

@@ -0,0 +1,52 @@
import { useQuery } from "@tanstack/react-query";
import type { WebsocketRequest } from "@yaakapp-internal/models";
import type {
CallWebsocketRequestActionRequest,
GetWebsocketRequestActionsResponse,
WebsocketRequestAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableWebSocketRequestAction = Pick<WebsocketRequestAction, "label" | "icon"> & {
call: (request: WebsocketRequest) => Promise<void>;
};
export function useWebsocketRequestActions() {
const pluginsKey = usePluginsKey();
const actionsResult = useQuery<CallableWebSocketRequestAction[]>({
queryKey: ["websocket_request_actions", pluginsKey],
queryFn: () => getWebsocketRequestActions(),
});
// oxlint-disable-next-line react-hooks/exhaustive-deps
const actions = useMemo(() => {
return actionsResult.data ?? [];
}, [JSON.stringify(actionsResult.data)]);
return actions;
}
export async function getWebsocketRequestActions() {
const responses = await rpc<GetWebsocketRequestActionsResponse[]>(
"cmd_websocket_request_actions",
);
const actions = responses.flatMap((r) =>
r.actions.map((a: WebsocketRequestAction, i: number) => ({
label: a.label,
icon: a.icon,
call: async (websocketRequest: WebsocketRequest) => {
const payload: CallWebsocketRequestActionRequest = {
index: i,
pluginRefId: r.pluginRefId,
args: { websocketRequest },
};
await rpc("cmd_call_websocket_request_action", { req: payload });
},
})),
);
return actions;
}

View File

@@ -0,0 +1,12 @@
import { platform } from "@yaakapp-internal/platform";
import { useEffect, useState } from "react";
export function useWindowFocus() {
const [visible, setVisible] = useState(true);
useEffect(() => {
return platform.window.onFocusChanged(setVisible);
}, []);
return visible;
}

View File

@@ -0,0 +1,50 @@
import { useQuery } from "@tanstack/react-query";
import type { Workspace } from "@yaakapp-internal/models";
import type {
CallWorkspaceActionRequest,
GetWorkspaceActionsResponse,
WorkspaceAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableWorkspaceAction = Pick<WorkspaceAction, "label" | "icon"> & {
call: (workspace: Workspace) => Promise<void>;
};
export function useWorkspaceActions() {
const pluginsKey = usePluginsKey();
const actionsResult = useQuery<CallableWorkspaceAction[]>({
queryKey: ["workspace_actions", pluginsKey],
queryFn: () => getWorkspaceActions(),
});
// oxlint-disable-next-line react-hooks/exhaustive-deps
const actions = useMemo(() => {
return actionsResult.data ?? [];
}, [JSON.stringify(actionsResult.data)]);
return actions;
}
export async function getWorkspaceActions() {
const responses = await rpc<GetWorkspaceActionsResponse[]>("cmd_workspace_actions");
const actions = responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
icon: a.icon,
call: async (workspace: Workspace) => {
const payload: CallWorkspaceActionRequest = {
index: i,
pluginRefId: r.pluginRefId,
args: { workspace },
};
await rpc("cmd_call_workspace_action", { req: payload });
},
})),
);
return actions;
}

Some files were not shown because too many files have changed in this diff Show More