feat: Pin/unpin request and collection request sorting (#9903)

add basic sort support
support toggle header & sidebar
add sort support
fix issues from comment
This commit is contained in:
Kent Wang
2026-05-07 17:35:52 +08:00
committed by GitHub
parent 7b822a3c9e
commit 41a0f0caff
28 changed files with 1174 additions and 253 deletions

View File

@@ -326,6 +326,10 @@ export const createNedbDatabase = <O = initOptions>(
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.SocketIORequest.db'),
}),
SocketIORequestMeta: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.SocketIORequestMeta.db'),
}),
SocketIOResponse: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.SocketIOResponse.db'),
@@ -358,6 +362,10 @@ export const createNedbDatabase = <O = initOptions>(
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.WebSocketRequest.db'),
}),
WebSocketRequestMeta: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.WebSocketRequestMeta.db'),
}),
WebSocketResponse: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.WebSocketResponse.db'),

View File

@@ -28,6 +28,7 @@ import * as runnerTestResultService from './runner-test-result';
import * as settingsService from './settings';
import * as socketIOPayloadService from './socket-io-payload';
import * as socketIORequestService from './socket-io-request';
import * as socketIORequestMetaService from './socket-io-request-meta';
import * as socketIOResponseService from './socket-io-response';
import * as statsService from './stats';
import * as unitTestService from './unit-test';
@@ -36,6 +37,7 @@ import * as unitTestSuiteService from './unit-test-suite';
import * as userSessionService from './user-session';
import * as webSocketPayloadService from './websocket-payload';
import * as webSocketRequestService from './websocket-request';
import * as webSocketRequestMetaService from './websocket-request-meta';
import * as webSocketResponseService from './websocket-response';
import * as workspaceService from './workspace';
import * as workspaceMetaService from './workspace-meta';
@@ -81,8 +83,10 @@ export const servicesNodeImpl = {
unitTestSuite: unitTestSuiteService,
socketIOPayload: socketIOPayloadService,
socketIORequest: socketIORequestService,
socketIORequestMeta: socketIORequestMetaService,
socketIOResponse: socketIOResponseService,
webSocketPayload: webSocketPayloadService,
webSocketRequest: webSocketRequestService,
webSocketRequestMeta: webSocketRequestMetaService,
webSocketResponse: webSocketResponseService,
} satisfies Record<string, Record<string, (...args: never[]) => Promise<unknown>>>;

View File

@@ -0,0 +1,59 @@
import type { SocketIORequestMeta } from '~/insomnia-data';
import { database as db, models } from '~/insomnia-data';
const { type } = models.socketIORequestMeta;
const { isSocketIORequestId } = models.socketIORequest;
function expectParentToBeSocketIORequest(parentId: string | null) {
if (!isSocketIORequestId(parentId)) {
throw new Error('Expected the parent of SocketIORequestMeta to be a SocketIORequest');
}
}
export function create(patch: Partial<SocketIORequestMeta> = {}) {
if (!patch.parentId) {
throw new Error('New SocketIORequestMeta missing `parentId`');
}
expectParentToBeSocketIORequest(patch.parentId);
return db.docCreate<SocketIORequestMeta>(type, patch);
}
export function update(requestMeta: SocketIORequestMeta, patch: Partial<SocketIORequestMeta>) {
expectParentToBeSocketIORequest(patch.parentId || requestMeta.parentId);
return db.docUpdate(requestMeta, patch);
}
export function getByParentId(parentId: string) {
expectParentToBeSocketIORequest(parentId);
return db.findOne<SocketIORequestMeta>(type, { parentId });
}
export async function getOrCreateByParentId(parentId: string) {
const requestMeta = await getByParentId(parentId);
if (requestMeta) {
return requestMeta;
}
return create({ parentId });
}
export async function updateOrCreateByParentId(parentId: string, patch: Partial<SocketIORequestMeta>) {
const requestMeta = await getByParentId(parentId);
if (requestMeta) {
return update(requestMeta, patch);
}
const newPatch = Object.assign(
{
parentId,
},
patch,
);
return create(newPatch);
}
export function all() {
return db.find<SocketIORequestMeta>(type);
}

View File

@@ -0,0 +1,59 @@
import type { WebSocketRequestMeta } from '~/insomnia-data';
import { database as db, models } from '~/insomnia-data';
const { type } = models.webSocketRequestMeta;
const { isWebSocketRequestId } = models.webSocketRequest;
function expectParentToBeWebSocketRequest(parentId: string | null) {
if (!isWebSocketRequestId(parentId)) {
throw new Error('Expected the parent of WebSocketRequestMeta to be a WebSocketRequest');
}
}
export function create(patch: Partial<WebSocketRequestMeta> = {}) {
if (!patch.parentId) {
throw new Error('New WebSocketRequestMeta missing `parentId`');
}
expectParentToBeWebSocketRequest(patch.parentId);
return db.docCreate<WebSocketRequestMeta>(type, patch);
}
export function update(requestMeta: WebSocketRequestMeta, patch: Partial<WebSocketRequestMeta>) {
expectParentToBeWebSocketRequest(patch.parentId || requestMeta.parentId);
return db.docUpdate(requestMeta, patch);
}
export function getByParentId(parentId: string) {
expectParentToBeWebSocketRequest(parentId);
return db.findOne<WebSocketRequestMeta>(type, { parentId });
}
export async function getOrCreateByParentId(parentId: string) {
const requestMeta = await getByParentId(parentId);
if (requestMeta) {
return requestMeta;
}
return create({ parentId });
}
export async function updateOrCreateByParentId(parentId: string, patch: Partial<WebSocketRequestMeta>) {
const requestMeta = await getByParentId(parentId);
if (requestMeta) {
return update(requestMeta, patch);
}
const newPatch = Object.assign(
{
parentId,
},
patch,
);
return create(newPatch);
}
export function all() {
return db.find<WebSocketRequestMeta>(type);
}

View File

@@ -29,6 +29,7 @@ export * as settings from './settings';
export * as socketIOPayload from './socket-io-payload';
export * as socketIORequest from './socket-io-request';
export * as socketIOResponse from './socket-io-response';
export * as socketIORequestMeta from './socket-io-request-meta';
export * as stats from './stats';
export * as unitTest from './unit-test';
export * as unitTestResult from './unit-test-result';
@@ -37,5 +38,6 @@ export * as userSession from './user-session';
export * as webSocketPayload from './websocket-payload';
export * as webSocketRequest from './websocket-request';
export * as webSocketResponse from './websocket-response';
export * as webSocketRequestMeta from './websocket-request-meta';
export * as workspace from './workspace';
export * as workspaceMeta from './workspace-meta';

View File

@@ -0,0 +1,26 @@
import type { BaseModel } from '~/models/types';
export const name = 'Socket.IO Request Meta';
export const type = 'SocketIORequestMeta';
export const prefix = 'socketio-req-meta';
export const canDuplicate = false;
export const canSync = false;
interface BaseSocketIORequestMeta {
pinned: boolean;
}
export type SocketIORequestMeta = BaseModel & BaseSocketIORequestMeta;
export const isSocketIORequestMeta = (model: Pick<BaseModel, 'type'>): model is SocketIORequestMeta =>
model.type === type;
export function init() {
return {
pinned: false,
};
}

View File

@@ -92,6 +92,8 @@ export type { UnitTestSuite } from './unit-test-suite';
export type { SocketIOPayload } from './socket-io-payload';
export type { BaseSocketIORequest, SocketIOEventListener, SocketIORequest } from './socket-io-request';
export type { SocketIOResponse } from './socket-io-response';
export type { SocketIORequestMeta } from './socket-io-request-meta';
export type { WebSocketPayload } from './websocket-payload';
export type { BaseWebSocketRequest, WebSocketRequest } from './websocket-request';
export type { WebSocketResponse } from './websocket-response';
export type { WebSocketRequestMeta } from './websocket-request-meta';

View File

@@ -0,0 +1,26 @@
import type { BaseModel } from '~/models/types';
export const name = 'WebSocket Request Meta';
export const type = 'WebSocketRequestMeta';
export const prefix = 'ws-req-meta';
export const canDuplicate = false;
export const canSync = false;
interface BaseWebSocketRequestMeta {
pinned: boolean;
}
export type WebSocketRequestMeta = BaseModel & BaseWebSocketRequestMeta;
export const isWebSocketRequestMeta = (model: Pick<BaseModel, 'type'>): model is WebSocketRequestMeta =>
model.type === type;
export function init() {
return {
pinned: false,
};
}

View File

@@ -36,8 +36,10 @@ export const workspaceMeta = models.workspaceMeta;
export const webSocketPayload = models.webSocketPayload;
export const webSocketRequest = models.webSocketRequest;
export const webSocketResponse = models.webSocketResponse;
export const webSocketRequestMeta = models.webSocketRequestMeta;
export const socketIORequest = models.socketIORequest;
export const socketIOPayload = models.socketIOPayload;
export const socketIORequestMeta = models.socketIORequestMeta;
export const socketIOResponse = models.socketIOResponse;
export * as organization from './organization';
export const userSession = models.userSession;

View File

@@ -27,6 +27,7 @@ export type AllTypes =
| 'SocketIOPayload'
| 'SocketIORequest'
| 'SocketIOResponse'
| 'SocketIORequestMeta'
| 'Stats'
| 'UnitTest'
| 'UnitTestResult'
@@ -35,6 +36,7 @@ export type AllTypes =
| 'WebSocketPayload'
| 'WebSocketRequest'
| 'WebSocketResponse'
| 'WebSocketRequestMeta'
| 'McpRequest'
| 'McpResponse'
| 'McpPayload'

View File

@@ -1,6 +1,7 @@
import { getLearningFeature } from 'insomnia-api';
import { useEffect, useRef } from 'react';
import { Button, Heading } from 'react-aria-components';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { type ImperativePanelHandle, Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { href, Outlet, redirect, useParams, useRouteLoaderData } from 'react-router';
import * as reactUse from 'react-use';
@@ -19,6 +20,7 @@ import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizatio
import { ScratchPadTutorialPanel } from '~/ui/components/panes/scratchpad-tutorial-pane';
import { ProjectNavigationSidebar } from '~/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar';
import { SyncBar } from '~/ui/components/sidebar/sync-bar';
import uiEventBus, { TOGGLE_PROJECT_SIDEBAR } from '~/ui/event-bus';
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features';
import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils';
@@ -135,6 +137,25 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
const { storagePromise } = storageRuleFetcher.data || {};
const [storageRules = DEFAULT_STORAGE_RULES] = useLoaderDeferData(storagePromise, organizationId);
const [learningFeature] = useLoaderDeferData<LearningFeature>(learningFeaturePromise);
const sidebarPanelRef = useRef<ImperativePanelHandle>(null);
const [isSidebarCollapsed] = reactUse.useLocalStorage('project-navigation-collapsed', false);
const isSidebarCollapsedRef = useRef(isSidebarCollapsed);
useEffect(() => {
if (isSidebarCollapsedRef.current) {
sidebarPanelRef.current?.collapse();
} else {
sidebarPanelRef.current?.expand();
}
return uiEventBus.on(TOGGLE_PROJECT_SIDEBAR, (collapsed: boolean) => {
if (collapsed) {
sidebarPanelRef.current?.collapse();
} else {
sidebarPanelRef.current?.expand();
}
});
}, []);
const { features } = useOrganizationPermissions();
@@ -149,6 +170,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
direction="horizontal"
>
<Panel
ref={sidebarPanelRef}
id="insomnia-global-navigation-sidebar"
className="sidebar theme--sidebar"
defaultSize={DEFAULT_SIDEBAR_SIZE}

View File

@@ -1,6 +1,6 @@
import { href } from 'react-router';
import type { GrpcRequestMeta, RequestMeta } from '~/insomnia-data';
import type { GrpcRequestMeta, RequestMeta, SocketIORequestMeta, WebSocketRequestMeta } from '~/insomnia-data';
import { services } from '~/insomnia-data';
import * as models from '~/models';
import { invariant } from '~/utils/invariant';
@@ -11,11 +11,21 @@ import type { Route } from './+types/organization.$organizationId.project.$proje
export async function clientAction({ params, request }: Route.ClientActionArgs) {
const { requestId } = params;
invariant(typeof requestId === 'string', 'Request ID is required');
const patch = (await request.json()) as Partial<RequestMeta | GrpcRequestMeta>;
const patch = (await request.json()) as Partial<
RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta
>;
if (models.grpcRequest.isGrpcRequestId(requestId)) {
await services.grpcRequestMeta.updateOrCreateByParentId(requestId, patch);
return null;
}
if (models.webSocketRequest.isWebSocketRequestId(requestId)) {
await services.webSocketRequestMeta.updateOrCreateByParentId(requestId, patch);
return null;
}
if (models.socketIORequest.isSocketIORequestId(requestId)) {
await services.socketIORequestMeta.updateOrCreateByParentId(requestId, patch);
return null;
}
await services.requestMeta.updateOrCreateByParentId(requestId, patch);
return null;
}
@@ -33,7 +43,7 @@ export const useRequestUpdateMetaActionFetcher = createFetcherSubmitHook(
projectId: string;
workspaceId: string;
requestId: string;
patch: Partial<RequestMeta | GrpcRequestMeta>;
patch: Partial<RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta>;
}) => {
const url = href(
'/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId/update-meta',

View File

@@ -19,7 +19,9 @@ import type {
RequestGroupMeta,
RequestMeta,
SocketIORequest,
SocketIORequestMeta,
WebSocketRequest,
WebSocketRequestMeta,
Workspace,
WorkspaceMeta,
} from '~/insomnia-data';
@@ -178,7 +180,18 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs)
const grpcRequestMetas = await database.find(models.grpcRequestMeta.type, {
parentId: { $in: grpcReqs.map(r => r._id) },
});
const grpcAndRequestMetas = [...requestMetas, ...grpcRequestMetas] as (RequestMeta | GrpcRequestMeta)[];
const webSocketRequestMetas = await database.find(models.webSocketRequestMeta.type, {
parentId: { $in: wsReqs.map(r => r._id) },
});
const socketIORequestMetas = await database.find(models.socketIORequestMeta.type, {
parentId: { $in: socketIORequests.map(r => r._id) },
});
const allRequestMetas = [...requestMetas, ...grpcRequestMetas, ...webSocketRequestMetas, ...socketIORequestMetas] as (
| RequestMeta
| GrpcRequestMeta
| WebSocketRequestMeta
| SocketIORequestMeta
)[];
const requestGroupMetas = (await database.find(models.requestGroupMeta.type, {
parentId: { $in: listOfParentIds },
})) as RequestGroupMeta[];
@@ -202,7 +215,7 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs)
levelReqs.sort(sortFunction).map(async (doc): Promise<Child> => {
const hidden = parentIsCollapsed;
const pinned = (!isRequestGroup(doc) && grpcAndRequestMetas.find(m => m.parentId === doc._id)?.pinned) || false;
const pinned = (!isRequestGroup(doc) && allRequestMetas.find(m => m.parentId === doc._id)?.pinned) || false;
const collapsed =
parentIsCollapsed ||
(isRequestGroup(doc) && requestGroupMetas.find(m => m.parentId === doc._id)?.collapsed) ||

View File

@@ -1,6 +1,6 @@
import { type Billing, type CurrentPlan, type FeatureList, type Organization, type UserProfile } from 'insomnia-api';
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { Button, Link, Tooltip, TooltipTrigger } from 'react-aria-components';
import { Button, Link, ToggleButton, Tooltip, TooltipTrigger } from 'react-aria-components';
import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRouteLoaderData } from 'react-router';
import * as reactUse from 'react-use';
@@ -27,8 +27,8 @@ import { OrganizationSelect } from '~/ui/components/project/organization-select'
import { InsomniaEventStreamProvider } from '~/ui/context/app/insomnia-event-stream-context';
import { InsomniaTabProvider } from '~/ui/context/app/insomnia-tab-context';
import { RunnerProvider } from '~/ui/context/app/runner-context';
import uiEventBus, { TOGGLE_PROJECT_SIDEBAR } from '~/ui/event-bus';
import { useCloseConnection } from '~/ui/hooks/use-close-connection';
import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features';
import { sortOrganizations } from '~/ui/organization-utils';
import type { AsyncTask } from '~/utils/router';
@@ -150,8 +150,7 @@ const NetworkAndSyncIndicator = ({ asyncTaskStatus, settings, sync }: IndicatorP
const Component = ({ loaderData }: Route.ComponentProps) => {
const { organizations, user, currentPlan } = loaderData;
const { userSession, settings } = useRootLoaderData()!;
const { billing } = useOrganizationPermissions();
const { settings } = useRootLoaderData()!;
const workspaceData = useWorkspaceLoaderData();
@@ -214,6 +213,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
});
const [isMinimal, setIsMinimal] = reactUse.useLocalStorage('isMinimal', false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = reactUse.useLocalStorage('project-navigation-collapsed', false);
return (
<InsomniaEventStreamProvider>
<InsomniaTabProvider>
@@ -221,58 +221,31 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
<div
className={`grid-template-app-layout relative grid h-full w-full divide-x divide-solid divide-(--hl-md) bg-(--color-bg)`}
>
{!isMinimal && (
<header className="grid grid-cols-3 items-center border-b border-solid border-(--hl-md) [grid-area:Header]">
<div className="flex items-center gap-2">
<div className="flex w-12.5 shrink-0 justify-center py-2">
<InsomniaLogo />
</div>
{!isScratchPad && (
<OrganizationSelect
organizationId={organizationId}
organizations={organizations || []}
onSelect={id => {
window.main.trackSegmentEvent({ event: SegmentEvent.organizationSwitched });
navigate(`/organization/${id}`);
}}
currentPlan={currentPlan}
isScratchpadWorkspace={!!isScratchpadWorkspace}
/>
)}
<header
className={`grid grid-cols-3 items-center border-b border-solid border-(--hl-md) [grid-area:Header] ${isMinimal ? 'hidden' : ''}`}
>
<div className="flex items-center gap-2">
<div className="flex w-12.5 shrink-0 justify-center py-2">
<InsomniaLogo />
</div>
{!isScratchPad && (
<OrganizationSelect
organizationId={organizationId}
organizations={organizations || []}
onSelect={id => {
window.main.trackSegmentEvent({ event: SegmentEvent.organizationSwitched });
navigate(`/organization/${id}`);
}}
currentPlan={currentPlan}
isScratchpadWorkspace={!!isScratchpadWorkspace}
/>
)}
{!user ? <GitHubStarsButton /> : null}
</div>
<CommandPalette />
<div className="flex min-w-min items-center justify-end gap-(--padding-sm) space-x-3 p-2">
{user ? (
<Fragment>
<PresentUsers />
<HeaderInviteButton
organizationId={organizationId}
className="border border-solid border-(--hl-md) bg-(--color-surprise) font-semibold text-(--color-font-surprise)"
/>
<HeaderPlanIndicator isMinimal={isMinimal} />
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
</Fragment>
) : (
<Fragment>
<NavLink
to={href('/auth/login')}
className="flex items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-4 py-1 text-sm font-semibold text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
Login
</NavLink>
<NavLink
className="flex items-center justify-center gap-2 rounded-xs bg-(--color-surprise) px-4 py-1 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all focus:bg-[rgba(var(--color-surprise-rgb),0.9)] focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-[rgba(var(--color-surprise-rgb),0.8)]"
to={href('/auth/login')}
>
Sign up for free
</NavLink>
</Fragment>
)}
</div>
</header>
)}
{!user ? <GitHubStarsButton /> : null}
</div>
<CommandPalette />
<div />
</header>
<div className="overflow-hidden border-b border-(--hl-md) [grid-area:Content]">
<RunnerProvider>
<Outlet />
@@ -281,6 +254,90 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
<div className="relative flex items-center overflow-hidden [grid-area:Statusbar]" data-testid="statusbar">
<div className="flex w-full items-center gap-2">
<div className="flex h-full shrink grow basis-1/3 items-center">
<TooltipTrigger>
<ToggleButton
className="ml-3 grow-0 gap-2 px-2 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-inset"
onChange={value => {
setIsSidebarCollapsed(!value);
uiEventBus.emit(TOGGLE_PROJECT_SIDEBAR, !value);
}}
isSelected={!isSidebarCollapsed}
>
{({ isSelected }) => {
return (
<svg
width={10}
height={10}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
{isSelected ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm12 13H7V2h7v12z"
/>
) : (
<path d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm0 13V2h4v12H2zm5 0V2h7v12H7z" />
)}
</svg>
);
}}
</ToggleButton>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Toggle sidebar
</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<ToggleButton
className="flex grow-0 items-center justify-center px-2 py-1 text-xs text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs)"
onChange={flag => {
setIsMinimal(!flag);
window.main.trackSegmentEvent({
event: SegmentEvent.statusbarTopbarToggled,
properties: {
status: !flag ? 'minimal' : 'expanded',
},
});
}}
isSelected={!isMinimal}
>
{({ isSelected }) => {
return (
<svg
width={10}
height={10}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className="rotate-90"
>
{isSelected ? (
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm12 13H7V2h7v12z"
/>
) : (
<path d="M2 1L1 2v12l1 1h12l1-1V2l-1-1H2zm0 13V2h4v12H2zm5 0V2h7v12H7z" />
)}
</svg>
);
}}
</ToggleButton>
<Tooltip
placement="top"
offset={8}
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Toggle header
</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Button
data-testid="settings-button"
@@ -368,36 +425,43 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
</Link>
)}
</div>
{isMinimal && (
<div className="flex items-center justify-end gap-(--padding-sm) p-2">
{user ? (
<Fragment>
<PresentUsers />
<HeaderInviteButton className="text-(--color-font)" organizationId={organizationId} />
<HeaderPlanIndicator isMinimal={isMinimal} />
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
</Fragment>
) : (
<Fragment>
<NavLink
to={href('/auth/login')}
className="flex items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-4 py-1 text-sm font-semibold text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
Login
</NavLink>
<NavLink
className="flex items-center justify-center gap-2 rounded-xs bg-(--color-surprise) px-4 py-1 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all focus:bg-[rgba(var(--color-surprise-rgb),0.9)] focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-[rgba(var(--color-surprise-rgb),0.8)]"
to={href('/auth/login')}
>
Sign up for free
</NavLink>
</Fragment>
)}
</div>
)}
</div>
</div>
</div>
<div
className={`flex items-center justify-end gap-(--padding-sm) self-center justify-self-end border-l-0 p-2 ${isMinimal ? '[grid-area:Statusbar]' : '[grid-area:Header]'}`}
>
{user ? (
<Fragment>
<PresentUsers />
<HeaderInviteButton
organizationId={organizationId}
className={
isMinimal
? 'text-(--color-font)'
: 'border border-solid border-(--hl-md) bg-(--color-surprise) font-semibold text-(--color-font-surprise)'
}
/>
<HeaderPlanIndicator isMinimal={isMinimal} />
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
</Fragment>
) : (
<Fragment>
<NavLink
to={href('/auth/login')}
className="flex items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-4 py-1 text-sm font-semibold text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
Login
</NavLink>
<NavLink
className="flex items-center justify-center gap-2 rounded-xs bg-(--color-surprise) px-4 py-1 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all focus:bg-[rgba(var(--color-surprise-rgb),0.9)] focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-[rgba(var(--color-surprise-rgb),0.8)]"
to={href('/auth/login')}
>
Sign up for free
</NavLink>
</Fragment>
)}
</div>
</div>
</div>
</InsomniaTabProvider>

View File

@@ -176,7 +176,7 @@ export const ProjectDropdown: FC<Props> = ({ project, organizationId, storageRul
// Use this because createNewWorkspace action will navigate to the newly create workspace page
setNewWorkspaceModalState(prev => prev && { ...prev, isOpen: false });
}
}, [newWorkspaceModalState?.isOpen, workspaceId]);
}, [workspaceId]);
return (
<Fragment>

View File

@@ -62,14 +62,15 @@ export const RequestActionsDropdown = ({
onOpenChange,
onRename,
}: Props) => {
const workspaceId = activeWorkspace._id;
const projectId = activeProject._id;
const { settings } = useRootLoaderData()!;
const patchRequestMeta = useRequestMetaPatcher();
const patchRequestMeta = useRequestMetaPatcher(workspaceId);
const { hotKeyRegistry } = settings;
const [actionPlugins, setActionPlugins] = useState<RequestAction[]>([]);
const duplicateRequestFetcher = useRequestDuplicateActionFetcher();
const deleteRequestFetcher = useRequestDeleteActionFetcher();
const workspaceId = activeWorkspace._id;
const projectId = activeProject._id;
const { organizationId } = useParams() as {
organizationId: string;
};
@@ -333,7 +334,7 @@ export const RequestActionsDropdown = ({
<Button
data-testid={`Dropdown-${toKebabCase(request.name)}`}
aria-label="Request Actions"
className="hidden aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-hover:flex group-hover:opacity-100 group-focus:flex group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:flex data-pressed:opacity-100"
className="aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-hover:flex group-hover:opacity-100 group-focus:flex group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:flex data-pressed:opacity-100"
>
<Icon icon="ellipsis" />
</Button>

View File

@@ -21,6 +21,7 @@ import {
Popover,
Radio,
RadioGroup,
SubmenuTrigger,
} from 'react-aria-components';
import { href } from 'react-router';
@@ -33,7 +34,7 @@ import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organiza
import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import type { CreateRequestType } from '~/ui/hooks/use-request';
import { getProductName } from '../../../common/constants';
import { getProductName, SORT_ORDERS, type SortOrder, sortOrderName } from '../../../common/constants';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import type { PlatformKeyCombinations } from '../../../common/settings';
import { SegmentEvent } from '../../analytics';
@@ -51,6 +52,8 @@ interface Props {
workspace: Workspace;
project: Project;
organizationId: string;
sortOrder?: SortOrder;
onSortOrderChange: (newSortOrder: SortOrder) => void;
}
interface ActionItem {
@@ -60,6 +63,8 @@ interface ActionItem {
hint?: PlatformKeyCombinations;
action: () => void;
className?: string;
hasSubmenu?: boolean;
submenuItems?: Omit<ActionItem, 'icon'>[];
}
interface ActionSection {
@@ -69,7 +74,13 @@ interface ActionSection {
items: ActionItem[];
}
export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }: Props) => {
export const SidebarWorkspaceDropdown = ({
workspace,
project,
organizationId,
sortOrder,
onSortOrderChange,
}: Props) => {
const projectId = project._id;
const workspaceId = workspace._id;
@@ -230,6 +241,22 @@ export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }:
},
]
: []),
...(models.workspace.isCollection(workspace)
? [
{
id: 'Sort',
name: 'Sort',
icon: 'sort' as IconName,
action: () => {},
hasSubmenu: true,
submenuItems: SORT_ORDERS.map(order => ({
id: order,
name: sortOrderName[order],
action: () => onSortOrderChange(order),
})),
},
]
: []),
{
id: 'Rename',
name: 'Rename',
@@ -308,7 +335,7 @@ export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }:
?.action()
}
items={allSections}
className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden"
className="max-h-128 min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden"
>
{section => (
<MenuSection className="flex flex-1 flex-col">
@@ -316,18 +343,52 @@ export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }:
<Icon icon={section.icon} /> <span>{section.name}</span>
</Header>
<Collection items={section.items}>
{item => (
<MenuItem
key={item.id}
id={item.id}
className={`flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold${item.className ? ` ${item.className}` : ''}`}
aria-label={item.name}
>
<Icon icon={item.icon} />
<span>{item.name}</span>
{item.hint && <DropdownHint keyBindings={item.hint} />}
</MenuItem>
)}
{item =>
!item.hasSubmenu ? (
<MenuItem
key={item.id}
id={item.id}
className={`flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold${item.className ? ` ${item.className}` : ''}`}
aria-label={item.name}
>
<Icon icon={item.icon} className="h-4 w-3" />
<span>{item.name}</span>
{item.hint && <DropdownHint keyBindings={item.hint} />}
</MenuItem>
) : (
<SubmenuTrigger>
<MenuItem
className={`flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold${item.className ? ` ${item.className}` : ''}`}
aria-label={item.name}
>
<Icon icon={item.icon} className="h-4 w-3" />
<span>{item.name}</span>
<Icon icon="chevron-right" className="ml-auto" />
</MenuItem>
<Popover className="flex min-w-max flex-col overflow-y-hidden">
<Menu
aria-label={`${item.name} submenu`}
onAction={key => item.submenuItems?.find(s => s.id === key)?.action()}
items={item.submenuItems}
className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden"
>
{subItem => (
<MenuItem
id={subItem.id}
className="flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold"
aria-label={subItem.name}
>
<span>{subItem.name}</span>
{sortOrder === subItem.id && (
<Icon icon="check" className="h-4 w-3 justify-self-end text-(--color-success)" />
)}
</MenuItem>
)}
</Menu>
</Popover>
</SubmenuTrigger>
)
}
</Collection>
</MenuSection>
)}

View File

@@ -0,0 +1,27 @@
export const KongLogo = ({ ...props }: {} & React.SVGProps<SVGSVGElement>) => (
<svg width="13" height="12" viewBox="0 0 13 12" xmlns="http://www.w3.org/2000/svg" className="z-1" {...props}>
<g opacity="0.6" clipPath="url(#clip0_4460_9459)">
<path
d="M4.25676 9.83691L3.94238 10.2486L4.66461 11.4048L4.58813 11.9391H7.64696L7.85938 11.4048L6.62735 9.83691H4.26526H4.25676Z"
fill="#CCFF00"
/>
<path
d="M5.93075 2.80273L4.82617 4.79106L10.2131 11.3429L10.0602 11.9473H12.5327L12.983 9.81879L7.21375 2.80273H5.93075Z"
fill="#CCFF00"
/>
<path
d="M6.66132 1.30511L6.13452 2.29489H7.43452L9.66916 5.02774L11.0031 3.90657V3.19708L10.5443 2.53139L10.8842 2.1635L8.21622 0L6.66132 1.30511Z"
fill="#CCFF00"
/>
<path
d="M2.65098 6.84105H1.92026L0 9.34616V11.9389H2.05621L2.42157 11.4571L4.01046 9.34616H6.30457L7.0098 8.23375L4.52026 5.19434L2.64248 6.84981L2.65098 6.84105Z"
fill="#CCFF00"
/>
</g>
<defs>
<clipPath id="clip0_4460_9459">
<rect width="13" height="12" fill="white" />
</clipPath>
</defs>
</svg>
);

View File

@@ -0,0 +1,261 @@
import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core';
import type { StorageRules } from 'insomnia-api';
import { useState } from 'react';
import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components';
import type { WorkspaceScope } from '~/insomnia-data';
import { useRequestNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new';
import { useRequestGroupNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request-group.new';
import { showModal } from '~/ui/components/modals';
import { NewWorkspaceModal } from '~/ui/components/modals/new-workspace-modal';
import { PromptModal } from '~/ui/components/modals/prompt-modal';
import type { CreateRequestType } from '~/ui/hooks/use-request';
import { Icon } from '../../icon';
import { GUIDE_LINE_CSS, ROW_CLASS } from './project-navigation-sidebar-utils';
import type { EmptyNodeFlatItem } from './types';
interface EmptyNodeProps {
item: EmptyNodeFlatItem;
storageRules: StorageRules;
}
interface ActionItem {
id: string;
name: string;
icon: IconProp;
action: () => void;
}
export const EmptyNode = ({ item, storageRules }: EmptyNodeProps) => {
const { organizationId, project, workspace, requestGroup, level = 0, kind } = item;
const [newWorkspaceModalState, setNewWorkspaceModalState] = useState<{
scope: WorkspaceScope;
isOpen: boolean;
} | null>({
scope: 'collection',
isOpen: false,
});
const createNewCollection = () => setNewWorkspaceModalState({ scope: 'collection', isOpen: true });
const createNewDocument = () => setNewWorkspaceModalState({ scope: 'design', isOpen: true });
const createNewMockServer = () => setNewWorkspaceModalState({ scope: 'mock-server', isOpen: true });
const createNewGlobalEnvironment = () => setNewWorkspaceModalState({ scope: 'environment', isOpen: true });
const createNewMcpClient = () => setNewWorkspaceModalState({ scope: 'mcp', isOpen: true });
const newRequestFetcher = useRequestNewActionFetcher();
const newRequestGroupFetcher = useRequestGroupNewActionFetcher();
const parentId = requestGroup?._id || workspace?._id || project._id;
const createRequest = ({ requestType }: { requestType: CreateRequestType }) => {
if (!workspace) return;
newRequestFetcher.submit({
organizationId,
projectId: project._id,
workspaceId: workspace._id,
requestType,
parentId,
});
};
const createFolder = () => {
if (!workspace) return;
showModal(PromptModal, {
title: 'New Folder',
defaultValue: 'My Folder',
submitName: 'Create',
label: 'Name',
selectText: true,
onComplete: (name: string) =>
newRequestGroupFetcher.submit({
organizationId,
projectId: project._id,
workspaceId: workspace._id,
parentId,
name,
}),
});
};
const createRequestActionItems: ActionItem[] = [
{
id: 'HTTP',
name: 'HTTP Request',
icon: 'plus-circle',
action: () =>
createRequest({
requestType: 'HTTP',
}),
},
{
id: 'Event Stream',
name: 'Event Stream Request (SSE)',
icon: 'plus-circle',
action: () =>
createRequest({
requestType: 'Event Stream',
}),
},
{
id: 'GraphQL Request',
name: 'GraphQL Request',
icon: 'plus-circle',
action: () =>
createRequest({
requestType: 'GraphQL',
}),
},
{
id: 'gRPC Request',
name: 'gRPC Request',
icon: 'plus-circle',
action: () =>
createRequest({
requestType: 'gRPC',
}),
},
{
id: 'WebSocket Request',
name: 'WebSocket Request',
icon: 'plus-circle',
action: () =>
createRequest({
requestType: 'WebSocket',
}),
},
{
id: 'Socket.IO Request',
name: 'Socket.IO Request',
icon: 'plus-circle',
action: () =>
createRequest({
requestType: 'SocketIO',
}),
},
{
id: 'New Folder',
name: 'New Folder',
icon: 'folder',
action: createFolder,
},
];
const createInProjectActionList: ActionItem[] = [
{
id: 'new-collection',
name: 'Request collection',
icon: 'bars',
action: createNewCollection,
},
{
id: 'new-document',
name: 'Design document',
icon: 'file',
action: createNewDocument,
},
{
id: 'new-mcp-client',
name: 'MCP Client',
icon: ['fac', 'mcp'] as unknown as IconProp,
action: createNewMcpClient,
},
{
id: 'new-mock-server',
name: 'Mock Server',
icon: 'server' as IconName,
action: createNewMockServer,
},
{
id: 'new-environment',
name: 'Environment',
icon: 'code',
action: createNewGlobalEnvironment,
},
];
const getLabel = () => {
switch (kind) {
case 'emptyProject': {
return 'Project is empty';
}
case 'emptyCollection': {
return 'Collection is empty';
}
case 'emptyFolder': {
return 'Folder is empty';
}
default: {
return '';
}
}
};
const paddingLeft = kind === 'emptyProject' ? '2em' : `${level + 3}rem`;
return (
<div className={ROW_CLASS} style={{ paddingLeft }}>
<span className={`${GUIDE_LINE_CSS} left-6 group-hover/tree:bg-(--hl-sm)`} />
{kind !== 'emptyProject' && <span className={`${GUIDE_LINE_CSS} left-10 group-hover/tree:bg-(--hl-sm)`} />}
{kind === 'emptyFolder' &&
Array.from({ length: level + 2 }, (_, i) => (
<span
key={i}
className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm)`}
style={{ left: `${i + 1.5}em` }}
/>
))}
<span className="ml-3 min-w-0 flex-1 truncate text-xs">{getLabel()}</span>
<MenuTrigger>
<Button
aria-label="Create in project"
className="flex items-center justify-center gap-2 rounded-xs bg-(--hl-xxs) px-2 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<Icon icon="plus-circle" /> <span className="hidden md:block">Create</span>
</Button>
<Popover className="flex min-w-max flex-col overflow-y-hidden">
<Menu
aria-label="Create in project actions"
selectionMode="single"
onAction={key => {
const item = (kind === 'emptyProject' ? createInProjectActionList : createRequestActionItems).find(
item => item.id === key,
);
if (item) {
item.action();
}
}}
items={kind === 'emptyProject' ? createInProjectActionList : createRequestActionItems}
className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-base shadow-lg select-none focus:outline-hidden"
>
{item => (
<MenuItem
key={item.id}
id={item.id}
className="flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold"
aria-label={item.name}
>
<Icon icon={item.icon} />
<span>{item.name}</span>
</MenuItem>
)}
</Menu>
</Popover>
</MenuTrigger>
{newWorkspaceModalState?.isOpen && (
<NewWorkspaceModal
isOpen
project={project}
storageRules={storageRules}
scope={newWorkspaceModalState.scope}
onOpenChange={isOpen => {
setNewWorkspaceModalState({
scope: newWorkspaceModalState.scope,
isOpen,
});
}}
/>
)}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import { database } from '~/common/database';
import { fuzzyMatchAll } from '~/common/misc';
import { metaSortKeySort } from '~/common/sorting';
import { sortMethodMap } from '~/common/sorting';
import type {
GrpcRequest,
GrpcRequestMeta,
@@ -9,7 +9,9 @@ import type {
RequestGroupMeta,
RequestMeta,
SocketIORequest,
SocketIORequestMeta,
WebSocketRequest,
WebSocketRequestMeta,
Workspace,
} from '~/insomnia-data';
import { models } from '~/insomnia-data';
@@ -28,7 +30,7 @@ type AllRequestDoc = Request | GrpcRequest | WebSocketRequest | SocketIORequest
export interface AllRequestsAndMetaInWorkspace {
allRequests: AllRequestDoc[];
allRequestMetas: (RequestMeta | GrpcRequestMeta)[];
allRequestMetas: (RequestMeta | GrpcRequestMeta | WebSocketRequestMeta | SocketIORequestMeta)[];
requestGroupMetas: RequestGroupMeta[];
}
@@ -66,11 +68,15 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) {
const requestGroupToWorkspaceId = new Map<string, string>();
const requestToWorkspaceId = new Map<string, string>();
const grpcRequestToWorkspaceId = new Map<string, string>();
const wsRequestToWorkspaceId = new Map<string, string>();
const socketIORequestToWorkspaceId = new Map<string, string>();
// Initialize the map with workspace IDs
workspaceIds.forEach(workspaceId => {
requestGroupToWorkspaceId.set(workspaceId, workspaceId);
requestToWorkspaceId.set(workspaceId, workspaceId);
grpcRequestToWorkspaceId.set(workspaceId, workspaceId);
wsRequestToWorkspaceId.set(workspaceId, workspaceId);
socketIORequestToWorkspaceId.set(workspaceId, workspaceId);
allRequestsAndMetaByWorkspaceId.set(workspaceId, { allRequests: [], allRequestMetas: [], requestGroupMetas: [] });
});
@@ -105,7 +111,7 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) {
const allRequests = [...reqs, ...allRequestGroups, ...grpcReqs, ...wsReqs, ...socketIOReqs] as AllRequestDoc[];
const [requestMetas, grpcRequestMetas, requestGroupMetas] = await Promise.all([
const [requestMetas, grpcRequestMetas, requestGroupMetas, wsRequestMetas, socketIORequestMetas] = await Promise.all([
database.find<RequestMeta>(models.requestMeta.type, { parentId: { $in: reqs.map(r => r._id) } }),
database.find<GrpcRequestMeta>(models.grpcRequestMeta.type, {
parentId: { $in: grpcReqs.map(r => r._id) },
@@ -113,17 +119,29 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) {
database.find<RequestGroupMeta>(models.requestGroupMeta.type, {
parentId: { $in: allRequestGroups.map(requestGroup => requestGroup._id) },
}),
database.find<WebSocketRequestMeta>(models.webSocketRequestMeta.type, {
parentId: { $in: wsReqs.map(r => r._id) },
}),
database.find<SocketIORequestMeta>(models.socketIORequestMeta.type, {
parentId: { $in: socketIOReqs.map(r => r._id) },
}),
]);
const allRequestMetas = [...requestMetas, ...grpcRequestMetas] as (RequestMeta | GrpcRequestMeta)[];
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) {
@@ -131,7 +149,7 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) {
}
}
});
// Build map of requestGroupMetas by workspace ID
requestGroupMetas.forEach(requestGroupMeta => {
const workspaceId = requestGroupToWorkspaceId.get(requestGroupMeta.parentId);
if (workspaceId) {
@@ -148,6 +166,10 @@ export async function getAllRequestsAndMetaByWorkspace(workspaceIds: string[]) {
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);
@@ -164,6 +186,7 @@ export function flattenCollectionChildren(
workspaceId: string,
parentIsCollapsed: boolean,
{ allRequests, allRequestMetas, requestGroupMetas }: AllRequestsAndMetaInWorkspace,
sortOrder: keyof typeof sortMethodMap = 'type-manual',
): Child[] {
const { isRequestGroup } = models.requestGroup;
const collection: Child[] = [];
@@ -178,8 +201,8 @@ export function flattenCollectionChildren(
requestsByParentId.set(req.parentId, [req]);
}
}
const rootRequests = (requestsByParentId.get(workspaceId) || []).sort(metaSortKeySort);
const sortFunction = sortMethodMap[sortOrder];
const rootRequests = (requestsByParentId.get(workspaceId) || []).sort(sortFunction);
const stack: { doc: AllRequestDoc; level: number; parentIsCollapsed: boolean; ancestors: string[] }[] = [
...rootRequests,
]
@@ -204,7 +227,7 @@ export function flattenCollectionChildren(
// if it's a request group, add its children to the stack
if (isRequestGroup(doc)) {
const childDocs = (requestsByParentId.get(doc._id) || []).sort(metaSortKeySort);
const childDocs = (requestsByParentId.get(doc._id) || []).sort(sortFunction);
const childAncestors = [...ancestors, doc._id];
for (let i = childDocs.length - 1; i >= 0; i--) {
stack.push({ doc: childDocs[i], level: level + 1, parentIsCollapsed: collapsed, ancestors: childAncestors });
@@ -257,7 +280,7 @@ export function filterCollection(collection: Child[], filter: string): Child[] {
// Common tailwind classes
export const ROW_CLASS =
'relative flex h-(--line-height-xs) w-full items-center gap-1 overflow-hidden text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font) pr-4';
'relative flex h-(--line-height-xs) w-full items-center gap-1 overflow-hidden text-[rgba(var(--color-font-rgb),0.8)] outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font) pr-4';
export const ACTIVE_BORDER_CLASS =
'absolute top-0 left-0 h-full w-0.5 bg-transparent transition-colors group-aria-selected:bg-(--color-surprise)';
@@ -265,7 +288,7 @@ export const GUIDE_LINE_CSS = 'absolute inset-y-0 w-px bg-transparent transition
// for toggle button
export const TOGGLE_BTN_CLASS =
'flex shrink-0 items-center justify-center text-sm text-(--hl) hover:text-(--color-font) focus:outline-none w-4 h-4';
'flex shrink-0 items-center justify-center text-base text-[rgba(var(--color-font-rgb),0.8)] hover:text-(--color-font) focus:outline-none w-4 h-4';
export const ICON_CLASS = 'h-3 w-3 shrink-0';
export const INDENT_PX = 16;

View File

@@ -5,22 +5,25 @@ import { Button, GridList, GridListItem, Input, SearchField } from 'react-aria-c
import { useNavigate, useParams, useSearchParams } from 'react-router';
import * as reactUse from 'react-use';
import type { SortOrder } from '~/common/constants';
import { fuzzyMatchAll } from '~/common/misc';
import {
getAllRemoteBackendProjectsByProjectId,
getUnsyncedRemoteWorkspaces,
type InsomniaFile,
} from '~/common/project';
import type { Workspace } from '~/insomnia-data';
import type { RequestGroup, Workspace } from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import type { SyncResult } from '~/konnect/sync';
import { useRootLoaderData } from '~/root';
import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId';
import { SegmentEvent } from '~/ui/analytics';
import { KongLogo } from '~/ui/components/kong-logo';
import { showModal } from '~/ui/components/modals';
import { AlertModal } from '~/ui/components/modals/alert-modal';
import { AskModal } from '~/ui/components/modals/ask-modal';
import { ProjectModal } from '~/ui/components/modals/project-modal';
import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node';
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';
@@ -38,8 +41,8 @@ import {
getWorkspacesByProjectIds,
} from './project-navigation-sidebar-utils';
import { ProjectNode } from './project-node';
import { RequestNode } from './request-node';
import type { FlatItem } from './types';
import { PinnedHeaderNode, RequestNode } from './request-node';
import type { EmptyNodeFlatItem, FlatItem } from './types';
import { useProjectNavigationSidebarNavigation } from './use-project-navigation-sidebar-navigation';
import { useSidebarDragAndDrop } from './use-sidebar-drag-and-drop';
import { WorkspaceNode } from './workspace-node';
@@ -101,6 +104,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
const tabNavigate = useTabNavigate();
const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false);
const [collectionSortOrders, setCollectionSortOrders] = useState<Record<string, SortOrder>>({});
const [unsyncedFilesByProjectId, setUnsyncedFilesByProjectId] = useState<Map<string, InsomniaFile[]>>(new Map());
const [projectNavigationSidebarFilter, setProjectNavigationSidebarFilter] = reactUse.useLocalStorage(
`${organizationId}:project-navigation-sidebar-filter`,
@@ -345,8 +349,19 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
const unsyncedWorkspaces = models.project.isRemoteProject(project)
? getUnsyncedRemoteWorkspaces(unsyncedFilesByProjectId.get(projectId) || [], sortedWorkspaces)
: [];
const allWorkspaces = [...sortedWorkspaces, ...unsyncedWorkspaces];
// If there is no workspace under the project, show an empty workspace node
if (allWorkspaces.length === 0) {
items.push({
kind: 'emptyProject',
organizationId,
project,
hidden: isProjectCollapsed,
doc: { _id: `empty-project-${projectId}`, name: '' },
});
}
for (const workspace of [...sortedWorkspaces, ...unsyncedWorkspaces]) {
for (const workspace of allWorkspaces) {
if (workspace.scope === 'unsynced') {
items.push({
kind: 'unsyncedWorkspace',
@@ -382,8 +397,14 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
const shouldHideCollectionChildren = isWorkspaceCollapsed || isProjectCollapsed;
let collectionChildren =
(!shouldHideCollectionChildren || !!projectNavigationSidebarFilter) && allRequestsAndMetaInWorkspace
? flattenCollectionChildren(workspaceId, shouldHideCollectionChildren, allRequestsAndMetaInWorkspace)
? flattenCollectionChildren(
workspaceId,
shouldHideCollectionChildren,
allRequestsAndMetaInWorkspace,
collectionSortOrders[workspaceId] || 'type-manual',
)
: [];
const pinnedCollectionChildren = collectionChildren.filter(child => child.pinned);
if (projectNavigationSidebarFilter) {
// apply filter to collection children first
@@ -402,6 +423,32 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
items.find(i => i.kind === 'workspace' && i.doc._id === workspaceId)!.hidden = shouldHide;
}
if (pinnedCollectionChildren.length > 0) {
items.push({
kind: 'pinnedHeader',
hidden: false,
doc: { _id: `${workspaceId}-pinned-header`, name: 'Pinned' },
});
}
pinnedCollectionChildren.forEach((child, idx) => {
items.push({
kind: 'pinnedRequest',
organizationId,
project: project,
workspace: workspace as Workspace,
children: child.children,
ancestors: child.ancestors,
doc: child.doc,
collapsed: child.collapsed,
hidden: child.hidden,
level: child.level,
pinned: child.pinned,
isFirstPinned: idx === 0,
isLastPinned: idx === pinnedCollectionChildren.length - 1,
});
});
collectionChildren.forEach(child => {
items.push({
kind: 'collectionChild',
@@ -416,7 +463,35 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
level: child.level,
pinned: child.pinned,
});
if (
models.requestGroup.isRequestGroupId(child.doc._id) &&
child.children?.length === 0 &&
!projectNavigationSidebarFilter
) {
// If there is a request group with no children, add an empty folder node
items.push({
kind: 'emptyFolder',
organizationId,
project,
workspace: workspace as Workspace,
requestGroup: child.doc as RequestGroup,
doc: { _id: `empty-folder-${child.doc._id}`, name: '' },
hidden: child.collapsed,
level: child.level,
});
}
});
if (collectionChildren.length === 0 && !shouldHideCollectionChildren && !projectNavigationSidebarFilter) {
items.push({
kind: 'emptyCollection',
organizationId,
project: project,
workspace: workspace as Workspace,
doc: { _id: `empty-collection-${(workspace as Workspace)._id}`, name: '' },
hidden: false,
});
}
}
}
@@ -435,7 +510,11 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
// If there is an active filter, expand all items to show matched results and their ancestors
if (projectNavigationSidebarFilter) {
items.forEach(item => (item.collapsed = false));
items.forEach(item => {
if ('collapsed' in item) {
item.collapsed = false;
}
});
}
setFlatItems(items);
@@ -448,6 +527,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
projectNavigationSidebarFilter,
projectsWithPresence,
unsyncedFilesByProjectId,
collectionSortOrders,
]);
const toggleProjectOrWorkspace = useCallback(
@@ -518,48 +598,64 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
const toggledChildren: { id: string; parentIsCollapsed: boolean }[] = [];
return nextFlatItems.map(item => {
if (item.kind !== 'collectionChild') {
return item;
}
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;
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(
...(item.children?.map(child => ({
id: child.doc._id,
parentIsCollapsed: isToggledRequestGroupCollapsed,
})) ?? []),
...(children?.map(child => ({ id: child.doc._id, parentIsCollapsed: collapsed })) ?? []),
);
return {
...item,
collapsed,
hidden: false,
};
}
return {
...item,
hidden: parentIsCollapsed,
};
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,
};
}
}
return item;
@@ -599,15 +695,23 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 border-b border-solid border-b-(--hl-md)">
{['projects', 'konnect'].map(tabName => (
<button
key={tabName}
className={`border-b-2 border-solid px-4 py-2 text-xs uppercase ${activeTab === tabName ? 'border-(--color-surprise) text-(--color-font)' : 'border-b-transparent text-(--hl) hover:bg-(--hl-xs)'}`}
onClick={() => setActiveTab(tabName as 'projects' | 'konnect')}
>
{tabName === 'projects' ? `Projects (${nonKonnectProjects.length})` : `Konnect (${konnectProjects.length})`}
</button>
))}
{!isScratchPad &&
['projects', 'konnect'].map(tabName => (
<button
key={tabName}
className={`border-b-2 border-solid px-4 py-2 text-xs ${activeTab === tabName ? 'border-(--color-surprise) text-(--color-font)' : 'border-b-transparent text-(--hl) hover:bg-(--hl-xs)'}`}
onClick={() => setActiveTab(tabName as 'projects' | 'konnect')}
>
{tabName === 'projects' ? (
`Projects (${nonKonnectProjects.length})`
) : (
<span className="flex items-center gap-1">
<KongLogo />
Konnect ({konnectProjects.length})
</span>
)}
</button>
))}
</div>
<div className="flex justify-between gap-1 p-(--padding-sm)">
<SearchField
@@ -679,8 +783,9 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
return (
<GridListItem
key={virtualItem.key}
id={item.doc._id}
// Prefix pinned-request to the key and id to ensure pinned items have a different key and id from non-pinned items with the same doc._id
key={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${virtualItem.key}`}
id={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${item.doc._id}`}
textValue={item.doc.name || item.kind}
onAuxClick={e => {
if (e.button === 1 && item.kind === 'collectionChild') {
@@ -719,7 +824,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
} else if (item.kind === 'collectionChild') {
} else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') {
if (
routeInfo?.resourceId === docId &&
models.requestGroup.isRequestGroupId(docId) &&
@@ -753,10 +858,33 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
<ProjectNode item={item} onToggle={toggleProjectOrWorkspace} storageRules={storageRules} />
)}
{item.kind === 'workspace' && <WorkspaceNode item={item} onToggle={toggleProjectOrWorkspace} />}
{item.kind === 'workspace' && (
<WorkspaceNode
item={item}
onToggle={toggleProjectOrWorkspace}
sortOrder={collectionSortOrders[item.doc._id] || 'type-manual'}
onSortOrderChange={newSortOder => {
if (item.doc.scope === 'collection') {
setCollectionSortOrders(prev => {
const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder };
return newCollectionSortOrders;
});
}
}}
/>
)}
{item.kind === 'pinnedHeader' && <PinnedHeaderNode />}
{item.kind === 'collectionChild' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
{item.kind === 'pinnedRequest' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
{item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? (
<EmptyNode item={item} storageRules={storageRules} />
) : null}
</GridListItem>
);
}}

View File

@@ -38,7 +38,7 @@ export const ProjectNode = ({ item, storageRules, onToggle }: ProjectNodeProps)
: 'laptop'
}
/>
<span className="min-w-0 flex-1 truncate text-sm">{projectName}</span>
<span className="min-w-0 flex-1 truncate text-base text-[rgb(var(--color-font-rgb),0.8)]">{projectName}</span>
</div>
{presence.length > 0 && <AvatarGroup size="small" maxAvatars={3} items={presence} />}
{projectId !== models.project.SCRATCHPAD_PROJECT_ID && (

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button } from 'react-aria-components';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import { toKebabCase } from '~/common/misc';
import type {
GrpcRequest,
McpRequest,
@@ -16,9 +17,12 @@ import { RequestGroupActionsDropdown } from '~/ui/components/dropdowns/request-g
import { EditableInput } from '~/ui/components/editable-input';
import { showModal } from '~/ui/components/modals';
import { PromptModal } from '~/ui/components/modals/prompt-modal';
import type { CollectionChildFlatItem } from '~/ui/components/sidebar/project-navigation-sidebar/types';
import type {
CollectionChildFlatItem,
PinnedRequestFlatItem,
} from '~/ui/components/sidebar/project-navigation-sidebar/types';
import { getMethodShortHand, getRequestMethodShortHand } from '~/ui/components/tags/method-tag';
import { useRequestGroupPatcher, useRequestPatcher } from '~/ui/hooks/use-request';
import { useRequestGroupPatcher, useRequestMetaPatcher, useRequestPatcher } from '~/ui/hooks/use-request';
import { Icon } from '../../icon';
import {
@@ -82,32 +86,28 @@ const getRequestNameOrFallback = (
};
interface RequestNodeProps {
item: CollectionChildFlatItem;
item: CollectionChildFlatItem | PinnedRequestFlatItem;
onToggleFolder: (requestGroupIds: string[], workspace: Workspace) => void;
className?: string;
}
export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => {
const { doc, level, workspace, project, collapsed } = item;
export const RequestNode = ({ item, onToggleFolder, className }: RequestNodeProps) => {
const { doc, level: requestLevel, workspace, project, collapsed, pinned, kind } = item;
const isPinnedRequest = kind === 'pinnedRequest';
const isLastPinned = item.kind === 'pinnedRequest' && item.isLastPinned;
const patchRequest = useRequestPatcher();
const patchGroup = useRequestGroupPatcher();
const workspaceId = workspace._id;
const patchRequest = useRequestPatcher(workspaceId);
const patchGroup = useRequestGroupPatcher(workspaceId);
const patchRequestMeta = useRequestMetaPatcher(workspaceId);
const isFolder = models.requestGroup.isRequestGroup(doc);
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [isEditable, setIsEditable] = useState(false);
// Pinned requests are always shown at the top level of the sidebar, so we set their level to 0.
const level = isPinnedRequest ? 0 : requestLevel;
return (
<div className={ROW_CLASS} style={{ paddingLeft: `${level + 3}rem` }}>
{Array.from({ length: level + 2 }, (_, i) => {
const isActive = i === level + 1;
return (
<span
key={i}
className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm) ${isActive ? 'group-hover:bg-(--hl-sm)' : ''}`}
style={{ left: `${i + 1.5}em` }}
/>
);
})}
<span className={ACTIVE_BORDER_CLASS} />
const content = (
<>
<Button
aria-label={`${collapsed ? 'Expand' : 'Collapse'} ${doc.name}`}
onPress={() => isFolder && onToggleFolder([doc._id], workspace)}
@@ -115,28 +115,40 @@ export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => {
>
{isFolder ? <Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} className={ICON_CLASS} /> : null}
</Button>
{isFolder ? (
<Icon icon="folder" className={ICON_CLASS} />
) : (
<>
<MethodBadge doc={doc} />
</>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left transition-colors">
{isFolder ? <Icon icon="folder" className={ICON_CLASS} /> : <MethodBadge doc={doc} />}
<EditableInput
value={getRequestNameOrFallback(doc)}
name="request name"
ariaLabel="request name"
className="flex-1 text-base hover:bg-transparent!"
onEditableChange={editable => setIsEditable(editable)}
onSubmit={newName => {
if (models.requestGroup.isRequestGroup(doc)) {
patchGroup(doc._id, { name: newName });
} else {
patchRequest(doc._id, { name: newName });
}
}}
/>
</div>
{!models.requestGroup.isRequestGroup(doc) && pinned && !isPinnedRequest && (
<TooltipTrigger>
<Button
data-testid={`pin-${toKebabCase(doc.name)}`}
className="flex aspect-square h-6 items-center justify-center rounded-xs text-base text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:ring-(--hl-md) focus:outline-hidden focus:ring-inset aria-pressed:bg-(--hl-sm)"
onPress={() => patchRequestMeta(item.doc._id, { pinned: false })}
>
<Icon icon="thumb-tack" />
</Button>
<Tooltip
offset={8}
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-base text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Unpin Request
</Tooltip>
</TooltipTrigger>
)}
<EditableInput
value={getRequestNameOrFallback(doc)}
name="request name"
ariaLabel="request name"
className="flex-1 px-1 text-sm"
onEditableChange={editable => setIsEditable(editable)}
onSubmit={newName => {
if (models.requestGroup.isRequestGroup(doc)) {
patchGroup(doc._id, { name: newName });
} else {
patchRequest(doc._id, { name: newName });
}
}}
/>
{models.requestGroup.isRequestGroup(doc) && !isEditable && (
<RequestGroupActionsDropdown
requestGroup={doc}
@@ -176,6 +188,54 @@ export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => {
onOpenChange={setIsContextMenuOpen}
/>
)}
</>
);
return (
<div
className={`${ROW_CLASS} ${className ?? ''} ${isPinnedRequest ? 'h-full! group-hover:bg-transparent! group-focus:bg-transparent!' : ''}`}
style={{ paddingLeft: `${level + 3}rem` }}
>
{isPinnedRequest ? (
<>
<span className={`${GUIDE_LINE_CSS} left-6 group-hover/tree:bg-(--hl-sm)`} />
<span className={`${GUIDE_LINE_CSS} left-10 group-hover/tree:bg-(--hl-sm)`} />
</>
) : (
Array.from({ length: level + 2 }, (_, i) => {
const isActive = i === level + 1;
return (
<span
key={i}
className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm) ${isActive ? 'group-hover:bg-(--hl-sm)' : ''}`}
style={{ left: `${i + 1.5}em` }}
/>
);
})
)}
<span className={ACTIVE_BORDER_CLASS} />
{isPinnedRequest ? (
<div
className={`ml-2 flex h-full min-w-0 flex-1 items-center overflow-hidden border-x border-solid border-(--hl-md) bg-(--hl-xs) pr-2 group-hover:bg-(--hl-sm) group-focus:bg-(--hl-sm) ${isLastPinned ? 'border-b' : ''}`}
>
{content}
</div>
) : (
content
)}
</div>
);
};
export const PinnedHeaderNode = () => {
return (
<div className={`${ROW_CLASS} group h-full! pl-12 group-hover:bg-transparent!`}>
<span className={`${GUIDE_LINE_CSS} left-6 group-hover/tree:bg-(--hl-sm)`} />
<span className={`${GUIDE_LINE_CSS} left-10 group-hover/tree:bg-(--hl-sm)`} />
<div className="ml-2 flex h-full w-full items-center border border-b-0 border-solid border-(--hl-md) bg-(--hl-xs) p-1 text-(--hl)">
<Icon icon="thumb-tack" className="h-4 w-4 shrink-0" />
<span className="ml-1 text-xs">Pinned</span>
</div>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import type { InsomniaFile } from '~/common/project';
import type { GitRepository, Project, Workspace, WorkspaceMeta } from '~/insomnia-data';
import type { GitRepository, Project, RequestGroup, Workspace, WorkspaceMeta } from '~/insomnia-data';
import type { BaseModel } from '~/models/types';
import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
@@ -40,7 +40,7 @@ export interface WorkspaceFlatItem extends BaseFlatItem<Workspace> {
//unsynced workspace in clod sync project
type UnsyncedWorkspaceDoc = InsomniaFile & { _id: string };
export type UnsyncedWorkspaceFlatItem = Exclude<BaseFlatItem<any>, 'doc'> &
export type UnsyncedWorkspaceFlatItem = Omit<BaseFlatItem<any>, 'doc'> &
Pick<WorkspaceFlatItem, 'project'> & {
kind: 'unsyncedWorkspace';
doc: UnsyncedWorkspaceDoc;
@@ -60,4 +60,34 @@ export interface CollectionChildFlatItem extends BaseFlatItem<Child['doc']> {
pinned: boolean;
}
export type FlatItem = ProjectFlatItem | WorkspaceFlatItem | CollectionChildFlatItem | UnsyncedWorkspaceFlatItem;
export interface PinnedRequestFlatItem extends Omit<CollectionChildFlatItem, 'kind'> {
kind: 'pinnedRequest';
isFirstPinned: boolean;
isLastPinned: boolean;
}
export interface PinnedHeaderFlatItem {
kind: 'pinnedHeader';
hidden: boolean;
doc: { _id: string; name: string };
}
export interface EmptyNodeFlatItem {
kind: 'emptyProject' | 'emptyCollection' | 'emptyFolder';
hidden: boolean;
organizationId: string;
doc: { _id: string; name: string };
project: ProjectWithPresence;
workspace?: Workspace;
requestGroup?: RequestGroup;
level?: number;
}
export type FlatItem =
| ProjectFlatItem
| WorkspaceFlatItem
| CollectionChildFlatItem
| UnsyncedWorkspaceFlatItem
| PinnedRequestFlatItem
| PinnedHeaderFlatItem
| EmptyNodeFlatItem;

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Button } from 'react-aria-components';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file';
import { showToast } from '~/ui/components/toast-notification';
@@ -41,24 +41,34 @@ export const UnsyncedWorkspaceNode = ({ item }: { item: UnsyncedWorkspaceFlatIte
<span className={ACTIVE_BORDER_CLASS} />
<span className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm)`} style={{ left: '1.5em' }} />
<Button className={TOGGLE_BTN_CLASS} aria-label="" isDisabled />
<Button
onPress={() => {
const { project, doc, organizationId } = item;
const { remoteId: backendProjectId } = doc;
if (project.remoteId && backendProjectId) {
pullRemoteFileFetcher.submit({
backendProjectId,
remoteId: project.remoteId,
organizationId,
});
}
}}
isDisabled={isPulling}
className={`flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left opacity-60 transition-colors ${isPulling ? 'animate-pulse cursor-not-allowed' : ''}`}
>
<Icon icon={isPulling ? 'spinner' : 'cloud-download'} className={ICON_CLASS} spin={isPulling} />
<span>{item.doc.name}</span>
</Button>
<TooltipTrigger>
<Button
onPress={() => {
const { project, doc, organizationId } = item;
const { remoteId: backendProjectId } = doc;
if (project.remoteId && backendProjectId) {
pullRemoteFileFetcher.submit({
backendProjectId,
remoteId: project.remoteId,
organizationId,
});
}
}}
isDisabled={isPulling}
className={`flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left opacity-60 transition-colors ${isPulling ? 'animate-pulse cursor-not-allowed' : ''}`}
>
<div className="flex h-5 w-5 items-center justify-center rounded-sm px-2">
<Icon icon={isPulling ? 'spinner' : 'cloud-download'} className={ICON_CLASS} spin={isPulling} />
</div>
<span className="text-base text-[rgb(var(--color-font-rgb),0.8)]">{item.doc.name}</span>
</Button>
<Tooltip
placement="top"
className="flex max-h-[85vh] min-w-max items-center gap-2 overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
>
Click to fetch this file
</Tooltip>
</TooltipTrigger>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import { Button } from 'react-aria-components';
import { scopeToIconMap } from '~/common/get-workspace-label';
import type { SortOrder } from '~/common/constants';
import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label';
import { SidebarWorkspaceDropdown } from '~/ui/components/dropdowns/sidebar-workspace-dropdown';
import { Icon } from '../../icon';
@@ -15,10 +16,12 @@ import { type WorkspaceFlatItem } from './types';
interface WorkspaceNodeProps {
item: WorkspaceFlatItem;
sortOrder: SortOrder;
onToggle: (workspaceId: string) => void;
onSortOrderChange: (newSortOrder: SortOrder) => void;
}
export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => {
export const WorkspaceNode = ({ item, sortOrder, onToggle, onSortOrderChange }: WorkspaceNodeProps) => {
const { doc, collapsed, project, organizationId } = item;
const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc;
const isCollection = workspaceScope === 'collection';
@@ -35,11 +38,22 @@ export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => {
{isCollection ? <Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} className={ICON_CLASS} /> : null}
</Button>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left transition-colors">
<Icon icon={scopeToIconMap[workspaceScope]} className={ICON_CLASS} />
<span className="min-w-0 flex-1 truncate text-sm">{workspaceName}</span>
<div
className={`${scopeToBgColorMap[workspaceScope]} ${scopeToTextColorMap[workspaceScope]} flex h-5 w-5 items-center justify-center rounded-sm px-2`}
>
<Icon icon={scopeToIconMap[workspaceScope]} className={ICON_CLASS} />
</div>
<span className="min-w-0 flex-1 truncate text-base">{workspaceName}</span>
</div>
<div className="shrink-0">
<SidebarWorkspaceDropdown workspace={doc} project={project} organizationId={organizationId} />
<SidebarWorkspaceDropdown
workspace={doc}
project={project}
sortOrder={sortOrder}
organizationId={organizationId}
onSortOrderChange={onSortOrderChange}
/>
</div>
</div>
);

View File

@@ -3,16 +3,19 @@ type EventHandler = (...args: any[]) => void;
export const OAUTH2_AUTHORIZATION_STATUS_CHANGE = 'OAUTH2_AUTHORIZATION_STATUS_CHANGE';
// This event is emitted when remote cloud sync file is changed, including project, workspace creation, deletion and update.
export const CLOUD_SYNC_FILE_CHANGE = 'CLOUD_SYNC_FILE_CHANGE';
export const TOGGLE_PROJECT_SIDEBAR = 'TOGGLE_PROJECT_SIDEBAR';
type UIEventType =
| 'CLOSE_TAB'
| 'CHANGE_ACTIVE_ENV'
| typeof TOGGLE_PROJECT_SIDEBAR
| typeof CLOUD_SYNC_FILE_CHANGE
| typeof OAUTH2_AUTHORIZATION_STATUS_CHANGE;
class EventBus {
private events: Record<UIEventType, EventHandler[]> = {
CLOSE_TAB: [],
CHANGE_ACTIVE_ENV: [],
[TOGGLE_PROJECT_SIDEBAR]: [],
[CLOUD_SYNC_FILE_CHANGE]: [],
[OAUTH2_AUTHORIZATION_STATUS_CHANGE]: [],
};

View File

@@ -23,7 +23,7 @@ import { useRequestGroupUpdateMetaActionFetcher } from '~/routes/organization.$o
import { useWorkspaceUpdateMetaActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.update-meta';
import { useSettingsUpdateActionFetcher } from '~/routes/settings.update';
export const useRequestPatcher = () => {
export const useRequestPatcher = (requestWorkspaceId = '') => {
const { organizationId, projectId, workspaceId } = useParams() as {
organizationId: string;
projectId: string;
@@ -44,12 +44,13 @@ export const useRequestPatcher = () => {
patch,
projectId,
requestId,
workspaceId,
// If workspaceId is not available in params, use the workspaceId from the argument. This is used in global navigation side bar where workspaceId might not in the url params
workspaceId: workspaceId || requestWorkspaceId,
});
};
};
export const useRequestMetaPatcher = () => {
export const useRequestMetaPatcher = (requestWorkspaceId = '') => {
const { organizationId, projectId, workspaceId } = useParams() as {
organizationId: string;
projectId: string;
@@ -60,14 +61,15 @@ export const useRequestMetaPatcher = () => {
fetcher.submit({
organizationId,
projectId,
workspaceId,
// If workspaceId is not available in params, use the workspaceId from the argument. This is used in global navigation side bar where workspaceId might not in the url params
workspaceId: workspaceId || requestWorkspaceId,
requestId,
patch,
});
};
};
export const useRequestGroupPatcher = () => {
export const useRequestGroupPatcher = (requestWorkspaceId = '') => {
const { organizationId, projectId, workspaceId } = useParams() as {
organizationId: string;
projectId: string;
@@ -78,14 +80,15 @@ export const useRequestGroupPatcher = () => {
fetcher.submit({
organizationId,
projectId,
workspaceId,
// If workspaceId is not available in params, use the workspaceId from the argument. This is used in global navigation side bar where workspaceId might not in the url params
workspaceId: workspaceId || requestWorkspaceId,
requestGroupId,
patch,
});
};
};
export const useRequestGroupMetaPatcher = () => {
export const useRequestGroupMetaPatcher = (requestWorkspaceId = '') => {
const { organizationId, projectId, workspaceId } = useParams() as {
organizationId: string;
projectId: string;
@@ -96,7 +99,8 @@ export const useRequestGroupMetaPatcher = () => {
fetcher.submit({
organizationId,
projectId,
workspaceId,
// If workspaceId is not available in params, use the workspaceId from the argument. This is used in global navigation side bar where workspaceId might not in the url params
workspaceId: workspaceId || requestWorkspaceId,
requestGroupId,
patch,
});