add package

remove workspaceCount from rootLoaderData

move unrelevant loaders out

add query context

adapt tanstack query

fix som issues

add new ways to load data

initial changes

clean code
This commit is contained in:
Kent Wang
2026-06-16 14:47:08 +08:00
parent e5e1ad1fe2
commit 6c8cd7ad89
18 changed files with 1401 additions and 519 deletions

27
package-lock.json generated
View File

@@ -9723,6 +9723,32 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
@@ -29096,6 +29122,7 @@
"@stoplight/spectral-ruleset-bundler": "1.7.0",
"@stoplight/spectral-rulesets": "^1.22.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-virtual": "3.13.12",
"@xmldom/xmldom": "^0.9.8",
"acorn": "^8.16.0",

View File

@@ -68,6 +68,7 @@
"@stoplight/spectral-rulesets": "^1.22.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-virtual": "3.13.12",
"@tanstack/react-query": "^5.101.0",
"@xmldom/xmldom": "^0.9.8",
"acorn": "^8.16.0",
"acorn-walk": "^8.3.5",

View File

@@ -203,7 +203,6 @@ export const ErrorBoundary: FC<Route.ErrorBoundaryProps> = ({ error }) => {
export interface RootLoaderData {
settings: Settings;
workspaceCount: number;
userSession: UserSession;
}
@@ -213,13 +212,11 @@ export const useRootLoaderData = () => {
export async function clientLoader(_args: Route.ClientLoaderArgs) {
const settings = await services.settings.get();
const workspaceCount = await services.workspace.count();
const userSession = await services.userSession.get();
const cloudCredentials = await services.cloudCredential.all();
return {
settings,
workspaceCount,
userSession,
cloudCredentials,
};

View File

@@ -1,6 +1,6 @@
import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core';
import type { WorkspaceScope } from 'insomnia-data';
import { models } from 'insomnia-data';
import type { WorkspaceMeta, WorkspaceScope } from 'insomnia-data';
import { database, models, services } from 'insomnia-data';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
@@ -29,7 +29,7 @@ import {
} from '~/common/constants';
import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label';
import { fuzzyMatchAll } from '~/common/misc';
import { getAllLocalFiles, type InsomniaFile } from '~/common/project';
import { type InsomniaFile } from '~/common/project';
import { sortMethodMap } from '~/common/sorting';
import { invariant } from '~/common/utils/invariant';
import { useRootLoaderData } from '~/root';
@@ -54,6 +54,8 @@ import { OrganizationTabList } from '~/ui/components/tabs/tab-list';
import { TimeFromNow } from '~/ui/components/time-from-now';
import { showResourceNotFoundToast } from '~/ui/components/toast-notification';
import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context';
import { useProjects } from '~/ui/hooks/data/projects';
import { useLocalFiles } from '~/ui/hooks/data/workspaces';
import { useGitFileIssues } from '~/ui/hooks/use-git-file-issues';
import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
@@ -69,23 +71,29 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
invariant(projectId, 'Project ID is required');
invariant(organizationId, 'Organization ID is required');
const projectWorkspaces = await services.workspace.findByParentId(projectId);
const workspaceMetas = await database.find<WorkspaceMeta>(models.workspaceMeta.type, {
parentId: { $in: projectWorkspaces.map(workspace => workspace._id) },
});
const remoteFilesPromise = getAllRemoteFiles({ projectId, organizationId });
const localFiles = await getAllLocalFiles({ projectId });
return {
localFiles,
remoteFilesPromise,
workspaceMetas,
};
}
const Component = ({ loaderData }: Route.ComponentProps) => {
const { localFiles, remoteFilesPromise } = loaderData;
const { activeProject, activeProjectGitRepository, projects } = useProjectLoaderData()!;
const { remoteFilesPromise } = loaderData;
const { activeProject, activeProjectGitRepository } = useProjectLoaderData()!;
const { activeSidebarTab } = useProjectRouteContext();
const { organizationId, projectId } = useParams() as {
organizationId: string;
projectId: string;
};
// Read the project list from the shared query cache (same source the sidebar uses)
const { data: projects = [] } = useProjects(organizationId);
const localFiles = useLocalFiles(projectId, loaderData.workspaceMetas);
const [remoteFiles] = useLoaderDeferData<InsomniaFile[]>(remoteFilesPromise, projectId);
useEffect(() => {

View File

@@ -20,7 +20,9 @@ import {
type ProjectNavigationSidebarTabId,
} from '~/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar';
import { SyncBar } from '~/ui/components/sidebar/sync-bar';
import { dbQueryClient } from '~/ui/context/app/insomnia-query-client';
import { useSidebarContext } from '~/ui/context/app/insomnia-sidebar-context';
import { projectKeys } from '~/ui/hooks/data/projects';
import { GitFileIssuesProvider, useProjectGitFileIssues } from '~/ui/hooks/use-git-file-issues';
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features';
@@ -121,6 +123,8 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const organizationProjects = await getProjectsWithGitRepositories({ organizationId });
const projects = models.project.sortProjects(organizationProjects);
// Seed the shared query cache so consumers (sidebar, project index page) read warm data on first render instead of refetching.
dbQueryClient.setQueryData(projectKeys.details(organizationId), projects);
const learningFeaturePromise = getInsomniaLearningFeature(fallbackLearningFeature);

View File

@@ -25,6 +25,7 @@ import { SettingsModal, showSettingsModal } from '~/ui/components/modals/setting
import { PresentUsers } from '~/ui/components/present-users';
import { OrganizationSelect } from '~/ui/components/project/organization-select';
import { InsomniaEventStreamProvider } from '~/ui/context/app/insomnia-event-stream-context';
import { InsomniaTanstackQueryClientContext } from '~/ui/context/app/insomnia-query-client';
import { SidebarContext } from '~/ui/context/app/insomnia-sidebar-context';
import { InsomniaTabProvider } from '~/ui/context/app/insomnia-tab-context';
import { RunnerProvider } from '~/ui/context/app/runner-context';
@@ -275,162 +276,167 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
<InsomniaEventStreamProvider>
<InsomniaTabProvider>
<SidebarContext.Provider value={{ isSidebarCollapsed: isSidebarCollapsed ?? false, toggleSidebar }}>
<div className="h-full w-full">
<div
className={`grid-template-app-layout relative grid h-full w-full divide-x divide-solid divide-(--hl-md) bg-(--color-bg)`}
>
<header className="grid grid-cols-3 items-center border-b border-solid border-(--hl-md) [grid-area:Header]">
<div className="flex items-center gap-2">
<div className="flex w-12.5 shrink-0 justify-center py-2">
<InsomniaLogo />
</div>
{!isScratchPad && (
<OrganizationSelect
organizationId={organizationId}
organizations={organizations || []}
onSelect={id => {
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.organizationSwitched });
navigate(`/organization/${id}`);
}}
currentPlan={currentPlan}
isScratchpadWorkspace={!!isScratchpadWorkspace}
/>
)}
<InsomniaTanstackQueryClientContext>
<div className="h-full w-full">
<div
className={`grid-template-app-layout relative grid h-full w-full divide-x divide-solid divide-(--hl-md) bg-(--color-bg)`}
>
<header className="grid grid-cols-3 items-center border-b border-solid border-(--hl-md) [grid-area:Header]">
<div className="flex items-center gap-2">
<div className="flex w-12.5 shrink-0 justify-center py-2">
<InsomniaLogo />
</div>
{!isScratchPad && (
<OrganizationSelect
organizationId={organizationId}
organizations={organizations || []}
onSelect={id => {
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.organizationSwitched });
navigate(`/organization/${id}`);
}}
currentPlan={currentPlan}
isScratchpadWorkspace={!!isScratchpadWorkspace}
/>
)}
{!user ? <GitHubStarsButton /> : null}
{!user ? <GitHubStarsButton /> : null}
</div>
<CommandPalette />
<div className="flex min-w-min items-center justify-end gap-(--padding-sm) space-x-3 p-2">
{user ? (
<LoginUserActions
organizationId={organizationId}
isMinimal={false}
user={user}
currentPlan={currentPlan}
/>
) : (
<ScratchPadAuthActions />
)}
</div>
</header>
<div className="overflow-hidden border-b border-(--hl-md) [grid-area:Content]">
<RunnerProvider>
<Outlet />
</RunnerProvider>
</div>
<CommandPalette />
<div className="flex min-w-min items-center justify-end gap-(--padding-sm) space-x-3 p-2">
{user ? (
<LoginUserActions
organizationId={organizationId}
isMinimal={false}
user={user}
currentPlan={currentPlan}
/>
) : (
<ScratchPadAuthActions />
)}
</div>
</header>
<div className="overflow-hidden border-b border-(--hl-md) [grid-area:Content]">
<RunnerProvider>
<Outlet />
</RunnerProvider>
</div>
<div className="relative flex items-center overflow-hidden [grid-area:Statusbar]" data-testid="statusbar">
<div className="flex w-full items-center gap-2">
<div className="flex h-full shrink grow basis-1/3 items-center">
<TooltipTrigger>
<ToggleButton
className="ml-3 grow-0 gap-2 px-2 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-inset"
onChange={value => {
setIsSidebarCollapsed(!value);
}}
isSelected={!isSidebarCollapsed}
>
{({ isSelected }) => {
return (
<svg
width={10}
height={10}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
{isSelected ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm12 13H7V2h7v12z"
/>
) : (
<path d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm0 13V2h4v12H2zm5 0V2h7v12H7z" />
)}
</svg>
);
}}
</ToggleButton>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Toggle sidebar
</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Toggle header
</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button
data-testid="settings-button"
className="flex h-full items-center justify-center gap-2 px-4 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
onPress={() => showSettingsModal()}
>
<Icon icon="gear" /> Preferences
</Button>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Preferences
<Hotkey keyBindings={settings.hotKeyRegistry.preferences_showGeneral} />
</Tooltip>
</TooltipTrigger>
{!isScratchpadWorkspace && hasUntrackedData && (
<TooltipTrigger delay={500}>
<Button
className="flex h-full items-center justify-center gap-2 px-4 py-1 text-xs text-(--color-warning) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
onPress={() => {
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.statusbarOrphanedProjectsClicked,
});
showModal(SettingsModal, { tab: 'data' });
<div
className="relative flex items-center overflow-hidden [grid-area:Statusbar]"
data-testid="statusbar"
>
<div className="flex w-full items-center gap-2">
<div className="flex h-full shrink grow basis-1/3 items-center">
<TooltipTrigger>
<ToggleButton
className="ml-3 grow-0 gap-2 px-2 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-inset"
onChange={value => {
setIsSidebarCollapsed(!value);
}}
isSelected={!isSidebarCollapsed}
>
<Icon icon="exclamation-circle" />
{({ isSelected }) => {
return (
<svg
width={10}
height={10}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
{isSelected ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm12 13H7V2h7v12z"
/>
) : (
<path d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm0 13V2h4v12H2zm5 0V2h7v12H7z" />
)}
</svg>
);
}}
</ToggleButton>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Toggle sidebar
</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Toggle header
</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button
data-testid="settings-button"
className="flex h-full items-center justify-center gap-2 px-4 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
onPress={() => showSettingsModal()}
>
<Icon icon="gear" /> Preferences
</Button>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
We have detected orphaned projects on your computer, click here to view them.
Preferences
<Hotkey keyBindings={settings.hotKeyRegistry.preferences_showGeneral} />
</Tooltip>
</TooltipTrigger>
)}
</div>
<div className="flex shrink grow basis-1/3 justify-end">
<div className="flex items-center gap-2">
<NetworkAndSyncIndicator
asyncTaskStatus={asyncTaskStatus}
settings={settings}
sync={syncOrgsAndProjects}
/>
{!isScratchpadWorkspace && hasUntrackedData && (
<TooltipTrigger delay={500}>
<Button
className="flex h-full items-center justify-center gap-2 px-4 py-1 text-xs text-(--color-warning) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
onPress={() => {
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.statusbarOrphanedProjectsClicked,
});
showModal(SettingsModal, { tab: 'data' });
}}
>
<Icon icon="exclamation-circle" />
</Button>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
We have detected orphaned projects on your computer, click here to view them.
</Tooltip>
</TooltipTrigger>
)}
</div>
<div className="flex shrink grow basis-1/3 justify-end">
<div className="flex items-center gap-2">
<NetworkAndSyncIndicator
asyncTaskStatus={asyncTaskStatus}
settings={settings}
sync={syncOrgsAndProjects}
/>
<Link>
<a
className="flex items-center gap-1 px-(--padding-md) text-xs text-(--color-font) focus:underline focus:outline-hidden"
href="https://konghq.com/"
>
Made with
<Icon className="text-(--color-surprise-font)" icon="heart" /> by Kong
</a>
</Link>
<Link>
<a
className="flex items-center gap-1 px-(--padding-md) text-xs text-(--color-font) focus:underline focus:outline-hidden"
href="https://konghq.com/"
>
Made with
<Icon className="text-(--color-surprise-font)" icon="heart" /> by Kong
</a>
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</InsomniaTanstackQueryClientContext>
</SidebarContext.Provider>
</InsomniaTabProvider>
</InsomniaEventStreamProvider>

View File

@@ -659,8 +659,14 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal, onModalChange }) =>
const workspaceData = useWorkspaceLoaderData();
const activeWorkspaceName = workspaceData?.activeWorkspace.name;
const { workspaceCount, userSession } = useRootLoaderData()!;
const { userSession } = useRootLoaderData()!;
const workspacesFetcher = useProjectListWorkspacesLoaderFetcher();
const [workspaceCount, setWorkspaceCount] = useState(0);
useEffect(() => {
services.workspace.count().then(setWorkspaceCount);
}, []);
useEffect(() => {
const isIdleAndUninitialized = workspacesFetcher.state === 'idle' && !workspacesFetcher.data;
if (

View File

@@ -2,12 +2,14 @@ import { services } from 'insomnia-data';
import { beforeEach, describe, expect, it } from 'vitest';
import { database as db } from '~/common/database';
import {
type AllRequestsAndMetaInWorkspace,
getAllRequestsAndMetaByWorkspace,
} from '~/ui/hooks/data/requests-and-metas';
import {
filterCollection,
flattenCollectionChildren,
getAllRequestsAndMetaByWorkspace,
getWorkspacesByProjectIds,
} from './project-navigation-sidebar-utils';

View File

@@ -1,23 +1,10 @@
import type {
GrpcRequest,
GrpcRequestMeta,
Request,
RequestGroup,
RequestGroupMeta,
RequestMeta,
SocketIORequest,
SocketIORequestMeta,
WebSocketRequest,
WebSocketRequestMeta,
Workspace,
} from 'insomnia-data';
import type { BaseModel } from 'insomnia-data';
import { models } from 'insomnia-data';
import { database } from '~/common/database';
import { fuzzyMatchAll } from '~/common/misc';
import { sortMethodMap } from '~/common/sorting';
import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import type { CollectionChildDoc, CollectionWorkspaceChildren } from '~/ui/hooks/data/workspace-children';
export interface SlimRequestDoc extends BaseModel {
type: 'Request' | 'GrpcRequest' | 'WebSocketRequest' | 'SocketIORequest' | 'RequestGroup';
@@ -27,14 +14,6 @@ export interface SlimRequestDoc extends BaseModel {
description?: string;
}
type AllRequestDoc = Request | GrpcRequest | WebSocketRequest | SocketIORequest | RequestGroup;
export interface AllRequestsAndMetaInWorkspace {
allRequests: AllRequestDoc[];
allRequestMetas: (RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta)[];
requestGroupMetas: RequestGroupMeta[];
}
// TODO SLIM THE REQUEST DOCS TO ONLY WHAT WE NEED FOR THE SIDEBAR TO IMPROVE PERFORMANCE
// const toSlimDoc = (r: AllRequestDoc): SlimRequestDoc => ({
// _id: r._id,
@@ -50,150 +29,21 @@ export interface AllRequestsAndMetaInWorkspace {
// created: r.created,
// });
export async function getWorkspacesByProjectIds(projectIds: string[]) {
const workspaces = await database.find<Workspace>(models.workspace.type, {
parentId: { $in: projectIds },
});
const workspacesByProjectId = new Map<string, Workspace[]>();
projectIds.forEach(projectId => {
workspacesByProjectId.set(projectId, workspaces.filter(w => w.parentId === projectId) || []);
});
return workspacesByProjectId;
}
export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) {
const allRequestsAndMetaByWorkspaceId = new Map<string, AllRequestsAndMetaInWorkspace>();
let requestGroupIdQueue = [...workspaceIds];
const allRequestGroups: RequestGroup[] = [];
// Map to track which workspace each request group belongs to
const requestGroupToWorkspaceId = new Map<string, string>();
const requestToWorkspaceId = new Map<string, string>();
const grpcRequestToWorkspaceId = new Map<string, string>();
const wsRequestToWorkspaceId = new Map<string, string>();
const socketIORequestToWorkspaceId = new Map<string, string>();
// Initialize the map with workspace IDs
workspaceIds.forEach(workspaceId => {
requestGroupToWorkspaceId.set(workspaceId, workspaceId);
requestToWorkspaceId.set(workspaceId, workspaceId);
grpcRequestToWorkspaceId.set(workspaceId, workspaceId);
wsRequestToWorkspaceId.set(workspaceId, workspaceId);
socketIORequestToWorkspaceId.set(workspaceId, workspaceId);
allRequestsAndMetaByWorkspaceId.set(workspaceId, { allRequests: [], allRequestMetas: [], requestGroupMetas: [] });
});
while (requestGroupIdQueue.length) {
const requestGroups = await database.find<RequestGroup>(models.requestGroup.type, {
parentId: { $in: requestGroupIdQueue },
});
if (requestGroups.length === 0) {
break;
}
requestGroups.forEach(requestGroup => {
const workspaceId = requestGroupToWorkspaceId.get(requestGroup.parentId);
if (workspaceId) {
requestGroupToWorkspaceId.set(requestGroup._id, workspaceId);
}
});
allRequestGroups.push(...requestGroups);
requestGroupIdQueue = requestGroups.map(rg => rg._id);
}
const listOfParentIds = [...workspaceIds, ...allRequestGroups.map(requestGroup => requestGroup._id)];
const [reqs, grpcReqs, wsReqs, socketIOReqs] = await Promise.all([
database.find(models.request.type, { parentId: { $in: listOfParentIds } }),
database.find<GrpcRequest>(models.grpcRequest.type, { parentId: { $in: listOfParentIds } }),
database.find(models.webSocketRequest.type, { parentId: { $in: listOfParentIds } }),
database.find(models.socketIORequest.type, { parentId: { $in: listOfParentIds } }),
]);
const allRequests = [...reqs, ...allRequestGroups, ...grpcReqs, ...wsReqs, ...socketIOReqs] as AllRequestDoc[];
const [requestMetas, grpcRequestMetas, requestGroupMetas, wsRequestMetas, socketIORequestMetas] = await Promise.all([
database.find<RequestMeta>(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }),
database.find<GrpcRequestMeta>(models.grpcRequestMeta.type, {
parentId: { $in: grpcReqs.map(r => r._id) },
}),
database.find<RequestGroupMeta>(models.requestGroupMeta.type, {
parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) },
}),
database.find<WebSocketRequestMeta>(models.webSocketRequestMeta.type, {
parentId: { $in: wsReqs.map(r => r._id) },
}),
database.find<SocketIORequestMeta>(models.socketIORequestMeta.type, {
parentId: { $in: socketIOReqs.map(r => r._id) },
}),
]);
const allRequestMetas = [...requestMetas, ...grpcRequestMetas, ...wsRequestMetas, ...socketIORequestMetas];
// Associate requests with their workspace IDs and group request metas by workspace ID
allRequests.forEach(request => {
const { parentId, _id: requestId } = request;
const workspaceId = requestGroupToWorkspaceId.get(parentId);
if (workspaceId) {
// Track which workspace this request belongs to
if (models.grpcRequest.isGrpcRequest(request)) {
grpcRequestToWorkspaceId.set(requestId, workspaceId);
} else if (models.request.isRequest(request)) {
requestToWorkspaceId.set(requestId, workspaceId);
} else if (models.webSocketRequest.isWebSocketRequest(request)) {
wsRequestToWorkspaceId.set(requestId, workspaceId);
} else if (models.socketIORequest.isSocketIORequest(request)) {
socketIORequestToWorkspaceId.set(requestId, workspaceId);
}
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.allRequests.push(request);
}
}
});
// Build map of requestGroupMetas by workspace ID
requestGroupMetas.forEach(requestGroupMeta => {
const workspaceId = requestGroupToWorkspaceId.get(requestGroupMeta.parentId);
if (workspaceId) {
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.requestGroupMetas.push(requestGroupMeta);
}
}
});
allRequestMetas.forEach(requestMeta => {
const requestOrGrpcRequestId = requestMeta.parentId;
let workspaceId: string | undefined;
if (models.request.isRequestId(requestOrGrpcRequestId)) {
workspaceId = requestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.grpcRequest.isGrpcRequestId(requestOrGrpcRequestId)) {
workspaceId = grpcRequestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.webSocketRequest.isWebSocketRequestId(requestOrGrpcRequestId)) {
workspaceId = wsRequestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.socketIORequest.isSocketIORequestId(requestOrGrpcRequestId)) {
workspaceId = socketIORequestToWorkspaceId.get(requestOrGrpcRequestId);
}
if (workspaceId) {
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.allRequestMetas.push(requestMeta);
}
}
});
return allRequestsAndMetaByWorkspaceId;
}
export function flattenCollectionChildren(
workspaceId: string,
parentIsCollapsed: boolean,
{ allRequests, allRequestMetas, requestGroupMetas }: AllRequestsAndMetaInWorkspace,
collectionWorkspaceChildren: CollectionWorkspaceChildren,
sortOrder: keyof typeof sortMethodMap = 'type-manual',
): Child[] {
const { isRequestGroup } = models.requestGroup;
const allRequests = collectionWorkspaceChildren.children.requestsAndGroups;
const { allRequestMetas, requestGroupMetas } = collectionWorkspaceChildren.childrenMetas;
const collection: Child[] = [];
// map of parentId to its direct children requests and request groups
const requestsByParentId = new Map<string, AllRequestDoc[]>();
const requestsByParentId = new Map<string, CollectionChildDoc[]>();
for (const req of allRequests) {
const allRequestsByParentId = requestsByParentId.get(req.parentId);
if (allRequestsByParentId) {
@@ -204,7 +54,7 @@ export function flattenCollectionChildren(
}
const sortFunction = sortMethodMap[sortOrder];
const rootRequests = (requestsByParentId.get(workspaceId) || []).sort(sortFunction);
const stack: { doc: AllRequestDoc; level: number; parentIsCollapsed: boolean; ancestors: string[] }[] = [
const stack: { doc: CollectionChildDoc; level: number; parentIsCollapsed: boolean; ancestors: string[] }[] = [
...rootRequests,
]
.reverse()

View File

@@ -1,3 +1,4 @@
import { useQueryClient } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { StorageRules } from 'insomnia-api';
import type { RequestGroup, Workspace } from 'insomnia-data';
@@ -36,7 +37,6 @@ import { getUnsyncedRemoteWorkspaces, type InsomniaFile } from '~/common/project
import { sortMethodMap } from '~/common/sorting';
import type { SyncResult } from '~/konnect/sync';
import { useRootLoaderData } from '~/root';
import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId';
import { AnalyticsEvent } from '~/ui/analytics';
import type { WorkspaceSortOrder } from '~/ui/components/dropdowns/sidebar-project-dropdown';
import { KongLogo } from '~/ui/components/kong-logo';
@@ -47,24 +47,23 @@ import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/em
import { KonnectEnvOnboarding } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-env-onboarding';
import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro';
import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node';
import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context';
import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus';
import { useProjects } from '~/ui/hooks/data/projects';
import {
type CollectionWorkspaceChildren,
useWorkspaceChildrenByWorkspaceIds,
workspaceChildrenKeys,
} from '~/ui/hooks/data/workspace-children';
import { useWorkspacesByProjectIds } from '~/ui/hooks/data/workspaces';
import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import { useKonnectSync } from '~/ui/hooks/use-konnect-sync';
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features';
import insomniaLogo from '~/ui/images/insomnia-logo.svg';
import { isPrimaryClickModifier } from '~/ui/utils';
import { getAllRemoteBackendProjectsOfOrg } from '~/ui/utils/remote-projects';
import { Icon } from '../../icon';
import {
type AllRequestsAndMetaInWorkspace,
filterCollection,
flattenCollectionChildren,
getAllRequestsAndMetaByWorkspace,
getWorkspacesByProjectIds,
} from './project-navigation-sidebar-utils';
import { filterCollection, flattenCollectionChildren } from './project-navigation-sidebar-utils';
import { ProjectNode } from './project-node';
import { PinnedHeaderNode, RequestNode } from './request-node';
import type { FlatItem } from './types';
@@ -202,19 +201,12 @@ const ProjectNavigationSidebarInner = (
requestId?: string;
requestGroupId?: string;
};
const { userSession, settings } = useRootLoaderData()!;
const projectLoaderData = useProjectLoaderData()!;
const { projects, projectsSyncStatusPromise } = projectLoaderData;
const [checkAllProjectSyncStatus] = useLoaderDeferData<Record<string, boolean>>(
projectsSyncStatusPromise,
organizationId,
);
const { presence } = useInsomniaEventStreamContext();
const { settings } = useRootLoaderData()!;
const [searchParams, _setSearchParams] = useSearchParams();
const tabNavigate = useTabNavigate();
const [collectionSortOrders, setCollectionSortOrders] = useState<Record<string, SortOrder>>({});
const [flatItems, setFlatItems] = useState<FlatItem[]>([]);
const [projectWorkspaceSortOrder, setProjectWorkspaceSortOrder] = useState<Record<string, WorkspaceSortOrder>>({});
const [unsyncedFilesByProjectId, setUnsyncedFilesByProjectId] = useState<Map<string, InsomniaFile[]>>(new Map());
// Customized workspace sort orders by projectId
@@ -241,6 +233,31 @@ const ProjectNavigationSidebarInner = (
null,
);
const queryClient = useQueryClient();
const { data: projects = [] } = useProjects(organizationId);
// Show konnect or none-konnect projects based on selected tab
const activeProjects = useMemo(
() => projects.filter(isProjectTabActive ? p => !p.konnectControlPlaneId : p => p.konnectControlPlaneId != null),
[isProjectTabActive, projects],
);
const projectIds = useMemo(() => activeProjects.map(p => p._id), [activeProjects]);
const workspacesByProjectId = useWorkspacesByProjectIds(projectIds);
// Get the list of collection workspace ids that should be cached based on the current filter and expanded projects/workspaces.
const collectionWorkspaceIds = useMemo(() => {
const ids: string[] = [];
projectIds.forEach(projectId => {
(workspacesByProjectId.get(projectId) || []).forEach(workspace => {
if (
workspace.scope === 'collection' &&
(!!projectNavigationSidebarFilter || (expandedProjectAndWorkspaceIds || []).includes(workspace._id))
) {
ids.push(workspace._id);
}
});
});
return ids;
}, [projectIds, workspacesByProjectId, projectNavigationSidebarFilter, expandedProjectAndWorkspaceIds]);
const collectionByWorkspaceId = useWorkspaceChildrenByWorkspaceIds(collectionWorkspaceIds, 'collection');
const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId);
const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null);
const [filterInputValue, setFilterInputValue] = useState(projectNavigationSidebarFilter || '');
@@ -269,46 +286,17 @@ const ProjectNavigationSidebarInner = (
reactUse.useDebounce(() => setProjectNavigationSidebarFilter(filterInputValue), 300, [filterInputValue]);
reactUse.useDebounce(() => setKonnectFilter(konnectFilterInputValue), 300, [konnectFilterInputValue]);
const activeFilter = ((isProjectTabActive ? projectNavigationSidebarFilter : konnectFilter) || '').trim();
// ref to cache queried workspaces by project id
const cachedWorkspacesRef = useRef<Map<string, Workspace[]>>(new Map());
// ref to cache queried collection children (request & requestGroups) data and meta by workspace id
const cachedCollectionChildrenAndMetaRef = useRef<Map<string, AllRequestsAndMetaInWorkspace>>(new Map());
// ref to track whether we are currently fetching unsynced files for cloud sync projects to avoid duplicate requests
const isFetchingUnsyncedFilesRef = useRef(false);
const syncKonnectProjectsAndNotifyRef = useRef<() => Promise<void>>(async () => {});
const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID;
const projectsWithPresence = useMemo(
() =>
projects
.filter(isProjectTabActive ? p => !p.konnectControlPlaneId : p => p.konnectControlPlaneId != null)
.map(project => {
const projectPresence = presence
.filter(p => p.project === project.remoteId)
.filter(p => p.acct !== userSession.accountId)
.map(user => {
return {
key: user.acct,
alt: user.firstName || user.lastName ? `${user.firstName} ${user.lastName}` : user.acct,
src: user.avatar,
};
});
return {
...project,
presence: projectPresence,
hasUncommittedOrUnpushedChanges:
checkAllProjectSyncStatus?.[project._id] ||
project.gitRepository?.hasUncommittedChanges ||
project.gitRepository?.hasUnpushedChanges,
};
}),
[projects, isProjectTabActive, presence, checkAllProjectSyncStatus, userSession.accountId],
);
const cloudSyncProjects = useMemo(() => projects.filter(p => models.project.isRemoteProject(p)), [projects]);
// Generate a stable string key to trigger getOrFetchUnsyncedFiles when the list of cloud sync projects changes.
const cloudSyncProjects = useMemo(
() => activeProjects.filter(p => models.project.isRemoteProject(p)),
[activeProjects],
); // Generate a stable string key to trigger getOrFetchUnsyncedFiles when the list of cloud sync projects changes.
const cloudSyncProjectIdsKey = useMemo(
() =>
cloudSyncProjects
@@ -474,7 +462,7 @@ const ProjectNavigationSidebarInner = (
},
});
}
}, [projectNavigationSidebarFilter, konnectFilter]);
}, [projectNavigationSidebarFilter, konnectFilter, activeProjectId]);
useEffect(() => {
getAllRemoteFilesByProjectId();
@@ -486,57 +474,20 @@ const ProjectNavigationSidebarInner = (
return uiEventBus.on(CLOUD_SYNC_FILE_CHANGE, updateUnsyncedFiles);
}, [getAllRemoteFilesByProjectId, organizationId]);
useEffect(() => {
// clear caches on any router data change to avoid showing stale data
cachedWorkspacesRef.current.clear();
cachedCollectionChildrenAndMetaRef.current.clear();
}, [projectLoaderData]);
// Note: the local-db → query-cache sync (invalidate/patch on db.changes) is registered once at the
// provider level in `subscribeQueryClientToDbChanges`, so it stays active even when this sidebar is
// unmounted. Do not re-register it here.
useEffect(() => {
const tryToGetWorkspacesFromCache = async (projectIds: string[]) => {
const uncachedProjectIds = projectIds.filter(id => !cachedWorkspacesRef.current.has(id));
if (uncachedProjectIds.length > 0) {
const workspacesByProjectId = await getWorkspacesByProjectIds(uncachedProjectIds);
for (const [projectId, workspaces] of workspacesByProjectId.entries()) {
cachedWorkspacesRef.current.set(projectId, workspaces);
}
}
return cachedWorkspacesRef.current;
};
const tryToGetCollectionChildrenAndMetaFromCache = async (workspaceIds: string[]) => {
const uncachedWorkspaceIds = workspaceIds.filter(id => !cachedCollectionChildrenAndMetaRef.current.has(id));
if (uncachedWorkspaceIds.length > 0) {
const collectionChildAndMetaByWorkspaceId = await getAllRequestsAndMetaByWorkspace(uncachedWorkspaceIds);
for (const [workspaceId, collectionChildrenAndMeta] of collectionChildAndMetaByWorkspaceId.entries()) {
cachedCollectionChildrenAndMetaRef.current.set(workspaceId, collectionChildrenAndMeta);
}
}
return cachedCollectionChildrenAndMetaRef.current;
};
const buildWorkspaceAndCollectionData = async () => {
const flatItems = useMemo<FlatItem[]>(() => {
const buildWorkspaceAndCollectionData = (): FlatItem[] => {
const items: FlatItem[] = [];
// Array of project and collection workspace ids that should get data from db
const activeFilterLower = activeFilter.toLowerCase();
const projectIds = projectsWithPresence.map(p => p._id);
const collectionWorkspaceIds: string[] = [];
const workspacesByProject = await tryToGetWorkspacesFromCache(projectIds);
projectIds.forEach(projectId => {
const workspaces = workspacesByProject.get(projectId) || [];
workspaces.forEach(wk => {
if (
wk.scope === 'collection' &&
// Fetch collection children and meta if 1) the workspace is expanded or 2) there is an active filter
(!!activeFilter || (expandedProjectAndWorkspaceIds || []).includes(wk._id))
) {
collectionWorkspaceIds.push(wk._id);
}
});
});
const collectionChildrenAndMetaByWorkspaceId =
await tryToGetCollectionChildrenAndMetaFromCache(collectionWorkspaceIds);
for (const project of projectsWithPresence) {
const workspacesByProject = workspacesByProjectId;
const collectionChildrenAndMetaByWorkspaceId = collectionByWorkspaceId;
for (const project of activeProjects) {
const projectId = project._id;
const isProjectCollapsed = !(expandedProjectAndWorkspaceIds ?? []).includes(projectId);
items.push({
@@ -620,7 +571,9 @@ const ProjectNavigationSidebarInner = (
hidden: isProjectCollapsed,
});
const allRequestsAndMetaInWorkspace = collectionChildrenAndMetaByWorkspaceId.get(workspaceId);
const allRequestsAndMetaInWorkspace = collectionChildrenAndMetaByWorkspaceId.get(
workspaceId,
) as CollectionWorkspaceChildren;
// build collection children if it's a collection workspace and parent workspace and project are not collapsed or there is an active filter
const shouldHideCollectionChildren = isWorkspaceCollapsed || isProjectCollapsed;
let collectionChildren =
@@ -747,19 +700,20 @@ const ProjectNavigationSidebarInner = (
});
}
setFlatItems(items);
return items;
};
buildWorkspaceAndCollectionData();
return buildWorkspaceAndCollectionData();
}, [
activeFilter,
activeProjects,
collectionByWorkspaceId,
collectionSortOrders,
projectWorkspaceSortOrder,
expandedProjectAndWorkspaceIds,
isProjectTabActive,
localWorkspaceOrders,
organizationId,
projectsWithPresence,
projectWorkspaceSortOrder,
unsyncedFilesByProjectId,
workspacesByProjectId,
]);
const handleLocalWorkspaceReorder = useCallback(
@@ -771,7 +725,7 @@ const ProjectNavigationSidebarInner = (
dropPosition: 'before' | 'after',
) => {
const isMoveToDifferentProject = sourceProjectId !== targetProjectId;
const workspaces = cachedWorkspacesRef.current.get(targetProjectId) || [];
const workspaces = workspacesByProjectId.get(targetProjectId) || [];
const currentWorkspaceSortOrder = projectWorkspaceSortOrder[targetProjectId] || 'type-manual';
// Get the base order of workspace before re-order
const baseOrder =
@@ -801,7 +755,7 @@ const ProjectNavigationSidebarInner = (
}
setLocalWorkspaceOrders({ ...localWorkspaceOrders, [targetProjectId]: reordered });
},
[projectWorkspaceSortOrder, setLocalWorkspaceOrders, localWorkspaceOrders],
[workspacesByProjectId, projectWorkspaceSortOrder, localWorkspaceOrders, setLocalWorkspaceOrders],
);
const toggleProjectOrWorkspace = useCallback(
@@ -866,98 +820,35 @@ const ProjectNavigationSidebarInner = (
};
});
const collapsedByRequestGroupId = new Map(
nextStates.map(({ requestGroupId, collapsed }) => [requestGroupId, collapsed]),
);
// Optimistically update the cached collection so the tree re-renders immediately.
queryClient.setQueryData<CollectionWorkspaceChildren>(workspaceChildrenKeys.details(workspace._id), previous => {
if (!previous) {
return previous;
}
return {
...previous,
childrenMetas: {
...previous.childrenMetas,
requestGroupMetas: previous.childrenMetas.requestGroupMetas.map(requestGroupMeta =>
collapsedByRequestGroupId.has(requestGroupMeta.parentId)
? { ...requestGroupMeta, collapsed: collapsedByRequestGroupId.get(requestGroupMeta.parentId)! }
: requestGroupMeta,
),
},
};
});
// Persist the change to database
await Promise.all(
nextStates.map(({ requestGroupId, collapsed }) =>
services.requestGroupMeta.updateOrCreateForParentId(requestGroupId, { collapsed }),
),
);
// Update the collapsed state in the cache.
cachedCollectionChildrenAndMetaRef.current.forEach(workspaceData => {
workspaceData.requestGroupMetas.forEach(requestGroupMeta => {
const nextState = nextStates.find(({ requestGroupId }) => requestGroupId === requestGroupMeta.parentId);
if (nextState) {
requestGroupMeta.collapsed = nextState.collapsed;
}
});
});
setFlatItems(previousFlatItems =>
nextStates.reduce((nextFlatItems, { requestGroupId, collapsed }) => {
const toggledChildren: { id: string; parentIsCollapsed: boolean }[] = [];
return nextFlatItems.map(item => {
if (item.kind === 'collectionChild') {
const { children, doc } = item;
// Find toggled request group and update collapsed state
if (doc._id === requestGroupId) {
// Add all children of the toggled request group to the array to update their hidden state
toggledChildren.push(
...(children?.map(child => ({ id: child.doc._id, parentIsCollapsed: collapsed })) ?? []),
);
return {
...item,
collapsed,
hidden: false,
};
}
const matchedToggledChild = toggledChildren.find(tc => tc.id === item.doc._id);
if (matchedToggledChild) {
const { parentIsCollapsed } = matchedToggledChild;
if (models.requestGroup.isRequestGroupId(doc._id)) {
// Add children of the toggled child request group to the array to update their hidden state
const isToggledRequestGroupCollapsed =
parentIsCollapsed ||
cachedCollectionChildrenAndMetaRef.current
.get(workspace._id)
?.requestGroupMetas.find(rgm => rgm.parentId === doc._id)?.collapsed ||
false;
toggledChildren.push(
...(item.children?.map(child => ({
id: child.doc._id,
parentIsCollapsed: isToggledRequestGroupCollapsed,
})) ?? []),
);
}
return {
...item,
hidden: parentIsCollapsed,
};
}
}
if (item.kind === 'emptyFolder') {
const parentFolder = item.requestGroup;
const parentFolderId = parentFolder?._id;
const matchedToggledChild = toggledChildren.find(tc => tc.id === parentFolderId);
// Update the emptyFolder node hidden state based on its parent request group collapsed state.
if (parentFolderId === requestGroupId) {
return {
...item,
hidden: collapsed,
};
} else if (matchedToggledChild) {
return {
...item,
hidden:
matchedToggledChild.parentIsCollapsed ||
// If the parent folder is toggled to be expanded, the empty folder node should still be hidden if its parent request group is collapsed.
cachedCollectionChildrenAndMetaRef.current
.get(workspace._id)
?.requestGroupMetas.find(rgm => rgm.parentId === matchedToggledChild.id)?.collapsed ||
false,
};
}
}
return item;
});
}, previousFlatItems),
);
},
[activeFilter],
[activeFilter, queryClient],
);
const parentRef = useRef<HTMLDivElement>(null);
@@ -1121,7 +1012,11 @@ const ProjectNavigationSidebarInner = (
if (routeInfo?.resourceId === docId) {
toggleProjectOrWorkspace(docId);
} else {
!isScratchPad && window.main.trackAnalyticsEvent({ event: AnalyticsEvent.projectSwitched, properties: { project_id: docId } });
!isScratchPad &&
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.projectSwitched,
properties: { project_id: docId },
});
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
}
} else if (item.kind === 'workspace') {

View File

@@ -3,14 +3,17 @@ import { models } from 'insomnia-data';
import { useState } from 'react';
import { Button } from 'react-aria-components';
import { useRootLoaderData } from '~/root';
import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId';
import { ProjectDropdown, type WorkspaceSortOrder } from '~/ui/components/dropdowns/sidebar-project-dropdown';
import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context';
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
import { AvatarGroup } from '../../avatar';
import { Icon } from '../../icon';
import { KonnectProjectIcon } from './konnect-project-icon/konnect-project-icon';
import { ACTIVE_BORDER_CLASS, ICON_CLASS, ROW_CLASS, TOGGLE_BTN_CLASS } from './project-navigation-sidebar-utils';
import { type ProjectFlatItem } from './types';
interface ProjectNodeProps {
item: ProjectFlatItem;
storageRules: StorageRules;
@@ -21,8 +24,30 @@ interface ProjectNodeProps {
export const ProjectNode = ({ item, storageRules, onToggle, sortOrder, onSortOrderChange }: ProjectNodeProps) => {
const { doc, collapsed, organizationId } = item;
const { name: projectName, presence, _id: projectId } = doc;
const { userSession } = useRootLoaderData()!;
const { presence } = useInsomniaEventStreamContext();
const { name: projectName, _id: projectId } = doc;
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const { projectsSyncStatusPromise } = useProjectLoaderData()!;
const [checkAllProjectSyncStatus] = useLoaderDeferData<Record<string, boolean>>(
projectsSyncStatusPromise,
organizationId,
);
const hasUncommittedOrUnpushedChanges =
checkAllProjectSyncStatus?.[projectId] ||
doc.gitRepository?.hasUncommittedChanges ||
doc.gitRepository?.hasUnpushedChanges;
const projectPresence = presence
.filter(p => p.project === doc.remoteId)
.filter(p => p.acct !== userSession.accountId)
.map(user => {
return {
key: user.acct,
alt: user.firstName || user.lastName ? `${user.firstName} ${user.lastName}` : user.acct,
src: user.avatar,
};
});
return (
<div
@@ -59,11 +84,14 @@ export const ProjectNode = ({ item, storageRules, onToggle, sortOrder, onSortOrd
)}
<span className="min-w-0 flex-1 truncate text-base text-[rgb(var(--color-font-rgb),0.8)]">{projectName}</span>
</div>
{presence.length > 0 && <AvatarGroup size="small" maxAvatars={3} items={presence} />}
{projectPresence.length > 0 && <AvatarGroup size="small" maxAvatars={3} items={projectPresence} />}{' '}
{projectId !== models.project.SCRATCHPAD_PROJECT_ID && (
<ProjectDropdown
organizationId={organizationId}
project={doc}
project={{
...doc,
hasUncommittedOrUnpushedChanges,
}}
storageRules={storageRules}
sortOrder={sortOrder}
onSortOrderChange={onSortOrderChange}

View File

@@ -3,15 +3,9 @@ import type { BaseModel, GitRepository, Project, RequestGroup, Workspace, Worksp
import type { InsomniaFile } from '~/common/project';
import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
export type ProjectWithPresence = Project & {
export type ProjectWithGitRepository = Project & {
gitRepository?: GitRepository;
presence: {
key: string;
alt: string;
src: string;
}[];
};
export interface WorkspaceSummary {
workspace: Workspace;
meta: WorkspaceMeta;
@@ -28,14 +22,14 @@ interface BaseFlatItem<T extends BaseModel> {
organizationId: string;
}
export interface ProjectFlatItem extends BaseFlatItem<ProjectWithPresence> {
export interface ProjectFlatItem extends BaseFlatItem<ProjectWithGitRepository> {
kind: 'project';
}
export interface WorkspaceFlatItem extends BaseFlatItem<Workspace> {
kind: 'workspace';
// parent project
project: ProjectWithPresence;
project: ProjectWithGitRepository;
}
// Unsynced workspace in cloud sync project
@@ -50,7 +44,7 @@ export type UnsyncedWorkspaceFlatItem = Omit<BaseFlatItem<any>, 'doc'> &
export interface CollectionChildFlatItem extends BaseFlatItem<Child['doc']> {
kind: 'collectionChild';
// parent project
project: ProjectWithPresence;
project: ProjectWithGitRepository;
// parent workspace
workspace: Workspace;
// nested children for request group
@@ -77,7 +71,7 @@ export interface EmptyNodeFlatItem {
hidden: boolean;
organizationId: string;
doc: { _id: string; name: string };
project: ProjectWithPresence;
project: ProjectWithGitRepository;
workspace?: Workspace;
requestGroup?: RequestGroup;
level?: number;

View File

@@ -0,0 +1,22 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type PropsWithChildren, useEffect } from 'react';
import { subscribeQueryClientToDbChanges } from '~/ui/hooks/data/db-changes-sync';
// Single shared QueryClient instance for all database queries.
// This client treats as the cache of the local NeDB database and will listen to db.change events
export const dbQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
},
});
export const InsomniaTanstackQueryClientContext = ({ children }: PropsWithChildren) => {
useEffect(() => subscribeQueryClientToDbChanges(dbQueryClient), []);
return <QueryClientProvider client={dbQueryClient}>{children}</QueryClientProvider>;
};

View File

@@ -0,0 +1,114 @@
import type { QueryClient } from '@tanstack/react-query';
import { models } from 'insomnia-data';
import type { ChangeBufferEvent } from '~/common/database';
import { projectKeys } from './projects';
import {
type CollectionWorkspaceChildren,
findWorkspaceIdsForChangedCollectionChildDoc,
updateCollectionChildrenWithUpdatedDoc,
workspaceChildrenKeys,
} from './workspace-children';
import { workspaceKeys } from './workspaces';
const COLLECTION_CHILDREN_DOC_TYPES = [
// collection scope
models.request.type,
models.grpcRequest.type,
models.webSocketRequest.type,
models.socketIORequest.type,
models.requestGroup.type,
models.requestMeta.type,
models.grpcRequestMeta.type,
models.webSocketRequestMeta.type,
models.socketIORequestMeta.type,
models.requestGroupMeta.type,
];
export const MONITOR_DOC_TYPES = [
models.project.type,
models.workspace.type,
...COLLECTION_CHILDREN_DOC_TYPES,
// mock-server scope
models.mockServer.type,
// design scope
models.apiSpec.type,
// mcp scope
models.mcpRequest.type,
// environment scope
models.environment.type,
];
// Bridge local NeDB change events to the TanStack cache: patch in place for plain field updates,
export const subscribeQueryClientToDbChanges = (queryClient: QueryClient): (() => void) => {
const unsubscribe = window.main.on('db.changes', (_, changes: ChangeBufferEvent[]) => {
const organizationIdsToRevalidate: string[] = [];
const projectIdsToRevalidate: string[] = [];
const workspaceIdsToRevalidate: string[] = [];
for (const [event, doc] of changes) {
if (!MONITOR_DOC_TYPES.includes(doc.type)) {
continue;
}
if (doc.type === models.project.type) {
// project changed: patch the owning organizations cached project list in place when possible.
organizationIdsToRevalidate.push(doc.parentId);
continue;
}
if (doc.type === models.workspace.type) {
const projectId = doc.parentId;
// workspace changed: refresh the owning projects cached workspace list + meta list
projectIdsToRevalidate.push(projectId);
continue;
}
if (COLLECTION_CHILDREN_DOC_TYPES.includes(doc.type)) {
// request/requestGroup/meta changed: patch the owning workspace's cached collection in place when possible.
for (const workspaceId of findWorkspaceIdsForChangedCollectionChildDoc(queryClient, doc)) {
// Avoid full db refresh when it is an update event and we just need to update a single doc in the cache.
if (event === 'update') {
let updated = false;
queryClient.setQueryData<CollectionWorkspaceChildren>(
workspaceChildrenKeys.details(workspaceId),
previous => {
if (!previous) {
return previous;
}
const updatedData = updateCollectionChildrenWithUpdatedDoc(previous, doc);
if (updatedData) {
updated = true;
return updatedData;
}
return previous;
},
);
if (updated) {
continue;
}
}
// Add, remove or changed doc can be updated (like due to move), refresh the entire workspaceChildrenKeys in the workspace
workspaceIdsToRevalidate.push(workspaceId);
}
} else {
// non-collection child doc changed (apiSpec/mockServer/environment/mcpRequest): refresh the workspace's
// children cache. The project dashboard reads apiSpec/mockServer from this same cache, so it updates too.
if (models.workspace.isWorkspaceId(doc.parentId)) {
workspaceIdsToRevalidate.push(doc.parentId);
}
}
}
organizationIdsToRevalidate.forEach(organizationId =>
queryClient.invalidateQueries({ queryKey: projectKeys.details(organizationId) }),
);
projectIdsToRevalidate.forEach(projectId =>
queryClient.invalidateQueries({ queryKey: workspaceKeys.details(projectId) }),
);
workspaceIdsToRevalidate.forEach(workspaceId =>
queryClient.invalidateQueries({ queryKey: workspaceChildrenKeys.details(workspaceId) }),
);
});
return unsubscribe;
};

View File

@@ -0,0 +1,23 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import type { GitRepository, Project } from 'insomnia-data';
import { models } from 'insomnia-data';
import { getProjectsWithGitRepositories } from '~/common/project';
export type ProjectWithGitRepository = Project & { gitRepository?: GitRepository };
export const projectKeys = {
all: ['projects'],
details: (organizationId: string) => [...projectKeys.all, organizationId],
detail: (projectId: string, organizationId: string) => [...projectKeys.details(organizationId), projectId],
};
export const getProjects = async (organizationId: string): Promise<ProjectWithGitRepository[]> =>
models.project.sortProjects(await getProjectsWithGitRepositories({ organizationId }));
export const useProjects = (organizationId: string): UseQueryResult<ProjectWithGitRepository[]> => {
return useQuery({
queryKey: projectKeys.details(organizationId),
queryFn: () => getProjects(organizationId),
});
};

View File

@@ -0,0 +1,289 @@
import type { QueryClient } from '@tanstack/react-query';
import { useQueries, useQuery, type UseQueryResult } from '@tanstack/react-query';
import type {
BaseModel,
GrpcRequest,
GrpcRequestMeta,
Request,
RequestGroup,
RequestGroupMeta,
RequestMeta,
SocketIORequest,
SocketIORequestMeta,
WebSocketRequest,
WebSocketRequestMeta,
} from 'insomnia-data';
import { models } from 'insomnia-data';
import { useCallback } from 'react';
import { database } from '~/common/database';
type AllRequestDoc = Request | GrpcRequest | WebSocketRequest | SocketIORequest | RequestGroup;
export type { AllRequestDoc };
export interface AllRequestsAndMetaInWorkspace {
allRequests: AllRequestDoc[];
allRequestMetas: (RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta)[];
requestGroupMetas: RequestGroupMeta[];
}
const EMPTY_REQUESTS_AND_META: AllRequestsAndMetaInWorkspace = {
allRequests: [],
allRequestMetas: [],
requestGroupMetas: [],
};
export const requestsAndMetaKeys = {
all: ['requestsAndMetas'],
detail: (workspaceId: string) => [...requestsAndMetaKeys.all, workspaceId],
};
// ALL request doc types
const REQUEST_DOC_TYPES: string[] = [
models.request.type,
models.grpcRequest.type,
models.webSocketRequest.type,
models.socketIORequest.type,
models.requestGroup.type,
];
// All request meta doc types
const REQUEST_META_DOC_TYPES: string[] = [
models.requestMeta.type,
models.grpcRequestMeta.type,
models.webSocketRequestMeta.type,
models.socketIORequestMeta.type,
];
// Given a changed request/group/meta doc, find the cached workspace id(s) whose collection contains it.
export const findWorkspaceIdsForChangedDoc = (queryClient: QueryClient, doc: BaseModel): string[] => {
const workspaceIds: string[] = [];
const cachedCollections = queryClient.getQueriesData<AllRequestsAndMetaInWorkspace>({
queryKey: requestsAndMetaKeys.all,
});
for (const [queryKey, data] of cachedCollections) {
const workspaceId = queryKey[1] as string;
if (!data) {
continue;
}
if (
doc.parentId === workspaceId ||
data.allRequests.some(request => request._id === doc._id || request._id === doc.parentId)
) {
workspaceIds.push(workspaceId);
}
}
return workspaceIds;
};
// Apply an `update` change for a single doc to a cached workspace tree without re-querying NeDB.
export function updateWorkspaceDataWithUpdatedDoc(
data: AllRequestsAndMetaInWorkspace,
doc: BaseModel,
): AllRequestsAndMetaInWorkspace | null {
const replaceById = <T extends BaseModel>(list: T[]): T[] | null => {
const index = list.findIndex(item => item._id === doc._id);
if (index === -1) {
return null;
}
// The item has been moved to another parent, consider failure to update
if (list[index].parentId !== doc.parentId) {
return null;
}
const next = list.slice();
next[index] = doc as unknown as T;
return next;
};
if (REQUEST_DOC_TYPES.includes(doc.type)) {
const allRequests = replaceById(data.allRequests);
return allRequests ? { ...data, allRequests } : null;
}
if (REQUEST_META_DOC_TYPES.includes(doc.type)) {
const allRequestMetas = replaceById(data.allRequestMetas);
return allRequestMetas ? { ...data, allRequestMetas } : null;
}
if (doc.type === models.requestGroupMeta.type) {
const requestGroupMetas = replaceById(data.requestGroupMetas);
return requestGroupMetas ? { ...data, requestGroupMetas } : null;
}
return null;
}
// Batched read: walk the request-group tree for the given workspaces and group every
// request, request and request-group meta under its owning workspace id.
export async function getAllRequestsAndMetaByWorkspace(
workspaceIds: string[],
): Promise<Map<string, AllRequestsAndMetaInWorkspace>> {
const allRequestsAndMetaByWorkspaceId = new Map<string, AllRequestsAndMetaInWorkspace>();
let requestGroupIdQueue = [...workspaceIds];
const allRequestGroups: RequestGroup[] = [];
// Map to track which workspace each request group belongs to
const requestGroupToWorkspaceId = new Map<string, string>();
const requestToWorkspaceId = new Map<string, string>();
const grpcRequestToWorkspaceId = new Map<string, string>();
const wsRequestToWorkspaceId = new Map<string, string>();
const socketIORequestToWorkspaceId = new Map<string, string>();
// Initialize the map with workspace IDs
workspaceIds.forEach(workspaceId => {
requestGroupToWorkspaceId.set(workspaceId, workspaceId);
requestToWorkspaceId.set(workspaceId, workspaceId);
grpcRequestToWorkspaceId.set(workspaceId, workspaceId);
wsRequestToWorkspaceId.set(workspaceId, workspaceId);
socketIORequestToWorkspaceId.set(workspaceId, workspaceId);
allRequestsAndMetaByWorkspaceId.set(workspaceId, { allRequests: [], allRequestMetas: [], requestGroupMetas: [] });
});
while (requestGroupIdQueue.length) {
const requestGroups = await database.find<RequestGroup>(models.requestGroup.type, {
parentId: { $in: requestGroupIdQueue },
});
if (requestGroups.length === 0) {
break;
}
requestGroups.forEach(requestGroup => {
const workspaceId = requestGroupToWorkspaceId.get(requestGroup.parentId);
if (workspaceId) {
requestGroupToWorkspaceId.set(requestGroup._id, workspaceId);
}
});
allRequestGroups.push(...requestGroups);
requestGroupIdQueue = requestGroups.map(rg => rg._id);
}
const listOfParentIds = [...workspaceIds, ...allRequestGroups.map(requestGroup => requestGroup._id)];
const [reqs, grpcReqs, wsReqs, socketIOReqs] = await Promise.all([
database.find(models.request.type, { parentId: { $in: listOfParentIds } }),
database.find<GrpcRequest>(models.grpcRequest.type, { parentId: { $in: listOfParentIds } }),
database.find(models.webSocketRequest.type, { parentId: { $in: listOfParentIds } }),
database.find(models.socketIORequest.type, { parentId: { $in: listOfParentIds } }),
]);
const allRequests = [...reqs, ...allRequestGroups, ...grpcReqs, ...wsReqs, ...socketIOReqs] as AllRequestDoc[];
const [requestMetas, grpcRequestMetas, requestGroupMetas, wsRequestMetas, socketIORequestMetas] = await Promise.all([
database.find<RequestMeta>(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }),
database.find<GrpcRequestMeta>(models.grpcRequestMeta.type, {
parentId: { $in: grpcReqs.map(r => r._id) },
}),
database.find<RequestGroupMeta>(models.requestGroupMeta.type, {
parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) },
}),
database.find<WebSocketRequestMeta>(models.webSocketRequestMeta.type, {
parentId: { $in: wsReqs.map(r => r._id) },
}),
database.find<SocketIORequestMeta>(models.socketIORequestMeta.type, {
parentId: { $in: socketIOReqs.map(r => r._id) },
}),
]);
const allRequestMetas = [...requestMetas, ...grpcRequestMetas, ...wsRequestMetas, ...socketIORequestMetas];
// Associate requests with their workspace IDs and group request metas by workspace ID
allRequests.forEach(request => {
const { parentId, _id: requestId } = request;
const workspaceId = requestGroupToWorkspaceId.get(parentId);
if (workspaceId) {
// Track which workspace this request belongs to
if (models.grpcRequest.isGrpcRequest(request)) {
grpcRequestToWorkspaceId.set(requestId, workspaceId);
} else if (models.request.isRequest(request)) {
requestToWorkspaceId.set(requestId, workspaceId);
} else if (models.webSocketRequest.isWebSocketRequest(request)) {
wsRequestToWorkspaceId.set(requestId, workspaceId);
} else if (models.socketIORequest.isSocketIORequest(request)) {
socketIORequestToWorkspaceId.set(requestId, workspaceId);
}
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.allRequests.push(request);
}
}
});
// Build map of requestGroupMetas by workspace ID
requestGroupMetas.forEach(requestGroupMeta => {
const workspaceId = requestGroupToWorkspaceId.get(requestGroupMeta.parentId);
if (workspaceId) {
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.requestGroupMetas.push(requestGroupMeta);
}
}
});
allRequestMetas.forEach(requestMeta => {
const requestOrGrpcRequestId = requestMeta.parentId;
let workspaceId: string | undefined;
if (models.request.isRequestId(requestOrGrpcRequestId)) {
workspaceId = requestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.grpcRequest.isGrpcRequestId(requestOrGrpcRequestId)) {
workspaceId = grpcRequestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.webSocketRequest.isWebSocketRequestId(requestOrGrpcRequestId)) {
workspaceId = wsRequestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.socketIORequest.isSocketIORequestId(requestOrGrpcRequestId)) {
workspaceId = socketIORequestToWorkspaceId.get(requestOrGrpcRequestId);
}
if (workspaceId) {
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.allRequestMetas.push(requestMeta);
}
}
});
return allRequestsAndMetaByWorkspaceId;
}
export const getRequestsAndMetaByWorkspaceId = async (workspaceId: string): Promise<AllRequestsAndMetaInWorkspace> => {
const result = await getAllRequestsAndMetaByWorkspace([workspaceId]);
return result.get(workspaceId) ?? EMPTY_REQUESTS_AND_META;
};
let pendingWorkspaceIds: string[] = [];
let pendingBatch: Promise<Map<string, AllRequestsAndMetaInWorkspace>> | null = null;
// Combine all per-workspace reads requested within the same microtask into a single batched DB sweep
const loadRequestsAndMetaBatched = (workspaceId: string): Promise<AllRequestsAndMetaInWorkspace> => {
pendingWorkspaceIds.push(workspaceId);
if (!pendingBatch) {
pendingBatch = Promise.resolve().then(() => {
const ids = [...pendingWorkspaceIds];
console.log(`Batching requests/meta load for workspaces: ${ids.join(', ')}`);
pendingWorkspaceIds = [];
pendingBatch = null;
return getAllRequestsAndMetaByWorkspace(ids);
});
}
return pendingBatch.then(map => map.get(workspaceId) ?? EMPTY_REQUESTS_AND_META);
};
// A single workspace's collection children (requests/requestGroups) and their metas.
export const useRequestsAndMeta = (workspaceId: string): UseQueryResult<AllRequestsAndMetaInWorkspace> => {
return useQuery({
queryKey: requestsAndMetaKeys.detail(workspaceId),
queryFn: () => loadRequestsAndMetaBatched(workspaceId),
});
};
export const useRequestsAndMetaByWorkspaceIds = (workspaceIds: string[]) =>
useQueries({
queries: workspaceIds.map(workspaceId => ({
queryKey: requestsAndMetaKeys.detail(workspaceId),
queryFn: () => loadRequestsAndMetaBatched(workspaceId),
})),
combine: useCallback(
(results: UseQueryResult<AllRequestsAndMetaInWorkspace>[]) => {
const dataByWorkspaceId = new Map<string, AllRequestsAndMetaInWorkspace>();
workspaceIds.forEach((workspaceId, index) => {
const data = results[index]?.data;
if (data) {
dataByWorkspaceId.set(workspaceId, data);
}
});
return dataByWorkspaceId;
},
[workspaceIds],
),
});

View File

@@ -0,0 +1,491 @@
import type { QueryClient } from '@tanstack/react-query';
import { useQueries, useQuery, type UseQueryResult } from '@tanstack/react-query';
import type {
ApiSpec,
BaseModel,
Environment,
GrpcRequest,
GrpcRequestMeta,
McpRequest,
MockServer,
Request,
RequestGroup,
RequestGroupMeta,
RequestMeta,
SocketIORequest,
SocketIORequestMeta,
WebSocketRequest,
WebSocketRequestMeta,
Workspace,
WorkspaceScope,
} from 'insomnia-data';
import { models } from 'insomnia-data';
import { useCallback } from 'react';
import { database } from '~/common/database';
type CollectionChildDoc = Request | GrpcRequest | WebSocketRequest | SocketIORequest | RequestGroup;
export type { CollectionChildDoc };
type CollectionRequestMeta = RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta;
interface CommonWorkspaceChildren<TChildren, TChildrenMeta extends object = {}> {
children: TChildren;
childrenMetas: TChildrenMeta;
}
export interface CollectionWorkspaceChildren
extends CommonWorkspaceChildren<
{ requestsAndGroups: CollectionChildDoc[] },
{
allRequestMetas: CollectionRequestMeta[];
requestGroupMetas: RequestGroupMeta[];
}
> {}
export interface MockServerWorkspaceChildren extends CommonWorkspaceChildren<{ mockServer?: MockServer }> {}
export interface DesignWorkspaceChildren extends CommonWorkspaceChildren<{ apiSpec?: ApiSpec }> {}
export interface EnvironmentWorkspaceChildren extends CommonWorkspaceChildren<{ baseEnvironment?: Environment }> {}
export interface McpWorkspaceChildren extends CommonWorkspaceChildren<{ mcpRequest?: McpRequest }> {}
export type WorkspaceChildren =
| CollectionWorkspaceChildren
| MockServerWorkspaceChildren
| DesignWorkspaceChildren
| EnvironmentWorkspaceChildren
| McpWorkspaceChildren;
const emptyWorkspaceChildren: WorkspaceChildren = {
children: {},
childrenMetas: {},
};
export const workspaceChildrenKeys = {
all: ['workspaceChildrenAndMetas'],
details: (workspaceId: string) => [...workspaceChildrenKeys.all, workspaceId],
};
// Walk the given collection and group every request, request and request-group meta under it.
async function getAllCollectionChildrenAndMetasByWorkspaceIds(
workspaceIds: string[],
): Promise<Map<string, CollectionWorkspaceChildren>> {
const allRequestsAndMetaByWorkspaceId = new Map<string, CollectionWorkspaceChildren>();
if (workspaceIds.length === 0) {
return allRequestsAndMetaByWorkspaceId;
}
let requestGroupIdQueue = [...workspaceIds];
const allRequestGroups: RequestGroup[] = [];
// Map to track which workspace each request group belongs to
const requestGroupToWorkspaceId = new Map<string, string>();
const requestToWorkspaceId = new Map<string, string>();
const grpcRequestToWorkspaceId = new Map<string, string>();
const wsRequestToWorkspaceId = new Map<string, string>();
const socketIORequestToWorkspaceId = new Map<string, string>();
// Initialize the map with workspace IDs
workspaceIds.forEach(workspaceId => {
requestGroupToWorkspaceId.set(workspaceId, workspaceId);
requestToWorkspaceId.set(workspaceId, workspaceId);
grpcRequestToWorkspaceId.set(workspaceId, workspaceId);
wsRequestToWorkspaceId.set(workspaceId, workspaceId);
socketIORequestToWorkspaceId.set(workspaceId, workspaceId);
allRequestsAndMetaByWorkspaceId.set(workspaceId, {
children: {
requestsAndGroups: [],
},
childrenMetas: { allRequestMetas: [], requestGroupMetas: [] },
});
});
while (requestGroupIdQueue.length) {
const requestGroups = await database.find<RequestGroup>(models.requestGroup.type, {
parentId: { $in: requestGroupIdQueue },
});
if (requestGroups.length === 0) {
break;
}
requestGroups.forEach(requestGroup => {
const workspaceId = requestGroupToWorkspaceId.get(requestGroup.parentId);
if (workspaceId) {
requestGroupToWorkspaceId.set(requestGroup._id, workspaceId);
}
});
allRequestGroups.push(...requestGroups);
requestGroupIdQueue = requestGroups.map(rg => rg._id);
}
const listOfParentIds = [...workspaceIds, ...allRequestGroups.map(requestGroup => requestGroup._id)];
const [reqs, grpcReqs, wsReqs, socketIOReqs] = await Promise.all([
database.find(models.request.type, { parentId: { $in: listOfParentIds } }),
database.find<GrpcRequest>(models.grpcRequest.type, { parentId: { $in: listOfParentIds } }),
database.find(models.webSocketRequest.type, { parentId: { $in: listOfParentIds } }),
database.find(models.socketIORequest.type, { parentId: { $in: listOfParentIds } }),
]);
const allRequests = [...reqs, ...allRequestGroups, ...grpcReqs, ...wsReqs, ...socketIOReqs] as CollectionChildDoc[];
const [requestMetas, grpcRequestMetas, requestGroupMetas, wsRequestMetas, socketIORequestMetas] = await Promise.all([
database.find<RequestMeta>(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }),
database.find<GrpcRequestMeta>(models.grpcRequestMeta.type, {
parentId: { $in: grpcReqs.map(r => r._id) },
}),
database.find<RequestGroupMeta>(models.requestGroupMeta.type, {
parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) },
}),
database.find<WebSocketRequestMeta>(models.webSocketRequestMeta.type, {
parentId: { $in: wsReqs.map(r => r._id) },
}),
database.find<SocketIORequestMeta>(models.socketIORequestMeta.type, {
parentId: { $in: socketIOReqs.map(r => r._id) },
}),
]);
const allRequestMetas = [...requestMetas, ...grpcRequestMetas, ...wsRequestMetas, ...socketIORequestMetas];
// Associate requests with their workspace IDs and group request metas by workspace ID
allRequests.forEach(request => {
const { parentId, _id: requestId } = request;
const workspaceId = requestGroupToWorkspaceId.get(parentId);
if (workspaceId) {
// Track which workspace this request belongs to
if (models.grpcRequest.isGrpcRequest(request)) {
grpcRequestToWorkspaceId.set(requestId, workspaceId);
} else if (models.request.isRequest(request)) {
requestToWorkspaceId.set(requestId, workspaceId);
} else if (models.webSocketRequest.isWebSocketRequest(request)) {
wsRequestToWorkspaceId.set(requestId, workspaceId);
} else if (models.socketIORequest.isSocketIORequest(request)) {
socketIORequestToWorkspaceId.set(requestId, workspaceId);
}
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.children.requestsAndGroups.push(request);
}
}
});
// Build map of requestGroupMetas by workspace ID
requestGroupMetas.forEach(requestGroupMeta => {
const workspaceId = requestGroupToWorkspaceId.get(requestGroupMeta.parentId);
if (workspaceId) {
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.childrenMetas.requestGroupMetas.push(requestGroupMeta);
}
}
});
allRequestMetas.forEach(requestMeta => {
const requestOrGrpcRequestId = requestMeta.parentId;
let workspaceId: string | undefined;
if (models.request.isRequestId(requestOrGrpcRequestId)) {
workspaceId = requestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.grpcRequest.isGrpcRequestId(requestOrGrpcRequestId)) {
workspaceId = grpcRequestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.webSocketRequest.isWebSocketRequestId(requestOrGrpcRequestId)) {
workspaceId = wsRequestToWorkspaceId.get(requestOrGrpcRequestId);
} else if (models.socketIORequest.isSocketIORequestId(requestOrGrpcRequestId)) {
workspaceId = socketIORequestToWorkspaceId.get(requestOrGrpcRequestId);
}
if (workspaceId) {
const workspaceData = allRequestsAndMetaByWorkspaceId.get(workspaceId);
if (workspaceData) {
workspaceData.childrenMetas.allRequestMetas.push(requestMeta);
}
}
});
return allRequestsAndMetaByWorkspaceId;
}
const getAllMockServerChildrenByWorkspaceIds = async (workspaceIds: string[]) => {
const map = new Map<string, MockServerWorkspaceChildren>();
if (workspaceIds.length > 0) {
const mockServers = await database.find<MockServer>(models.mockServer.type, {
parentId: { $in: workspaceIds },
});
workspaceIds.forEach(workspaceId => {
map.set(workspaceId, {
children: {
mockServer: mockServers.find(mockServer => mockServer.parentId === workspaceId),
},
childrenMetas: {},
});
});
}
return map;
};
const getAllDesignChildrenByWorkspaceIds = async (workspaceIds: string[]) => {
const map = new Map<string, DesignWorkspaceChildren>();
if (workspaceIds.length > 0) {
const apiSpecs = await database.find<ApiSpec>(models.apiSpec.type, {
parentId: { $in: workspaceIds },
});
workspaceIds.forEach(workspaceId => {
map.set(workspaceId, {
children: {
apiSpec: apiSpecs.find(apiSpec => apiSpec.parentId === workspaceId),
},
childrenMetas: {},
});
});
}
return map;
};
const getAllEnvironmentChildrenByWorkspaceIds = async (workspaceIds: string[]) => {
const map = new Map<string, EnvironmentWorkspaceChildren>();
if (workspaceIds.length > 0) {
const environments = await database.find<Environment>(models.environment.type, {
parentId: { $in: workspaceIds },
});
workspaceIds.forEach(workspaceId => {
map.set(workspaceId, {
children: {
baseEnvironment: environments.find(environment => environment.parentId === workspaceId),
},
childrenMetas: {},
});
});
}
return map;
};
const getAllMcpChildrenByWorkspaceIds = async (workspaceIds: string[]) => {
const map = new Map<string, McpWorkspaceChildren>();
if (workspaceIds.length > 0) {
const mcpRequests = await database.find<McpRequest>(models.mcpRequest.type, {
parentId: { $in: workspaceIds },
});
workspaceIds.forEach(workspaceId => {
map.set(workspaceId, {
children: {
mcpRequest: mcpRequests.find(mcpRequest => mcpRequest.parentId === workspaceId),
},
childrenMetas: {},
});
});
}
return map;
};
export const findWorkspaceIdsForChangedCollectionChildDoc = (queryClient: QueryClient, doc: BaseModel): string[] => {
const workspaceIds: string[] = [];
const cachedWorkspaces = queryClient.getQueriesData<WorkspaceChildren>({
queryKey: workspaceChildrenKeys.all,
});
for (const [queryKey, data] of cachedWorkspaces) {
const workspaceId = queryKey[1] as string;
if (!data || !data.children || !('requestsAndGroups' in data.children)) {
continue;
}
if (
doc.parentId === workspaceId ||
data.children.requestsAndGroups.some(r => r._id === doc._id || r._id === doc.parentId)
) {
workspaceIds.push(workspaceId);
}
}
return workspaceIds;
};
export function updateCollectionChildrenWithUpdatedDoc(
collectionChildren: CollectionWorkspaceChildren,
doc: BaseModel,
): CollectionWorkspaceChildren | null {
// ALL request doc types
const REQUEST_DOC_TYPES: string[] = [
models.request.type,
models.grpcRequest.type,
models.webSocketRequest.type,
models.socketIORequest.type,
models.requestGroup.type,
];
// All request meta doc types
const REQUEST_META_DOC_TYPES: string[] = [
models.requestMeta.type,
models.grpcRequestMeta.type,
models.webSocketRequestMeta.type,
models.socketIORequestMeta.type,
];
const replaceById = <T extends BaseModel>(list: T[]): T[] | null => {
const index = list.findIndex(item => item._id === doc._id);
if (index === -1) {
return null;
}
// The item has been moved to another parent, consider failure to update
if (list[index].parentId !== doc.parentId) {
return null;
}
const next = list.slice();
next[index] = doc as unknown as T;
return next;
};
if (REQUEST_DOC_TYPES.includes(doc.type)) {
const requestsAndGroups = replaceById(collectionChildren.children.requestsAndGroups);
return requestsAndGroups
? {
...collectionChildren,
children: {
requestsAndGroups,
},
}
: null;
}
if (REQUEST_META_DOC_TYPES.includes(doc.type)) {
const allRequestMetas = replaceById(collectionChildren.childrenMetas.allRequestMetas);
return allRequestMetas
? { ...collectionChildren, childrenMetas: { ...collectionChildren.childrenMetas, allRequestMetas } }
: null;
}
if (doc.type === models.requestGroupMeta.type) {
const requestGroupMetas = replaceById(collectionChildren.childrenMetas.requestGroupMetas);
return requestGroupMetas
? { ...collectionChildren, childrenMetas: { ...collectionChildren.childrenMetas, requestGroupMetas } }
: null;
}
return null;
}
const getWorkspaceChildren = async (
workspaceIds: string[],
scope?: WorkspaceScope,
): Promise<Map<string, WorkspaceChildren>> => {
if (scope) {
switch (scope) {
case 'design': {
const designChildrenByWorkspaceIds = await getAllDesignChildrenByWorkspaceIds(workspaceIds);
return designChildrenByWorkspaceIds;
}
case 'collection': {
const collectionChildrenByWorkspaceIds = await getAllCollectionChildrenAndMetasByWorkspaceIds(workspaceIds);
return collectionChildrenByWorkspaceIds;
}
case 'mock-server': {
const mockServerChildrenByWorkspaceIds = await getAllMockServerChildrenByWorkspaceIds(workspaceIds);
return mockServerChildrenByWorkspaceIds;
}
case 'environment': {
const environmentChildrenByWorkspaceIds = await getAllEnvironmentChildrenByWorkspaceIds(workspaceIds);
return environmentChildrenByWorkspaceIds;
}
case 'mcp': {
const mcpChildrenByWorkspaceIds = await getAllMcpChildrenByWorkspaceIds(workspaceIds);
return mcpChildrenByWorkspaceIds;
}
default: {
console.warn(`Unsupported workspace scope: ${scope}`);
return new Map();
}
}
}
const workspaces = await database.find<Workspace>(models.workspace.type, {
_id: { $in: workspaceIds },
});
if (workspaces.length > 0) {
const workspaceIdToScopeMap = new Map(workspaces.map(workspace => [workspace._id, workspace.scope]));
const collectionWorkspaceIds = workspaces.filter(w => w.scope === 'collection').map(w => w._id);
const mockServerWorkspaceIds = workspaces.filter(w => w.scope === 'mock-server').map(w => w._id);
const designWorkspaceIds = workspaces.filter(w => w.scope === 'design').map(w => w._id);
const environmentWorkspaceIds = workspaces.filter(w => w.scope === 'environment').map(w => w._id);
const mcpWorkspaceIds = workspaces.filter(w => w.scope === 'mcp').map(w => w._id);
const collectionChildrenPromise = getAllCollectionChildrenAndMetasByWorkspaceIds(collectionWorkspaceIds);
const mockServerChildrenPromise = getAllMockServerChildrenByWorkspaceIds(mockServerWorkspaceIds);
const designChildrenPromise = getAllDesignChildrenByWorkspaceIds(designWorkspaceIds);
const environmentChildrenPromise = getAllEnvironmentChildrenByWorkspaceIds(environmentWorkspaceIds);
const mcpChildrenPromise = getAllMcpChildrenByWorkspaceIds(mcpWorkspaceIds);
const [
collectionChildrenByWorkspaceIds,
mockServerChildrenByWorkspaceIds,
designChildrenByWorkspaceIds,
environmentChildrenByWorkspaceIds,
mcpChildrenByWorkspaceIds,
] = await Promise.all([
collectionChildrenPromise,
mockServerChildrenPromise,
designChildrenPromise,
environmentChildrenPromise,
mcpChildrenPromise,
]);
const map = new Map<string, WorkspaceChildren>();
workspaceIds.forEach(workspaceId => {
const scope = workspaceIdToScopeMap.get(workspaceId);
if (scope === 'collection') {
map.set(workspaceId, collectionChildrenByWorkspaceIds.get(workspaceId) ?? emptyWorkspaceChildren);
} else if (scope === 'mock-server') {
map.set(workspaceId, mockServerChildrenByWorkspaceIds.get(workspaceId) ?? emptyWorkspaceChildren);
} else if (scope === 'design') {
map.set(workspaceId, designChildrenByWorkspaceIds.get(workspaceId) ?? emptyWorkspaceChildren);
} else if (scope === 'environment') {
map.set(workspaceId, environmentChildrenByWorkspaceIds.get(workspaceId) ?? emptyWorkspaceChildren);
} else if (scope === 'mcp') {
map.set(workspaceId, mcpChildrenByWorkspaceIds.get(workspaceId) ?? emptyWorkspaceChildren);
} else {
console.warn(`Unsupported workspace scope: ${scope} for workspace ${workspaceId}`);
map.set(workspaceId, emptyWorkspaceChildren);
}
});
return map;
}
return new Map();
};
// Pending batches keyed by scope. Reads must only batch together when they share a scope: the batch runs a
// single `getWorkspaceChildren(ids, scope)` sweep, so mixing scopes (e.g. the sidebar's 'collection' with
// the project index's 'design'/'mock-server') would apply one caller's scope to every workspace in the batch.
const SCOPELESS_BATCH_KEY = '__all__';
const pendingBatches = new Map<string, { ids: string[]; batch: Promise<Map<string, WorkspaceChildren>> }>();
//Combine all per-workspace reads of the same scope requested within the same microtask into a single batched DB sweep
const loadWorkspaceChildrenBatched = (workspaceId: string, scope?: WorkspaceScope): Promise<WorkspaceChildren> => {
const scopeKey = scope ?? SCOPELESS_BATCH_KEY;
let pending = pendingBatches.get(scopeKey);
if (!pending) {
const ids: string[] = [];
const batch = Promise.resolve().then(() => {
pendingBatches.delete(scopeKey);
console.log(`Batching requests/meta load for workspaces (${scopeKey}): ${ids.join(', ')}`);
return getWorkspaceChildren(ids, scope);
});
pending = { ids, batch };
pendingBatches.set(scopeKey, pending);
}
pending.ids.push(workspaceId);
return pending.batch.then(map => map.get(workspaceId) ?? emptyWorkspaceChildren);
};
export const useWorkspaceChildren = (
workspaceId: string,
scope?: WorkspaceScope,
): UseQueryResult<WorkspaceChildren> => {
return useQuery({
queryKey: workspaceChildrenKeys.details(workspaceId),
queryFn: () => loadWorkspaceChildrenBatched(workspaceId, scope),
});
};
export const useWorkspaceChildrenByWorkspaceIds = (workspaceIds: string[], scope?: WorkspaceScope) =>
useQueries({
queries: workspaceIds.map(workspaceId => ({
queryKey: workspaceChildrenKeys.details(workspaceId),
queryFn: () => loadWorkspaceChildrenBatched(workspaceId, scope),
})),
combine: useCallback(
(results: UseQueryResult<WorkspaceChildren>[]) => {
const dataByWorkspaceId = new Map<string, WorkspaceChildren>();
workspaceIds.forEach((workspaceId, index) => {
const data = results[index]?.data;
if (data) {
dataByWorkspaceId.set(workspaceId, data);
}
});
return dataByWorkspaceId;
},
[workspaceIds],
),
});

View File

@@ -0,0 +1,125 @@
import { useQueries, useQuery, type UseQueryResult } from '@tanstack/react-query';
import type { Workspace, WorkspaceMeta } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { useCallback } from 'react';
import { parseApiSpec, type ParsedApiSpec } from '~/common/api-specs';
import { scopeToLabelMap } from '~/common/get-workspace-label';
import { isNotNullOrUndefined } from '~/common/misc';
import { type InsomniaFile } from '~/common/project';
import { descendingNumberSort } from '~/common/sorting';
import {
type DesignWorkspaceChildren,
type MockServerWorkspaceChildren,
useWorkspaceChildrenByWorkspaceIds,
} from './workspace-children';
export const workspaceKeys = {
all: ['workspaces'],
// all workspace details under a project
details: (projectId: string) => [...workspaceKeys.all, 'details', projectId],
// a single workspace detail, scoped under its parent project
detail: (workspaceId: string, projectId: string) => [...workspaceKeys.details(projectId), workspaceId],
};
export const getWorkspacesByProjectId = (projectId: string): Promise<Workspace[]> =>
services.workspace.findByParentId(projectId);
export const getWorkspaceById = async (workspaceId: string): Promise<Workspace | undefined> =>
await services.workspace.getById(workspaceId);
// All workspace details under a project.
export const useWorkspaces = (projectId: string): UseQueryResult<Workspace[]> =>
useQuery({
queryKey: workspaceKeys.details(projectId),
queryFn: () => getWorkspacesByProjectId(projectId),
});
export const useWorkspace = (workspaceId: string, projectId: string): UseQueryResult<Workspace | undefined> =>
useQuery({
queryKey: workspaceKeys.detail(projectId, workspaceId),
queryFn: () => getWorkspaceById(workspaceId),
});
export const useWorkspacesByProjectIds = (projectIds: string[]) =>
useQueries({
queries: projectIds.map(projectId => ({
queryKey: workspaceKeys.details(projectId),
queryFn: async () => await getWorkspacesByProjectId(projectId),
})),
combine: useCallback(
(results: UseQueryResult<Workspace[]>[]) => {
const map = new Map<string, Workspace[]>();
projectIds.forEach((projectId, index) => map.set(projectId, results[index]?.data ?? []));
return map;
},
[projectIds],
),
});
export const useLocalFiles = (projectId: string, workspaceMetas: WorkspaceMeta[]): InsomniaFile[] => {
const workspacesByProjectId = useWorkspacesByProjectIds([projectId]);
const workspaces = workspacesByProjectId.get(projectId) ?? [];
const designWorkspaceIds = workspaces
.filter(workspace => workspace.scope === 'design')
.map(workspace => workspace._id);
const mockServerWorkspaceIds = workspaces
.filter(workspace => workspace.scope === 'mock-server')
.map(workspace => workspace._id);
const designChildrenByWorkspaceId = useWorkspaceChildrenByWorkspaceIds(designWorkspaceIds, 'design');
const mockServerChildrenByWorkspaceId = useWorkspaceChildrenByWorkspaceIds(mockServerWorkspaceIds, 'mock-server');
return workspaces.map(workspace => {
const apiSpec = (designChildrenByWorkspaceId.get(workspace._id) as DesignWorkspaceChildren)?.children.apiSpec;
const mockServer = (mockServerChildrenByWorkspaceId.get(workspace._id) as MockServerWorkspaceChildren)?.children
.mockServer;
let spec: ParsedApiSpec['contents'] = null;
let specFormat: ParsedApiSpec['format'] = null;
let specFormatVersion: ParsedApiSpec['formatVersion'] = null;
if (apiSpec) {
try {
const result = parseApiSpec(apiSpec.contents);
spec = result.contents;
specFormat = result.format;
specFormatVersion = result.formatVersion;
} catch {
// Assume there is no spec
// TODO: Check for parse errors if it's an invalid spec
}
}
const workspaceMeta = workspaceMetas?.find(wm => wm.parentId === workspace._id);
// WorkspaceMeta is a good proxy for last modified time
const workspaceModified = workspaceMeta?.modified || workspace.modified;
const modifiedLocally = models.workspace.isDesign(workspace) ? apiSpec?.modified || 0 : workspaceModified;
// Span spec, workspace and sync related timestamps for card last modified label and sort order
const lastModifiedFrom = [workspace?.modified, workspaceMeta?.modified, modifiedLocally];
const lastModifiedTimestamp = lastModifiedFrom.filter(isNotNullOrUndefined).sort(descendingNumberSort)[0];
const specVersion = spec?.info?.version ? String(spec?.info?.version) : '';
return {
id: workspace._id,
name: workspace.name,
scope: workspace.scope,
label: scopeToLabelMap[workspace.scope],
created: workspace.created,
lastModifiedTimestamp: lastModifiedTimestamp,
branch: '',
lastCommit: '',
version: specVersion ? `${specVersion?.startsWith('v') ? '' : 'v'}${specVersion}` : '',
oasFormat: specFormat ? `${specFormat === 'openapi' ? 'OpenAPI' : 'Swagger'} ${specFormatVersion || ''}` : '',
mockServer,
apiSpec,
workspace,
hasUncommittedChanges: workspaceMeta?.hasUncommittedChanges,
hasUnpushedChanges: workspaceMeta?.hasUnpushedChanges,
gitFilePath: workspaceMeta?.gitFilePath,
};
});
};