feat: auto detect select resource then expand and scroll to

This commit is contained in:
Bingbing
2026-04-20 17:17:15 +08:00
parent ae8731c5b2
commit 8f4e21e665
7 changed files with 860 additions and 396 deletions

View File

@@ -6,7 +6,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router';
import * as reactUse from 'react-use';
import { fuzzyMatchAll } from '~/common/misc';
import type { RequestGroup, Workspace } from '~/insomnia-data';
import type { Workspace } from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import type { SyncResult } from '~/konnect/sync';
import { useRootLoaderData } from '~/root';
@@ -32,6 +32,7 @@ import {
import { ProjectNode } from './project-node';
import { RequestNode } from './request-node';
import type { FlatItem } from './types';
import { useProjectNavigationSidebarNavigation } from './use-project-navigation-sidebar-navigation';
import { useSidebarDragAndDrop } from './use-sidebar-drag-and-drop';
import { WorkspaceNode } from './workspace-node';
@@ -367,53 +368,81 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
[expandedProjectAndWorkspaceIds, projectNavigationSidebarFilter, setExpandedProjectAndWorkspaceIds],
);
const toggleRequestGroup = useCallback(
async (requestGroup: RequestGroup) => {
// Do not update toggle state if there is an active filter
if (!projectNavigationSidebarFilter) {
const requestGroupId = requestGroup._id;
const requestGroupMeta = await services.requestGroupMeta.getByParentId(requestGroupId);
const newCollapsed = requestGroupMeta ? !requestGroupMeta.collapsed : false;
await services.requestGroupMeta.updateOrCreateForParentId(requestGroupId!, { collapsed: newCollapsed });
const toggleItemChildrenIds: string[] = [];
setFlatItems(prev => {
return prev.map(item => {
if (item.kind === 'collectionChild') {
const { children, doc } = item;
if (doc._id === requestGroupId) {
// Update the toggle item first and get its children ids
toggleItemChildrenIds.push(...(children?.map(c => c.doc._id) ?? []));
return {
...item,
collapsed: newCollapsed,
hidden: false,
};
// recursively hide all children of the toggle item
} else if (toggleItemChildrenIds.includes(doc._id)) {
toggleItemChildrenIds.push(...(children?.map(c => c.doc._id) ?? []));
return {
...item,
hidden: newCollapsed,
};
}
const toggleRequestGroups = useCallback(
async (requestGroupIds: string[], collapsed?: boolean) => {
if (requestGroupIds.length === 0) {
return;
}
if (projectNavigationSidebarFilter && collapsed === undefined) {
return;
}
const requestGroupMetas = await Promise.all(
requestGroupIds.map(requestGroupId => services.requestGroupMeta.getByParentId(requestGroupId)),
);
const nextStates = requestGroupIds.map((requestGroupId, index) => {
const requestGroupMeta = requestGroupMetas[index];
return {
requestGroupId,
collapsed: collapsed ?? (requestGroupMeta ? !requestGroupMeta.collapsed : false),
};
});
await Promise.all(
nextStates.map(({ requestGroupId, collapsed }) =>
services.requestGroupMeta.updateOrCreateForParentId(requestGroupId, { collapsed }),
),
);
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 toggledChildrenIds: string[] = [];
return nextFlatItems.map(item => {
if (item.kind !== 'collectionChild') {
return item;
}
const { children, doc } = item;
if (doc._id === requestGroupId) {
toggledChildrenIds.push(...(children?.map(child => child.doc._id) ?? []));
return {
...item,
collapsed,
hidden: false,
};
}
if (toggledChildrenIds.includes(doc._id)) {
toggledChildrenIds.push(...(children?.map(child => child.doc._id) ?? []));
return {
...item,
hidden: collapsed,
};
}
return item;
});
});
}
}, previousFlatItems),
);
},
[projectNavigationSidebarFilter],
);
// Derive selected key from current route
const selectedKeys = useMemo(() => {
if (activeRequestGroupId) return [activeRequestGroupId];
if (activeRequestId) return [activeRequestId];
if (activeWorkspaceId) return [activeWorkspaceId];
if (activeProjectId) return [activeProjectId];
return [];
}, [activeRequestGroupId, activeRequestId, activeWorkspaceId, activeProjectId]);
const parentRef = useRef<HTMLDivElement>(null);
const visibleFlatItems = useMemo(() => flatItems.filter(i => !i.hidden), [flatItems]);
const virtualizer = useVirtualizer({
@@ -428,6 +457,16 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
organizationId,
virtualizer,
});
const { selectedItemId, routeInfo } = useProjectNavigationSidebarNavigation({
setExpandedProjectAndWorkspaceIds,
toggleRequestGroups,
visibleFlatItems,
virtualizer,
});
const selectedKeys =
selectedItemId && visibleFlatItems.findIndex(item => item.doc._id === selectedItemId) !== -1
? [selectedItemId]
: [];
return (
<div className="flex flex-1 flex-col overflow-hidden">
@@ -537,32 +576,43 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
const { doc } = item;
const docId = doc._id;
if (item.kind === 'project') {
toggleProjectOrWorkspace(docId);
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
} else if (item.kind === 'workspace') {
toggleProjectOrWorkspace(docId);
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.doc,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
} else if (item.kind === 'collectionChild') {
if (models.requestGroup.isRequestGroup(doc)) {
toggleRequestGroup(doc);
if (routeInfo?.resourceId === docId) {
toggleProjectOrWorkspace(docId);
} else {
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
}
} else if (item.kind === 'workspace') {
if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') {
toggleProjectOrWorkspace(docId);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.doc,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
} else if (item.kind === 'collectionChild') {
if (
routeInfo?.resourceId === docId &&
models.requestGroup.isRequestGroupId(docId) &&
routeInfo?.routeId !== 'runner'
) {
toggleRequestGroups([docId]);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
}}
className="group outline-hidden select-none"
@@ -581,7 +631,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
{item.kind === 'workspace' && <WorkspaceNode item={item} onToggle={toggleProjectOrWorkspace} />}
{item.kind === 'collectionChild' && <RequestNode item={item} onToggleFolder={toggleRequestGroup} />}
{item.kind === 'collectionChild' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
</GridListItem>
);
}}

View File

@@ -70,7 +70,7 @@ function MethodBadge({ doc }: { doc: Request | WebSocketRequest | GrpcRequest |
interface RequestNodeProps {
item: CollectionChildFlatItem;
onToggleFolder: (requestGroup: RequestGroup) => void;
onToggleFolder: (requestGroupIds: string[]) => void;
}
export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => {
@@ -94,7 +94,7 @@ export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => {
<span className={ACTIVE_BORDER_CLASS} />
<Button
aria-label={`${collapsed ? 'Expand' : 'Collapse'} ${doc.name}`}
onPress={() => isFolder && onToggleFolder(doc)}
onPress={() => isFolder && onToggleFolder([doc._id])}
className={TOGGLE_BTN_CLASS}
>
{isFolder ? <Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} className={ICON_CLASS} /> : null}

View File

@@ -0,0 +1,133 @@
import type { Virtualizer } from '@tanstack/react-virtual';
import type { Dispatch, SetStateAction } from 'react';
import { useEffect, useRef, useState } from 'react';
import { database, models } from '~/insomnia-data';
import type { NavigationResources } from '~/ui/hooks/use-insomnia-navigation';
import { useInsomniaNavigation } from '~/ui/hooks/use-insomnia-navigation';
import type { FlatItem } from './types';
const getSelectedItemId = (resources?: NavigationResources) => {
if (!resources?.project) {
return null;
}
if (!resources.workspace) {
return resources.project._id;
}
if (!models.workspace.isCollection(resources.workspace)) {
return resources.workspace._id;
}
return resources.resource?._id || null;
};
export const useProjectNavigationSidebarNavigation = ({
setExpandedProjectAndWorkspaceIds,
toggleRequestGroups,
visibleFlatItems,
virtualizer,
}: {
setExpandedProjectAndWorkspaceIds: Dispatch<SetStateAction<string[] | undefined>>;
toggleRequestGroups: (requestGroupIds: string[], collapsed?: boolean) => Promise<void>;
visibleFlatItems: FlatItem[];
virtualizer: Virtualizer<HTMLDivElement, Element>;
}) => {
const { scrollToIndex } = virtualizer;
const { routeInfo, getNavigationResources } = useInsomniaNavigation();
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const navigationKey = routeInfo ? `${routeInfo.routeId}:${routeInfo.resourceId}` : 'unknown-route';
const lastHandledNavigationKeyRef = useRef<string | null>(null);
const lastHandledScrollKeyRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!routeInfo) {
lastHandledNavigationKeyRef.current = null;
setSelectedItemId(null);
return;
}
if (lastHandledNavigationKeyRef.current === navigationKey) {
return;
}
lastHandledNavigationKeyRef.current = navigationKey;
getNavigationResources().then(async resources => {
if (cancelled) {
return;
}
const nextSelectedItemId = getSelectedItemId(resources);
setSelectedItemId(nextSelectedItemId);
if (!resources?.project || !nextSelectedItemId) {
return;
}
const idsToExpand = [resources.project._id];
if (resources.workspace && models.workspace.isCollection(resources.workspace)) {
idsToExpand.push(resources.workspace._id);
}
if (
resources.resource &&
resources.workspace &&
!models.workspace.isWorkspace(resources.resource) &&
models.workspace.isCollection(resources.workspace)
) {
const requestGroupIds = (await database.withAncestors(resources.resource))
.reverse()
.filter(models.requestGroup.isRequestGroup)
.map(requestGroup => requestGroup._id);
if (cancelled) {
return;
}
idsToExpand.push(...requestGroupIds);
if (requestGroupIds.length > 0) {
await toggleRequestGroups(requestGroupIds, false);
}
}
setExpandedProjectAndWorkspaceIds(previousExpandedIds => {
const expandedIds = previousExpandedIds || [];
const missingIds = idsToExpand.filter(id => !expandedIds.includes(id));
return missingIds.length > 0 ? [...expandedIds, ...missingIds] : expandedIds;
});
});
return () => {
cancelled = true;
};
}, [getNavigationResources, navigationKey, routeInfo, setExpandedProjectAndWorkspaceIds, toggleRequestGroups]);
useEffect(() => {
if (!selectedItemId) {
lastHandledScrollKeyRef.current = null;
return;
}
if (lastHandledScrollKeyRef.current === selectedItemId) {
return;
}
const targetIndex = visibleFlatItems.findIndex(item => item.doc._id === selectedItemId);
if (targetIndex !== -1) {
scrollToIndex(targetIndex, { align: 'auto' });
lastHandledScrollKeyRef.current = selectedItemId;
}
}, [scrollToIndex, selectedItemId, visibleFlatItems]);
return {
selectedItemId,
routeInfo,
};
};

View File

@@ -6,7 +6,7 @@ import { useRequestLoaderData } from '~/routes/organization.$organizationId.proj
import { useRequestGroupLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request-group.$requestGroupId';
import type { PaneBreadcrumb } from '~/ui/components/pane-header';
import { ResourceIcon } from '~/ui/components/workspace/resource-icon';
import { buildResourceUrl } from '~/ui/hooks/use-insomnia-tab';
import { buildResourceUrl } from '~/ui/hooks/use-insomnia-navigation';
export function useWorkspaceBreadcrumbs({ isMcp }: { isMcp: boolean }) {
const { activeWorkspace, activeProject, collection } = useWorkspaceLoaderData()!;

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest';
import type { InsomniaNavigationRouteInfo, NavigationResource } from './use-insomnia-navigation';
import { buildResourceUrl, extractNavigationRouteFromUrl } from './use-insomnia-navigation';
interface NavigationCase {
title: string;
resource: NavigationResource;
route: Omit<InsomniaNavigationRouteInfo, 'searchParams'> & {
pathname: string;
};
}
const navigationCases: readonly NavigationCase[] = [
{
title: 'project routes',
resource: {
_id: 'proj_1',
type: 'Project',
},
route: {
pathname: '/organization/org_1/project/proj_1',
routeId: 'project',
resourceId: 'proj_1',
organizationId: 'org_1',
projectId: 'proj_1',
workspaceId: undefined,
},
},
{
title: 'mock server routes',
resource: {
_id: 'mock_1',
parentId: 'wrk_1',
type: 'MockServer',
},
route: {
pathname: '/organization/org_1/project/proj_1/workspace/wrk_1/mock-server',
routeId: 'workspace:mock-server',
resourceId: 'wrk_1',
organizationId: 'org_1',
projectId: 'proj_1',
workspaceId: 'wrk_1',
},
},
{
title: 'mcp workspace routes',
resource: {
_id: 'wrk_1',
type: 'Workspace',
scope: 'mcp',
},
route: {
pathname: '/organization/org_1/project/proj_1/workspace/wrk_1/mcp',
routeId: 'workspace:mcp',
resourceId: 'wrk_1',
organizationId: 'org_1',
projectId: 'proj_1',
workspaceId: 'wrk_1',
},
},
{
title: 'grpc request routes',
resource: {
_id: 'greq_1',
type: 'GrpcRequest',
},
route: {
pathname: '/organization/org_1/project/proj_1/workspace/wrk_1/debug/request/greq_1',
routeId: 'request',
resourceId: 'greq_1',
organizationId: 'org_1',
projectId: 'proj_1',
workspaceId: 'wrk_1',
},
},
{
title: 'unit test suite routes',
resource: {
_id: 'uts_1',
type: 'UnitTestSuite',
},
route: {
pathname: '/organization/org_1/project/proj_1/workspace/wrk_1/test/test-suite/uts_1',
routeId: 'unit-test-suite',
resourceId: 'uts_1',
organizationId: 'org_1',
projectId: 'proj_1',
workspaceId: 'wrk_1',
},
},
{
title: 'project index routes',
resource: undefined,
route: {
pathname: '/organization/org_1/project',
routeId: 'project:index',
organizationId: 'org_1',
projectId: undefined,
workspaceId: undefined,
resourceId: undefined,
},
},
];
const attachSearchParams = (
routeInfo: Omit<InsomniaNavigationRouteInfo, 'searchParams'>,
searchParams: URLSearchParams,
): InsomniaNavigationRouteInfo => ({
...routeInfo,
searchParams,
});
const buildInputFromCase = ({ resource, route }: NavigationCase) => ({
organizationId: route.organizationId,
projectId: route.projectId,
workspaceId: route.workspaceId,
resource,
});
describe('extractNavigationRouteFromUrl', () => {
it.each(navigationCases)('parses $title', ({ route }) => {
const searchParams = new URLSearchParams();
const { pathname, ...routeInfo } = route;
expect(extractNavigationRouteFromUrl(pathname, searchParams)).toEqual(attachSearchParams(routeInfo, searchParams));
});
});
describe('buildResourceUrl', () => {
it.each(navigationCases)('builds $title', testCase => {
expect(buildResourceUrl(buildInputFromCase(testCase))).toBe(testCase.route.pathname);
});
});

View File

@@ -0,0 +1,369 @@
import { useCallback, useMemo } from 'react';
import { href, matchPath, useLocation, useSearchParams } from 'react-router';
import type {
GrpcRequest,
McpRequest,
MockRoute,
MockServer,
Project,
Request,
RequestGroup,
SocketIORequest,
UnitTest,
UnitTestSuite,
WebSocketRequest,
Workspace,
} from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import * as requestOperations from '~/models/helpers/request-operations';
export type NavigationResource =
| undefined
| Project
| Workspace
| RequestGroup
| Request
| GrpcRequest
| WebSocketRequest
| SocketIORequest
| McpRequest
| MockServer
| MockRoute
| UnitTest
| UnitTestSuite;
const NAVIGATION_ROUTES = [
{
id: 'request-group',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request-group/:requestGroupId',
getResourceId: params => params.requestGroupId,
getResource: (resourceId: string) => services.requestGroup.getById(resourceId),
},
{
id: 'request',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId',
getResourceId: params => params.requestId,
getResource: (resourceId: string) => requestOperations.getById(resourceId),
},
{
id: 'runner',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/runner',
getResourceId: (params, searchParams) => searchParams.get('folder') || params.workspaceId,
getResource: (resourceId: string) =>
models.requestGroup.isRequestGroupId(resourceId)
? services.requestGroup.getById(resourceId)
: services.workspace.getById(resourceId),
},
{
id: 'mock-route',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/mock-server/mock-route/:mockRouteId',
getResourceId: params => params.mockRouteId,
getResource: (resourceId: string) => services.mockRoute.getById(resourceId),
},
{
id: 'unit-test-suite',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/test/test-suite/:testSuiteId',
end: false,
getResourceId: params => params.testSuiteId,
getResource: (resourceId: string) => services.unitTestSuite.getById(resourceId),
},
{
id: 'unit-test',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/test',
getResourceId: params => params.workspaceId,
getResource: (resourceId: string) => services.workspace.getById(resourceId),
},
{
id: 'workspace:mcp',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/mcp',
getResourceId: params => params.workspaceId,
getResource: (resourceId: string) => services.workspace.getById(resourceId),
},
{
id: 'workspace:mock-server',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/mock-server',
getResourceId: params => params.workspaceId,
getResource: (resourceId: string) => services.workspace.getById(resourceId),
},
{
id: 'workspace:environment',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/environment',
getResourceId: params => params.workspaceId,
getResource: (resourceId: string) => services.workspace.getById(resourceId),
},
{
id: 'workspace:design',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/spec',
getResourceId: params => params.workspaceId,
getResource: (resourceId: string) => services.workspace.getById(resourceId),
},
{
id: 'workspace:collection',
path: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug',
getResourceId: params => params.workspaceId,
getResource: (resourceId: string) => services.workspace.getById(resourceId),
},
{
id: 'project',
path: '/organization/:organizationId/project/:projectId',
getResourceId: params => params.projectId,
getResource: (resourceId: string) => services.project.getById(resourceId),
},
{
id: 'project:index',
path: '/organization/:organizationId/project',
},
] as const satisfies {
id: string;
path: Parameters<typeof href>[0];
end?: boolean;
getResourceId?: (params: Record<string, string | undefined>, searchParams: URLSearchParams) => string | undefined;
getResource?: (resourceId: string) => Promise<NavigationResource>;
}[];
type NavigationRoute = (typeof NAVIGATION_ROUTES)[number];
type NavigationRouteId = NavigationRoute['id'];
export interface InsomniaNavigationRouteInfo {
routeId: NavigationRouteId;
organizationId: string;
projectId?: string;
workspaceId?: string;
resourceId?: string;
searchParams: URLSearchParams;
}
export interface NavigationResources {
project?: Project;
workspace?: Workspace;
resource?: NavigationResource;
}
const buildSearchString = (searchParams?: URLSearchParams) => {
const search = searchParams?.toString() || '';
return search ? `?${search}` : '';
};
const getRoute = (routeId: NavigationRoute['id']) => {
const route = NAVIGATION_ROUTES.find(route => route.id === routeId);
if (!route) {
return NAVIGATION_ROUTES[NAVIGATION_ROUTES.length - 1]; // Default to project:index route
}
return route;
};
const getMatchedRoute = (pathname: string, searchParams: URLSearchParams) => {
for (const route of NAVIGATION_ROUTES) {
const match = matchPath({ path: route.path, end: 'end' in route ? route.end : true }, pathname);
if (!match) {
continue;
}
const resourceId = 'getResourceId' in route ? route.getResourceId(match.params, searchParams) : undefined;
return {
route,
params: match.params,
resourceId,
};
}
return null;
};
const getRouteIdForResource = (resource: NavigationResource): NavigationRoute['id'] => {
if (!resource) return 'project:index';
if (models.project.isProject(resource)) return 'project';
if (models.mockServer.isMockServer(resource)) return 'workspace:mock-server';
if (models.request.isRequest(resource)) return 'request';
if (models.grpcRequest.isGrpcRequest(resource)) return 'request';
if (models.webSocketRequest.isWebSocketRequest(resource)) return 'request';
if (models.socketIORequest.isSocketIORequest(resource)) return 'request';
if (models.mcpRequest.isMcpRequest(resource)) return 'request';
if (models.requestGroup.isRequestGroup(resource)) return 'request-group';
if (models.mockRoute.isMockRoute(resource)) return 'mock-route';
if (models.unitTest.isUnitTest(resource)) return 'unit-test';
if (models.unitTestSuite.isUnitTestSuite(resource)) return 'unit-test-suite';
if (models.workspace.isWorkspace(resource)) {
if (models.workspace.isDesign(resource)) return 'workspace:design';
if (models.workspace.isEnvironment(resource)) return 'workspace:environment';
if (models.workspace.isMockServer(resource)) return 'workspace:mock-server';
if (models.workspace.isMcp(resource)) return 'workspace:mcp';
return 'workspace:collection';
}
return 'workspace:collection';
};
export function buildResourceUrl({
organizationId,
projectId,
workspaceId,
resource,
searchParams,
}: {
organizationId: string;
projectId?: string;
workspaceId?: string;
resource: NavigationResource;
searchParams?: URLSearchParams;
}) {
const routeId = getRouteIdForResource(resource);
const route = getRoute(routeId);
let url = '';
switch (routeId) {
case 'project:index': {
url = href(route.path, {
organizationId,
});
break;
}
case 'project': {
if (!projectId && !resource) {
return '';
}
url = href(route.path, {
organizationId,
projectId: projectId || resource!._id,
});
break;
}
case 'workspace:collection':
case 'workspace:design':
case 'workspace:environment':
case 'workspace:mock-server':
case 'workspace:mcp': {
const resolvedWorkspaceId =
workspaceId ||
(resource && models.workspace.isWorkspace(resource)
? resource._id
: resource && models.mockServer.isMockServer(resource)
? resource.parentId
: undefined);
if (!projectId || !resolvedWorkspaceId) {
return '';
}
url = href(route.path, {
organizationId,
projectId,
workspaceId: resolvedWorkspaceId,
});
break;
}
case 'request-group': {
if (!projectId || !workspaceId || !resource) {
return '';
}
url = href(route.path, {
organizationId,
projectId,
workspaceId,
requestGroupId: resource!._id,
});
break;
}
case 'request': {
if (!projectId || !workspaceId || !resource) {
return '';
}
url = href(route.path, {
organizationId,
projectId,
workspaceId,
requestId: resource!._id,
});
break;
}
case 'mock-route': {
if (!projectId || !workspaceId || !resource) {
return '';
}
url = href(route.path, {
organizationId,
projectId,
workspaceId,
mockRouteId: resource!._id,
});
break;
}
case 'unit-test-suite': {
if (!projectId || !workspaceId || !resource) {
return '';
}
url = href(route.path, {
organizationId,
projectId,
workspaceId,
testSuiteId: resource!._id,
});
break;
}
default: {
return '';
}
}
return `${url}${buildSearchString(searchParams)}`;
}
export const extractNavigationRouteFromUrl = (pathname: string, searchParams: URLSearchParams) => {
const matchedRoute = getMatchedRoute(pathname, searchParams);
if (!matchedRoute) {
return null;
}
const { params, resourceId, route } = matchedRoute;
if (!params.organizationId) {
return null;
}
return {
routeId: route.id,
organizationId: params.organizationId,
projectId: params.projectId,
workspaceId: params.workspaceId,
resourceId,
searchParams,
} satisfies InsomniaNavigationRouteInfo;
};
const getRouteResource = async (routeInfo: InsomniaNavigationRouteInfo): Promise<NavigationResource> => {
const route = getRoute(routeInfo.routeId);
return 'getResource' in route ? await route.getResource(routeInfo.resourceId!) : undefined;
};
export const getNavigationResources = async (
routeInfo?: InsomniaNavigationRouteInfo | null,
): Promise<NavigationResources> => {
if (!routeInfo) return {};
return {
project: routeInfo.projectId ? await services.project.getById(routeInfo.projectId) : undefined,
workspace: routeInfo.workspaceId ? await services.workspace.getById(routeInfo.workspaceId) : undefined,
resource: routeInfo.resourceId ? await getRouteResource(routeInfo) : undefined,
};
};
export const useInsomniaNavigation = () => {
const location = useLocation();
const [searchParams] = useSearchParams();
const routeInfo = useMemo(
() => extractNavigationRouteFromUrl(location.pathname, searchParams),
[location.pathname, searchParams],
);
const getNavigationResourcesFromRoute = useCallback(() => getNavigationResources(routeInfo), [routeInfo]);
return {
routeInfo,
getNavigationResources: getNavigationResourcesFromRoute,
};
};

View File

@@ -1,29 +1,22 @@
import type { Organization } from 'insomnia-api';
import { useCallback, useEffect, useMemo } from 'react';
import { href, matchPath, useLocation, useNavigate, useSearchParams } from 'react-router';
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { database } from '~/common/database';
import type {
GrpcRequest,
McpRequest,
MockRoute,
MockServer,
Project,
Request,
RequestGroup,
SocketIORequest,
UnitTestSuite,
WebSocketRequest,
Workspace,
} from '~/insomnia-data';
import type { Project, Request, Workspace } from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import * as requestOperations from '~/models/helpers/request-operations';
import { formatMethodName, getRequestMethodShortHand } from '~/ui/components/tags/method-tag';
import { showResourceNotFoundToast } from '~/ui/components/toast-notification';
import { useDocBodyKeyboardShortcuts } from '../components/keydown-binder';
import type { BaseTab, TabType } from '../components/tabs/tab';
import { useInsomniaTabContext } from '../context/app/insomnia-tab-context';
import {
buildResourceUrl,
type InsomniaNavigationRouteInfo,
type NavigationResource,
type NavigationResources,
useInsomniaNavigation,
} from './use-insomnia-navigation';
const { isRequest } = models.request;
const { isRequestGroup } = models.requestGroup;
@@ -32,17 +25,7 @@ interface InsomniaTabProps {
organizationId: string;
}
type TabResource =
| Request
| GrpcRequest
| WebSocketRequest
| SocketIORequest
| McpRequest
| RequestGroup
| MockServer
| MockRoute
| Workspace
| UnitTestSuite;
type TabResource = Exclude<NavigationResource, Project | undefined>;
interface AddTabParams {
resource: TabResource;
@@ -88,145 +71,12 @@ function inferTabType(resource: TabResource): TabType | null {
}
return null;
}
export const TAB_ROUTER_PATH = {
folder: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request-group/:requestGroupId',
request: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId',
environment: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/environment',
mockServer: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/mock-server',
runner: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/runner',
document: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/spec',
mockRoute:
'/organization/:organizationId/project/:projectId/workspace/:workspaceId/mock-server/mock-route/:mockRouteId',
testSuite: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/test/test-suite/:testSuiteId',
test: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/test',
collection: '/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug',
} as const;
const TAB_ROUTE_MATCH_END: Partial<Record<TabType, boolean>> = {
testSuite: false,
};
const buildSearchString = (searchParams: URLSearchParams) => {
const search = searchParams.toString();
return search ? `?${search}` : '';
};
export function buildResourceUrl({
organizationId,
projectId,
workspaceId,
resource,
}: {
organizationId: string;
projectId: string;
workspaceId: string;
resource: TabResource;
}) {
const type = inferTabType(resource);
if (!type) return '';
return buildTabUrl(type, {
organizationId,
projectId,
workspaceId,
resourceId: resource._id,
});
}
// Build tab URL based on type and params
const buildTabUrl = (
type: TabType,
{
organizationId,
projectId,
workspaceId,
resourceId,
searchParams,
withTab,
}: {
organizationId: string;
projectId: string;
workspaceId: string;
resourceId: string;
searchParams?: URLSearchParams;
withTab?: boolean;
},
): string => {
const url = (() => {
switch (type) {
case 'request': {
return href(TAB_ROUTER_PATH.request, {
organizationId,
projectId,
workspaceId,
requestId: resourceId,
});
}
case 'folder': {
return href(TAB_ROUTER_PATH.folder, {
organizationId,
projectId,
workspaceId,
requestGroupId: resourceId,
});
}
case 'collection': {
return href(TAB_ROUTER_PATH.collection, { organizationId, projectId, workspaceId });
}
case 'document': {
return href(TAB_ROUTER_PATH.document, { organizationId, projectId, workspaceId });
}
case 'environment': {
return href(TAB_ROUTER_PATH.environment, { organizationId, projectId, workspaceId });
}
case 'mockServer': {
return href(TAB_ROUTER_PATH.mockServer, { organizationId, projectId, workspaceId });
}
case 'mockRoute': {
return href(TAB_ROUTER_PATH.mockRoute, {
organizationId,
projectId,
workspaceId,
mockRouteId: resourceId,
});
}
case 'test': {
return href(TAB_ROUTER_PATH.test, { organizationId, projectId, workspaceId });
}
case 'testSuite': {
return href(TAB_ROUTER_PATH.testSuite, {
organizationId,
projectId,
workspaceId,
testSuiteId: resourceId,
});
}
case 'runner': {
return href(TAB_ROUTER_PATH.runner, { organizationId, projectId, workspaceId });
}
default: {
return href(TAB_ROUTER_PATH.collection, { organizationId, projectId, workspaceId });
}
}
})();
const newSearchParams = new URLSearchParams(searchParams);
// Ensure we do not skip to active request when opening a permanent collection tab
if (type === 'collection' && withTab) {
newSearchParams.set('doNotSkipToActiveRequest', 'true');
}
const search = buildSearchString(newSearchParams);
return `${url}${search}`;
};
export const buildRunnerTabId = (workspaceId: string, folderId?: string | null) => {
return folderId ? `runner_${folderId}` : `runner_${workspaceId}`;
};
// Note: runner tab is a special case that doesn't directly correspond to a single resource
export const buildRunnerTab = ({
const buildRunnerTab = ({
organizationId,
projectId,
workspaceId,
@@ -243,16 +93,13 @@ export const buildRunnerTab = ({
folderId?: string | null;
searchParams?: URLSearchParams;
}): BaseTab => {
const nextSearchParams = new URLSearchParams(searchParams);
if (folderId) {
searchParams.set('folder', folderId);
nextSearchParams.set('folder', folderId);
}
const url = buildTabUrl('runner', {
organizationId,
projectId,
workspaceId,
resourceId: folderId || workspaceId,
searchParams,
});
const url = `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/runner${
nextSearchParams.toString() ? `?${nextSearchParams.toString()}` : ''
}`;
return {
type: 'runner',
id: buildRunnerTabId(workspaceId, folderId),
@@ -266,18 +113,52 @@ export const buildRunnerTab = ({
};
};
export const buildTabFromResource = async (params: AddTabParams, withTab?: boolean): Promise<BaseTab | null> => {
const buildTabUrlFromResource = ({
resource,
organizationId,
projectId,
workspaceId,
searchParams,
withTab,
}: {
resource: TabResource;
organizationId: string;
projectId: string;
workspaceId: string;
searchParams?: URLSearchParams;
withTab?: boolean;
}) => {
const type = inferTabType(resource);
if (!type) {
return '';
}
const nextSearchParams = new URLSearchParams(searchParams);
if (type === 'collection' && withTab) {
nextSearchParams.set('doNotSkipToActiveRequest', 'true');
}
return buildResourceUrl({
organizationId,
projectId,
workspaceId,
resource,
searchParams: nextSearchParams,
});
};
const buildTabFromResource = async (params: AddTabParams, withTab?: boolean): Promise<BaseTab | null> => {
const { resource, organizationId, projectId, workspaceId, projectName, workspaceName, searchParams } = params;
const effectiveWorkspaceId = workspaceId ?? resource._id;
const type = inferTabType(resource);
if (!type) return null;
const url = buildTabUrl(type, {
const url = buildTabUrlFromResource({
resource,
organizationId,
projectId,
workspaceId: effectiveWorkspaceId,
resourceId: resource._id,
searchParams,
withTab,
});
@@ -305,11 +186,11 @@ export const buildTabFromResource = async (params: AddTabParams, withTab?: boole
baseTab.id = mcpRequestData._id;
baseTab.type = 'request';
baseTab.tag = 'mcp';
baseTab.url = buildTabUrl('request', {
baseTab.url = buildTabUrlFromResource({
resource: mcpRequestData,
organizationId,
projectId,
workspaceId: effectiveWorkspaceId,
resourceId: mcpRequestData._id,
});
}
@@ -331,6 +212,47 @@ export const buildTabFromResource = async (params: AddTabParams, withTab?: boole
return baseTab;
};
const buildTabFromNavigation = async (
routeInfo: InsomniaNavigationRouteInfo,
getNavigationResources: () => Promise<NavigationResources>,
): Promise<BaseTab | null> => {
const { project, workspace, resource } = await getNavigationResources();
if (!project || !workspace || !resource || models.project.isProject(resource)) {
return null;
}
if (routeInfo.routeId === 'runner') {
return buildRunnerTab({
organizationId: routeInfo.organizationId,
projectId: project._id,
workspaceId: workspace._id,
projectName: project.name,
workspaceName: workspace.name,
folderId: routeInfo.searchParams.get('folder'),
});
}
return await buildTabFromResource({
resource,
organizationId: routeInfo.organizationId,
projectId: project._id,
workspaceId: workspace._id,
projectName: project.name,
workspaceName: workspace.name,
});
};
const getNavigationTabId = async (routeInfo?: InsomniaNavigationRouteInfo | null) => {
if (!routeInfo || !routeInfo.workspaceId || !routeInfo.resourceId) {
return null;
}
return routeInfo.routeId === 'runner'
? buildRunnerTabId(routeInfo.workspaceId, routeInfo.searchParams.get('folder'))
: routeInfo.resourceId;
};
export const useTabNavigate = () => {
const navigate = useNavigate();
const { addTab } = useInsomniaTabContext();
@@ -393,165 +315,22 @@ export const useTabNavigate = () => {
return tabNavigate;
};
// Determine tab type from current URL path
const getTabType = (pathname: string): TabType | null => {
const tabTypes = Object.keys(TAB_ROUTER_PATH) as TabType[];
for (const type of tabTypes) {
const ifMatch = matchPath(
{
path: TAB_ROUTER_PATH[type],
end: TAB_ROUTE_MATCH_END[type] ?? true,
},
pathname,
);
if (ifMatch) {
return type;
}
}
return null;
};
const extractTabInfoFromUrl = (pathname: string, searchParams: URLSearchParams) => {
const tabType = getTabType(pathname);
if (!tabType) return null;
const match = matchPath(
{
path: TAB_ROUTER_PATH[tabType],
end: TAB_ROUTE_MATCH_END[tabType] ?? true,
},
pathname,
);
if (!match) return null;
const { params } = match;
if (!params.organizationId || !params.projectId || !params.workspaceId) return null;
const id = (() => {
switch (tabType) {
case 'runner': {
return buildRunnerTabId(params.workspaceId, searchParams.get('folder'));
}
case 'collection':
case 'environment':
case 'mockServer':
case 'test':
case 'document': {
return params.workspaceId;
}
case 'folder': {
return params.requestGroupId;
}
case 'request': {
return params.requestId;
}
case 'mockRoute': {
return params.mockRouteId;
}
case 'testSuite': {
return params.testSuiteId;
}
default: {
return null;
}
}
})();
if (!id) return null;
return {
id,
organizationId: params.organizationId,
projectId: params.projectId,
workspaceId: params.workspaceId,
tabType,
};
};
// Build tab info from URL (used for temporary tabs when navigating to a route without an existing tab)
const buildTabFromUrl = async (pathname: string, searchParams: URLSearchParams): Promise<BaseTab | null> => {
const tabInfo = extractTabInfoFromUrl(pathname, searchParams);
if (!tabInfo) return null;
const { id, tabType, organizationId, projectId, workspaceId } = tabInfo;
const project = await database.findOne('Project', { _id: projectId });
const workspace = await database.findOne('Workspace', { _id: workspaceId });
if (!project || !workspace) return null;
const resource = await (async () => {
switch (tabType) {
case 'request': {
return await requestOperations.getById(id);
}
case 'folder': {
return await database.findOne('RequestGroup', { _id: id });
}
case 'environment':
case 'mockServer':
case 'document':
case 'collection':
case 'test': {
return await database.findOne('Workspace', { _id: id });
}
case 'runner': {
return await database.findOne('Workspace', { _id: workspaceId });
}
case 'mockRoute': {
return await database.findOne('MockRoute', { _id: id });
}
case 'testSuite': {
return await database.findOne('UnitTestSuite', { _id: id });
}
default: {
return null;
}
}
})();
if (!resource) return null;
return tabType === 'runner'
? buildRunnerTab({
organizationId,
projectId,
workspaceId,
projectName: project.name,
workspaceName: workspace.name,
folderId: searchParams.get('folder'),
})
: await buildTabFromResource({
resource: resource as TabResource,
organizationId,
projectId,
workspaceId,
projectName: project.name,
workspaceName: workspace.name,
});
};
/**
* Hook to sync active tab status with the current route.
*/
export const useInsomniaTab = ({ organizationId }: InsomniaTabProps) => {
const { appTabsRef, changeActiveTab, closeTabById, addTemporaryTab } = useInsomniaTabContext();
const location = useLocation();
const [searchParams] = useSearchParams();
const tabInfoFromUrl = useMemo(
() => extractTabInfoFromUrl(location.pathname, searchParams),
[location.pathname, searchParams],
);
const { routeInfo, getNavigationResources } = useInsomniaNavigation();
// Sync active tab with current route (only activates existing tabs, or creates/updates temporary tab if no match)
useEffect(() => {
const currentOrgTab = appTabsRef?.current?.[organizationId];
const currentTabList = currentOrgTab?.tabList;
const currentActiveTabId = currentOrgTab?.activeTabId;
const matchingTab = (tabInfoFromUrl && currentTabList?.find(tab => tab.id === tabInfoFromUrl.id)) || null;
(async () => {
if (!matchingTab) {
// If no existing tab for this route, create/update the temporary tab
const newTemporaryTab = await buildTabFromUrl(location.pathname, searchParams);
const routeTabId = await getNavigationTabId(routeInfo);
const matchingTab = (routeTabId && currentTabList?.find(tab => tab.id === routeTabId)) || null;
if (!matchingTab && routeInfo) {
const newTemporaryTab = await buildTabFromNavigation(routeInfo, getNavigationResources);
if (newTemporaryTab) {
addTemporaryTab(newTemporaryTab, { setActive: true });
@@ -560,11 +339,10 @@ export const useInsomniaTab = ({ organizationId }: InsomniaTabProps) => {
}
if (currentActiveTabId !== matchingTab?.id) {
// If there's an existing tab for this route, make it active
changeActiveTab(matchingTab?.id ?? '');
}
})();
}, [addTemporaryTab, appTabsRef, changeActiveTab, location.pathname, tabInfoFromUrl, organizationId, searchParams]);
}, [addTemporaryTab, appTabsRef, changeActiveTab, getNavigationResources, organizationId, routeInfo]);
// Keyboard shortcut to close current tab
useDocBodyKeyboardShortcuts({