Performance sweep (#147)

This commit is contained in:
Gregory Schier
2024-12-20 17:31:15 -08:00
committed by GitHub
parent 42bf016e90
commit 27134a52ad
85 changed files with 2337 additions and 1413 deletions

1134
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +0,0 @@
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { MotionConfig } from 'framer-motion';
import React, { Suspense } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { HelmetProvider } from 'react-helmet-async';
import { AppRouter } from './AppRouter';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (err, query) => {
console.log('Query client error', { err, query });
},
}),
defaultOptions: {
queries: {
retry: false,
networkMode: 'always',
refetchOnWindowFocus: true,
refetchOnReconnect: false,
refetchOnMount: false, // Don't refetch when a hook mounts
},
},
});
const ENABLE_REACT_QUERY_DEVTOOLS = false;
export function App() {
return (
<QueryClientProvider client={queryClient}>
{ENABLE_REACT_QUERY_DEVTOOLS && <ReactQueryDevtools buttonPosition="bottom-left" />}
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<DndProvider backend={HTML5Backend}>
<Suspense>
<AppRouter />
</Suspense>
</DndProvider>
</HelmetProvider>
</MotionConfig>
</QueryClientProvider>
);
}

View File

@@ -1,85 +0,0 @@
import { lazy } from 'react';
import { createBrowserRouter, Navigate, RouterProvider, useParams } from 'react-router-dom';
import { paths, useAppRoutes } from '../hooks/useAppRoutes';
import { DefaultLayout } from './DefaultLayout';
import { RedirectToLatestWorkspace } from './RedirectToLatestWorkspace';
import RouteError from './RouteError';
const LazyWorkspace = lazy(() => import('./Workspace'));
const LazySettings = lazy(() => import('./Settings/Settings'));
const router = createBrowserRouter([
{
path: '/',
errorElement: <RouteError />,
element: <DefaultLayout />,
children: [
{
path: '/',
element: <RedirectToLatestWorkspace />,
},
{
path: paths.workspaces(),
element: <RedirectToLatestWorkspace />,
},
{
path: paths.workspace({
workspaceId: ':workspaceId',
environmentId: null,
cookieJarId: null,
}),
element: <LazyWorkspace />,
},
{
path: paths.request({
workspaceId: ':workspaceId',
requestId: ':requestId',
environmentId: null,
cookieJarId: null,
}),
element: <LazyWorkspace />,
},
{
path: '/workspaces/:workspaceId/environments/:environmentId/requests/:requestId',
element: <RedirectLegacyEnvironmentURLs />,
},
{
path: paths
.workspaceSettings({
workspaceId: ':workspaceId',
})
.replace(/\?.*/, ''),
element: <LazySettings />,
},
],
},
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}
function RedirectLegacyEnvironmentURLs() {
const routes = useAppRoutes();
const {
requestId,
environmentId: rawEnvironmentId,
workspaceId,
} = useParams<{
requestId?: string;
workspaceId?: string;
environmentId?: string;
}>();
const environmentId = (rawEnvironmentId === '__default__' ? undefined : rawEnvironmentId) ?? null;
let to;
if (workspaceId != null && requestId != null) {
to = routes.paths.request({ workspaceId, environmentId, requestId, cookieJarId: null });
} else if (workspaceId != null) {
to = routes.paths.workspace({ workspaceId, environmentId, cookieJarId: null });
} else {
to = routes.paths.workspaces();
}
return <Navigate to={to} />;
}

View File

@@ -1,12 +1,10 @@
import type { KeyboardEvent, ReactNode } from 'react';
import type { HotkeyAction } from '../hooks/useHotKey';
import classNames from 'classnames';
import { fuzzyFilter } from 'fuzzbunny';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
@@ -14,6 +12,7 @@ import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useEnvironments } from '../hooks/useEnvironments';
import type { HotkeyAction } from '../hooks/useHotKey';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useOpenSettings } from '../hooks/useOpenSettings';
@@ -28,6 +27,8 @@ import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId/requests/$requestId';
import { CookieDialog } from './CookieDialog';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
@@ -58,7 +59,6 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const [activeEnvironment, setActiveEnvironmentId] = useActiveEnvironment();
const httpRequestActions = useHttpRequestActions();
const routes = useAppRoutes();
const workspaces = useWorkspaces();
const environments = useEnvironments();
const recentEnvironments = useRecentEnvironments();
@@ -268,11 +268,13 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
</HStack>
),
onSelect: () => {
return routes.navigate('request', {
workspaceId: r.workspaceId,
requestId: r.id,
environmentId: activeEnvironment?.id ?? null,
cookieJarId: activeCookieJar?.id ?? null,
router.navigate({
to: Route.fullPath,
params: {
workspaceId: r.workspaceId,
requestId: r.id,
},
search: (prev) => ({ ...prev }),
});
},
});
@@ -313,9 +315,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
}, [
workspaceCommands,
sortedRequests,
routes,
activeEnvironment?.id,
activeCookieJar?.id,
sortedEnvironments,
setActiveEnvironmentId,
sortedWorkspaces,

View File

@@ -1,30 +0,0 @@
import classNames from 'classnames';
import { Outlet } from 'react-router-dom';
import { useOsInfo } from '../hooks/useOsInfo';
import { DialogProvider, Dialogs } from './DialogContext';
import { ToastProvider, Toasts } from './ToastContext';
import { GlobalHooks } from './GlobalHooks';
export function DefaultLayout() {
const osInfo = useOsInfo();
return (
<DialogProvider>
<ToastProvider>
<>
{/* Must be inside all the providers, so they have access to them */}
<Toasts />
<Dialogs />
</>
<div
className={classNames(
'w-full h-full',
osInfo?.osType === 'linux' && 'border border-border-subtle',
)}
>
<Outlet />
</div>
<GlobalHooks />
</ToastProvider>
</DialogProvider>
);
}

View File

@@ -1,8 +1,13 @@
import { emit } from '@tauri-apps/api/event';
import type { PromptTextRequest, PromptTextResponse } from '@yaakapp-internal/plugin';
import { useEnsureActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveRequest } from '../hooks/useActiveRequest';
import {useSubscribeActiveRequestId} from "../hooks/useActiveRequestId";
import {useSubscribeActiveWorkspaceId} from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
import {useGenerateThemeCss} from "../hooks/useGenerateThemeCss";
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest';
import { useGenerateThemeCss } from '../hooks/useGenerateThemeCss';
import { useHotKey } from '../hooks/useHotKey';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useNotificationToast } from '../hooks/useNotificationToast';
@@ -11,10 +16,11 @@ import { useRecentCookieJars } from '../hooks/useRecentCookieJars';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import {useSyncFontSizeSetting} from "../hooks/useSyncFontSizeSetting";
import {useSyncModelStores} from "../hooks/useSyncModelStores";
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
import { useSyncModelStores } from '../hooks/useSyncModelStores';
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
import {useSyncZoomSetting} from "../hooks/useSyncZoomSetting";
import {useSyncWorkspaceRequestTitle} from "../hooks/useSyncWorkspaceRequestTitle";
import { useSyncZoomSetting } from '../hooks/useSyncZoomSetting';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
export function GlobalHooks() {
@@ -22,6 +28,9 @@ export function GlobalHooks() {
useSyncZoomSetting();
useSyncFontSizeSetting();
useGenerateThemeCss();
useSyncWorkspaceRequestTitle();
useSubscribeActiveWorkspaceId();
useSubscribeActiveRequestId();
// Include here so they always update, even if no component references them
useRecentWorkspaces();
@@ -35,6 +44,23 @@ export function GlobalHooks() {
useActiveWorkspaceChangedToast();
useEnsureActiveCookieJar();
const activeRequest = useActiveRequest();
const duplicateHttpRequest = useDuplicateHttpRequest({
id: activeRequest?.id ?? null,
navigateAfter: true,
});
const duplicateGrpcRequest = useDuplicateGrpcRequest({
id: activeRequest?.id ?? null,
navigateAfter: true,
});
useHotKey('http_request.duplicate', async () => {
if (activeRequest?.model === 'http_request') {
await duplicateHttpRequest.mutateAsync();
} else {
await duplicateGrpcRequest.mutateAsync();
}
});
const toggleCommandPalette = useToggleCommandPalette();
useHotKey('command_palette.toggle', toggleCommandPalette);

View File

@@ -1,10 +1,11 @@
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import React, { useState } from 'react';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId/index';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { Select } from './core/Select';
@@ -22,7 +23,6 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
const updateHttpRequest = useUpdateAnyHttpRequest();
const updateGrpcRequest = useUpdateAnyGrpcRequest();
const toast = useToast();
const routes = useAppRoutes();
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);
return (
@@ -71,10 +71,9 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
className="mr-auto min-w-[5rem]"
onClick={() => {
toast.hide('workspace-moved');
routes.navigate('workspace', {
workspaceId: selectedWorkspaceId,
cookieJarId: null,
environmentId: null,
router.navigate({
to: Route.fullPath,
params: { workspaceId: selectedWorkspaceId },
});
}}
>

View File

@@ -1,15 +1,14 @@
import classNames from 'classnames';
import { useMemo, useRef } from 'react';
import { useKeyPressEvent } from 'react-use';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useHotKey } from '../hooks/useHotKey';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRequests } from '../hooks/useRequests';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId/requests/$requestId';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem, DropdownRef } from './core/Dropdown';
@@ -20,9 +19,6 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
const dropdownRef = useRef<DropdownRef>(null);
const activeRequest = useActiveRequest();
const activeWorkspace = useActiveWorkspace();
const [activeEnvironment] = useActiveEnvironment();
const [activeCookieJar] = useActiveCookieJar();
const routes = useAppRoutes();
const allRecentRequestIds = useRecentRequests();
const recentRequestIds = useMemo(() => allRecentRequestIds.slice(1), [allRecentRequestIds]);
const requests = useRequests();
@@ -57,11 +53,13 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
// leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length} />,
leftSlot: <HttpMethodTag className="text-right" shortNames request={request} />,
onSelect: () => {
routes.navigate('request', {
requestId: request.id,
workspaceId: activeWorkspace.id,
environmentId: activeEnvironment?.id ?? null,
cookieJarId: activeCookieJar?.id ?? null,
router.navigate({
to: Route.fullPath,
params: {
requestId: request.id,
workspaceId: activeWorkspace.id,
},
search: (prev) => ({ ...prev }),
});
},
});
@@ -79,7 +77,7 @@ export function RecentRequestsDropdown({ className }: Pick<ButtonProps, 'classNa
}
return recentRequestItems.slice(0, 20);
}, [activeWorkspace, recentRequestIds, requests, routes, activeEnvironment?.id, activeCookieJar?.id]);
}, [activeWorkspace, recentRequestIds, requests]);
return (
<Dropdown ref={dropdownRef} items={items}>

View File

@@ -1,15 +1,14 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { getRecentCookieJars } from '../hooks/useRecentCookieJars';
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
import { getRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { router } from '../main';
import { Route as WorkspaceRoute } from '../routes/workspaces/$workspaceId';
import { Route as RequestRoute } from '../routes/workspaces/$workspaceId/requests/$requestId';
export function RedirectToLatestWorkspace() {
const navigate = useNavigate();
const routes = useAppRoutes();
const workspaces = useWorkspaces();
const recentWorkspaces = useRecentWorkspaces();
@@ -26,12 +25,20 @@ export function RedirectToLatestWorkspace() {
const requestId = (await getRecentRequests(workspaceId))[0] ?? null;
if (workspaceId != null && requestId != null) {
navigate(routes.paths.request({ workspaceId, environmentId, requestId, cookieJarId }));
await router.navigate({
to: RequestRoute.fullPath,
params: { workspaceId, requestId },
search: { cookieJarId, environmentId },
});
} else {
navigate(routes.paths.workspace({ workspaceId, environmentId, cookieJarId }));
await router.navigate({
to: WorkspaceRoute.fullPath,
params: { workspaceId },
search: { cookieJarId, environmentId },
});
}
})();
}, [navigate, recentWorkspaces, routes.paths, workspaces, workspaces.length]);
}, [recentWorkspaces, workspaces, workspaces.length]);
return <></>;
}

View File

@@ -0,0 +1,162 @@
import React, { useMemo } from 'react';
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateFolder } from '../hooks/useDuplicateFolder';
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useMoveToWorkspace } from '../hooks/useMoveToWorkspace';
import { useRenameRequest } from '../hooks/useRenameRequest';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSendManyRequests } from '../hooks/useSendManyRequests';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { getHttpRequest } from '../lib/store';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import { Icon } from './core/Icon';
import { useDialog } from './DialogContext';
import { FolderSettingsDialog } from './FolderSettingsDialog';
import type { SidebarTreeNode } from './Sidebar';
interface Props {
child: SidebarTreeNode;
show: { x: number; y: number } | null;
close: () => void;
}
export function RequestContextMenu({ child, show, close }: Props) {
const sendManyRequests = useSendManyRequests();
const duplicateFolder = useDuplicateFolder(child.item.id);
const deleteFolder = useDeleteFolder(child.item.id);
const httpRequestActions = useHttpRequestActions();
const sendRequest = useSendAnyHttpRequest();
const workspaces = useWorkspaces();
const dialog = useDialog();
const deleteRequest = useDeleteRequest(child.item.id);
const renameRequest = useRenameRequest(child.item.id);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: child.item.id, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: child.item.id, navigateAfter: true });
const moveToWorkspace = useMoveToWorkspace(child.item.id);
const createDropdownItems = useCreateDropdownItems({
folderId: child.item.model === 'folder' ? child.item.id : null,
});
const items = useMemo<DropdownItem[]>(() => {
if (child.item.model === 'folder') {
return [
{
key: 'send-all',
label: 'Send All',
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{
key: 'folder-settings',
label: 'Settings',
leftSlot: <Icon icon="settings" />,
onSelect: () =>
dialog.show({
id: 'folder-settings',
title: 'Folder Settings',
size: 'md',
render: () => <FolderSettingsDialog folderId={child.item.id} />,
}),
},
{
key: 'duplicateFolder',
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => duplicateFolder.mutate(),
},
{
key: 'delete-folder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
...createDropdownItems,
];
} else {
const requestItems: DropdownItem[] =
child.item.model === 'http_request'
? [
{
key: 'send-request',
label: 'Send',
hotKeyAction: 'http_request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => sendRequest.mutate(child.item.id),
},
...httpRequestActions.map((a) => ({
key: a.key,
label: a.label,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
onSelect: async () => {
const request = await getHttpRequest(child.item.id);
if (request != null) await a.call(request);
},
})),
{ type: 'separator' },
]
: [];
return [
...requestItems,
{
key: 'rename-request',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: renameRequest.mutate,
},
{
key: 'duplicate-request',
label: 'Duplicate',
hotKeyAction: 'http_request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () =>
child.item.model === 'http_request'
? duplicateHttpRequest.mutate()
: duplicateGrpcRequest.mutate(),
},
{
key: 'move-workspace',
label: 'Move',
leftSlot: <Icon icon="arrow_right_circle" />,
hidden: workspaces.length <= 1,
onSelect: moveToWorkspace.mutate,
},
{
key: 'delete-request',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteRequest.mutate(),
},
];
}
}, [
child.children,
child.item.id,
child.item.model,
createDropdownItems,
deleteFolder,
deleteRequest,
dialog,
duplicateFolder,
duplicateGrpcRequest,
duplicateHttpRequest,
httpRequestActions,
moveToWorkspace.mutate,
renameRequest.mutate,
sendManyRequests,
sendRequest,
workspaces.length,
]);
return <ContextMenu triggerPosition={show} items={items} onClose={close} />;
}

View File

@@ -1,5 +1,6 @@
import { useRouteError } from 'react-router-dom';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { router } from '../main';
import { Route } from '../routes/workspaces';
import { Button } from './core/Button';
import { FormattedError } from './core/FormattedError';
import { Heading } from './core/Heading';
@@ -11,7 +12,6 @@ export default function RouteError() {
const stringified = JSON.stringify(error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (error as any).message ?? stringified;
const routes = useAppRoutes();
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="max-w-[50rem] !h-auto">
@@ -21,7 +21,7 @@ export default function RouteError() {
<Button
color="primary"
onClick={() => {
routes.navigate('workspaces');
router.navigate({ to: Route.fullPath });
}}
>
Go Home

View File

@@ -1,7 +1,6 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import classNames from 'classnames';
import React, { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useKeyPressEvent } from 'react-use';
import { useOsInfo } from '../../hooks/useOsInfo';
import { capitalize } from '../../lib/capitalize';
@@ -16,6 +15,7 @@ import { SettingsProxy } from './SettingsProxy';
interface Props {
hide?: () => void;
defaultTab?: SettingsTab;
}
export enum SettingsTab {
@@ -34,10 +34,9 @@ const tabs = [
SettingsTab.License,
];
export default function Settings({ hide }: Props) {
export default function Settings({ hide, defaultTab }: Props) {
const osInfo = useOsInfo();
const [params] = useSearchParams();
const [tab, setTab] = useState<string>(params.get('tab') ?? SettingsTab.General);
const [tab, setTab] = useState<string>(defaultTab ?? SettingsTab.General);
// Close settings window on escape
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window

View File

@@ -1,162 +1,105 @@
import type {
AnyModel,
Folder,
GrpcConnection,
GrpcRequest,
HttpRequest,
HttpResponse,
Workspace,
} from '@yaakapp-internal/models';
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { atom, useAtom } from 'jotai';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useKey, useKeyPressEvent } from 'react-use';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { getActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
import { useDeleteFolder } from '../hooks/useDeleteFolder';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateFolder } from '../hooks/useDuplicateFolder';
import { useDuplicateGrpcRequest } from '../hooks/useDuplicateGrpcRequest';
import { useDuplicateHttpRequest } from '../hooks/useDuplicateHttpRequest';
import { useFolders } from '../hooks/useFolders';
import { useGrpcConnections } from '../hooks/useGrpcConnections';
import { useHotKey } from '../hooks/useHotKey';
import type { CallableHttpRequestAction } from '../hooks/useHttpRequestActions';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useHttpResponses } from '../hooks/useHttpResponses';
import { useKeyValue } from '../hooks/useKeyValue';
import { useMoveToWorkspace } from '../hooks/useMoveToWorkspace';
import { useRenameRequest } from '../hooks/useRenameRequest';
import { useRequests } from '../hooks/useRequests';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSendManyRequests } from '../hooks/useSendManyRequests';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { isResponseLoading } from '../lib/model_util';
import { getHttpRequest } from '../lib/store';
import type { DropdownItem } from './core/Dropdown';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId/requests/$requestId';
import { ContextMenu } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { useDialog } from './DialogContext';
import { DropMarker } from './DropMarker';
import { FolderSettingsDialog } from './FolderSettingsDialog';
import type { SidebarItemProps } from './SidebarItem';
import { SidebarItems } from './SidebarItems';
interface Props {
className?: string;
}
enum ItemTypes {
REQUEST = 'request',
}
interface TreeNode {
export interface SidebarTreeNode {
item: Workspace | Folder | HttpRequest | GrpcRequest;
children: TreeNode[];
children: SidebarTreeNode[];
depth: number;
}
export function Sidebar({ className }: Props) {
// This is an atom so we can use it in the child items to avoid re-rendering the entire list
export const sidebarSelectedIdAtom = atom<string | null>(null);
export const Sidebar = memo(function Sidebar({ className }: Props) {
const [hidden, setHidden] = useSidebarHidden();
const sidebarRef = useRef<HTMLLIElement>(null);
const activeRequest = useActiveRequest();
const [activeEnvironment] = useActiveEnvironment();
const [activeCookieJar] = useActiveCookieJar();
const folders = useFolders();
const requests = useRequests();
const activeWorkspace = useActiveWorkspace();
const httpRequestActions = useHttpRequestActions();
const httpResponses = useHttpResponses();
const grpcConnections = useGrpcConnections();
const duplicateHttpRequest = useDuplicateHttpRequest({
id: activeRequest?.id ?? null,
navigateAfter: true,
});
const duplicateGrpcRequest = useDuplicateGrpcRequest({
id: activeRequest?.id ?? null,
navigateAfter: true,
});
const routes = useAppRoutes();
const [hasFocus, setHasFocus] = useState<boolean>(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedTree, setSelectedTree] = useState<TreeNode | null>(null);
const updateAnyHttpRequest = useUpdateAnyHttpRequest();
const updateAnyGrpcRequest = useUpdateAnyGrpcRequest();
const updateAnyFolder = useUpdateAnyFolder();
const [selectedId, setSelectedId] = useAtom(sidebarSelectedIdAtom);
const [selectedTree, setSelectedTree] = useState<SidebarTreeNode | null>(null);
const { mutateAsync: updateAnyHttpRequest } = useUpdateAnyHttpRequest();
const { mutateAsync: updateAnyGrpcRequest } = useUpdateAnyGrpcRequest();
const { mutateAsync: updateAnyFolder } = useUpdateAnyFolder();
const [draggingId, setDraggingId] = useState<string | null>(null);
const [hoveredTree, setHoveredTree] = useState<TreeNode | null>(null);
const [hoveredTree, setHoveredTree] = useState<SidebarTreeNode | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const collapsed = useKeyValue<Record<string, boolean>>({
const { value: collapsed, set: setCollapsed } = useKeyValue<Record<string, boolean>>({
key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'],
fallback: {},
namespace: 'no_sync',
});
useHotKey('http_request.duplicate', async () => {
if (activeRequest?.model === 'http_request') {
await duplicateHttpRequest.mutateAsync();
} else {
await duplicateGrpcRequest.mutateAsync();
}
});
const isCollapsed = useCallback(
(id: string) => collapsed.value?.[id] ?? false,
[collapsed.value],
);
const isCollapsed = useCallback((id: string) => collapsed?.[id] ?? false, [collapsed]);
const { tree, treeParentMap, selectableRequests } = useMemo<{
tree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
selectedRequest: HttpRequest | GrpcRequest | null;
tree: SidebarTreeNode | null;
treeParentMap: Record<string, SidebarTreeNode>;
selectableRequests: {
id: string;
index: number;
tree: TreeNode;
tree: SidebarTreeNode;
}[];
}>(() => {
const treeParentMap: Record<string, TreeNode> = {};
const childrenMap: Record<string, (HttpRequest | GrpcRequest | Folder)[]> = {};
for (const item of [...requests, ...folders]) {
if (item.folderId == null) {
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
childrenMap[item.workspaceId]!.push(item);
} else {
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
childrenMap[item.folderId]!.push(item);
}
}
const treeParentMap: Record<string, SidebarTreeNode> = {};
const selectableRequests: {
id: string;
index: number;
tree: TreeNode;
tree: SidebarTreeNode;
}[] = [];
if (activeWorkspace == null) {
return { tree: null, treeParentMap, selectableRequests, selectedRequest: null };
return { tree: null, treeParentMap, selectableRequests };
}
let selectedRequest: HttpRequest | GrpcRequest | null = null;
const selectedRequest: HttpRequest | GrpcRequest | null = null;
let selectableRequestIndex = 0;
// Put requests and folders into a tree structure
const next = (node: TreeNode): TreeNode => {
if (
node.item.id === selectedId &&
(node.item.model === 'http_request' || node.item.model === 'grpc_request')
) {
selectedRequest = node.item;
}
const childItems = [...requests, ...folders].filter((f) =>
node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id,
);
const next = (node: SidebarTreeNode): SidebarTreeNode => {
const childItems = childrenMap[node.item.id] ?? [];
// Recurse to children
const isCollapsed = collapsed.value?.[node.item.id];
const isCollapsed = collapsed?.[node.item.id];
const depth = node.depth + 1;
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
for (const item of childItems) {
@@ -175,18 +118,19 @@ export function Sidebar({ className }: Props) {
const tree = next({ item: activeWorkspace, children: [], depth: 0 });
return { tree, treeParentMap, selectableRequests, selectedRequest };
}, [activeWorkspace, selectedId, requests, folders, collapsed.value]);
}, [activeWorkspace, requests, folders, collapsed]);
const focusActiveRequest = useCallback(
(
args: {
forced?: {
id: string;
tree: TreeNode;
tree: SidebarTreeNode;
};
noFocusSidebar?: boolean;
} = {},
) => {
const activeRequest = getActiveRequest();
const { forced, noFocusSidebar } = args;
const tree = forced?.tree ?? treeParentMap[activeRequest?.id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
@@ -204,11 +148,11 @@ export function Sidebar({ className }: Props) {
sidebarRef.current?.focus();
}
},
[activeRequest, treeParentMap],
[setHasFocus, setSelectedId, treeParentMap],
);
const handleSelect = useCallback(
async (id: string, opts: { noFocus?: boolean } = {}) => {
async (id: string) => {
const tree = treeParentMap[id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const node = children.find((m) => m.item.id === id) ?? null;
@@ -219,40 +163,36 @@ export function Sidebar({ className }: Props) {
const { item } = node;
if (item.model === 'folder') {
await collapsed.set((c) => ({ ...c, [item.id]: !c[item.id] }));
await setCollapsed((c) => ({ ...c, [item.id]: !c[item.id] }));
} else {
routes.navigate('request', {
requestId: id,
workspaceId: item.workspaceId,
environmentId: activeEnvironment?.id ?? null,
cookieJarId: activeCookieJar?.id ?? null,
router.navigate({
to: Route.fullPath,
params: {
requestId: id,
workspaceId: item.workspaceId,
},
search: (prev) => ({ ...prev }),
});
setHasFocus(true);
setSelectedId(id);
setSelectedTree(tree);
if (!opts.noFocus) focusActiveRequest({ forced: { id, tree } });
}
},
[
treeParentMap,
collapsed,
routes,
activeEnvironment?.id,
activeCookieJar?.id,
focusActiveRequest,
],
[treeParentMap, setCollapsed, setHasFocus, setSelectedId],
);
const handleClearSelected = useCallback(() => {
setSelectedId(null);
setSelectedTree(null);
}, []);
}, [setSelectedId]);
const handleFocus = useCallback(() => {
if (hasFocus) return;
focusActiveRequest({ noFocusSidebar: true });
}, [focusActiveRequest, hasFocus]);
const handleBlur = useCallback(() => setHasFocus(false), []);
const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]);
useHotKey('sidebar.focus', async () => {
// Hide the sidebar if it's already focused
@@ -277,16 +217,18 @@ export function Sidebar({ className }: Props) {
useKeyPressEvent('Enter', (e) => {
if (!hasFocus) return;
const selected = selectableRequests.find((r) => r.id === selectedId);
if (!selected || selected.id === activeRequest?.id || activeWorkspace == null) {
if (!selected || activeWorkspace == null) {
return;
}
e.preventDefault();
routes.navigate('request', {
requestId: selected.id,
workspaceId: activeWorkspace?.id ?? null,
environmentId: activeEnvironment?.id ?? null,
cookieJarId: activeCookieJar?.id ?? null,
router.navigate({
to: Route.fullPath,
params: {
requestId: selected.id,
workspaceId: activeWorkspace?.id ?? null,
},
search: (prev) => ({ ...prev }),
});
});
@@ -395,13 +337,13 @@ export function Sidebar({ className }: Props) {
const sortPriority = i * 1000;
if (child.item.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
return updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder });
return updateAnyFolder({ id: child.item.id, update: updateFolder });
} else if (child.item.model === 'grpc_request') {
const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId });
return updateAnyGrpcRequest.mutateAsync({ id: child.item.id, update: updateRequest });
return updateAnyGrpcRequest({ id: child.item.id, update: updateRequest });
} else if (child.item.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
return updateAnyHttpRequest.mutateAsync({ id: child.item.id, update: updateRequest });
return updateAnyHttpRequest({ id: child.item.id, update: updateRequest });
}
}),
);
@@ -409,13 +351,13 @@ export function Sidebar({ className }: Props) {
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
if (child.item.model === 'folder') {
const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId });
await updateAnyFolder.mutateAsync({ id: child.item.id, update: updateFolder });
await updateAnyFolder({ id: child.item.id, update: updateFolder });
} else if (child.item.model === 'grpc_request') {
const updateRequest = (r: GrpcRequest) => ({ ...r, sortPriority, folderId });
await updateAnyGrpcRequest.mutateAsync({ id: child.item.id, update: updateRequest });
await updateAnyGrpcRequest({ id: child.item.id, update: updateRequest });
} else if (child.item.model === 'http_request') {
const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId });
await updateAnyHttpRequest.mutateAsync({ id: child.item.id, update: updateRequest });
await updateAnyHttpRequest({ id: child.item.id, update: updateRequest });
}
}
setDraggingId(null);
@@ -445,7 +387,7 @@ export function Sidebar({ className }: Props) {
const mainContextMenuItems = useCreateDropdownItems();
// Not ready to render yet
if (tree == null || collapsed.value == null) {
if (tree == null || collapsed == null) {
return null;
}
@@ -457,7 +399,14 @@ export function Sidebar({ className }: Props) {
onBlur={handleBlur}
tabIndex={hidden ? -1 : 0}
onContextMenu={handleMainContextMenu}
className={classNames(className, 'h-full grid grid-rows-[minmax(0,1fr)_auto]')}
data-focused={hasFocus}
className={classNames(
className,
// Style item selection color here, because it's very hard to do in an efficient
// way in the item itself (selection ID makes it hard)
hasFocus && '[&_[data-selected=true]]:bg-surface-active',
'h-full grid grid-rows-[minmax(0,1fr)_auto]',
)}
>
<div className="pb-3 overflow-x-visible overflow-y-scroll pt-2">
<ContextMenu
@@ -467,15 +416,11 @@ export function Sidebar({ className }: Props) {
/>
<SidebarItems
treeParentMap={treeParentMap}
activeId={activeRequest?.id ?? null}
selectedId={selectedId}
selectedTree={selectedTree}
isCollapsed={isCollapsed}
httpRequestActions={httpRequestActions}
httpResponses={httpResponses}
grpcConnections={grpcConnections}
tree={tree}
focused={hasFocus}
draggingId={draggingId}
onSelect={handleSelect}
hoveredIndex={hoveredIndex}
@@ -487,485 +432,4 @@ export function Sidebar({ className }: Props) {
</div>
</aside>
);
}
interface SidebarItemsProps {
tree: TreeNode;
focused: boolean;
draggingId: string | null;
activeId: string | null;
selectedId: string | null;
selectedTree: TreeNode | null;
treeParentMap: Record<string, TreeNode>;
hoveredTree: TreeNode | null;
hoveredIndex: number | null;
handleMove: (id: string, side: 'above' | 'below') => void;
handleEnd: (id: string) => void;
handleDragStart: (id: string) => void;
onSelect: (requestId: string) => void;
isCollapsed: (id: string) => boolean;
httpRequestActions: CallableHttpRequestAction[];
httpResponses: HttpResponse[];
grpcConnections: GrpcConnection[];
}
function SidebarItems({
tree,
focused,
activeId,
selectedId,
selectedTree,
draggingId,
onSelect,
treeParentMap,
isCollapsed,
hoveredTree,
hoveredIndex,
handleEnd,
handleMove,
handleDragStart,
httpRequestActions,
httpResponses,
grpcConnections,
}: SidebarItemsProps) {
return (
<VStack
as="ul"
role="menu"
aria-orientation="vertical"
dir="ltr"
className={classNames(
tree.depth > 0 && 'border-l border-border-subtle',
tree.depth === 0 && 'ml-0',
tree.depth >= 1 && 'ml-[1.2rem]',
)}
>
{tree.children.map((child, i) => {
const selected = selectedId === child.item.id;
const active = activeId === child.item.id;
return (
<Fragment key={child.item.id}>
{hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />}
<SidebarItem
selected={selected}
itemId={child.item.id}
itemName={child.item.name}
itemFallbackName={
child.item.model === 'http_request' || child.item.model === 'grpc_request'
? fallbackRequestName(child.item)
: 'New Folder'
}
itemModel={child.item.model}
itemPrefix={
(child.item.model === 'http_request' || child.item.model === 'grpc_request') && (
<HttpMethodTag
request={child.item}
className={classNames(!(active || selected) && 'text-text-subtlest')}
/>
)
}
httpRequestActions={httpRequestActions}
latestHttpResponse={httpResponses.find((r) => r.requestId === child.item.id) ?? null}
latestGrpcConnection={
grpcConnections.find((c) => c.requestId === child.item.id) ?? null
}
onMove={handleMove}
onEnd={handleEnd}
onSelect={onSelect}
onDragStart={handleDragStart}
useProminentStyles={focused}
isCollapsed={isCollapsed}
child={child}
>
{child.item.model === 'folder' &&
!isCollapsed(child.item.id) &&
draggingId !== child.item.id && (
<SidebarItems
activeId={activeId}
draggingId={draggingId}
focused={focused}
handleDragStart={handleDragStart}
handleEnd={handleEnd}
handleMove={handleMove}
hoveredIndex={hoveredIndex}
hoveredTree={hoveredTree}
httpRequestActions={httpRequestActions}
httpResponses={httpResponses}
grpcConnections={grpcConnections}
isCollapsed={isCollapsed}
onSelect={onSelect}
selectedId={selectedId}
selectedTree={selectedTree}
tree={child}
treeParentMap={treeParentMap}
/>
)}
</SidebarItem>
</Fragment>
);
})}
{hoveredIndex === tree.children.length && hoveredTree?.item.id === tree.item.id && (
<DropMarker />
)}
</VStack>
);
}
type SidebarItemProps = {
className?: string;
itemId: string;
itemName: string;
itemFallbackName: string;
itemModel: AnyModel['model'];
itemPrefix: ReactNode;
useProminentStyles?: boolean;
selected: boolean;
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onDragStart: (id: string) => void;
children?: ReactNode;
child: TreeNode;
latestHttpResponse: HttpResponse | null;
latestGrpcConnection: GrpcConnection | null;
} & Pick<SidebarItemsProps, 'isCollapsed' | 'onSelect' | 'httpRequestActions'>;
type DragItem = {
id: string;
itemName: string;
};
function SidebarItem({
itemName,
itemId,
itemModel,
child,
onMove,
onEnd,
onDragStart,
onSelect,
isCollapsed,
itemPrefix,
className,
selected,
itemFallbackName,
useProminentStyles,
latestHttpResponse,
latestGrpcConnection,
httpRequestActions,
children,
}: SidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (_, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [, connectDrag] = useDrag<
DragItem,
unknown,
{
isDragging: boolean;
}
>(
() => ({
type: ItemTypes.REQUEST,
item: () => {
// Cancel drag when editing
if (editing) return null;
onDragStart(itemId);
return { id: itemId, itemName };
},
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(itemId),
}),
[onEnd],
);
connectDrag(connectDrop(ref));
const dialog = useDialog();
const activeRequest = useActiveRequest();
const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(itemId);
const renameRequest = useRenameRequest(itemId);
const duplicateFolder = useDuplicateFolder(itemId);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const sendRequest = useSendAnyHttpRequest();
const moveToWorkspace = useMoveToWorkspace(itemId);
const sendManyRequests = useSendManyRequests();
const updateHttpRequest = useUpdateAnyHttpRequest();
const workspaces = useWorkspaces();
const updateGrpcRequest = useUpdateAnyGrpcRequest();
const [editing, setEditing] = useState<boolean>(false);
const isActive = activeRequest?.id === itemId;
const createDropdownItems = useCreateDropdownItems({ folderId: itemId });
useScrollIntoView(ref.current, isActive);
const handleSubmitNameEdit = useCallback(
async (el: HTMLInputElement) => {
if (itemModel === 'http_request') {
await updateHttpRequest.mutateAsync({
id: itemId,
update: (r) => ({ ...r, name: el.value }),
});
} else if (itemModel === 'grpc_request') {
await updateGrpcRequest.mutateAsync({
id: itemId,
update: (r) => ({ ...r, name: el.value }),
});
}
setEditing(false);
},
[itemId, itemModel, updateGrpcRequest, updateHttpRequest],
);
const handleFocus = useCallback((el: HTMLInputElement | null) => {
el?.focus();
el?.select();
}, []);
const handleInputKeyDown = useCallback(
async (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation();
switch (e.key) {
case 'Enter':
e.preventDefault();
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
e.preventDefault();
setEditing(false);
break;
}
},
[handleSubmitNameEdit],
);
const handleStartEditing = useCallback(() => {
if (itemModel !== 'http_request' && itemModel !== 'grpc_request') return;
setEditing(true);
}, [setEditing, itemModel]);
const handleBlur = useCallback(
async (e: React.FocusEvent<HTMLInputElement>) => {
await handleSubmitNameEdit(e.currentTarget);
},
[handleSubmitNameEdit],
);
const handleSelect = useCallback(() => onSelect(itemId), [onSelect, itemId]);
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const items = useMemo<DropdownItem[]>(() => {
if (itemModel === 'folder') {
return [
{
key: 'send-all',
label: 'Send All',
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{
key: 'folder-settings',
label: 'Settings',
leftSlot: <Icon icon="settings" />,
onSelect: () =>
dialog.show({
id: 'folder-settings',
title: 'Folder Settings',
size: 'md',
render: () => <FolderSettingsDialog folderId={itemId} />,
}),
},
{
key: 'duplicateFolder',
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => duplicateFolder.mutate(),
},
{
key: 'delete-folder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteFolder.mutate(),
},
{ type: 'separator' },
...createDropdownItems,
];
} else {
const requestItems: DropdownItem[] =
itemModel === 'http_request'
? [
{
key: 'send-request',
label: 'Send',
hotKeyAction: 'http_request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => sendRequest.mutate(itemId),
},
...httpRequestActions.map((a) => ({
key: a.key,
label: a.label,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
onSelect: async () => {
const request = await getHttpRequest(itemId);
if (request != null) await a.call(request);
},
})),
{ type: 'separator' },
]
: [];
return [
...requestItems,
{
key: 'rename-request',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: renameRequest.mutate,
},
{
key: 'duplicate-request',
label: 'Duplicate',
hotKeyAction: 'http_request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () =>
itemModel === 'http_request'
? duplicateHttpRequest.mutate()
: duplicateGrpcRequest.mutate(),
},
{
key: 'move-workspace',
label: 'Move',
leftSlot: <Icon icon="arrow_right_circle" />,
hidden: workspaces.length <= 1,
onSelect: moveToWorkspace.mutate,
},
{
key: 'delete-request',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteRequest.mutate(),
},
];
}
}, [
child.children,
createDropdownItems,
deleteFolder,
deleteRequest,
duplicateFolder,
duplicateGrpcRequest,
duplicateHttpRequest,
httpRequestActions,
itemId,
itemModel,
moveToWorkspace.mutate,
renameRequest.mutate,
sendManyRequests,
sendRequest,
workspaces.length,
]);
return (
<li ref={ref} draggable>
<div className={classNames(className, 'block relative group/item px-1.5 pb-0.5')}>
<ContextMenu
triggerPosition={showContextMenu}
items={items}
onClose={handleCloseContextMenu}
/>
<button
// tabIndex={-1} // Will prevent drag-n-drop
disabled={editing}
onClick={handleSelect}
onDoubleClick={handleStartEditing}
onContextMenu={handleContextMenu}
data-active={isActive}
data-selected={selected}
className={classNames(
'w-full flex gap-1.5 items-center h-xs px-1.5 rounded-md focus-visible:ring focus-visible:ring-border-focus outline-0',
editing && 'ring-1 focus-within:ring-focus',
isActive && 'bg-surface-highlight text-text',
!isActive && 'text-text-subtle group-hover/item:text-text',
showContextMenu && '!text-text', // Show as "active" when context menu is open
selected && useProminentStyles && '!bg-surface-active',
)}
>
{itemModel === 'folder' && (
<Icon
size="sm"
icon="chevron_right"
className={classNames(
'text-text-subtlest',
'transition-transform',
!isCollapsed(itemId) && 'transform rotate-90',
)}
/>
)}
<div className="flex items-center gap-2 min-w-0">
{itemPrefix}
{editing ? (
<input
ref={handleFocus}
defaultValue={itemName}
className="bg-transparent outline-none w-full cursor-text"
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className="truncate">{itemName || itemFallbackName}</span>
)}
</div>
{latestGrpcConnection ? (
<div className="ml-auto">
{isResponseLoading(latestGrpcConnection) && (
<Icon spin size="sm" icon="update" className="text-text-subtlest" />
)}
</div>
) : latestHttpResponse ? (
<div className="ml-auto">
{isResponseLoading(latestHttpResponse) ? (
<Icon spin size="sm" icon="refresh" className="text-text-subtlest" />
) : (
<StatusTag className="text-xs" response={latestHttpResponse} />
)}
</div>
) : null}
</button>
</div>
{children}
</li>
);
}
});

View File

@@ -0,0 +1,278 @@
import type { AnyModel, GrpcConnection, HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { activeRequestAtom } from '../hooks/useActiveRequest';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { isResponseLoading } from '../lib/model_util';
import { jotaiStore } from '../routes/__root';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { StatusTag } from './core/StatusTag';
import { RequestContextMenu } from './RequestContextMenu';
import type { SidebarTreeNode } from './Sidebar';
import { sidebarSelectedIdAtom } from './Sidebar';
import type { SidebarItemsProps } from './SidebarItems';
enum ItemTypes {
REQUEST = 'request',
}
export type SidebarItemProps = {
className?: string;
itemId: string;
itemName: string;
itemFallbackName: string;
itemModel: AnyModel['model'];
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onDragStart: (id: string) => void;
children?: ReactNode;
child: SidebarTreeNode;
latestHttpResponse: HttpResponse | null;
latestGrpcConnection: GrpcConnection | null;
} & Pick<SidebarItemsProps, 'isCollapsed' | 'onSelect'>;
type DragItem = {
id: string;
itemName: string;
};
function SidebarItem_({
itemName,
itemId,
itemModel,
child,
onMove,
onEnd,
onDragStart,
onSelect,
isCollapsed,
className,
itemFallbackName,
latestHttpResponse,
latestGrpcConnection,
children,
}: SidebarItemProps) {
const ref = useRef<HTMLLIElement>(null);
const [, connectDrop] = useDrop<DragItem, void>(
{
accept: ItemTypes.REQUEST,
hover: (_, monitor) => {
if (!ref.current) return;
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below');
},
},
[onMove],
);
const [, connectDrag] = useDrag<
DragItem,
unknown,
{
isDragging: boolean;
}
>(
() => ({
type: ItemTypes.REQUEST,
item: () => {
// Cancel drag when editing
if (editing) return null;
onDragStart(itemId);
return { id: itemId, itemName };
},
collect: (m) => ({ isDragging: m.isDragging() }),
options: { dropEffect: 'move' },
end: () => onEnd(itemId),
}),
[onEnd],
);
connectDrag(connectDrop(ref));
const updateHttpRequest = useUpdateAnyHttpRequest();
const updateGrpcRequest = useUpdateAnyGrpcRequest();
const [editing, setEditing] = useState<boolean>(false);
const [selected, setSelected] = useState<boolean>(
jotaiStore.get(sidebarSelectedIdAtom) == itemId,
);
useEffect(() => {
jotaiStore.sub(sidebarSelectedIdAtom, () => {
const value = jotaiStore.get(sidebarSelectedIdAtom);
setSelected(value === itemId);
});
}, [itemId]);
const [active, setActive] = useState<boolean>(jotaiStore.get(activeRequestAtom)?.id === itemId);
useEffect(() => {
jotaiStore.sub(activeRequestAtom, () => {
const value = jotaiStore.get(activeRequestAtom);
setActive(value?.id === itemId);
});
}, [itemId]);
useScrollIntoView(ref.current, active);
const handleSubmitNameEdit = useCallback(
async (el: HTMLInputElement) => {
if (itemModel === 'http_request') {
await updateHttpRequest.mutateAsync({
id: itemId,
update: (r) => ({ ...r, name: el.value }),
});
} else if (itemModel === 'grpc_request') {
await updateGrpcRequest.mutateAsync({
id: itemId,
update: (r) => ({ ...r, name: el.value }),
});
}
setEditing(false);
},
[itemId, itemModel, updateGrpcRequest, updateHttpRequest],
);
const handleFocus = useCallback((el: HTMLInputElement | null) => {
el?.focus();
el?.select();
}, []);
const handleInputKeyDown = useCallback(
async (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation();
switch (e.key) {
case 'Enter':
e.preventDefault();
await handleSubmitNameEdit(e.currentTarget);
break;
case 'Escape':
e.preventDefault();
setEditing(false);
break;
}
},
[handleSubmitNameEdit],
);
const handleStartEditing = useCallback(() => {
if (itemModel !== 'http_request' && itemModel !== 'grpc_request') return;
setEditing(true);
}, [setEditing, itemModel]);
const handleBlur = useCallback(
async (e: React.FocusEvent<HTMLInputElement>) => {
await handleSubmitNameEdit(e.currentTarget);
},
[handleSubmitNameEdit],
);
const handleSelect = useCallback(() => onSelect(itemId), [onSelect, itemId]);
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const handleCloseContextMenu = useCallback(() => setShowContextMenu(null), []);
const itemPrefix = (child.item.model === 'http_request' ||
child.item.model === 'grpc_request') && (
<HttpMethodTag
request={child.item}
className={classNames(!(active || selected) && 'text-text-subtlest')}
/>
);
return (
<li ref={ref} draggable>
<div className={classNames(className, 'block relative group/item px-1.5 pb-0.5')}>
<RequestContextMenu child={child} show={showContextMenu} close={handleCloseContextMenu} />
<button
// tabIndex={-1} // Will prevent drag-n-drop
disabled={editing}
onClick={handleSelect}
onDoubleClick={handleStartEditing}
onContextMenu={handleContextMenu}
data-active={active}
data-selected={selected}
className={classNames(
'w-full flex gap-1.5 items-center h-xs px-1.5 rounded-md focus-visible:ring focus-visible:ring-border-focus outline-0',
editing && 'ring-1 focus-within:ring-focus',
active && 'bg-surface-highlight text-text',
!active && 'text-text-subtle group-hover/item:text-text',
showContextMenu && '!text-text', // Show as "active" when context menu is open
)}
>
{itemModel === 'folder' && (
<Icon
size="sm"
icon="chevron_right"
className={classNames(
'text-text-subtlest',
'transition-transform',
!isCollapsed(itemId) && 'transform rotate-90',
)}
/>
)}
<div className="flex items-center gap-2 min-w-0">
{itemPrefix}
{editing ? (
<input
ref={handleFocus}
defaultValue={itemName}
className="bg-transparent outline-none w-full cursor-text"
onBlur={handleBlur}
onKeyDown={handleInputKeyDown}
/>
) : (
<span className="truncate">{itemName || itemFallbackName}</span>
)}
</div>
{latestGrpcConnection ? (
<div className="ml-auto">
{isResponseLoading(latestGrpcConnection) && (
<Icon spin size="sm" icon="update" className="text-text-subtlest" />
)}
</div>
) : latestHttpResponse ? (
<div className="ml-auto">
{isResponseLoading(latestHttpResponse) ? (
<Icon spin size="sm" icon="refresh" className="text-text-subtlest" />
) : (
<StatusTag className="text-xs" response={latestHttpResponse} />
)}
</div>
) : null}
</button>
</div>
{children}
</li>
);
}
export const SidebarItem = memo<SidebarItemProps>(SidebarItem_, (a, b) => {
const different = [];
for (const key of Object.keys(a) as (keyof SidebarItemProps)[]) {
if (a[key] !== b[key]) {
different.push(key);
}
}
if (different.length > 0) {
console.log('ITEM DIFFERENT -------------------', different.join(', '));
}
return different.length === 0;
});

View File

@@ -0,0 +1,118 @@
import type { GrpcConnection, HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import React, { Fragment, memo } from 'react';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { VStack } from './core/Stacks';
import { DropMarker } from './DropMarker';
import type { SidebarTreeNode } from './Sidebar';
import { SidebarItem } from './SidebarItem';
export interface SidebarItemsProps {
tree: SidebarTreeNode;
draggingId: string | null;
selectedTree: SidebarTreeNode | null;
treeParentMap: Record<string, SidebarTreeNode>;
hoveredTree: SidebarTreeNode | null;
hoveredIndex: number | null;
handleMove: (id: string, side: 'above' | 'below') => void;
handleEnd: (id: string) => void;
handleDragStart: (id: string) => void;
onSelect: (requestId: string) => void;
isCollapsed: (id: string) => boolean;
httpResponses: HttpResponse[];
grpcConnections: GrpcConnection[];
}
function SidebarItems_({
tree,
selectedTree,
draggingId,
onSelect,
treeParentMap,
isCollapsed,
hoveredTree,
hoveredIndex,
handleEnd,
handleMove,
handleDragStart,
httpResponses,
grpcConnections,
}: SidebarItemsProps) {
return (
<VStack
as="ul"
role="menu"
aria-orientation="vertical"
dir="ltr"
className={classNames(
tree.depth > 0 && 'border-l border-border-subtle',
tree.depth === 0 && 'ml-0',
tree.depth >= 1 && 'ml-[1.2rem]',
)}
>
{tree.children.map((child, i) => {
return (
<Fragment key={child.item.id}>
{hoveredIndex === i && hoveredTree?.item.id === tree.item.id && <DropMarker />}
<SidebarItem
itemId={child.item.id}
itemName={child.item.name}
itemFallbackName={
child.item.model === 'http_request' || child.item.model === 'grpc_request'
? fallbackRequestName(child.item)
: 'New Folder'
}
itemModel={child.item.model}
latestHttpResponse={httpResponses.find((r) => r.requestId === child.item.id) ?? null}
latestGrpcConnection={
grpcConnections.find((c) => c.requestId === child.item.id) ?? null
}
onMove={handleMove}
onEnd={handleEnd}
onSelect={onSelect}
onDragStart={handleDragStart}
isCollapsed={isCollapsed}
child={child}
>
{child.item.model === 'folder' &&
!isCollapsed(child.item.id) &&
draggingId !== child.item.id && (
<SidebarItems
draggingId={draggingId}
handleDragStart={handleDragStart}
handleEnd={handleEnd}
handleMove={handleMove}
hoveredIndex={hoveredIndex}
hoveredTree={hoveredTree}
httpResponses={httpResponses}
grpcConnections={grpcConnections}
isCollapsed={isCollapsed}
onSelect={onSelect}
selectedTree={selectedTree}
tree={child}
treeParentMap={treeParentMap}
/>
)}
</SidebarItem>
</Fragment>
);
})}
{hoveredIndex === tree.children.length && hoveredTree?.item.id === tree.item.id && (
<DropMarker />
)}
</VStack>
);
}
export const SidebarItems = memo<SidebarItemsProps>(SidebarItems_, (a, b) => {
const different = [];
for (const key of Object.keys(a) as (keyof SidebarItemsProps)[]) {
if (a[key] !== b[key]) {
different.push(key);
}
}
if (different.length > 0) {
console.log('ITEMS DIFFERENT -------------------', different.join(', '));
}
return different.length === 0;
});

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
@@ -9,7 +9,6 @@ import { useImportData } from '../hooks/useImportData';
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
@@ -31,22 +30,21 @@ const head = { gridArea: 'head' };
const body = { gridArea: 'body' };
const drag = { gridArea: 'drag' };
export default function Workspace() {
useSyncWorkspaceRequestTitle();
export function Workspace() {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const { setWidth, width, resetWidth } = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeRequest = useActiveRequest();
const importData = useImportData();
const floating = useShouldFloatSidebar();
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
useEffect(() => {
console.log('RENDER WORKSPACE');
}, []);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
@@ -161,34 +159,50 @@ export default function Workspace() {
>
<WorkspaceHeader className="pointer-events-none" />
</HeaderSize>
{activeWorkspace == null ? (
<div className="m-auto">
<Banner color="warning" className="max-w-[30rem]">
The active workspace was not found. Select a workspace from the header menu or report
this bug to <FeedbackLink />
</Banner>
</div>
) : activeRequest == null ? (
<HotKeyList
hotkeys={['http_request.create', 'sidebar.focus', 'settings.show']}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
Import
</Button>
<CreateDropdown hideFolder>
<Button variant="border" forDropdown size="sm">
New Request
</Button>
</CreateDropdown>
</HStack>
}
/>
) : activeRequest.model === 'grpc_request' ? (
<GrpcConnectionLayout style={body} />
) : (
<HttpRequestLayout activeRequest={activeRequest} style={body} />
)}
<WorkspaceBody />
</div>
);
}
function WorkspaceBody() {
const activeRequest = useActiveRequest();
const activeWorkspace = useActiveWorkspace();
const importData = useImportData();
if (activeWorkspace == null) {
return (
<div className="m-auto">
<Banner color="warning" className="max-w-[30rem]">
The active workspace was not found. Select a workspace from the header menu or report this
bug to <FeedbackLink />
</Banner>
</div>
);
}
if (activeRequest == null) {
return (
<HotKeyList
hotkeys={['http_request.create', 'sidebar.focus', 'settings.show']}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
Import
</Button>
<CreateDropdown hideFolder>
<Button variant="border" forDropdown size="sm">
New Request
</Button>
</CreateDropdown>
</HStack>
}
/>
);
}
if (activeRequest.model === 'grpc_request') {
return <GrpcConnectionLayout style={body} />;
}
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
}

View File

@@ -86,7 +86,7 @@
&.cm-singleline {
.cm-editor {
@apply w-full h-auto;
@apply w-full h-full;
}
.cm-scroller {

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink } from '@tanstack/react-router';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon';

View File

@@ -1,5 +1,6 @@
import { useSearch } from '@tanstack/react-router';
import { useCallback, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Route } from '../routes/workspaces/$workspaceId';
import { useCookieJars } from './useCookieJars';
export const QUERY_COOKIE_JAR_ID = 'cookie_jar_id';
@@ -34,23 +35,18 @@ export function useEnsureActiveCookieJar() {
// There's no active jar, so set it to the first one
console.log('Setting active cookie jar to', firstJar.id);
setActiveCookieJarId(firstJar.id);
setActiveCookieJarId(firstJar.id).catch(console.error);
}, [activeCookieJarId, cookieJars, setActiveCookieJarId]);
}
function useActiveCookieJarId() {
// NOTE: This query param is accessed from Rust side, so do not change
const [params, setParams] = useSearchParams();
const id = params.get(QUERY_COOKIE_JAR_ID);
const navigate = Route.useNavigate();
const { cookieJarId: id } = useSearch({ strict: false });
const setId = useCallback(
(id: string) => {
setParams((p) => {
const existing = Object.fromEntries(p);
return { ...existing, [QUERY_COOKIE_JAR_ID]: id };
});
},
[setParams],
(id: string) => navigate({ search: (prev) => ({ ...prev, cookieJarId: id }) }),
[navigate],
);
return [id, setId] as const;

View File

@@ -1,5 +1,6 @@
import { useSearch } from '@tanstack/react-router';
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Route } from '../routes/workspaces/$workspaceId';
import { useEnvironments } from './useEnvironments';
export function useActiveEnvironment() {
@@ -16,22 +17,13 @@ export const QUERY_ENVIRONMENT_ID = 'environment_id';
function useActiveEnvironmentId() {
// NOTE: This query param is accessed from Rust side, so do not change
const [params, setParams] = useSearchParams();
const id = params.get(QUERY_ENVIRONMENT_ID);
const navigate = Route.useNavigate();
const { environmentId: id } = useSearch({ strict: false });
const setId = useCallback(
(id: string | null) => {
setParams((p) => {
const existing = Object.fromEntries(p);
if (id == null) {
delete existing[QUERY_ENVIRONMENT_ID];
} else {
existing[QUERY_ENVIRONMENT_ID] = id;
}
return existing;
});
},
[setParams],
(environment_id: string | null) =>
navigate({ search: (prev) => ({ ...prev, environment_id: environment_id ?? undefined }) }),
[navigate],
);
return [id, setId] as const;

View File

@@ -1,24 +1,30 @@
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import { useActiveRequestId } from './useActiveRequestId';
import { useRequests } from './useRequests';
import { atom, useAtomValue } from 'jotai';
import { jotaiStore } from '../routes/__root';
import { activeRequestIdAtom } from './useActiveRequestId';
import { grpcRequestsAtom } from './useGrpcRequests';
import { httpRequestsAtom } from './useHttpRequests';
interface TypeMap {
http_request: HttpRequest;
grpc_request: GrpcRequest;
}
export const activeRequestAtom = atom<HttpRequest | GrpcRequest | null>((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = [...get(httpRequestsAtom), ...get(grpcRequestsAtom)];
return requests.find((r) => r.id === activeRequestId) ?? null;
});
export function getActiveRequest() {
return jotaiStore.get(activeRequestAtom);
}
export function useActiveRequest<T extends keyof TypeMap>(
model?: T | undefined,
): TypeMap[T] | null {
const requestId = useActiveRequestId();
const requests = useRequests();
for (const request of requests) {
const modelMatch = model == null ? true : request.model === model;
if (modelMatch && request.id === requestId) {
return request as TypeMap[T];
}
}
const activeRequest = useAtomValue(activeRequestAtom);
if (model == null) return activeRequest as TypeMap[T];
if (activeRequest?.model === model) return activeRequest as TypeMap[T];
return null;
}

View File

@@ -1,6 +1,17 @@
import { useParams } from 'react-router-dom';
import { useParams } from '@tanstack/react-router';
import { atom, useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { jotaiStore } from '../routes/__root';
export const activeRequestIdAtom = atom<string>();
export function useActiveRequestId(): string | null {
const { requestId } = useParams();
return requestId ?? null;
return useAtomValue(activeRequestIdAtom) ?? null;
}
export function useSubscribeActiveRequestId() {
const { requestId } = useParams({ strict: false });
useEffect(() => {
jotaiStore.set(activeRequestIdAtom, requestId);
}, [requestId]);
}

View File

@@ -1,19 +1,25 @@
import { useParams } from '@tanstack/react-router';
import type { Workspace } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { atom, useAtomValue } from 'jotai/index';
import { useEffect } from 'react';
import { jotaiStore } from '../routes/__root';
import { useWorkspaces } from './useWorkspaces';
export const activeWorkspaceIdAtom = atom<string>();
export function useActiveWorkspace(): Workspace | null {
const workspaceId = useActiveWorkspaceId();
const workspaces = useWorkspaces();
return useMemo(
() => workspaces.find((w) => w.id === workspaceId) ?? null,
[workspaces, workspaceId],
);
return workspaces.find((w) => w.id === workspaceId) ?? null;
}
function useActiveWorkspaceId(): string | null {
const { workspaceId } = useParams();
return workspaceId ?? null;
return useAtomValue(activeWorkspaceIdAtom) ?? null;
}
export function useSubscribeActiveWorkspaceId() {
const { workspaceId } = useParams({ strict: false });
useEffect(() => {
jotaiStore.set(activeWorkspaceIdAtom, workspaceId);
}, [workspaceId]);
}

View File

@@ -1,76 +0,0 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { SettingsTab } from '../components/Settings/Settings';
import { QUERY_COOKIE_JAR_ID } from './useActiveCookieJar';
import { QUERY_ENVIRONMENT_ID } from './useActiveEnvironment';
export type RouteParamsWorkspace = {
workspaceId: string;
environmentId: string | null;
cookieJarId: string | null;
};
export type RouteParamsRequest = RouteParamsWorkspace & {
requestId: string;
};
export type RouteParamsSettings = {
workspaceId: string;
tab?: SettingsTab;
};
export const paths = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
workspaces(_ = {}) {
return '/workspaces';
},
workspaceSettings({ workspaceId, tab } = { workspaceId: ':workspaceId' } as RouteParamsSettings) {
return `/workspaces/${workspaceId}/settings?tab=${tab ?? SettingsTab.General}`;
},
workspace(
{ workspaceId, environmentId, cookieJarId } = {
workspaceId: ':workspaceId',
environmentId: ':environmentId',
cookieJarId: ':cookieJarId',
} as RouteParamsWorkspace,
) {
const path = `/workspaces/${workspaceId}`;
const params = new URLSearchParams();
if (environmentId != null) params.set(QUERY_ENVIRONMENT_ID, environmentId);
if (cookieJarId != null) params.set(QUERY_COOKIE_JAR_ID, cookieJarId);
return `${path}?${params}`;
},
request(
{ workspaceId, environmentId, requestId, cookieJarId } = {
workspaceId: ':workspaceId',
environmentId: ':environmentId',
requestId: ':requestId',
} as RouteParamsRequest,
) {
const path = `/workspaces/${workspaceId}/requests/${requestId}`;
const params = new URLSearchParams();
if (environmentId != null) params.set(QUERY_ENVIRONMENT_ID, environmentId);
if (cookieJarId != null) params.set(QUERY_COOKIE_JAR_ID, cookieJarId);
return `${path}?${params}`;
},
};
export function useAppRoutes() {
const nav = useNavigate();
const navigate = useCallback(
<T extends keyof typeof paths>(path: T, ...params: Parameters<(typeof paths)[T]>) => {
// Not sure how to make TS work here, but it's good from the
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = paths[path](...(params as any));
nav(resolvedPath);
},
[nav],
);
return {
paths,
navigate,
};
}

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { event } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { InlineCode } from '../components/core/InlineCode';
import { minPromiseMillis } from '../lib/minPromiseMillis';
import { invokeCmd } from '../lib/tauri';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpResponse } from '@yaakapp-internal/models';
import { useCopy } from './useCopy';
import { getResponseBodyText } from '../lib/responseBody';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { CookieJar } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { trackEvent } from '../lib/analytics';

View File

@@ -15,9 +15,9 @@ export function useCreateDropdownItems({
hideIcons?: boolean;
folderId?: string | null;
} = {}): DropdownItem[] {
const createHttpRequest = useCreateHttpRequest();
const createGrpcRequest = useCreateGrpcRequest();
const createFolder = useCreateFolder();
const { mutate: createHttpRequest } = useCreateHttpRequest();
const { mutate: createGrpcRequest } = useCreateGrpcRequest();
const { mutate: createFolder } = useCreateFolder();
return useMemo<DropdownItem[]>(
() => [
@@ -25,14 +25,14 @@ export function useCreateDropdownItems({
key: 'create-http-request',
label: 'HTTP Request',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createHttpRequest.mutate({ folderId }),
onSelect: () => createHttpRequest({ folderId }),
},
{
key: 'create-graphql-request',
label: 'GraphQL Query',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () =>
createHttpRequest.mutate({
createHttpRequest({
folderId,
bodyType: BODY_TYPE_GRAPHQL,
method: 'POST',
@@ -43,7 +43,7 @@ export function useCreateDropdownItems({
key: 'create-grpc-request',
label: 'gRPC Call',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createGrpcRequest.mutate({ folderId }),
onSelect: () => createGrpcRequest({ folderId }),
},
...((hideFolder
? []
@@ -55,7 +55,7 @@ export function useCreateDropdownItems({
key: 'create-folder',
label: 'Folder',
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
onSelect: () => createFolder.mutate({ folderId }),
onSelect: () => createFolder({ folderId }),
},
]) as DropdownItem[]),
],

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Environment } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { trackEvent } from '../lib/analytics';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Folder } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai';
import { trackEvent } from '../lib/analytics';
@@ -20,6 +20,7 @@ export function useCreateFolder() {
>({
mutationKey: ['create_folder'],
mutationFn: async (patch) => {
console.log("FOLDER", workspace);
if (workspace === null) {
throw new Error("Cannot create folder when there's no active workspace");
}

View File

@@ -1,22 +1,17 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { GrpcRequest } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { useSetAtom } from 'jotai';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
import {useActiveCookieJar} from "./useActiveCookieJar";
import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveRequest } from './useActiveRequest';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId/requests/$requestId';
import { getActiveRequest } from './useActiveRequest';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes';
import {grpcRequestsAtom} from "./useGrpcRequests";
import {updateModelList} from "./useSyncModelStores";
import { grpcRequestsAtom } from './useGrpcRequests';
import { updateModelList } from './useSyncModelStores';
export function useCreateGrpcRequest() {
const workspace = useActiveWorkspace();
const [activeEnvironment] = useActiveEnvironment();
const [activeCookieJar] = useActiveCookieJar();
const activeRequest = useActiveRequest();
const routes = useAppRoutes();
const setGrpcRequests = useSetAtom(grpcRequestsAtom);
return useMutation<
@@ -29,6 +24,7 @@ export function useCreateGrpcRequest() {
if (workspace === null) {
throw new Error("Cannot create grpc request when there's no active workspace");
}
const activeRequest = getActiveRequest();
if (patch.sortPriority === undefined) {
if (activeRequest != null) {
// Place above currently active request
@@ -50,11 +46,13 @@ export function useCreateGrpcRequest() {
// Optimistic update
setGrpcRequests(updateModelList(request));
routes.navigate('request', {
workspaceId: request.workspaceId,
requestId: request.id,
environmentId: activeEnvironment?.id ?? null,
cookieJarId: activeCookieJar?.id ?? null,
router.navigate({
to: Route.fullPath,
params: {
workspaceId: request.workspaceId,
requestId: request.id,
},
search: (prev) => ({ ...prev }),
});
},
});

View File

@@ -1,28 +1,24 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpRequest } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai/index';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
import { useActiveCookieJar } from './useActiveCookieJar';
import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveRequest } from './useActiveRequest';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId/requests/$requestId';
import { getActiveRequest } from './useActiveRequest';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes';
import { httpRequestsAtom } from './useHttpRequests';
import { updateModelList } from './useSyncModelStores';
export function useCreateHttpRequest() {
const workspace = useActiveWorkspace();
const [activeEnvironment] = useActiveEnvironment();
const [activeCookieJar] = useActiveCookieJar();
const activeRequest = useActiveRequest();
const routes = useAppRoutes();
const activeWorkspace = useActiveWorkspace();
const setHttpRequests = useSetAtom(httpRequestsAtom);
return useMutation<HttpRequest, unknown, Partial<HttpRequest>>({
mutationKey: ['create_http_request'],
mutationFn: async (patch = {}) => {
if (workspace === null) {
const activeRequest = getActiveRequest();
if (activeWorkspace === null) {
throw new Error("Cannot create request when there's no active workspace");
}
if (patch.sortPriority === undefined) {
@@ -36,7 +32,7 @@ export function useCreateHttpRequest() {
}
patch.folderId = patch.folderId || activeRequest?.folderId;
return invokeCmd<HttpRequest>('cmd_create_http_request', {
request: { workspaceId: workspace.id, ...patch },
request: { workspaceId: activeWorkspace.id, ...patch },
});
},
onSettled: () => trackEvent('http_request', 'create'),
@@ -44,11 +40,10 @@ export function useCreateHttpRequest() {
// Optimistic update
setHttpRequests(updateModelList(request));
routes.navigate('request', {
workspaceId: request.workspaceId,
requestId: request.id,
environmentId: activeEnvironment?.id ?? null,
cookieJarId: activeCookieJar?.id ?? null,
await router.navigate({
to: Route.fullPath,
params: { workspaceId: request.workspaceId, requestId: request.id },
search: (prev) => ({ ...prev }),
});
},
});

View File

@@ -1,14 +1,14 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Workspace } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai/index';
import { invokeCmd } from '../lib/tauri';
import { useAppRoutes } from './useAppRoutes';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId';
import { usePrompt } from './usePrompt';
import { updateModelList } from './useSyncModelStores';
import { workspacesAtom } from './useWorkspaces';
export function useCreateWorkspace() {
const routes = useAppRoutes();
const prompt = usePrompt();
const setWorkspaces = useSetAtom(workspacesAtom);
@@ -34,10 +34,9 @@ export function useCreateWorkspace() {
// Optimistic update
setWorkspaces(updateModelList(workspace));
routes.navigate('workspace', {
workspaceId: workspace.id,
environmentId: null,
cookieJarId: null,
router.navigate({
to: Route.fullPath,
params: { workspaceId: workspace.id },
});
},
});

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { GrpcRequest } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { InlineCode } from '../components/core/InlineCode';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpRequest } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai';
import { InlineCode } from '../components/core/InlineCode';

View File

@@ -1,12 +1,12 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { CookieJar } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { useSetAtom } from 'jotai';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
import { useConfirm } from './useConfirm';
import {cookieJarsAtom} from "./useCookieJars";
import {removeModelById} from "./useSyncModelStores";
import { cookieJarsAtom } from './useCookieJars';
import { removeModelById } from './useSyncModelStores';
export function useDeleteCookieJar(cookieJar: CookieJar | null) {
const confirm = useConfirm();
@@ -33,6 +33,6 @@ export function useDeleteCookieJar(cookieJar: CookieJar | null) {
if (cookieJar == null) return;
setCookieJars(removeModelById(cookieJar));
}
},
});
}

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Environment } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { InlineCode } from '../components/core/InlineCode';

View File

@@ -1,4 +1,3 @@
import { useMutation } from '@tanstack/react-query';
import type { Folder } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai';
import { InlineCode } from '../components/core/InlineCode';
@@ -8,6 +7,7 @@ import { invokeCmd } from '../lib/tauri';
import { useConfirm } from './useConfirm';
import { foldersAtom } from './useFolders';
import { removeModelById } from './useSyncModelStores';
import { useMutation } from './useMutation';
export function useDeleteFolder(id: string | null) {
const confirm = useConfirm();

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { GrpcConnection } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { trackEvent } from '../lib/analytics';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { useSetAtom } from 'jotai';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpResponse } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { trackEvent } from '../lib/analytics';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { useSetAtom } from 'jotai';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';

View File

@@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query';
import { useDeleteAnyGrpcRequest } from './useDeleteAnyGrpcRequest';
import { useDeleteAnyHttpRequest } from './useDeleteAnyHttpRequest';
import { useMutation } from './useMutation';
export function useDeleteRequest(id: string | null) {
const deleteAnyHttpRequest = useDeleteAnyHttpRequest();

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { useSetAtom } from 'jotai/index';
import { count } from '../lib/pluralize';
import { invokeCmd } from '../lib/tauri';

View File

@@ -1,18 +1,18 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Workspace } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai";
import { useSetAtom } from 'jotai';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
import { router } from '../main';
import { Route } from '../routes/workspaces';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes';
import { useConfirm } from './useConfirm';
import {removeModelById} from "./useSyncModelStores";
import {workspacesAtom} from "./useWorkspaces";
import { removeModelById } from './useSyncModelStores';
import { workspacesAtom } from './useWorkspaces';
export function useDeleteWorkspace(workspace: Workspace | null) {
const activeWorkspace = useActiveWorkspace();
const routes = useAppRoutes();
const confirm = useConfirm();
const setWorkspaces = useSetAtom(workspacesAtom);
@@ -41,7 +41,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
const { id: workspaceId } = workspace;
if (workspaceId === activeWorkspace?.id) {
routes.navigate('workspaces');
router.navigate({ to: Route.fullPath });
}
},
});

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';

View File

@@ -1,11 +1,9 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { GrpcRequest } from '@yaakapp-internal/models';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
import {useActiveCookieJar} from "./useActiveCookieJar";
import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId/requests/$requestId';
import { getGrpcProtoFiles, setGrpcProtoFiles } from './useGrpcProtoFiles';
export function useDuplicateGrpcRequest({
@@ -15,11 +13,6 @@ export function useDuplicateGrpcRequest({
id: string | null;
navigateAfter: boolean;
}) {
const activeWorkspace = useActiveWorkspace();
const [activeEnvironment] = useActiveEnvironment();
const [activeCookieJar] = useActiveCookieJar();
const routes = useAppRoutes();
return useMutation<GrpcRequest, string>({
mutationKey: ['duplicate_grpc_request', id],
mutationFn: async () => {
@@ -34,12 +27,11 @@ export function useDuplicateGrpcRequest({
const protoFiles = await getGrpcProtoFiles(id);
await setGrpcProtoFiles(request.id, protoFiles);
if (navigateAfter && activeWorkspace !== null) {
routes.navigate('request', {
workspaceId: activeWorkspace.id,
requestId: request.id,
environmentId: activeEnvironment?.id ?? null,
cookieJarId: activeCookieJar?.id ?? null,
if (navigateAfter) {
await router.navigate({
to: Route.fullPath,
params: { workspaceId: request.workspaceId, requestId: request.id },
search: (prev) => ({ ...prev }),
});
}
},

View File

@@ -1,11 +1,9 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpRequest } from '@yaakapp-internal/models';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
import {useActiveCookieJar} from "./useActiveCookieJar";
import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes';
import { router } from '../main';
import { Route } from '../routes/workspaces/$workspaceId/requests/$requestId';
export function useDuplicateHttpRequest({
id,
@@ -14,10 +12,6 @@ export function useDuplicateHttpRequest({
id: string | null;
navigateAfter: boolean;
}) {
const activeWorkspace = useActiveWorkspace();
const [activeEnvironment] = useActiveEnvironment();
const [activeCookieJar] = useActiveCookieJar();
const routes = useAppRoutes();
return useMutation<HttpRequest, string>({
mutationKey: ['duplicate_http_request', id],
mutationFn: async () => {
@@ -26,12 +20,14 @@ export function useDuplicateHttpRequest({
},
onSettled: () => trackEvent('http_request', 'duplicate'),
onSuccess: async (request) => {
if (navigateAfter && activeWorkspace !== null) {
routes.navigate('request', {
workspaceId: activeWorkspace.id,
requestId: request.id,
environmentId: activeEnvironment?.id ?? null,
cookieJarId: activeCookieJar?.id ?? null,
if (navigateAfter) {
router.navigate({
to: Route.fullPath,
params: {
workspaceId: request.workspaceId,
requestId: request.id,
},
search: (prev) => ({ ...prev }),
});
}
},

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { useDialog } from '../components/DialogContext';
import { ExportDataDialog } from '../components/ExportDataDialog';
import { useActiveWorkspace } from './useActiveWorkspace';

View File

@@ -7,6 +7,7 @@ import type {
} from '@yaakapp-internal/plugin';
import { invokeCmd } from '../lib/tauri';
import { usePluginsKey } from './usePlugins';
import { useMemo } from 'react';
export type CallableHttpRequestAction = Pick<HttpRequestAction, 'key' | 'label' | 'icon'> & {
call: (httpRequest: HttpRequest) => Promise<void>;
@@ -15,32 +16,35 @@ export type CallableHttpRequestAction = Pick<HttpRequestAction, 'key' | 'label'
export function useHttpRequestActions() {
const pluginsKey = usePluginsKey();
const httpRequestActions = useQuery({
const actionsResult = useQuery<CallableHttpRequestAction[]>({
queryKey: ['http_request_actions', pluginsKey],
queryFn: async () => {
const responses = (await invokeCmd(
const responses = await invokeCmd<GetHttpRequestActionsResponse[]>(
'cmd_http_request_actions',
)) as GetHttpRequestActionsResponse[];
return responses;
);
return responses.flatMap((r) =>
r.actions.map((a) => ({
key: a.key,
label: a.label,
icon: a.icon,
call: async (httpRequest: HttpRequest) => {
const payload: CallHttpRequestActionRequest = {
key: a.key,
pluginRefId: r.pluginRefId,
args: { httpRequest },
};
await invokeCmd('cmd_call_http_request_action', { req: payload });
},
})),
);
},
});
const actions: CallableHttpRequestAction[] =
httpRequestActions.data?.flatMap((r) =>
r.actions.map((a) => ({
key: a.key,
label: a.label,
icon: a.icon,
call: async (httpRequest: HttpRequest) => {
const payload: CallHttpRequestActionRequest = {
key: a.key,
pluginRefId: r.pluginRefId,
args: { httpRequest },
};
await invokeCmd('cmd_call_http_request_action', { req: payload });
},
})),
) ?? [];
const actions = useMemo(() => {
return actionsResult.data ?? [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(actionsResult.data)]);
return actions;
}

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpRequest } from '@yaakapp-internal/models';
import { useToast } from '../components/ToastContext';
import { invokeCmd } from '../lib/tauri';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type {
Environment,
Folder,
@@ -13,12 +13,12 @@ import { useDialog } from '../components/DialogContext';
import { ImportDataDialog } from '../components/ImportDataDialog';
import { count } from '../lib/pluralize';
import { invokeCmd } from '../lib/tauri';
import { Route } from '../routes/workspaces/$workspaceId';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAlert } from './useAlert';
import { useAppRoutes } from './useAppRoutes';
import { router } from '../main';
export function useImportData() {
const routes = useAppRoutes();
const dialog = useDialog();
const alert = useAlert();
const activeWorkspace = useActiveWorkspace();
@@ -64,10 +64,11 @@ export function useImportData() {
});
if (importedWorkspace != null) {
routes.navigate('workspace', {
workspaceId: importedWorkspace.id,
environmentId: imported.environments[0]?.id ?? null,
cookieJarId: null,
const environmentId = imported.environments[0]?.id ?? null;
router.navigate({
to: Route.fullPath,
params: { workspaceId: importedWorkspace.id },
search: { environmentId },
});
}

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpUrlParameter } from '@yaakapp-internal/models';
import { useToast } from '../components/ToastContext';
import { pluralize } from '../lib/pluralize';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';

View File

@@ -49,7 +49,7 @@ export function useKeyValue<T extends object | boolean | number | string | null>
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[fallback, key, namespace],
[typeof key === 'string' ? key : key.join('::'), namespace],
);
const reset = useCallback(async () => mutate.mutateAsync(fallback), [mutate, fallback]);

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import React from 'react';
import { useDialog } from '../components/DialogContext';
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';

View File

@@ -0,0 +1,42 @@
import type { MutationKey } from '@tanstack/react-query';
import { useCallback } from 'react';
export function useMutation<TData = unknown, TError = unknown, TVariables = void>({
mutationKey,
mutationFn,
onSuccess,
onSettled,
}: {
mutationKey: MutationKey;
mutationFn: (vars: TVariables) => Promise<TData>;
onSettled?: () => void;
onSuccess?: (data: TData) => void;
}) {
const mutateAsync = useCallback(
async (variables: TVariables) => {
try {
const data = await mutationFn(variables);
onSuccess?.(data);
} catch (err: unknown) {
const e = err as TError;
console.log('MUTATION FAILED', mutationKey, e);
} finally {
onSettled?.();
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
mutationKey,
);
const mutate = useCallback(
(variables: TVariables) => {
setTimeout(() => mutateAsync(variables));
},
[mutateAsync],
);
return {
mutate,
mutateAsync,
};
}

View File

@@ -1,21 +1,27 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { SettingsTab } from '../components/Settings/Settings';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';
import { router } from '../main';
import { Route as SettingsRoute } from '../routes/workspaces/settings';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes';
export function useOpenSettings(tab = SettingsTab.General) {
const routes = useAppRoutes();
const workspace = useActiveWorkspace();
return useMutation({
mutationKey: ['open_settings'],
mutationFn: async () => {
if (workspace == null) return;
trackEvent('dialog', 'show', { id: 'settings', tab: `${tab}` });
const location = router.buildLocation({
to: SettingsRoute.fullPath,
params: { workspaceId: workspace.id },
search: { tab },
});
await invokeCmd('cmd_new_child_window', {
url: routes.paths.workspaceSettings({ workspaceId: workspace.id, tab }),
url: location,
label: 'settings',
title: 'Yaak Settings',
innerSize: [600, 550],

View File

@@ -1,13 +1,13 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { invokeCmd } from '../lib/tauri';
import { useAppRoutes } from './useAppRoutes';
import { router } from '../main';
import { Route as WorkspaceRoute } from '../routes/workspaces/$workspaceId';
import { Route as RequestRoute } from '../routes/workspaces/$workspaceId/requests/$requestId';
import { getRecentCookieJars } from './useRecentCookieJars';
import { getRecentEnvironments } from './useRecentEnvironments';
import { getRecentRequests } from './useRecentRequests';
export function useOpenWorkspace() {
const routes = useAppRoutes();
return useMutation({
mutationKey: ['open_workspace'],
mutationFn: async ({
@@ -17,22 +17,25 @@ export function useOpenWorkspace() {
workspaceId: string;
inNewWindow: boolean;
}) => {
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null;
const requestId = (await getRecentRequests(workspaceId))[0] ?? null;
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null;
const baseArgs = { workspaceId, environmentId, cookieJarId } as const;
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined;
const search = { environmentId, cookieJarId };
if (inNewWindow) {
const path =
requestId != null
? routes.paths.request({ ...baseArgs, requestId })
: routes.paths.workspace({ ...baseArgs });
await invokeCmd('cmd_new_main_window', { url: path });
const location = router.buildLocation({
to: WorkspaceRoute.fullPath,
params: { workspaceId },
search,
});
await invokeCmd('cmd_new_main_window', { url: location });
return;
}
if (requestId != null) {
router.navigate({ to: RequestRoute.fullPath, params: { workspaceId, requestId }, search });
} else {
if (requestId != null) {
routes.navigate('request', { ...baseArgs, requestId });
} else {
routes.navigate('workspace', { ...baseArgs });
}
router.navigate({ to: WorkspaceRoute.fullPath, params: { workspaceId }, search });
}
},
});

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Plugin } from '@yaakapp-internal/models';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { minPromiseMillis } from '../lib/minPromiseMillis';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import { InlineCode } from '../components/core/InlineCode';
import { usePrompt } from './usePrompt';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { save } from '@tauri-apps/plugin-dialog';
import mime from 'mime';
import slugify from 'slugify';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpResponse } from '@yaakapp-internal/models';
import { trackEvent } from '../lib/analytics';
import { getHttpRequest } from '../lib/store';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import { useSendAnyHttpRequest } from './useSendAnyHttpRequest';
export function useSendManyRequests() {

View File

@@ -1,15 +1,14 @@
import { emit } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { useEffect } from 'react';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { useActiveEnvironment } from './useActiveEnvironment';
import { useActiveRequest } from './useActiveRequest';
import { getActiveRequest } from './useActiveRequest';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppInfo } from './useAppInfo';
import { useOsInfo } from './useOsInfo';
import { emit } from '@tauri-apps/api/event';
export function useSyncWorkspaceRequestTitle() {
const activeRequest = useActiveRequest();
const activeWorkspace = useActiveWorkspace();
const [activeEnvironment] = useActiveEnvironment();
const osInfo = useOsInfo();
@@ -24,6 +23,7 @@ export function useSyncWorkspaceRequestTitle() {
if (activeEnvironment) {
newTitle += ` [${activeEnvironment.name}]`;
}
const activeRequest = getActiveRequest();
if (activeRequest) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
newTitle += ` ${fallbackRequestName(activeRequest)}`;
@@ -40,5 +40,5 @@ export function useSyncWorkspaceRequestTitle() {
} else {
emit('yaak_title_changed', newTitle).catch(console.error);
}
}, [activeEnvironment, activeRequest, activeWorkspace, appInfo.isDev, osInfo.osType]);
}, [activeEnvironment, activeWorkspace, appInfo.isDev, osInfo.osType]);
}

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Plugin } from '@yaakapp-internal/models';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Folder } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai/index";
import { getFolder } from '../lib/store';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { GrpcRequest } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai/index';
import { getGrpcRequest } from '../lib/store';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { HttpRequest } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai/index";
import { getHttpRequest } from '../lib/store';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { CookieJar } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai/index';
import { getCookieJar } from '../lib/store';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Environment } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai/index';
import { getEnvironment } from '../lib/store';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Settings } from '@yaakapp-internal/models';
import { useSetAtom } from 'jotai';
import { getSettings } from '../lib/store';

View File

@@ -1,4 +1,4 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation } from './useMutation';
import type { Workspace } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai/index";
import { getWorkspace } from '../lib/store';

View File

@@ -1,9 +1,10 @@
import './main.css';
import { createRouter, RouterProvider } from '@tanstack/react-router';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './components/App';
import './main.css';
import { routeTree } from './routeTree.gen';
import('react-pdf').then(({ pdfjs }) => {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
@@ -24,8 +25,20 @@ window.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && e.target === document.body) e.preventDefault();
});
// Create a new router instance
export const router = createRouter({
routeTree,
});
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>
<App />
<RouterProvider router={router} />
</StrictMode>,
);

View File

@@ -20,8 +20,9 @@
"@lezer/lr": "^1.3.3",
"@react-hook/size": "^2.1.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.59.16",
"@tanstack/react-virtual": "^3.10.8",
"@tanstack/react-query": "^5.62.8",
"@tanstack/react-router": "^1.91.3",
"@tanstack/react-virtual": "^3.11.2",
"@tauri-apps/api": "^2.0.1",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
@@ -40,6 +41,7 @@
"format-graphql": "^1.5.0",
"framer-motion": "^11.5.4",
"fuzzbunny": "^1.0.1",
"history": "^5.3.0",
"jotai": "^2.9.3",
"lucide-react": "^0.439.0",
"mime": "^4.0.4",
@@ -63,7 +65,9 @@
"devDependencies": {
"@lezer/generator": "^1.7.1",
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tanstack/react-query-devtools": "^5.55.4",
"@tanstack/react-query-devtools": "^5.62.8",
"@tanstack/router-devtools": "^1.91.3",
"@tanstack/router-plugin": "^1.91.1",
"@types/node": "^22.5.4",
"@types/papaparse": "^5.3.14",
"@types/parse-color": "^1.0.3",

200
src-web/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,200 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index'
import { Route as WorkspacesIndexImport } from './routes/workspaces/index'
import { Route as WorkspacesSettingsImport } from './routes/workspaces/settings'
import { Route as WorkspacesWorkspaceIdIndexImport } from './routes/workspaces/$workspaceId/index'
import { Route as WorkspacesWorkspaceIdRequestsRequestIdImport } from './routes/workspaces/$workspaceId/requests/$requestId'
// Create/Update Routes
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any)
const WorkspacesIndexRoute = WorkspacesIndexImport.update({
id: '/workspaces/',
path: '/workspaces/',
getParentRoute: () => rootRoute,
} as any)
const WorkspacesSettingsRoute = WorkspacesSettingsImport.update({
id: '/workspaces/settings',
path: '/workspaces/settings',
getParentRoute: () => rootRoute,
} as any)
const WorkspacesWorkspaceIdIndexRoute = WorkspacesWorkspaceIdIndexImport.update(
{
id: '/workspaces/$workspaceId/',
path: '/workspaces/$workspaceId/',
getParentRoute: () => rootRoute,
} as any,
)
const WorkspacesWorkspaceIdRequestsRequestIdRoute =
WorkspacesWorkspaceIdRequestsRequestIdImport.update({
id: '/workspaces/$workspaceId/requests/$requestId',
path: '/workspaces/$workspaceId/requests/$requestId',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/workspaces/settings': {
id: '/workspaces/settings'
path: '/workspaces/settings'
fullPath: '/workspaces/settings'
preLoaderRoute: typeof WorkspacesSettingsImport
parentRoute: typeof rootRoute
}
'/workspaces/': {
id: '/workspaces/'
path: '/workspaces'
fullPath: '/workspaces'
preLoaderRoute: typeof WorkspacesIndexImport
parentRoute: typeof rootRoute
}
'/workspaces/$workspaceId/': {
id: '/workspaces/$workspaceId/'
path: '/workspaces/$workspaceId'
fullPath: '/workspaces/$workspaceId'
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexImport
parentRoute: typeof rootRoute
}
'/workspaces/$workspaceId/requests/$requestId': {
id: '/workspaces/$workspaceId/requests/$requestId'
path: '/workspaces/$workspaceId/requests/$requestId'
fullPath: '/workspaces/$workspaceId/requests/$requestId'
preLoaderRoute: typeof WorkspacesWorkspaceIdRequestsRequestIdImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/workspaces/settings': typeof WorkspacesSettingsRoute
'/workspaces': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/workspaces/settings': typeof WorkspacesSettingsRoute
'/workspaces': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/workspaces/settings': typeof WorkspacesSettingsRoute
'/workspaces/': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/workspaces/settings'
| '/workspaces'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/requests/$requestId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/workspaces/settings'
| '/workspaces'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/requests/$requestId'
id:
| '__root__'
| '/'
| '/workspaces/settings'
| '/workspaces/'
| '/workspaces/$workspaceId/'
| '/workspaces/$workspaceId/requests/$requestId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
WorkspacesSettingsRoute: typeof WorkspacesSettingsRoute
WorkspacesIndexRoute: typeof WorkspacesIndexRoute
WorkspacesWorkspaceIdIndexRoute: typeof WorkspacesWorkspaceIdIndexRoute
WorkspacesWorkspaceIdRequestsRequestIdRoute: typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
WorkspacesSettingsRoute: WorkspacesSettingsRoute,
WorkspacesIndexRoute: WorkspacesIndexRoute,
WorkspacesWorkspaceIdIndexRoute: WorkspacesWorkspaceIdIndexRoute,
WorkspacesWorkspaceIdRequestsRequestIdRoute:
WorkspacesWorkspaceIdRequestsRequestIdRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/workspaces/settings",
"/workspaces/",
"/workspaces/$workspaceId/",
"/workspaces/$workspaceId/requests/$requestId"
]
},
"/": {
"filePath": "index.tsx"
},
"/workspaces/settings": {
"filePath": "workspaces/settings.tsx"
},
"/workspaces/": {
"filePath": "workspaces/index.tsx"
},
"/workspaces/$workspaceId/": {
"filePath": "workspaces/$workspaceId/index.tsx"
},
"/workspaces/$workspaceId/requests/$requestId": {
"filePath": "workspaces/$workspaceId/requests/$requestId.tsx"
}
}
}
ROUTE_MANIFEST_END */

91
src-web/routes/__root.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRootRoute, Outlet } from '@tanstack/react-router';
import classNames from 'classnames';
import { MotionConfig } from 'framer-motion';
import { createStore, Provider as JotaiProvider } from 'jotai';
import React, { Suspense } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { HelmetProvider } from 'react-helmet-async';
import { DialogProvider, Dialogs } from '../components/DialogContext';
import { GlobalHooks } from '../components/GlobalHooks';
import { ToastProvider, Toasts } from '../components/ToastContext';
import { useOsInfo } from '../hooks/useOsInfo';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (err, query) => {
console.log('Query client error', { err, query });
},
}),
defaultOptions: {
queries: {
retry: false,
networkMode: 'always',
refetchOnWindowFocus: true,
refetchOnReconnect: false,
refetchOnMount: false, // Don't refetch when a hook mounts
},
},
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const TanStackRouterDevtools =
process.env.NODE_ENV === 'production'
? () => null // Render nothing in production
: React.lazy(() =>
import('@tanstack/router-devtools').then((res) => ({
default: res.TanStackRouterDevtools,
})),
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ReactQueryDevtools =
process.env.NODE_ENV === 'production'
? () => null // Render nothing in production
: React.lazy(() =>
import('@tanstack/react-query-devtools').then((res) => ({
default: res.ReactQueryDevtools,
})),
);
export const Route = createRootRoute({
component: RouteComponent,
});
export const jotaiStore = createStore();
function RouteComponent() {
const osInfo = useOsInfo();
return (
<JotaiProvider store={jotaiStore}>
<QueryClientProvider client={queryClient}>
<MotionConfig transition={{ duration: 0.1 }}>
<HelmetProvider>
<DndProvider backend={HTML5Backend}>
<Suspense>
<DialogProvider>
<ToastProvider>
<GlobalHooks />
<Toasts />
<Dialogs />
<div
className={classNames(
'w-full h-full',
osInfo?.osType === 'linux' && 'border border-border-subtle',
)}
>
<Outlet />
</div>
</ToastProvider>
</DialogProvider>
</Suspense>
</DndProvider>
</HelmetProvider>
</MotionConfig>
{/*<ReactQueryDevtools initialIsOpen />*/}
{/*<TanStackRouterDevtools initialIsOpen />*/}
</QueryClientProvider>
</JotaiProvider>
);
}

10
src-web/routes/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { RedirectToLatestWorkspace } from '../components/RedirectToLatestWorkspace'
export const Route = createFileRoute('/')({
component: RouteComponent,
})
function RouteComponent() {
return <RedirectToLatestWorkspace />
}

View File

@@ -0,0 +1,19 @@
import { createFileRoute } from '@tanstack/react-router';
import { Workspace } from '../../../components/Workspace';
interface WorkspaceSearchSchema {
cookieJarId?: string | null;
environmentId?: string | null;
}
export const Route = createFileRoute('/workspaces/$workspaceId/')({
component: RouteComponent,
validateSearch: (search: Record<string, unknown>): WorkspaceSearchSchema => ({
environmentId: search.environment_id as string,
cookieJarId: search.cookie_jar_id as string,
}),
});
function RouteComponent() {
return <Workspace />;
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { Workspace } from '../../../../components/Workspace';
export const Route = createFileRoute('/workspaces/$workspaceId/requests/$requestId')({
component: RouteComponent,
});
function RouteComponent() {
return <Workspace />;
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { RedirectToLatestWorkspace } from '../../components/RedirectToLatestWorkspace'
export const Route = createFileRoute('/workspaces/')({
component: RouteComponent,
})
function RouteComponent() {
return <RedirectToLatestWorkspace />
}

View File

@@ -0,0 +1,17 @@
import { createFileRoute } from '@tanstack/react-router'
import Settings, { SettingsTab } from '../../components/Settings/Settings'
interface SettingsSearchSchema {
tab?: SettingsTab
}
export const Route = createFileRoute('/workspaces/settings')({
component: RouteComponent,
validateSearch: (search: Record<string, unknown>): SettingsSearchSchema => ({
tab: (search.tab ?? SettingsTab.General) as SettingsTab,
}),
})
function RouteComponent() {
return <Settings />
}

3
src-web/tsr.config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"autoCodeSplitting": true
}

View File

@@ -1,7 +1,8 @@
import path from 'node:path';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import { internalIpV4 } from 'internal-ip';
import { createRequire } from 'node:module';
import path from 'node:path';
import { defineConfig, normalizePath } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import svgr from 'vite-plugin-svgr';
@@ -20,6 +21,10 @@ const mobile = !!/android|ios/.exec(process.env.TAURI_ENV_PLATFORM ?? '');
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [
TanStackRouterVite({
routesDirectory: './routes',
generatedRouteTree: './routeTree.gen.ts',
}),
svgr(),
react(),
topLevelAwait(),