diff --git a/packages/insomnia-smoke-test/playwright/pages/preferences/data-tab.ts b/packages/insomnia-smoke-test/playwright/pages/preferences/data-tab.ts index 66b7635f5c..d800c9ca00 100644 --- a/packages/insomnia-smoke-test/playwright/pages/preferences/data-tab.ts +++ b/packages/insomnia-smoke-test/playwright/pages/preferences/data-tab.ts @@ -55,6 +55,6 @@ export class PreferencesDataTab extends BasePage { */ private async waitForExportCompleteAlert(): Promise { await this.page.getByText('Export Complete').waitFor({ state: 'visible', timeout: 10_000 }); - await this.page.getByRole('button', { name: 'Ok' }).click(); + await this.page.getByRole('button', { name: 'Ok', exact: true }).click(); } } diff --git a/packages/insomnia-smoke-test/tests/smoke/insomnia-vault.test.ts b/packages/insomnia-smoke-test/tests/smoke/insomnia-vault.test.ts index b623a0c888..e8b0f4162a 100644 --- a/packages/insomnia-smoke-test/tests/smoke/insomnia-vault.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/insomnia-vault.test.ts @@ -45,7 +45,7 @@ test.describe('Vault key actions', () => { await expect.soft(modal).toBeVisible(); const vaultKeyValueInModal = await modal.getByTestId('VaultKeyDisplayPanel').innerText(); expect.soft(vaultKeyValueInModal.length).toBeGreaterThan(0); - await page.getByText('OK').click(); + await page.getByText('OK', { exact: true }).click(); const vaultKeyValue = page.getByTestId('VaultKeyDisplayPanel'); await expect.soft(vaultKeyValue).toHaveText(vaultKeyValueInModal); }); diff --git a/packages/insomnia/src/basic-components/select-popover.tsx b/packages/insomnia/src/basic-components/select-popover.tsx new file mode 100644 index 0000000000..0de611d304 --- /dev/null +++ b/packages/insomnia/src/basic-components/select-popover.tsx @@ -0,0 +1,151 @@ +import type { Key } from '@react-types/shared'; +import type { ReactNode } from 'react'; +import { useMemo, useState } from 'react'; +import type { Placement } from 'react-aria'; +import { Dialog, DialogTrigger, Heading, ListBox, ListBoxItem, Popover } from 'react-aria-components'; +import { twMerge } from 'tailwind-merge'; + +import { Button } from './button'; + +export interface SelectPopoverItem { + id: Key; + label: string; + textValue?: string; + isDisabled?: boolean; +} + +export interface SelectPopoverProps { + ariaLabel: string; + items: T[]; + selectedKey?: Key | null; + onSelectionChange: (key: Key) => void; + renderTrigger: (selectedItem: T | null) => ReactNode; + renderItem?: (item: T, isSelected: boolean) => ReactNode; + emptyState?: ReactNode; + footer?: ReactNode; + title?: ReactNode; + isDisabled?: boolean; + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + placement?: Placement; + offset?: number; + triggerClassName?: string; + popoverClassName?: string; + dialogClassName?: string; + listClassName?: string; + itemClassName?: string; +} + +export function SelectPopover({ + ariaLabel, + items, + selectedKey, + onSelectionChange, + renderTrigger, + renderItem, + emptyState, + footer, + title, + isDisabled, + isOpen: isOpenProp, + onOpenChange, + placement = 'bottom start', + offset = 8, + triggerClassName, + popoverClassName, + dialogClassName, + listClassName, + itemClassName, +}: SelectPopoverProps) { + const [internalIsOpen, setInternalIsOpen] = useState(false); + const isControlled = isOpenProp !== undefined; + const isOpen = isControlled ? isOpenProp : internalIsOpen; + + const setOpen = (nextIsOpen: boolean) => { + if (!isControlled) { + setInternalIsOpen(nextIsOpen); + } + + onOpenChange?.(nextIsOpen); + }; + + const selectedItem = useMemo(() => { + if (selectedKey === null || selectedKey === undefined) { + return null; + } + + return items.find(item => String(item.id) === String(selectedKey)) ?? null; + }, [items, selectedKey]); + + return ( + + + + + {title ? ( + + {title} + + ) : null} + { + if (keys === 'all' || !keys) { + return; + } + + const [nextKey] = keys.values(); + + if (nextKey === undefined) { + return; + } + + onSelectionChange(nextKey); + setOpen(false); + }} + renderEmptyState={() => (emptyState ?
{emptyState}
: null)} + className={twMerge( + 'flex min-h-0 flex-1 flex-col overflow-y-auto p-2 text-sm focus:outline-hidden data-empty:py-0', + listClassName, + )} + > + {item => ( + + {({ isSelected }) => renderItem?.(item, isSelected) ?? {item.label}} + + )} +
+ {footer ?
{footer}
: null} +
+
+
+ ); +} diff --git a/packages/insomnia/src/common/project.ts b/packages/insomnia/src/common/project.ts index 1e3118d85a..f5c98d12e5 100644 --- a/packages/insomnia/src/common/project.ts +++ b/packages/insomnia/src/common/project.ts @@ -6,10 +6,14 @@ import { type ApiSpec, database, type GitRepository, + type GrpcRequest, type MockServer, models, type Project, + type Request, services, + type SocketIORequest, + type WebSocketRequest, type Workspace, type WorkspaceMeta, type WorkspaceScope, @@ -87,6 +91,117 @@ const lockGenerator = () => { // TODO: move all project operations to this file to ensure they are properly wrapped with locks export const projectLock = lockGenerator(); +type TrackableRecentRequest = Request | WebSocketRequest | GrpcRequest | SocketIORequest; + +export interface RecentProjectRequest { + workspaceId: string; + request: TrackableRecentRequest; +} + +interface CachedProjectRecentRequest { + requestId: string; + workspaceId: string; +} + +// Keep a small buffer beyond the 3 visible items so Jump back in stays populated after deletions. +const MAX_RECENT_PROJECT_REQUESTS = 5; +const RECENT_PROJECT_REQUESTS_STORAGE_KEY_PREFIX = 'recent-project-requests'; + +const getRecentProjectRequestsStorageKey = (projectId: string) => + `${RECENT_PROJECT_REQUESTS_STORAGE_KEY_PREFIX}:${projectId}`; + +const writeCachedProjectRecentRequests = (projectId: string, recentRequests: CachedProjectRecentRequest[]) => { + if (typeof window === 'undefined' || !window.localStorage) { + return; + } + + const trimmedRecentRequests = recentRequests.slice(0, MAX_RECENT_PROJECT_REQUESTS); + + const storageKey = getRecentProjectRequestsStorageKey(projectId); + + if (trimmedRecentRequests.length === 0) { + window.localStorage.removeItem(storageKey); + return; + } + + window.localStorage.setItem(storageKey, JSON.stringify(trimmedRecentRequests)); +}; + +export const getCachedProjectRecentRequests = (projectId?: string): CachedProjectRecentRequest[] => { + if (!projectId || typeof window === 'undefined' || !window.localStorage) { + return []; + } + + try { + const storedRequestIds = window.localStorage.getItem(getRecentProjectRequestsStorageKey(projectId)); + + if (!storedRequestIds) { + return []; + } + + const parsedRequestIds = JSON.parse(storedRequestIds); + + if (!Array.isArray(parsedRequestIds)) { + return []; + } + + return parsedRequestIds as CachedProjectRecentRequest[]; + } catch { + return []; + } +}; + +export const recordProjectRecentRequest = ({ + projectId, + requestId, + workspaceId, +}: { + projectId: string; + requestId: string; + workspaceId: string; +}) => { + if (!projectId || !requestId || !workspaceId) { + return; + } + + const existingRecentRequests = getCachedProjectRecentRequests(projectId); + writeCachedProjectRecentRequests(projectId, [ + { requestId, workspaceId }, + ...existingRecentRequests.filter(storedRequest => storedRequest.requestId !== requestId), + ]); +}; + +export const getProjectRecentRequests = async (projectId?: string) => { + const cachedRecentRequests = getCachedProjectRecentRequests(projectId); + + if (!projectId || cachedRecentRequests.length === 0) { + return []; + } + + const recentRequests = ( + await Promise.all( + cachedRecentRequests.map(async ({ requestId, workspaceId }): Promise => { + try { + const request = (await services.helpers.getRequestById(requestId)) as TrackableRecentRequest | null; + + if (!request) { + return null; + } + + return { + workspaceId, + request, + }; + } catch { + return null; + } + }), + ) + ).filter(isNotNullOrUndefined); + + return recentRequests; +}; + export const checkSingleProjectSyncStatus = async (projectId: string) => { const projectWorkspaces = await services.workspace.findByParentId(projectId); const workspaceMetas = await database.find(models.workspaceMeta.type, { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 372846b896..283ff52733 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -41,6 +41,7 @@ import { AnalyticsEvent, trackOnceDaily } from '~/ui/analytics'; import { AvatarGroup } from '~/ui/components/avatar'; import { WorkspaceCardDropdown } from '~/ui/components/dropdowns/workspace-card-dropdown'; import { ErrorBoundary } from '~/ui/components/error-boundary'; +import { FirstRequestCreation } from '~/ui/components/first-request-creation'; import { Icon } from '~/ui/components/icon'; import { ImportModal } from '~/ui/components/modals/import-modal/import-modal'; import { NewWorkspaceModal } from '~/ui/components/modals/new-workspace-modal'; @@ -142,6 +143,36 @@ const Component = () => { userSession.accountId && models.organization.isOwnerOfOrganization({ organization, accountId: userSession.accountId }); const isPersonalOrg = organization && models.organization.isPersonalOrganization(organization); + const greetingName = userSession.firstName || userSession.email.split('@')[0] || 'there'; + const collectionItems = useMemo( + () => + localFiles + .filter(file => file.scope === 'collection' && file.workspace) + .map(file => ({ + id: file.workspace!._id, + label: file.name, + })), + [localFiles], + ); + const [selectedCollectionId, setSelectedCollectionId] = useState(null); + const [newWorkspaceModalState, setNewWorkspaceModalState] = useState<{ + scope: WorkspaceScope; + isOpen: boolean; + redirect?: boolean; + } | null>({ + scope: 'collection', + isOpen: false, + }); + + useEffect(() => { + setSelectedCollectionId(currentSelection => { + if (currentSelection && collectionItems.some(collection => collection.id === currentSelection)) { + return currentSelection; + } + + return collectionItems[0]?.id ?? null; + }); + }, [collectionItems]); const tabNavigate = useTabNavigate(); @@ -219,14 +250,6 @@ const Component = () => { }, })); - 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 = () => @@ -308,6 +331,17 @@ const Component = () => { +
+ { + setNewWorkspaceModalState({ scope: 'collection', isOpen: true, redirect: false }); + }} + /> +
{activeProject ? (
{billing.isActive ? null : ( @@ -668,10 +702,17 @@ const Component = () => { project={activeProject} storageRules={storageRules} scope={newWorkspaceModalState.scope} + onCreateWorkspace={workspaceId => { + if (newWorkspaceModalState.scope === 'collection' && newWorkspaceModalState.redirect === false) { + setSelectedCollectionId(workspaceId); + } + }} + redirectAfterCreate={newWorkspaceModalState.redirect} onOpenChange={isOpen => { setNewWorkspaceModalState({ scope: newWorkspaceModalState.scope, isOpen, + redirect: newWorkspaceModalState.redirect, }); }} /> diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index 86bec9cd4c..48a129aba5 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -61,13 +61,12 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { invariant(projectId, 'Project ID is required'); invariant(organizationId, 'Organization ID is required'); - if (!models.project.isScratchpadProject({ _id: projectId })) { - const { id: sessionId } = await services.userSession.get(); + const userSession = await services.userSession.get(); + const { id: sessionId, accountId } = userSession; - if (!sessionId) { - await logout(); - throw redirect(href('/auth/login')); - } + if (!models.project.isScratchpadProject({ _id: projectId }) && !sessionId) { + await logout(); + throw redirect(href('/auth/login')); } const project = await services.project.get(projectId); @@ -76,6 +75,16 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { return redirect(href('/organization/:organizationId', { organizationId })); } + const organization = await services.organization.get(organizationId); + + if (accountId && organization && models.organization.isPersonalOrganization(organization)) { + const firstPersonalOrgLandingKey = `firstPersonalOrgLandingHandled:${accountId}`; + + if (!window.localStorage.getItem(firstPersonalOrgLandingKey)) { + window.localStorage.setItem(firstPersonalOrgLandingKey, 'true'); + } + } + const fallbackLearningFeature = { active: false, title: '', diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new.tsx index e4ed640de9..97960170ff 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new.tsx @@ -23,7 +23,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) const { requestType, parentId, req } = (await request.json()) as { requestType: CreateRequestType; parentId?: string; - req?: Request; + req?: Partial; }; const settings = await services.settings.getOrCreate(); @@ -44,7 +44,8 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) await services.request.create({ parentId: parentId || workspaceId, method: METHOD_GET, - name: 'New Request', + name: req?.name || 'New Request', + url: req?.url || '', headers: defaultHeaders, }) )._id; @@ -65,9 +66,11 @@ export async function clientAction({ params, request }: Route.ClientActionArgs) headers: [...defaultHeaders, { name: 'Content-Type', value: CONTENT_TYPE_JSON }], body: { mimeType: CONTENT_TYPE_GRAPHQL, - text: '', + text: req?.body?.text || '', }, - name: 'New Request', + name: req?.name || 'New Request', + url: req?.url || '', + authentication: req?.authentication, }) )._id; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx index 947575f8f1..132be74f13 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.new.tsx @@ -18,6 +18,7 @@ import { mockRouteToHar } from './organization.$organizationId.project.$projectI interface NewWorkspaceData { name: string; scope: WorkspaceScope; + mcpServerUrl?: string; folderPath?: string; mockServerType?: 'self-hosted' | 'cloud'; mockServerUrl?: string; @@ -36,6 +37,7 @@ interface NewWorkspaceData { export async function clientAction({ request, params }: Route.ClientActionArgs) { const { organizationId, projectId } = params; try { + const redirectAfterCreate = new URL(request.url).searchParams.get('redirectAfterCreate') !== 'false'; const workspaceData = (await request.json()) as NewWorkspaceData; const project = await services.project.get(projectId); @@ -138,7 +140,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) await services.mcpRequest.create({ parentId: workspace._id, transportType: 'streamable-http', - url: '', + url: workspaceData.mcpServerUrl?.trim() || '', name: 'MCP Client', headers: defaultHeaders, description: '', @@ -214,6 +216,13 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) window.main.trackAnalyticsEvent({ event: AnalyticsEvent.requestCreated, properties: { requestType: 'HTTP' } }); + if (!redirectAfterCreate) { + return { + workspaceId: workspace._id, + requestId: activeRequestId, + }; + } + return redirect( href(`/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId`, { organizationId, @@ -224,6 +233,12 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) ); } + if (!redirectAfterCreate) { + return { + workspaceId: workspace._id, + }; + } + return redirect( `${href('/organization/:organizationId/project/:projectId/workspace/:workspaceId', { organizationId, @@ -245,14 +260,22 @@ export const useWorkspaceNewActionFetcher = createFetcherSubmitHook( ({ organizationId, projectId, + redirectAfterCreate, ...workspaceData - }: NewWorkspaceData & { organizationId: string; projectId: string }) => { + }: NewWorkspaceData & { organizationId: string; projectId: string; redirectAfterCreate?: boolean }) => { + const action = href('/organization/:organizationId/project/:projectId/workspace/new', { + organizationId, + projectId, + }); + const query = new URLSearchParams(); + + if (redirectAfterCreate !== undefined) { + query.set('redirectAfterCreate', String(redirectAfterCreate)); + } + return submit(JSON.stringify(workspaceData), { method: 'POST', - action: href('/organization/:organizationId/project/:projectId/workspace/new', { - organizationId, - projectId, - }), + action: query.toString() ? `${action}?${query.toString()}` : action, encType: 'application/json', }); }, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index 5a5f61bb85..cbaf292c04 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -22,11 +22,33 @@ export interface ProjectIndexLoaderData { projects: (Project & { gitRepository?: GitRepository })[]; } +const shouldAutoCreateInitialProject = async ({ + organizationId, + accountId, +}: { + organizationId: string; + accountId: string | null | undefined; +}) => { + if (!accountId) { + return false; + } + + const organization = await services.organization.get(organizationId); + + if (!organization || !models.organization.isPersonalOrganization(organization)) { + return false; + } + + const firstPersonalOrgLandingKey = `firstPersonalOrgLandingHandled:${accountId}`; + + return !window.localStorage.getItem(firstPersonalOrgLandingKey); +}; + export async function clientLoader({ params }: LoaderFunctionArgs) { const { organizationId } = params; invariant(organizationId, 'Organization ID is required'); - const { id: sessionId } = await services.userSession.get(); + const { id: sessionId, accountId } = await services.userSession.get(); if (!sessionId) { await logout(); @@ -40,6 +62,33 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { return redirect(`/organization/${organizationId}/project/${projects[0]._id}`); } + let isFirstPersonalOrgLanding = false; + + try { + isFirstPersonalOrgLanding = await shouldAutoCreateInitialProject({ organizationId, accountId }); + } catch (error) { + console.warn('[project] Failed to evaluate first personal org landing state', error); + } + + if (isFirstPersonalOrgLanding) { + try { + const project = await services.project.create({ + name: 'Drafts', + parentId: organizationId, + }); + + await services.workspace.create({ + name: 'My first collection', + scope: 'collection', + parentId: project._id, + }); + + return redirect(`/organization/${organizationId}/project/${project._id}`); + } catch (error) { + console.warn('[project] Failed to auto-create initial local project', error); + } + } + return { projects, projectsCount: organizationProjects.length, diff --git a/packages/insomnia/src/ui/components/assets/svgr/IcnGraphql.tsx b/packages/insomnia/src/ui/components/assets/svgr/IcnGraphql.tsx new file mode 100644 index 0000000000..d4f42c03d8 --- /dev/null +++ b/packages/insomnia/src/ui/components/assets/svgr/IcnGraphql.tsx @@ -0,0 +1,46 @@ +import React, { memo, type SVGProps } from 'react'; +export const SvgIcnGraphql = memo>(props => ( + + + + + + + + + + + + + + + + +)); diff --git a/packages/insomnia/src/ui/components/first-request-creation.tsx b/packages/insomnia/src/ui/components/first-request-creation.tsx new file mode 100644 index 0000000000..c7150f13d3 --- /dev/null +++ b/packages/insomnia/src/ui/components/first-request-creation.tsx @@ -0,0 +1,473 @@ +import type { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; + +import { Button } from '~/basic-components/button'; +import { SelectPopover } from '~/basic-components/select-popover'; +import { getProjectRecentRequests, type RecentProjectRequest } from '~/common/project'; +import type { Request } from '~/insomnia-data'; +import { useRequestNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new'; +import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new'; +import { createKeybindingsHandler, useKeyboardShortcuts } from '~/ui/components/keydown-binder'; +import { ImportModal } from '~/ui/components/modals/import-modal/import-modal'; +import { SvgIcon } from '~/ui/components/svg-icon'; +import { showToast } from '~/ui/components/toast-notification'; +import { Tooltip } from '~/ui/components/tooltip'; +import { getBadgeClassName, ResourceIcon } from '~/ui/components/workspace/resource-icon'; +import { useIsLightTheme } from '~/ui/hooks/theme'; +import { setDefaultProtocol } from '~/utils/url/protocol'; + +import { Icon } from './icon'; +const CURL_COMMAND_PATTERN = /^\s*\$?\s*curl(?:\s|$)/i; +const NOTION_MCP_SERVER_URL = 'https://mcp.notion.com/mcp'; + +const parseCurlImportError = (error: unknown) => { + const rawMessage = error instanceof Error ? error.message : String(error); + return rawMessage.includes('No importers found for file') + ? 'Invalid cURL request' + : rawMessage.replace("Error invoking remote method 'parseImport': Error: ", ''); +}; + +const parseCurlRequest = async (value: string) => { + try { + const { data } = await window.main.parseImport({ contentStr: value }, { importerId: 'curl' }); + const importedRequest = data?.resources?.[0] as Partial | undefined; + + if (!importedRequest?.url) { + throw new Error('Invalid cURL request'); + } + + return importedRequest; + } catch (error) { + throw new Error(parseCurlImportError(error)); + } +}; + +const normalizeRequestUrl = (value: string) => { + const normalizedUrl = setDefaultProtocol(value.trim()); + + try { + new URL(normalizedUrl); + return normalizedUrl; + } catch { + throw new Error('Enter a valid endpoint URL'); + } +}; + +interface CollectionItem { + id: string; + label: string; +} + +interface QuickStartItem { + id: string; + label: string; + icon: JSX.Element; + badge?: string; + onClick: () => void | Promise; +} + +interface FirstRequestCreationProps { + greetingName: string; + collectionItems: CollectionItem[]; + selectedCollectionId: string | null; + onSelectedCollectionChange: (collectionId: string | null) => void; + onCreateCollection: () => void; +} + +export const FirstRequestCreation = ({ + greetingName, + collectionItems, + selectedCollectionId, + onSelectedCollectionChange, + onCreateCollection, +}: FirstRequestCreationProps) => { + const navigate = useNavigate(); + const { organizationId, projectId } = useParams() as { + organizationId: string; + projectId: string; + }; + const inputRef = useRef(null); + const createRequestFetcher = useRequestNewActionFetcher(); + const createWorkspaceFetcher = useWorkspaceNewActionFetcher(); + const createWorkspaceFetcherRef = useRef(createWorkspaceFetcher); + createWorkspaceFetcherRef.current = createWorkspaceFetcher; + const [isImportModalOpen, setIsImportModalOpen] = useState(false); + const [requestInput, setRequestInput] = useState(''); + const [recentRequests, setRecentRequests] = useState([]); + const [curlParseError, setCurlParseError] = useState(false); + const [selectOpen, setSelectOpen] = useState(false); + const trimmedInput = requestInput.trim(); + const isCreatingRequest = createRequestFetcher.state !== 'idle'; + const selectedCollection = collectionItems.find(collection => collection.id === selectedCollectionId) ?? null; + const shouldShowJumpBackIn = recentRequests.length >= 3; + + const ensureWorkspaceId = async () => { + if (selectedCollectionId) { + return selectedCollectionId; + } + + await createWorkspaceFetcher.submit({ + organizationId, + projectId, + name: 'My first collection', + scope: 'collection', + redirectAfterCreate: false, + }); + + const createdWorkspace = createWorkspaceFetcherRef.current.data; + + if ( + !createdWorkspace || + createdWorkspace.error || + !('workspaceId' in createdWorkspace) || + !createdWorkspace.workspaceId + ) { + showToast({ + icon: 'circle-exclamation', + title: 'Unable to create collection, please create collection manually', + status: 'error', + }); + return null; + } + console.log('Created workspace', createdWorkspace.workspaceId); + return createdWorkspace.workspaceId; + }; + + const handleInputEnter = (event: ReactKeyboardEvent | KeyboardEvent) => { + event.preventDefault(); + handleCreateRequest(); + }; + + const handleRequestCreateShortcut = (_event: KeyboardEvent) => { + if (!selectedCollectionId) { + createWorkspaceFetcher.submit({ + organizationId, + projectId, + name: 'My first collection', + scope: 'collection', + withRequest: true, + }); + return; + } + createRequestFetcher.submit({ + organizationId, + projectId, + workspaceId: selectedCollectionId, + parentId: selectedCollectionId, + requestType: 'HTTP', + }); + }; + + useKeyboardShortcuts(() => inputRef.current as HTMLTextAreaElement, { + request_createHTTP: handleRequestCreateShortcut, + }); + + const handleCreateRequest = async () => { + if (!trimmedInput) { + return; + } + const workspaceId = await ensureWorkspaceId(); + if (!workspaceId) { + return; + } + + try { + if (CURL_COMMAND_PATTERN.test(trimmedInput)) { + let req: Partial; + try { + req = await parseCurlRequest(trimmedInput); + } catch { + setCurlParseError(true); + return; + } + + createRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + parentId: workspaceId, + requestType: 'From Curl', + req, + }); + + return; + } + + createRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + parentId: workspaceId, + requestType: 'HTTP', + req: { + url: normalizeRequestUrl(trimmedInput), + }, + }); + } catch (error) { + showToast({ + icon: 'circle-exclamation', + title: error instanceof Error ? error.message : 'Unable to create request', + status: 'error', + }); + } + }; + + useEffect(() => { + setSelectOpen(false); + }, [selectedCollectionId]); + + useEffect(() => { + let isActive = true; + + const loadRecentRequests = async () => { + const nextRecentRequests = await getProjectRecentRequests(projectId); + + if (!isActive) { + return; + } + + setRecentRequests(nextRecentRequests); + }; + + loadRecentRequests(); + + return () => { + isActive = false; + }; + }, [projectId]); + + const handleCreateNotionMcpWorkspace = () => { + createWorkspaceFetcher.submit({ + organizationId, + projectId, + name: 'Notion MCP Server', + scope: 'mcp', + mcpServerUrl: NOTION_MCP_SERVER_URL, + }); + }; + + const handleCreatePokemonRequest = async () => { + const workspaceId = await ensureWorkspaceId(); + + if (!workspaceId) { + return; + } + + createRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + parentId: workspaceId, + requestType: 'HTTP', + req: { + url: 'https://pokeapi.co/api/v2/pokemon/ditto', + name: 'List a pokemon', + }, + }); + }; + + const handleCreateGithubLookupRequest = async () => { + const workspaceId = await ensureWorkspaceId(); + + if (!workspaceId) { + return; + } + + const graphqlQuery = + 'query { viewer { repositories(first: 100, privacy: PUBLIC, affiliations: [OWNER]) { nodes { name description url stargazerCount } } } }'; + + const githubGraphqlLookupCurl = `curl --request POST \ + --url https://api.github.com/graphql \ + --header 'Authorization: Bearer replace with your own token' \ + --header 'Content-Type: application/json' \ + --header 'User-Agent: insomnia/12.5.1-alpha.0' \ + --data '${JSON.stringify({ query: graphqlQuery })}'`; + try { + const req = await parseCurlRequest(githubGraphqlLookupCurl); + createRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + parentId: workspaceId, + requestType: 'GraphQL', + req: { + ...req, + name: 'Lookup GitHub repository', + }, + }); + } catch (error) { + showToast({ + icon: 'circle-exclamation', + title: error instanceof Error ? error.message : 'Unable to create GitHub lookup request', + status: 'error', + }); + } + }; + + const quickStartItems: QuickStartItem[] = [ + { + id: 'mcp-server', + label: 'Notion MCP Server', + icon: , + onClick: handleCreateNotionMcpWorkspace, + }, + { + id: 'pokemon', + label: 'List a pokemon', + icon: GET, + badge: 'GET', + onClick: handleCreatePokemonRequest, + }, + { + id: 'github-lookup', + label: 'Lookup GitHub repository', + icon: , + onClick: handleCreateGithubLookupRequest, + }, + ]; + + const isLightTheme = useIsLightTheme(); + const wrapperClassName = isLightTheme + ? 'w-full rounded-sm bg-[radial-gradient(95.72%_95.72%_at_-0.32%_2.6%,#999999_0%,#DDDDDD_100%),radial-gradient(100%_100.41%_at_100%_99.92%,#999999_0%,#DDDDDD_100%)] p-px' + : 'w-full rounded-sm bg-[radial-gradient(100%_100.41%_at_100%_99.92%,#4C4C4C_0%,rgba(3,3,3,0)_100%),radial-gradient(95.72%_95.72%_at_-0.32%_2.6%,#4C4C4C_0%,rgba(3,3,3,0)_100%)] p-px'; + const wrapperSurfaceClassName = isLightTheme + ? 'flex w-full flex-col items-center rounded-[inherit] bg-[#FFFFFF] bg-linear-[360deg,rgba(27,27,27,0)_27.2%,rgba(96,48,191,0.2)_100%] px-6 pt-6 pb-5' + : 'flex w-full flex-col items-center rounded-[inherit] bg-[#1B1B1B] bg-linear-[360deg,rgba(27,27,27,0)_27.2%,rgba(165,151,248,0.2)_100%] px-6 pt-6 pb-5'; + + return ( + <> +
+
+

+ {shouldShowJumpBackIn ? `Welcome back, ${greetingName}!` : `Welcome, ${greetingName}!`} +

+

+ {shouldShowJumpBackIn + ? `Today is a new day, we’re rooting for you!` + : `We have a sneaking suspicion that you came here to send a request, so let’s get started!`} +

+
+
+
+