mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-17 14:29:46 +02:00
Refactor desktop app into separate client and proxy apps
This commit is contained in:
58
apps/yaak-client/hooks/useActiveCookieJar.ts
Normal file
58
apps/yaak-client/hooks/useActiveCookieJar.ts
Normal 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
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
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]);
|
||||
}
|
||||
29
apps/yaak-client/hooks/useActiveEnvironment.ts
Normal file
29
apps/yaak-client/hooks/useActiveEnvironment.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
8
apps/yaak-client/hooks/useActiveEnvironmentVariables.ts
Normal file
8
apps/yaak-client/hooks/useActiveEnvironmentVariables.ts
Normal 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);
|
||||
}
|
||||
9
apps/yaak-client/hooks/useActiveFolder.ts
Normal file
9
apps/yaak-client/hooks/useActiveFolder.ts
Normal 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;
|
||||
});
|
||||
11
apps/yaak-client/hooks/useActiveFolderId.ts
Normal file
11
apps/yaak-client/hooks/useActiveFolderId.ts
Normal 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]);
|
||||
}
|
||||
25
apps/yaak-client/hooks/useActiveRequest.ts
Normal file
25
apps/yaak-client/hooks/useActiveRequest.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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 | undefined,
|
||||
): 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;
|
||||
}
|
||||
11
apps/yaak-client/hooks/useActiveRequestId.ts
Normal file
11
apps/yaak-client/hooks/useActiveRequestId.ts
Normal 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]);
|
||||
}
|
||||
24
apps/yaak-client/hooks/useActiveWorkspace.ts
Normal file
24
apps/yaak-client/hooks/useActiveWorkspace.ts
Normal 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]);
|
||||
}
|
||||
31
apps/yaak-client/hooks/useActiveWorkspaceChangedToast.tsx
Normal file
31
apps/yaak-client/hooks/useActiveWorkspaceChangedToast.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
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]);
|
||||
}
|
||||
16
apps/yaak-client/hooks/useAllRequests.ts
Normal file
16
apps/yaak-client/hooks/useAllRequests.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
grpcRequestsAtom,
|
||||
httpRequestsAtom,
|
||||
websocketRequestsAtom,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
|
||||
export const allRequestsAtom = atom((get) => [
|
||||
...get(httpRequestsAtom),
|
||||
...get(grpcRequestsAtom),
|
||||
...get(websocketRequestsAtom),
|
||||
]);
|
||||
|
||||
export function useAllRequests() {
|
||||
return useAtomValue(allRequestsAtom);
|
||||
}
|
||||
171
apps/yaak-client/hooks/useAuthTab.tsx
Normal file
171
apps/yaak-client/hooks/useAuthTab.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { Folder } from '@yaakapp-internal/models';
|
||||
import { modelTypeLabel, patchModel } from '@yaakapp-internal/models';
|
||||
import { useMemo } from 'react';
|
||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
|
||||
import { Icon } from '../components/core/Icon';
|
||||
import { IconTooltip } from '../components/core/IconTooltip';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import { HStack } from '../components/core/Stacks';
|
||||
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 authentication = useHttpAuthenticationSummaries();
|
||||
const inheritedAuth = useInheritedAuthentication(model);
|
||||
const ancestors = useModelAncestors(model);
|
||||
const parentModel = ancestors[0] ?? null;
|
||||
|
||||
return useMemo<TabItem[]>(() => {
|
||||
if (model == null) return [];
|
||||
|
||||
const tab: TabItem = {
|
||||
value: tabValue,
|
||||
label: 'Auth',
|
||||
options: {
|
||||
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="magic_wand"
|
||||
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 });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return [tab];
|
||||
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
|
||||
}
|
||||
9
apps/yaak-client/hooks/useCancelHttpResponse.ts
Normal file
9
apps/yaak-client/hooks/useCancelHttpResponse.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { event } from '@tauri-apps/api';
|
||||
import { useFastMutation } from './useFastMutation';
|
||||
|
||||
export function useCancelHttpResponse(id: string | null) {
|
||||
return useFastMutation<void>({
|
||||
mutationKey: ['cancel_http_response', id],
|
||||
mutationFn: () => event.emit(`cancel_http_response_${id}`),
|
||||
});
|
||||
}
|
||||
26
apps/yaak-client/hooks/useCheckForUpdates.tsx
Normal file
26
apps/yaak-client/hooks/useCheckForUpdates.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import { showAlert } from '../lib/alert';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import { minPromiseMillis } from '../lib/minPromiseMillis';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
|
||||
export function useCheckForUpdates() {
|
||||
return useMutation({
|
||||
mutationKey: ['check_for_updates'],
|
||||
mutationFn: async () => {
|
||||
const hasUpdate: boolean = await minPromiseMillis(invokeCmd('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>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
41
apps/yaak-client/hooks/useClickOutside.ts
Normal file
41
apps/yaak-client/hooks/useClickOutside.ts
Normal 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]);
|
||||
}
|
||||
30
apps/yaak-client/hooks/useContainerQuery.ts
Normal file
30
apps/yaak-client/hooks/useContainerQuery.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
export function useContainerSize(ref: RefObject<HTMLElement | null>) {
|
||||
const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (el) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === el) {
|
||||
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => {
|
||||
observer.unobserve(el);
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [ref]);
|
||||
|
||||
return size;
|
||||
}
|
||||
14
apps/yaak-client/hooks/useCopyHttpResponse.ts
Normal file
14
apps/yaak-client/hooks/useCopyHttpResponse.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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) {
|
||||
return useFastMutation({
|
||||
mutationKey: ['copy_http_response', response.id],
|
||||
async mutationFn() {
|
||||
const body = await getResponseBodyText({ response, filter: null });
|
||||
copyToClipboard(body);
|
||||
},
|
||||
});
|
||||
}
|
||||
33
apps/yaak-client/hooks/useCreateCookieJar.ts
Normal file
33
apps/yaak-client/hooks/useCreateCookieJar.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
118
apps/yaak-client/hooks/useCreateDropdownItems.tsx
Normal file
118
apps/yaak-client/hooks/useCreateDropdownItems.tsx
Normal 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 '../components/core/Icon';
|
||||
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 | 'active-folder';
|
||||
} = {}): 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 | 'active-folder';
|
||||
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[]),
|
||||
];
|
||||
}
|
||||
14
apps/yaak-client/hooks/useCreateWorkspace.tsx
Normal file
14
apps/yaak-client/hooks/useCreateWorkspace.tsx
Normal 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} />,
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
12
apps/yaak-client/hooks/useDebouncedState.ts
Normal file
12
apps/yaak-client/hooks/useDebouncedState.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { debounce } from '@yaakapp-internal/lib';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
export function useDebouncedState<T>(
|
||||
defaultValue: T,
|
||||
delay = 500,
|
||||
): [T, Dispatch<SetStateAction<T>>, Dispatch<SetStateAction<T>>] {
|
||||
const [state, setState] = useState<T>(defaultValue);
|
||||
const debouncedSetState = useMemo(() => debounce(setState, delay), [delay]);
|
||||
return [state, debouncedSetState, setState];
|
||||
}
|
||||
8
apps/yaak-client/hooks/useDebouncedValue.ts
Normal file
8
apps/yaak-client/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDebouncedState } from './useDebouncedState';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay = 500) {
|
||||
const [state, setState] = useDebouncedState<T>(value, delay);
|
||||
useEffect(() => setState(value), [setState, value]);
|
||||
return state;
|
||||
}
|
||||
12
apps/yaak-client/hooks/useDeleteGrpcConnections.ts
Normal file
12
apps/yaak-client/hooks/useDeleteGrpcConnections.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { useFastMutation } from './useFastMutation';
|
||||
|
||||
export function useDeleteGrpcConnections(requestId?: string) {
|
||||
return useFastMutation({
|
||||
mutationKey: ['delete_grpc_connections', requestId],
|
||||
mutationFn: async () => {
|
||||
if (requestId === undefined) return;
|
||||
await invokeCmd('cmd_delete_all_grpc_connections', { requestId });
|
||||
},
|
||||
});
|
||||
}
|
||||
12
apps/yaak-client/hooks/useDeleteHttpResponses.ts
Normal file
12
apps/yaak-client/hooks/useDeleteHttpResponses.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { useFastMutation } from './useFastMutation';
|
||||
|
||||
export function useDeleteHttpResponses(requestId?: string) {
|
||||
return useFastMutation({
|
||||
mutationKey: ['delete_http_responses', requestId],
|
||||
mutationFn: async () => {
|
||||
if (requestId === undefined) return;
|
||||
await invokeCmd('cmd_delete_all_http_responses', { requestId });
|
||||
},
|
||||
});
|
||||
}
|
||||
52
apps/yaak-client/hooks/useDeleteSendHistory.tsx
Normal file
52
apps/yaak-client/hooks/useDeleteSendHistory.tsx
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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 invokeCmd('cmd_delete_send_history', { workspaceId });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
10
apps/yaak-client/hooks/useEnvironmentValueVisibility.ts
Normal file
10
apps/yaak-client/hooks/useEnvironmentValueVisibility.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
66
apps/yaak-client/hooks/useEnvironmentVariables.ts
Normal file
66
apps/yaak-client/hooks/useEnvironmentVariables.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
35
apps/yaak-client/hooks/useEnvironmentsBreakdown.ts
Normal file
35
apps/yaak-client/hooks/useEnvironmentsBreakdown.ts
Normal 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);
|
||||
}
|
||||
85
apps/yaak-client/hooks/useEventViewerKeyboard.ts
Normal file
85
apps/yaak-client/hooks/useEventViewerKeyboard.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
41
apps/yaak-client/hooks/useExportData.tsx
Normal file
41
apps/yaak-client/hooks/useExportData.tsx
Normal 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',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
76
apps/yaak-client/hooks/useFastMutation.ts
Normal file
76
apps/yaak-client/hooks/useFastMutation.ts
Normal 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}`,
|
||||
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);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Force it!
|
||||
}, defaultArgs.mutationKey);
|
||||
}
|
||||
14
apps/yaak-client/hooks/useFloatingSidebarHidden.ts
Normal file
14
apps/yaak-client/hooks/useFloatingSidebarHidden.ts
Normal 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;
|
||||
}
|
||||
50
apps/yaak-client/hooks/useFolderActions.ts
Normal file
50
apps/yaak-client/hooks/useFolderActions.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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(),
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
const actions = useMemo(() => {
|
||||
return actionsResult.data ?? [];
|
||||
}, [JSON.stringify(actionsResult.data)]);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function getFolderActions() {
|
||||
const responses = await invokeCmd<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 invokeCmd('cmd_call_folder_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
return actions;
|
||||
}
|
||||
30
apps/yaak-client/hooks/useFormatText.ts
Normal file
30
apps/yaak-client/hooks/useFormatText.ts
Normal 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;
|
||||
}
|
||||
71
apps/yaak-client/hooks/useGrpc.ts
Normal file
71
apps/yaak-client/hooks/useGrpc.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import type { GrpcConnection, GrpcRequest } from '@yaakapp-internal/models';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { minPromiseMillis } from '../lib/minPromiseMillis';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { activeEnvironmentIdAtom, useActiveEnvironment } from './useActiveEnvironment';
|
||||
import { useDebouncedValue } from './useDebouncedValue';
|
||||
|
||||
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: () =>
|
||||
invokeCmd<void>('cmd_grpc_go', { requestId, environmentId: environment?.id, protoFiles }),
|
||||
});
|
||||
|
||||
const send = useMutation({
|
||||
mutationKey: ['grpc_send', conn?.id],
|
||||
mutationFn: ({ message }: { message: string }) =>
|
||||
emit(`grpc_client_msg_${conn?.id ?? 'none'}`, { Message: message }),
|
||||
});
|
||||
|
||||
const cancel = useMutation({
|
||||
mutationKey: ['grpc_cancel', conn?.id ?? 'n/a'],
|
||||
mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? 'none'}`, 'Cancel'),
|
||||
});
|
||||
|
||||
const commit = useMutation({
|
||||
mutationKey: ['grpc_commit', conn?.id ?? 'n/a'],
|
||||
mutationFn: () => 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[]>(
|
||||
invokeCmd('cmd_grpc_reflect', { requestId, protoFiles, environmentId }),
|
||||
300,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
go,
|
||||
reflect,
|
||||
cancel,
|
||||
commit,
|
||||
isStreaming: conn != null && conn.state !== 'closed',
|
||||
send,
|
||||
};
|
||||
}
|
||||
17
apps/yaak-client/hooks/useGrpcProtoFiles.ts
Normal file
17
apps/yaak-client/hooks/useGrpcProtoFiles.ts
Normal 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: [] });
|
||||
}
|
||||
53
apps/yaak-client/hooks/useGrpcRequestActions.ts
Normal file
53
apps/yaak-client/hooks/useGrpcRequestActions.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
const actions = useMemo(() => {
|
||||
return actionsResult.data ?? [];
|
||||
}, [JSON.stringify(actionsResult.data)]);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function getGrpcRequestActions() {
|
||||
const responses = await invokeCmd<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 invokeCmd('cmd_call_grpc_request_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
31
apps/yaak-client/hooks/useHeadersTab.tsx
Normal file
31
apps/yaak-client/hooks/useHeadersTab.tsx
Normal 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]);
|
||||
}
|
||||
424
apps/yaak-client/hooks/useHotKey.ts
Normal file
424
apps/yaak-client/hooks/useHotKey.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
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'
|
||||
| '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'],
|
||||
'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'],
|
||||
'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[]> =
|
||||
type() === '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: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
||||
merged[action as HotkeyAction] = keys;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
/** Helper function to get current hotkeys from the store */
|
||||
function getHotkeys(): 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',
|
||||
'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',
|
||||
];
|
||||
|
||||
export const hotkeyActions: HotkeyAction[] = (
|
||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||
).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 = type();
|
||||
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') {
|
||||
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);
|
||||
}
|
||||
49
apps/yaak-client/hooks/useHttpAuthentication.ts
Normal file
49
apps/yaak-client/hooks/useHttpAuthentication.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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 invokeCmd<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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
84
apps/yaak-client/hooks/useHttpAuthenticationConfig.ts
Normal file
84
apps/yaak-client/hooks/useHttpAuthenticationConfig.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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 invokeCmd<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 invokeCmd('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);
|
||||
},
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
50
apps/yaak-client/hooks/useHttpRequestActions.ts
Normal file
50
apps/yaak-client/hooks/useHttpRequestActions.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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(),
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
const actions = useMemo(() => {
|
||||
return actionsResult.data ?? [];
|
||||
}, [JSON.stringify(actionsResult.data)]);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function getHttpRequestActions() {
|
||||
const responses = await invokeCmd<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 invokeCmd('cmd_call_http_request_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
return actions;
|
||||
}
|
||||
32
apps/yaak-client/hooks/useHttpRequestBody.ts
Normal file
32
apps/yaak-client/hooks/useHttpRequestBody.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
|
||||
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 invokeCmd<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 };
|
||||
}
|
||||
29
apps/yaak-client/hooks/useHttpResponseEvents.ts
Normal file
29
apps/yaak-client/hooks/useHttpResponseEvents.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
||||
import {
|
||||
httpResponseEventsAtom,
|
||||
mergeModelsInStore,
|
||||
replaceModelsInStore,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
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
|
||||
invoke<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 };
|
||||
}
|
||||
52
apps/yaak-client/hooks/useImportCurl.ts
Normal file
52
apps/yaak-client/hooks/useImportCurl.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { patchModelById } from '@yaakapp-internal/models';
|
||||
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { showToast } from '../lib/toast';
|
||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
||||
import { useFastMutation } from './useFastMutation';
|
||||
import { wasUpdatedExternally } from './useRequestUpdateKey';
|
||||
|
||||
export function useImportCurl() {
|
||||
return useFastMutation({
|
||||
mutationKey: ['import_curl'],
|
||||
mutationFn: async ({
|
||||
overwriteRequestId,
|
||||
command,
|
||||
}: {
|
||||
overwriteRequestId?: string;
|
||||
command: string;
|
||||
}) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
const importedRequest: HttpRequest = await invokeCmd('cmd_curl_to_request', {
|
||||
command,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
let verb: string;
|
||||
if (overwriteRequestId == null) {
|
||||
verb = 'Created';
|
||||
await createRequestAndNavigate(importedRequest);
|
||||
} else {
|
||||
verb = 'Updated';
|
||||
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
|
||||
...importedRequest,
|
||||
id: r.id,
|
||||
createdAt: r.createdAt,
|
||||
workspaceId: r.workspaceId,
|
||||
folderId: r.folderId,
|
||||
name: r.name,
|
||||
sortPriority: r.sortPriority,
|
||||
}));
|
||||
|
||||
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
|
||||
}
|
||||
|
||||
showToast({
|
||||
color: 'success',
|
||||
message: `${verb} request from Curl`,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
46
apps/yaak-client/hooks/useInheritedAuthentication.ts
Normal file
46
apps/yaak-client/hooks/useInheritedAuthentication.ts
Normal 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);
|
||||
}
|
||||
53
apps/yaak-client/hooks/useInheritedHeaders.ts
Normal file
53
apps/yaak-client/hooks/useInheritedHeaders.ts
Normal 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());
|
||||
}
|
||||
11
apps/yaak-client/hooks/useInstallPlugin.ts
Normal file
11
apps/yaak-client/hooks/useInstallPlugin.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
158
apps/yaak-client/hooks/useIntrospectGraphQL.ts
Normal file
158
apps/yaak-client/hooks/useIntrospectGraphQL.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { GraphQlIntrospection, HttpRequest } from '@yaakapp-internal/models';
|
||||
import type { GraphQLSchema, IntrospectionQuery } from 'graphql';
|
||||
import { buildClientSchema, getIntrospectionQuery } from 'graphql';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { minPromiseMillis } from '../lib/minPromiseMillis';
|
||||
import { getResponseBodyText } from '../lib/responseBody';
|
||||
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
|
||||
import { useActiveEnvironment } from './useActiveEnvironment';
|
||||
import { useDebouncedValue } from './useDebouncedValue';
|
||||
|
||||
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);
|
||||
|
||||
const upsertIntrospection = useCallback(
|
||||
async (content: string | null) => {
|
||||
const v = await invoke<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 = await minPromiseMillis(
|
||||
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
|
||||
700,
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
return setError(response.error);
|
||||
}
|
||||
|
||||
const bodyText = await getResponseBodyText({ response, filter: null });
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return setError(
|
||||
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyText === null) {
|
||||
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]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
useEffect(() => {
|
||||
// Skip introspection if automatic is disabled and we already have one
|
||||
if (options.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
refetch().catch(console.error);
|
||||
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
setError('');
|
||||
setSchema(null);
|
||||
await upsertIntrospection(null);
|
||||
}, [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 };
|
||||
}
|
||||
|
||||
function useIntrospectionResult(request: HttpRequest) {
|
||||
return useQuery({
|
||||
queryKey: ['introspection', request.id],
|
||||
queryFn: async () =>
|
||||
invoke<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;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
} catch (e: any) {
|
||||
return { error: String('message' in e ? e.message : e) };
|
||||
}
|
||||
|
||||
try {
|
||||
return { schema: buildClientSchema(parsedResponse, {}) };
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
} catch (e: any) {
|
||||
return { error: String('message' in e ? e.message : e) };
|
||||
}
|
||||
}
|
||||
7
apps/yaak-client/hooks/useIsEncryptionEnabled.ts
Normal file
7
apps/yaak-client/hooks/useIsEncryptionEnabled.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { activeWorkspaceMetaAtom } from './useActiveWorkspace';
|
||||
|
||||
export function useIsEncryptionEnabled() {
|
||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
||||
return workspaceMeta?.encryptionKey != null;
|
||||
}
|
||||
22
apps/yaak-client/hooks/useIsFullscreen.ts
Normal file
22
apps/yaak-client/hooks/useIsFullscreen.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { useDebouncedValue } from './useDebouncedValue';
|
||||
|
||||
export function useIsFullscreen() {
|
||||
const windowSize = useWindowSize();
|
||||
const debouncedWindowWidth = useDebouncedValue(windowSize.width);
|
||||
|
||||
// NOTE: Fullscreen state isn't updated right after resize event on Mac (needs to wait for animation) so
|
||||
// we'll wait for a bit using the debounced window size. Hopefully Tauri eventually adds a way to listen
|
||||
// for fullscreen change events.
|
||||
|
||||
return (
|
||||
useQuery({
|
||||
queryKey: ['is_fullscreen', debouncedWindowWidth],
|
||||
queryFn: async () => {
|
||||
return getCurrentWebviewWindow().isFullscreen();
|
||||
},
|
||||
}).data ?? false
|
||||
);
|
||||
}
|
||||
90
apps/yaak-client/hooks/useKeyValue.ts
Normal file
90
apps/yaak-client/hooks/useKeyValue.ts
Normal 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(
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 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 }),
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
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;
|
||||
}
|
||||
16
apps/yaak-client/hooks/useKeyboardEvent.ts
Normal file
16
apps/yaak-client/hooks/useKeyboardEvent.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useKeyboardEvent(
|
||||
event: 'keyup' | 'keydown',
|
||||
key: KeyboardEvent['key'],
|
||||
cb: () => void,
|
||||
) {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 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]);
|
||||
}
|
||||
7
apps/yaak-client/hooks/useLatestGrpcConnection.ts
Normal file
7
apps/yaak-client/hooks/useLatestGrpcConnection.ts
Normal 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;
|
||||
}
|
||||
7
apps/yaak-client/hooks/useLatestHttpResponse.ts
Normal file
7
apps/yaak-client/hooks/useLatestHttpResponse.ts
Normal 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;
|
||||
}
|
||||
28
apps/yaak-client/hooks/useListenToTauriEvent.ts
Normal file
28
apps/yaak-client/hooks/useListenToTauriEvent.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { EventCallback, EventName } from '@tauri-apps/api/event';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useListenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {
|
||||
const handlerRef = useRef(fn);
|
||||
useEffect(() => {
|
||||
handlerRef.current = fn;
|
||||
}, [fn]);
|
||||
|
||||
useEffect(() => {
|
||||
return listenToTauriEvent<T>(event, (p) => handlerRef.current(p));
|
||||
}, [event]);
|
||||
}
|
||||
|
||||
export function listenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {
|
||||
const unsubPromise = listen<T>(
|
||||
event,
|
||||
fn,
|
||||
// Listen to `emit_all()` events or events specific to the current window
|
||||
{ target: { label: getCurrentWebviewWindow().label, kind: 'Window' } },
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubPromise.then((unsub) => unsub()).catch(console.error);
|
||||
};
|
||||
}
|
||||
41
apps/yaak-client/hooks/useModelAncestors.ts
Normal file
41
apps/yaak-client/hooks/useModelAncestors.ts
Normal 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 [];
|
||||
}
|
||||
26
apps/yaak-client/hooks/useParentFolders.ts
Normal file
26
apps/yaak-client/hooks/useParentFolders.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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);
|
||||
|
||||
return useMemo(() => getParentFolders(folders, m), [folders, m]);
|
||||
}
|
||||
|
||||
function getParentFolders(
|
||||
folders: Folder[],
|
||||
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
|
||||
): Folder[] {
|
||||
if (currentModel == null) return [];
|
||||
|
||||
const parentFolder = currentModel.folderId
|
||||
? folders.find((f) => f.id === currentModel.folderId)
|
||||
: null;
|
||||
if (parentFolder == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [parentFolder, ...getParentFolders(folders, parentFolder)];
|
||||
}
|
||||
81
apps/yaak-client/hooks/usePinnedGrpcConnection.ts
Normal file
81
apps/yaak-client/hooks/usePinnedGrpcConnection.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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 { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||
import { activeRequestIdAtom } from './useActiveRequestId';
|
||||
|
||||
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
|
||||
invoke<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],
|
||||
);
|
||||
}
|
||||
29
apps/yaak-client/hooks/usePinnedHttpResponse.ts
Normal file
29
apps/yaak-client/hooks/usePinnedHttpResponse.ts
Normal 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;
|
||||
}
|
||||
68
apps/yaak-client/hooks/usePinnedWebsocketConnection.ts
Normal file
68
apps/yaak-client/hooks/usePinnedWebsocketConnection.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
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 { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { activeRequestIdAtom } from './useActiveRequestId';
|
||||
|
||||
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
|
||||
invoke<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],
|
||||
);
|
||||
}
|
||||
29
apps/yaak-client/hooks/usePluginInfo.ts
Normal file
29
apps/yaak-client/hooks/usePluginInfo.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
|
||||
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 invokeCmd<PluginMetadata>('cmd_plugin_info', { id });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateAllPluginInfo() {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin_info'] }).catch(console.error);
|
||||
}
|
||||
37
apps/yaak-client/hooks/usePlugins.ts
Normal file
37
apps/yaak-client/hooks/usePlugins.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
||||
import { useDebouncedValue } from './useDebouncedValue';
|
||||
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 invokeCmd('cmd_reload_plugins');
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
await changeModelStoreWorkspace(workspaceId); // Force refresh models
|
||||
invalidateAllPluginInfo();
|
||||
})(),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
20
apps/yaak-client/hooks/usePortal.ts
Normal file
20
apps/yaak-client/hooks/usePortal.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
const PORTAL_CONTAINER_ID = 'react-portal';
|
||||
|
||||
export function usePortal(name: string) {
|
||||
const ref = useRef(getOrCreatePortal(name));
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
function getOrCreatePortal(name: string) {
|
||||
const portalContainer = document.getElementById(PORTAL_CONTAINER_ID) as HTMLDivElement;
|
||||
let existing = portalContainer.querySelector(`:scope > [data-portal-name="${name}"]`);
|
||||
if (!existing) {
|
||||
const el: HTMLDivElement = document.createElement('div');
|
||||
el.setAttribute('data-portal-name', name);
|
||||
portalContainer.appendChild(el);
|
||||
existing = el;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
9
apps/yaak-client/hooks/usePreferredAppearance.ts
Normal file
9
apps/yaak-client/hooks/usePreferredAppearance.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Appearance } from '../lib/theme/appearance';
|
||||
import { getCSSAppearance, subscribeToPreferredAppearance } from '../lib/theme/appearance';
|
||||
|
||||
export function usePreferredAppearance() {
|
||||
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
|
||||
useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []);
|
||||
return preferredAppearance;
|
||||
}
|
||||
8
apps/yaak-client/hooks/useRandomKey.ts
Normal file
8
apps/yaak-client/hooks/useRandomKey.ts
Normal 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;
|
||||
}
|
||||
53
apps/yaak-client/hooks/useRecentCookieJars.ts
Normal file
53
apps/yaak-client/hooks/useRecentCookieJars.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
51
apps/yaak-client/hooks/useRecentEnvironments.ts
Normal file
51
apps/yaak-client/hooks/useRecentEnvironments.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
53
apps/yaak-client/hooks/useRecentRequests.ts
Normal file
53
apps/yaak-client/hooks/useRecentRequests.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
48
apps/yaak-client/hooks/useRecentWorkspaces.ts
Normal file
48
apps/yaak-client/hooks/useRecentWorkspaces.ts
Normal 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 });
|
||||
}
|
||||
71
apps/yaak-client/hooks/useRenderTemplate.ts
Normal file
71
apps/yaak-client/hooks/useRenderTemplate.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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 invokeCmd('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 invokeCmd('cmd_decrypt_template', { template, workspaceId, environmentId });
|
||||
}
|
||||
85
apps/yaak-client/hooks/useRequestEditor.tsx
Normal file
85
apps/yaak-client/hooks/useRequestEditor.tsx
Normal 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);
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 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);
|
||||
}
|
||||
})();
|
||||
34
apps/yaak-client/hooks/useRequestUpdateKey.ts
Normal file
34
apps/yaak-client/hooks/useRequestUpdateKey.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
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>>({});
|
||||
|
||||
getCurrentWebviewWindow()
|
||||
.listen<ModelPayload>('model_write', ({ payload }) => {
|
||||
if (payload.change.type !== 'upsert') return;
|
||||
|
||||
if (
|
||||
(payload.model.model === 'http_request' ||
|
||||
payload.model.model === 'grpc_request' ||
|
||||
payload.model.model === 'websocket_request') &&
|
||||
((payload.updateSource.type === 'window' &&
|
||||
payload.updateSource.label !== getCurrentWebviewWindow().label) ||
|
||||
payload.updateSource.type !== 'window')
|
||||
) {
|
||||
wasUpdatedExternally(payload.model.id);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
export function wasUpdatedExternally(changedRequestId: string) {
|
||||
jotaiStore.set(requestUpdateKeyAtom, (m) => ({ ...m, [changedRequestId]: generateId() }));
|
||||
}
|
||||
|
||||
export function useRequestUpdateKey(requestId: string | null) {
|
||||
const keys = useAtomValue(requestUpdateKeyAtom);
|
||||
const key = keys[requestId ?? 'n/a'];
|
||||
return `${requestId}::${key ?? 'default'}`;
|
||||
}
|
||||
10
apps/yaak-client/hooks/useResolvedAppearance.ts
Normal file
10
apps/yaak-client/hooks/useResolvedAppearance.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { resolveAppearance } from '../lib/theme/appearance';
|
||||
import { usePreferredAppearance } from './usePreferredAppearance';
|
||||
|
||||
export function useResolvedAppearance() {
|
||||
const preferredAppearance = usePreferredAppearance();
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
return resolveAppearance(preferredAppearance, settings.appearance);
|
||||
}
|
||||
25
apps/yaak-client/hooks/useResolvedTheme.ts
Normal file
25
apps/yaak-client/hooks/useResolvedTheme.ts
Normal 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/theme/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()) };
|
||||
},
|
||||
});
|
||||
}
|
||||
12
apps/yaak-client/hooks/useResponseBodyEventSource.ts
Normal file
12
apps/yaak-client/hooks/useResponseBodyEventSource.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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.contentLength],
|
||||
queryFn: () => getResponseBodyEventSource(response),
|
||||
});
|
||||
}
|
||||
31
apps/yaak-client/hooks/useResponseBodyText.ts
Normal file
31
apps/yaak-client/hooks/useResponseBodyText.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { getResponseBodyBytes, getResponseBodyText } from '../lib/responseBody';
|
||||
|
||||
export function useResponseBodyText({
|
||||
response,
|
||||
filter,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
filter: string | null;
|
||||
}) {
|
||||
return useQuery({
|
||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||
queryKey: [
|
||||
'response_body_text',
|
||||
response.id,
|
||||
response.updatedAt,
|
||||
response.contentLength,
|
||||
filter ?? '',
|
||||
],
|
||||
queryFn: () => getResponseBodyText({ 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),
|
||||
});
|
||||
}
|
||||
8
apps/yaak-client/hooks/useResponseViewMode.ts
Normal file
8
apps/yaak-client/hooks/useResponseViewMode.ts
Normal 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];
|
||||
}
|
||||
36
apps/yaak-client/hooks/useSaveResponse.tsx
Normal file
36
apps/yaak-client/hooks/useSaveResponse.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { getModel } from '@yaakapp-internal/models';
|
||||
import mime from 'mime';
|
||||
import slugify from 'slugify';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { showToast } from '../lib/toast';
|
||||
import { useFastMutation } from './useFastMutation';
|
||||
|
||||
export function useSaveResponse(response: HttpResponse) {
|
||||
return useFastMutation({
|
||||
mutationKey: ['save_response', response.id],
|
||||
mutationFn: async () => {
|
||||
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 save({
|
||||
defaultPath: ext ? `${slug}.${ext}` : slug,
|
||||
title: 'Save Response',
|
||||
});
|
||||
await invokeCmd('cmd_save_response', { responseId: response.id, filepath });
|
||||
showToast({
|
||||
message: (
|
||||
<>
|
||||
Response saved to <InlineCode>{filepath}</InlineCode>
|
||||
</>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
9
apps/yaak-client/hooks/useScrollIntoView.ts
Normal file
9
apps/yaak-client/hooks/useScrollIntoView.ts
Normal 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]);
|
||||
}
|
||||
40
apps/yaak-client/hooks/useSendAnyHttpRequest.ts
Normal file
40
apps/yaak-client/hooks/useSendAnyHttpRequest.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { getModel } from '@yaakapp-internal/models';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { getActiveCookieJar } from './useActiveCookieJar';
|
||||
import { getActiveEnvironment } from './useActiveEnvironment';
|
||||
import { createFastMutation, useFastMutation } from './useFastMutation';
|
||||
|
||||
export function useSendAnyHttpRequest() {
|
||||
return useFastMutation<HttpResponse | null, string, string | null>({
|
||||
mutationKey: ['send_any_request'],
|
||||
mutationFn: async (id) => {
|
||||
const request = getModel('http_request', id ?? 'n/a');
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return invokeCmd('cmd_send_http_request', {
|
||||
request,
|
||||
environmentId: getActiveEnvironment()?.id,
|
||||
cookieJarId: getActiveCookieJar()?.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
||||
mutationKey: ['send_any_request'],
|
||||
mutationFn: async (id) => {
|
||||
const request = getModel('http_request', id ?? 'n/a');
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return invokeCmd('cmd_send_http_request', {
|
||||
request,
|
||||
environmentId: getActiveEnvironment()?.id,
|
||||
cookieJarId: getActiveCookieJar()?.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
14
apps/yaak-client/hooks/useSendManyRequests.ts
Normal file
14
apps/yaak-client/hooks/useSendManyRequests.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
8
apps/yaak-client/hooks/useShouldFloatSidebar.ts
Normal file
8
apps/yaak-client/hooks/useShouldFloatSidebar.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useWindowSize } from 'react-use';
|
||||
|
||||
const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
|
||||
|
||||
export function useShouldFloatSidebar() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;
|
||||
}
|
||||
14
apps/yaak-client/hooks/useSidebarHidden.ts
Normal file
14
apps/yaak-client/hooks/useSidebarHidden.ts
Normal 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;
|
||||
}
|
||||
12
apps/yaak-client/hooks/useSidebarItemCollapsed.ts
Normal file
12
apps/yaak-client/hooks/useSidebarItemCollapsed.ts
Normal 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), {});
|
||||
});
|
||||
14
apps/yaak-client/hooks/useSidebarWidth.ts
Normal file
14
apps/yaak-client/hooks/useSidebarWidth.ts
Normal 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;
|
||||
}
|
||||
14
apps/yaak-client/hooks/useStateWithDeps.ts
Normal file
14
apps/yaak-client/hooks/useStateWithDeps.ts
Normal 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);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
useEffect(() => {
|
||||
setValue(defaultValue);
|
||||
}, [...deps]);
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
8
apps/yaak-client/hooks/useStoplightsVisible.ts
Normal file
8
apps/yaak-client/hooks/useStoplightsVisible.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { useIsFullscreen } from './useIsFullscreen';
|
||||
|
||||
export function useStoplightsVisible() {
|
||||
const fullscreen = useIsFullscreen();
|
||||
const stoplightsVisible = type() === 'macos' && !fullscreen;
|
||||
return stoplightsVisible;
|
||||
}
|
||||
17
apps/yaak-client/hooks/useSyncFontSizeSetting.ts
Normal file
17
apps/yaak-client/hooks/useSyncFontSizeSetting.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
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;
|
||||
getCurrentWebviewWindow().setZoom(interfaceScale).catch(console.error);
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${editorFontSize}px`);
|
||||
}, [settings]);
|
||||
}
|
||||
17
apps/yaak-client/hooks/useSyncWorkspaceChildModels.ts
Normal file
17
apps/yaak-client/hooks/useSyncWorkspaceChildModels.ts
Normal 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);
|
||||
}
|
||||
34
apps/yaak-client/hooks/useSyncWorkspaceRequestTitle.ts
Normal file
34
apps/yaak-client/hooks/useSyncWorkspaceRequestTitle.ts
Normal 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]);
|
||||
}
|
||||
16
apps/yaak-client/hooks/useSyncZoomSetting.ts
Normal file
16
apps/yaak-client/hooks/useSyncZoomSetting.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useHotKey } from './useHotKey';
|
||||
import { useListenToTauriEvent } from './useListenToTauriEvent';
|
||||
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);
|
||||
useListenToTauriEvent('zoom_in', zoom.zoomIn);
|
||||
useHotKey('app.zoom_out', zoom.zoomOut);
|
||||
useListenToTauriEvent('zoom_out', zoom.zoomOut);
|
||||
useHotKey('app.zoom_reset', zoom.zoomReset);
|
||||
useListenToTauriEvent('zoom_reset', zoom.zoomReset);
|
||||
}
|
||||
76
apps/yaak-client/hooks/useTemplateFunctionConfig.ts
Normal file
76
apps/yaak-client/hooks/useTemplateFunctionConfig.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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 invokeCmd<GetTemplateFunctionConfigResponse>(
|
||||
'cmd_template_function_config',
|
||||
{
|
||||
functionName,
|
||||
values,
|
||||
model,
|
||||
environmentId,
|
||||
},
|
||||
);
|
||||
return config.function;
|
||||
}
|
||||
61
apps/yaak-client/hooks/useTemplateFunctions.tsx
Normal file
61
apps/yaak-client/hooks/useTemplateFunctions.tsx
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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 invokeCmd<GetTemplateFunctionSummaryResponse[]>(
|
||||
'cmd_template_function_summaries',
|
||||
);
|
||||
setNumFns(result.length);
|
||||
const functions = result.flatMap((r) => r.functions) ?? [];
|
||||
setAtom(functions);
|
||||
return functions;
|
||||
},
|
||||
});
|
||||
}
|
||||
15
apps/yaak-client/hooks/useTemplateTokensToString.ts
Normal file
15
apps/yaak-client/hooks/useTemplateTokensToString.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { Tokens } from '@yaakapp-internal/templates';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
|
||||
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 invokeCmd('cmd_template_tokens_to_string', { tokens });
|
||||
}
|
||||
19
apps/yaak-client/hooks/useTimedBoolean.ts
Normal file
19
apps/yaak-client/hooks/useTimedBoolean.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useUnmount } from 'react-use';
|
||||
|
||||
/** Returns a boolean that is true for a given number of milliseconds. */
|
||||
export function useTimedBoolean(millis = 1500): [boolean, () => void] {
|
||||
const [value, setValue] = useState(false);
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const reset = () => timeout.current && clearTimeout(timeout.current);
|
||||
|
||||
useUnmount(reset);
|
||||
|
||||
const setToTrue = () => {
|
||||
setValue(true);
|
||||
reset();
|
||||
timeout.current = setTimeout(() => setValue(false), millis);
|
||||
};
|
||||
|
||||
return [value, setToTrue];
|
||||
}
|
||||
14
apps/yaak-client/hooks/useTimelineViewMode.ts
Normal file
14
apps/yaak-client/hooks/useTimelineViewMode.ts
Normal 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;
|
||||
}
|
||||
7
apps/yaak-client/hooks/useToggle.ts
Normal file
7
apps/yaak-client/hooks/useToggle.ts
Normal 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;
|
||||
}
|
||||
20
apps/yaak-client/hooks/useToggleCommandPalette.tsx
Normal file
20
apps/yaak-client/hooks/useToggleCommandPalette.tsx
Normal 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-[4rem] !max-h-[min(30rem,calc(100vh-4rem))]',
|
||||
vAlign: 'top',
|
||||
noPadding: true,
|
||||
noScroll: true,
|
||||
render: ({ hide }) => <CommandPaletteDialog onClose={hide} />,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return togglePalette;
|
||||
}
|
||||
52
apps/yaak-client/hooks/useWebsocketRequestActions.ts
Normal file
52
apps/yaak-client/hooks/useWebsocketRequestActions.ts
Normal 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 { invokeCmd } from '../lib/tauri';
|
||||
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(),
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
const actions = useMemo(() => {
|
||||
return actionsResult.data ?? [];
|
||||
}, [JSON.stringify(actionsResult.data)]);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function getWebsocketRequestActions() {
|
||||
const responses = await invokeCmd<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 invokeCmd('cmd_call_websocket_request_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
return actions;
|
||||
}
|
||||
18
apps/yaak-client/hooks/useWindowFocus.ts
Normal file
18
apps/yaak-client/hooks/useWindowFocus.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useWindowFocus() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = getCurrentWebviewWindow().onFocusChanged((e) => {
|
||||
setVisible(e.payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user