mirror of
https://github.com/Kong/insomnia.git
synced 2026-05-19 06:12:37 -04:00
feat: auto detect select resource then expand and scroll to
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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()!;
|
||||
|
||||
134
packages/insomnia/src/ui/hooks/use-insomnia-navigation.test.ts
Normal file
134
packages/insomnia/src/ui/hooks/use-insomnia-navigation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
369
packages/insomnia/src/ui/hooks/use-insomnia-navigation.ts
Normal file
369
packages/insomnia/src/ui/hooks/use-insomnia-navigation.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user