From 6c8cd7ad8946bb234f5cc88c65155bac807e1a17 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Tue, 16 Jun 2026 14:47:08 +0800 Subject: [PATCH] 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 --- package-lock.json | 27 + packages/insomnia/package.json | 1 + packages/insomnia/src/root.tsx | 3 - ...ganizationId.project.$projectId._index.tsx | 22 +- ...ion.$organizationId.project.$projectId.tsx | 4 + packages/insomnia/src/routes/organization.tsx | 282 +++++----- .../ui/components/settings/import-export.tsx | 8 +- .../project-navigation-sidebar-utils.test.ts | 6 +- .../project-navigation-sidebar-utils.ts | 166 +----- .../project-navigation-sidebar.tsx | 285 ++++------ .../project-node.tsx | 36 +- .../project-navigation-sidebar/types.ts | 16 +- .../ui/context/app/insomnia-query-client.tsx | 22 + .../src/ui/hooks/data/db-changes-sync.ts | 114 ++++ .../insomnia/src/ui/hooks/data/projects.ts | 23 + .../src/ui/hooks/data/requests-and-metas.ts | 289 +++++++++++ .../src/ui/hooks/data/workspace-children.ts | 491 ++++++++++++++++++ .../insomnia/src/ui/hooks/data/workspaces.ts | 125 +++++ 18 files changed, 1401 insertions(+), 519 deletions(-) create mode 100644 packages/insomnia/src/ui/context/app/insomnia-query-client.tsx create mode 100644 packages/insomnia/src/ui/hooks/data/db-changes-sync.ts create mode 100644 packages/insomnia/src/ui/hooks/data/projects.ts create mode 100644 packages/insomnia/src/ui/hooks/data/requests-and-metas.ts create mode 100644 packages/insomnia/src/ui/hooks/data/workspace-children.ts create mode 100644 packages/insomnia/src/ui/hooks/data/workspaces.ts diff --git a/package-lock.json b/package-lock.json index 1586fbc912..9726d4a870 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 4849f4e394..b1110ac41a 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -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", diff --git a/packages/insomnia/src/root.tsx b/packages/insomnia/src/root.tsx index 5901083f33..6531573269 100644 --- a/packages/insomnia/src/root.tsx +++ b/packages/insomnia/src/root.tsx @@ -203,7 +203,6 @@ export const ErrorBoundary: FC = ({ 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, }; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index e2cb86d76c..a77f19b8fc 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -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(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(remoteFilesPromise, projectId); useEffect(() => { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index 2739783821..02c7de7210 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -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); diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index a242dcff7c..b9239c6cbb 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -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) => { -
-
-
-
-
- -
- {!isScratchPad && ( - { - window.main.trackAnalyticsEvent({ event: AnalyticsEvent.organizationSwitched }); - navigate(`/organization/${id}`); - }} - currentPlan={currentPlan} - isScratchpadWorkspace={!!isScratchpadWorkspace} - /> - )} + +
+
+
+
+
+ +
+ {!isScratchPad && ( + { + window.main.trackAnalyticsEvent({ event: AnalyticsEvent.organizationSwitched }); + navigate(`/organization/${id}`); + }} + currentPlan={currentPlan} + isScratchpadWorkspace={!!isScratchpadWorkspace} + /> + )} - {!user ? : null} + {!user ? : null} +
+ +
+ {user ? ( + + ) : ( + + )} +
+
+
+ + +
- -
- {user ? ( - - ) : ( - - )} -
-
-
- - - -
-
-
-
- - { - setIsSidebarCollapsed(!value); - }} - isSelected={!isSidebarCollapsed} - > - {({ isSelected }) => { - return ( - - {isSelected ? ( - - ) : ( - - )} - - ); - }} - - - Toggle sidebar - - - - - Toggle header - - - - - - Preferences - - - - {!isScratchpadWorkspace && hasUntrackedData && ( - - - We have detected orphaned projects on your computer, click here to view them. + Preferences + - )} -
-
-
- + {!isScratchpadWorkspace && hasUntrackedData && ( + + + + We have detected orphaned projects on your computer, click here to view them. + + + )} +
+
-
+
diff --git a/packages/insomnia/src/ui/components/settings/import-export.tsx b/packages/insomnia/src/ui/components/settings/import-export.tsx index 7e24142ab3..058786d8ad 100644 --- a/packages/insomnia/src/ui/components/settings/import-export.tsx +++ b/packages/insomnia/src/ui/components/settings/import-export.tsx @@ -659,8 +659,14 @@ export const ImportExport: FC = ({ 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 ( diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.test.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.test.ts index b95321c4b2..ec067276b5 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.test.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.test.ts @@ -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'; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts index be6383c9f3..688e838775 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts @@ -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(models.workspace.type, { - parentId: { $in: projectIds }, - }); - const workspacesByProjectId = new Map(); - projectIds.forEach(projectId => { - workspacesByProjectId.set(projectId, workspaces.filter(w => w.parentId === projectId) || []); - }); - return workspacesByProjectId; -} - -export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) { - const allRequestsAndMetaByWorkspaceId = new Map(); - let requestGroupIdQueue = [...workspaceIds]; - const allRequestGroups: RequestGroup[] = []; - // Map to track which workspace each request group belongs to - const requestGroupToWorkspaceId = new Map(); - const requestToWorkspaceId = new Map(); - const grpcRequestToWorkspaceId = new Map(); - const wsRequestToWorkspaceId = new Map(); - const socketIORequestToWorkspaceId = new Map(); - // 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(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(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(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }), - database.find(models.grpcRequestMeta.type, { - parentId: { $in: grpcReqs.map(r => r._id) }, - }), - database.find(models.requestGroupMeta.type, { - parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) }, - }), - database.find(models.webSocketRequestMeta.type, { - parentId: { $in: wsReqs.map(r => r._id) }, - }), - database.find(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(); + const requestsByParentId = new Map(); + 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() diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index fbc1bf0b8d..50aef98daf 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -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>( - projectsSyncStatusPromise, - organizationId, - ); - const { presence } = useInsomniaEventStreamContext(); + const { settings } = useRootLoaderData()!; + const [searchParams, _setSearchParams] = useSearchParams(); const tabNavigate = useTabNavigate(); const [collectionSortOrders, setCollectionSortOrders] = useState>({}); - const [flatItems, setFlatItems] = useState([]); const [projectWorkspaceSortOrder, setProjectWorkspaceSortOrder] = useState>({}); const [unsyncedFilesByProjectId, setUnsyncedFilesByProjectId] = useState>(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>(new Map()); - // ref to cache queried collection children (request & requestGroups) data and meta by workspace id - const cachedCollectionChildrenAndMetaRef = useRef>(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>(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(() => { + 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(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(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') { diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx index de172e8021..d45e8586b6 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx @@ -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>( + 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 (
{projectName}
- {presence.length > 0 && } + {projectPresence.length > 0 && }{' '} {projectId !== models.project.SCRATCHPAD_PROJECT_ID && ( { organizationId: string; } -export interface ProjectFlatItem extends BaseFlatItem { +export interface ProjectFlatItem extends BaseFlatItem { kind: 'project'; } export interface WorkspaceFlatItem extends BaseFlatItem { kind: 'workspace'; // parent project - project: ProjectWithPresence; + project: ProjectWithGitRepository; } // Unsynced workspace in cloud sync project @@ -50,7 +44,7 @@ export type UnsyncedWorkspaceFlatItem = Omit, 'doc'> & export interface CollectionChildFlatItem extends BaseFlatItem { 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; diff --git a/packages/insomnia/src/ui/context/app/insomnia-query-client.tsx b/packages/insomnia/src/ui/context/app/insomnia-query-client.tsx new file mode 100644 index 0000000000..08a9a4c69c --- /dev/null +++ b/packages/insomnia/src/ui/context/app/insomnia-query-client.tsx @@ -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 {children}; +}; diff --git a/packages/insomnia/src/ui/hooks/data/db-changes-sync.ts b/packages/insomnia/src/ui/hooks/data/db-changes-sync.ts new file mode 100644 index 0000000000..00fc700ac9 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/db-changes-sync.ts @@ -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 organization’s 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 project’s 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( + 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; +}; diff --git a/packages/insomnia/src/ui/hooks/data/projects.ts b/packages/insomnia/src/ui/hooks/data/projects.ts new file mode 100644 index 0000000000..90ac854402 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/projects.ts @@ -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 => + models.project.sortProjects(await getProjectsWithGitRepositories({ organizationId })); + +export const useProjects = (organizationId: string): UseQueryResult => { + return useQuery({ + queryKey: projectKeys.details(organizationId), + queryFn: () => getProjects(organizationId), + }); +}; diff --git a/packages/insomnia/src/ui/hooks/data/requests-and-metas.ts b/packages/insomnia/src/ui/hooks/data/requests-and-metas.ts new file mode 100644 index 0000000000..3fb0389aa3 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/requests-and-metas.ts @@ -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({ + 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 = (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> { + const allRequestsAndMetaByWorkspaceId = new Map(); + let requestGroupIdQueue = [...workspaceIds]; + const allRequestGroups: RequestGroup[] = []; + // Map to track which workspace each request group belongs to + const requestGroupToWorkspaceId = new Map(); + const requestToWorkspaceId = new Map(); + const grpcRequestToWorkspaceId = new Map(); + const wsRequestToWorkspaceId = new Map(); + const socketIORequestToWorkspaceId = new Map(); + // 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(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(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(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }), + database.find(models.grpcRequestMeta.type, { + parentId: { $in: grpcReqs.map(r => r._id) }, + }), + database.find(models.requestGroupMeta.type, { + parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) }, + }), + database.find(models.webSocketRequestMeta.type, { + parentId: { $in: wsReqs.map(r => r._id) }, + }), + database.find(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 => { + const result = await getAllRequestsAndMetaByWorkspace([workspaceId]); + return result.get(workspaceId) ?? EMPTY_REQUESTS_AND_META; +}; + +let pendingWorkspaceIds: string[] = []; +let pendingBatch: Promise> | null = null; + +// Combine all per-workspace reads requested within the same microtask into a single batched DB sweep +const loadRequestsAndMetaBatched = (workspaceId: string): Promise => { + 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 => { + 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[]) => { + const dataByWorkspaceId = new Map(); + workspaceIds.forEach((workspaceId, index) => { + const data = results[index]?.data; + if (data) { + dataByWorkspaceId.set(workspaceId, data); + } + }); + return dataByWorkspaceId; + }, + [workspaceIds], + ), + }); diff --git a/packages/insomnia/src/ui/hooks/data/workspace-children.ts b/packages/insomnia/src/ui/hooks/data/workspace-children.ts new file mode 100644 index 0000000000..b0bc36ffc3 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/workspace-children.ts @@ -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 { + 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> { + const allRequestsAndMetaByWorkspaceId = new Map(); + 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(); + const requestToWorkspaceId = new Map(); + const grpcRequestToWorkspaceId = new Map(); + const wsRequestToWorkspaceId = new Map(); + const socketIORequestToWorkspaceId = new Map(); + // 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(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(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(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }), + database.find(models.grpcRequestMeta.type, { + parentId: { $in: grpcReqs.map(r => r._id) }, + }), + database.find(models.requestGroupMeta.type, { + parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) }, + }), + database.find(models.webSocketRequestMeta.type, { + parentId: { $in: wsReqs.map(r => r._id) }, + }), + database.find(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(); + if (workspaceIds.length > 0) { + const mockServers = await database.find(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(); + if (workspaceIds.length > 0) { + const apiSpecs = await database.find(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(); + if (workspaceIds.length > 0) { + const environments = await database.find(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(); + if (workspaceIds.length > 0) { + const mcpRequests = await database.find(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({ + 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 = (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> => { + 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(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(); + 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> }>(); + +//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 => { + 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 => { + 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[]) => { + const dataByWorkspaceId = new Map(); + workspaceIds.forEach((workspaceId, index) => { + const data = results[index]?.data; + if (data) { + dataByWorkspaceId.set(workspaceId, data); + } + }); + return dataByWorkspaceId; + }, + [workspaceIds], + ), + }); diff --git a/packages/insomnia/src/ui/hooks/data/workspaces.ts b/packages/insomnia/src/ui/hooks/data/workspaces.ts new file mode 100644 index 0000000000..e174740101 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/data/workspaces.ts @@ -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 => + services.workspace.findByParentId(projectId); + +export const getWorkspaceById = async (workspaceId: string): Promise => + await services.workspace.getById(workspaceId); + +// All workspace details under a project. +export const useWorkspaces = (projectId: string): UseQueryResult => + useQuery({ + queryKey: workspaceKeys.details(projectId), + queryFn: () => getWorkspacesByProjectId(projectId), + }); +export const useWorkspace = (workspaceId: string, projectId: string): UseQueryResult => + 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[]) => { + const map = new Map(); + 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, + }; + }); +};