feat: Add workspace dropdown for new sidebar (#9821)

* support workspace-dropdown in new sidenav

* make the drag more easy to touch
This commit is contained in:
Kent Wang
2026-04-21 11:39:04 +08:00
committed by GitHub
parent ae8731c5b2
commit 5f4f71aa2b
3 changed files with 468 additions and 2 deletions

View File

@@ -182,7 +182,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
{activeProject && models.project.isRemoteProject(activeProject) && <CloudSyncProjectBar />}
</div>
</Panel>
<PanelResizeHandle className="h-full w-px bg-(--hl-md)" hitAreaMargins={{ coarse: 15, fine: 5 }} />
<PanelResizeHandle className="relative z-10 h-full w-px bg-(--hl-md)" hitAreaMargins={{ coarse: 20, fine: 20 }} />
<Panel id="pane-one" className="pane-one theme--pane flex flex-col">
<Outlet />
</Panel>

View File

@@ -0,0 +1,462 @@
import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core';
import {
exportGlobalEnvironmentToFile,
exportMcpClientToFile,
exportMockServerToFile,
} from 'insomnia/src/ui/components/settings/import-export';
import React, { Fragment, useState } from 'react';
import {
Button,
Collection,
Dialog,
Header,
Heading,
Label,
Menu,
MenuItem,
MenuSection,
MenuTrigger,
Modal,
ModalOverlay,
Popover,
Radio,
RadioGroup,
} from 'react-aria-components';
import { href } from 'react-router';
import type { Project, Workspace } from '~/insomnia-data';
import { models } 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 { useWorkspaceDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.delete';
import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.update';
import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import type { CreateRequestType } from '~/ui/hooks/use-request';
import { getProductName } from '../../../common/constants';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import type { PlatformKeyCombinations } from '../../../common/settings';
import { SegmentEvent } from '../../analytics';
import { DropdownHint } from '../base/dropdown/dropdown-hint';
import { Icon } from '../icon';
import { showModal } from '../modals';
import { ExportRequestsModal } from '../modals/export-requests-modal';
import { ImportModal } from '../modals/import-modal/import-modal';
import { PasteCurlModal } from '../modals/paste-curl-modal';
import { PromptModal } from '../modals/prompt-modal';
import { WorkspaceDuplicateModal } from '../modals/workspace-duplicate-modal';
import { WorkspaceSettingsModal } from '../modals/workspace-settings-modal';
interface Props {
workspace: Workspace;
project: Project;
organizationId: string;
}
interface ActionItem {
id: string;
name: string;
icon: IconName;
hint?: PlatformKeyCombinations;
action: () => void;
className?: string;
}
interface ActionSection {
name: string;
id: string;
icon: IconProp;
items: ActionItem[];
}
export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }: Props) => {
const projectId = project._id;
const workspaceId = workspace._id;
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isPasteCurlModalOpen, setPasteCurlModalOpen] = useState(false);
const updateWorkspaceFetcher = useWorkspaceUpdateActionFetcher();
const deleteWorkspaceFetcher = useWorkspaceDeleteActionFetcher();
const newRequestFetcher = useRequestNewActionFetcher();
const newRequestGroupFetcher = useRequestGroupNewActionFetcher();
const tabNavigate = useTabNavigate();
const workspaceName = workspace.name;
const projectName = project.name || getProductName();
const isCollection = workspace.scope === 'collection';
const createRequest = (requestType: CreateRequestType) => {
newRequestFetcher.submit({
organizationId,
projectId,
workspaceId,
requestType,
parentId: workspaceId,
});
};
const openInNewTab = (isRunner = false) => {
tabNavigate(
{ organization: organizationId, project, workspace, item: workspace },
isRunner ? { shouldNavigate: true, asRunner: true } : { withTab: true, shouldNavigate: true },
);
};
const createSections: ActionSection[] = isCollection
? [
{
name: 'Create',
id: 'create',
icon: 'plus',
items: [
{
id: 'New Folder',
name: 'New Folder',
icon: 'folder',
action: () =>
showModal(PromptModal, {
title: 'New Folder',
defaultValue: 'My Folder',
submitName: 'Create',
label: 'Name',
selectText: true,
onComplete: (name: string) =>
newRequestGroupFetcher.submit({
organizationId,
projectId,
workspaceId,
parentId: workspaceId,
name,
}),
}),
},
{
id: 'HTTP',
name: 'HTTP Request',
icon: 'plus-circle',
action: () => createRequest('HTTP'),
},
{
id: 'Event Stream',
name: 'Event Stream Request (SSE)',
icon: 'plus-circle',
action: () => createRequest('Event Stream'),
},
{
id: 'GraphQL Request',
name: 'GraphQL Request',
icon: 'plus-circle',
action: () => createRequest('GraphQL'),
},
{
id: 'gRPC Request',
name: 'gRPC Request',
icon: 'plus-circle',
action: () => createRequest('gRPC'),
},
{
id: 'WebSocket Request',
name: 'WebSocket Request',
icon: 'plus-circle',
action: () => createRequest('WebSocket'),
},
{
id: 'Socket.IO Request',
name: 'Socket.IO Request',
icon: 'plus-circle',
action: () => createRequest('SocketIO'),
},
],
},
{
name: 'Import',
id: 'import-create',
icon: 'file-import',
items: [
{
id: 'From Curl',
name: 'From Curl',
icon: 'terminal',
action: () => setPasteCurlModalOpen(true),
},
{
id: 'from-file',
name: 'From File',
icon: 'file-import',
action: () => setIsImportModalOpen(true),
},
],
},
{
name: 'Run',
id: 'run',
icon: 'circle-play',
items: [
{
id: 'RunCollection',
name: 'Run Collection',
icon: 'circle-play',
action: () => openInNewTab(true),
},
],
},
]
: [];
const actionSection: ActionSection = {
name: 'Actions',
id: 'actions',
icon: 'cog',
items: [
{
id: 'OpenInNewTab',
name: 'Open in New Tab',
icon: 'external-link-alt',
action: openInNewTab,
},
...(!models.workspace.isMcp(workspace)
? [
{
id: 'Duplicate',
name: 'Duplicate / Move',
icon: 'copy' as IconName,
action: () => setIsDuplicateModalOpen(true),
},
]
: []),
{
id: 'Rename',
name: 'Rename',
icon: 'pen-to-square' as IconName,
action: () =>
showModal(PromptModal, {
title: `Rename ${getWorkspaceLabel(workspace).singular}`,
defaultValue: workspaceName,
submitName: 'Rename',
selectText: true,
label: 'Name',
onComplete: (name: string) =>
updateWorkspaceFetcher.submit({
organizationId,
projectId,
patch: { name, workspaceId },
}),
}),
},
{
id: 'Export',
name: 'Export',
icon: 'file-export',
action: () => {
window.main.trackSegmentEvent({
event: SegmentEvent.exportStarted,
properties: { source: `${workspace.scope}-list` },
});
if (workspace.scope === 'mock-server') {
return exportMockServerToFile(workspace);
}
if (workspace.scope === 'environment') {
return exportGlobalEnvironmentToFile(workspace);
}
if (workspace.scope === 'mcp') {
return exportMcpClientToFile(workspace);
}
return setIsExportModalOpen(true);
},
},
{
id: 'Settings',
name: 'Settings',
icon: 'gear',
action: () => setIsSettingsModalOpen(true),
},
{
id: 'Delete',
name: 'Delete',
icon: 'trash',
className: 'text-(--color-danger)',
action: () => setIsDeleteModalOpen(true),
},
],
};
const allSections: ActionSection[] = [...createSections, actionSection];
return (
<Fragment>
<MenuTrigger>
<Button
aria-label="SideBar Workspace 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"
>
<Icon icon="ellipsis" />
</Button>
<Popover className="flex min-w-max flex-col overflow-y-hidden" placement="bottom end">
<Menu
aria-label="Workspace Actions Menu"
selectionMode="single"
onAction={key =>
allSections
.find(s => s.items.find(a => a.id === key))
?.items.find(a => a.id === key)
?.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"
>
{section => (
<MenuSection className="flex flex-1 flex-col">
<Header className="flex items-center gap-2 py-1 pl-2 text-xs text-(--hl) uppercase">
<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>
)}
</Collection>
</MenuSection>
)}
</Menu>
</Popover>
</MenuTrigger>
{isDuplicateModalOpen && (
<WorkspaceDuplicateModal workspace={workspace} onHide={() => setIsDuplicateModalOpen(false)} />
)}
{isImportModalOpen && (
<ImportModal
onHide={() => setIsImportModalOpen(false)}
from={{ type: 'file' }}
projectName={projectName}
workspaceName={workspaceName}
organizationId={organizationId}
defaultProjectId={projectId}
defaultWorkspaceId={workspaceId}
/>
)}
{isExportModalOpen && (
<ExportRequestsModal workspaceIdToExport={workspaceId} onClose={() => setIsExportModalOpen(false)} />
)}
{isSettingsModalOpen && (
<WorkspaceSettingsModal workspace={workspace} project={project} onClose={() => setIsSettingsModalOpen(false)} />
)}
{isDeleteModalOpen && (
<ModalOverlay
isOpen
onOpenChange={() => setIsDeleteModalOpen(false)}
isDismissable
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30"
>
<Modal
onOpenChange={() => setIsDeleteModalOpen(false)}
className="max-h-full w-full max-w-2xl rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)"
>
<Dialog className="outline-hidden">
{({ close }) => (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<Heading className="text-2xl">Delete {getWorkspaceLabel(workspace).singular}</Heading>
<Button
className="flex aspect-square h-6 shrink-0 items-center justify-center rounded-xs 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)"
onPress={close}
>
<Icon icon="x" />
</Button>
</div>
<deleteWorkspaceFetcher.Form
action={href(`/organization/:organizationId/project/:projectId/workspace/delete`, {
organizationId,
projectId: workspace.parentId,
})}
method="POST"
className="flex flex-col gap-4"
>
<input type="hidden" name="workspaceId" value={workspaceId} />
<div>
<p className="line-clamp-5">
This will permanently delete the{' '}
<strong className="break-all whitespace-pre-wrap">{workspaceName}</strong>{' '}
{getWorkspaceLabel(workspace).singular}
</p>
{models.project.isRemoteProject(project) && (
<RadioGroup name="localOnly" defaultValue="false" className="mb-2 flex flex-col gap-2">
<Label className="text-sm text-(--hl)">How do you want to delete it?</Label>
<div className="flex gap-2">
<Radio
value="true"
aria-label="Remove Local Copy"
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
>
<div>
<Heading className="text-lg font-bold">Remove Local Copy</Heading>
<p className="pt-2">The project will still exist on the Cloud.</p>
</div>
</Radio>
<Radio
value="false"
aria-label="Delete Permanently"
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
>
<div>
<Heading className="text-lg font-bold">Delete Permanently</Heading>
<p className="pt-2">
The project will be deleted everywhere. You cannot undo this action.
</p>
</div>
</Radio>
</div>
</RadioGroup>
)}
</div>
{deleteWorkspaceFetcher.data?.error && (
<p className="notice error margin-bottom-sm no-margin-top">{deleteWorkspaceFetcher.data.error}</p>
)}
<div className="flex justify-end">
<Button
type="submit"
aria-label="Delete Workspace"
className="rounded-xs border border-solid border-(--hl-md) bg-(--color-danger) px-3 py-2 text-(--color-font-danger) transition-colors hover:bg-(--color-danger)/90 hover:no-underline"
>
Delete
</Button>
</div>
</deleteWorkspaceFetcher.Form>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
)}
{isPasteCurlModalOpen && (
<PasteCurlModal
onImport={req => {
newRequestFetcher.submit({
organizationId,
projectId,
workspaceId,
requestType: 'From Curl',
parentId: workspaceId,
req,
});
}}
onHide={() => setPasteCurlModalOpen(false)}
/>
)}
</Fragment>
);
};

View File

@@ -1,6 +1,7 @@
import { Button } from 'react-aria-components';
import { scopeToIconMap } from '~/common/get-workspace-label';
import { SidebarWorkspaceDropdown } from '~/ui/components/dropdowns/sidebar-workspace-dropdown';
import { Icon } from '../../icon';
import {
@@ -18,7 +19,7 @@ interface WorkspaceNodeProps {
}
export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => {
const { doc, collapsed } = item;
const { doc, collapsed, project, organizationId } = item;
const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc;
return (
@@ -38,6 +39,9 @@ export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => {
<Icon icon={scopeToIconMap[workspaceScope]} className={ICON_CLASS} />
<span className="min-w-0 flex-1 truncate text-sm">{workspaceName}</span>
</div>
<div className="shrink-0">
<SidebarWorkspaceDropdown workspace={doc} project={project} organizationId={organizationId} />
</div>
</div>
);
};