From 15a0d50afde15a943705de1cdebdc134d448a8f6 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Tue, 26 May 2026 06:47:00 +0100 Subject: [PATCH 01/32] fix: prevent race condition in environment-editor smoke test (#9944) --- .../environment-editor-interactions.test.ts | 73 +++++++++++++++---- .../.client/codemirror/one-line-editor.tsx | 10 +++ .../components/modals/code-prompt-modal.tsx | 12 ++- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts index b98a45e0c6..4f34bc6a21 100644 --- a/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts @@ -68,9 +68,10 @@ test.describe('Environment Editor', () => { await dialog.getByTestId('CodeEditor').getByRole('textbox').press('Enter'); await dialog.getByTestId('CodeEditor').getByRole('textbox').fill('"testString":"Gandalf",'); - // Open request - // Delay the click to let debounce finish - await dialog.getByRole('button', { name: 'Close' }).click({ delay: 200 }); + // Blur the editor before closing so the debounce flush is triggered by the button's mousedown + await dialog.getByRole('button', { name: 'Close' }).click(); + // Wait for the dialog to be gone before navigating away + await expect.soft(page.getByRole('heading', { name: 'Manage Environments' })).toBeHidden(); await page.getByLabel('Manage collection environments').press('Escape'); await insomnia.navigationSidebar.clickRequestOrFolder('New Request'); @@ -93,17 +94,17 @@ test.describe('Environment Editor', () => { firstRow = kvTable.getByRole('option').first(); await firstRow.getByTestId('OneLineEditor').first().click(); await page.keyboard.type('exampleString'); - await firstRow.getByTestId('OneLineEditor').nth(1).click({ delay: 200 }); + // Clicking the value cell blurs the key cell, triggering its debounce flush + await firstRow.getByTestId('OneLineEditor').nth(1).click(); await page.keyboard.type('kvstring'); - // add one more row - // Delay the click to let debounce finish - await page.getByRole('button', { name: 'Add Row' }).click({ delay: 200 }); + // Clicking Add Row blurs the value cell; wait for the new row to confirm the state settled + await page.getByRole('button', { name: 'Add Row' }).click(); const secondRow = kvTable.getByRole('option').nth(1); + await expect.soft(secondRow).toBeVisible(); await secondRow.getByTestId('OneLineEditor').first().click(); await page.keyboard.type('exampleObject'); - // change type to json - // Delay the click to let debounce finish - await secondRow.getByRole('button', { name: 'Type Selection' }).click({ delay: 200 }); + // Clicking Type Selection blurs the key cell, triggering its debounce flush + await secondRow.getByRole('button', { name: 'Type Selection' }).click(); await page.getByRole('menuitemradio', { name: 'JSON' }).click(); await secondRow.getByRole('button', { name: 'Edit JSON' }).click(); // wait for modal to show @@ -113,19 +114,65 @@ test.describe('Environment Editor', () => { await bodyEditor.focus(); await bodyEditor.press('ArrowRight'); await bodyEditor.fill('"anotherString":"kvAnotherStr","anotherNumber": 12345'); - // Delay the click to let debounce finish - await page.getByRole('button', { name: 'Modal Submit' }).click({ delay: 200 }); + // Submit and wait for the JSON modal to close before proceeding + await page.getByRole('button', { name: 'Modal Submit' }).click(); + await expect.soft(page.getByRole('dialog', { name: 'Modal' })).toBeHidden(); - // Open request + // Close the environment editor and wait for the dialog to disappear before navigating await page.getByRole('button', { name: 'Close', exact: true }).click(); + await expect.soft(page.getByRole('heading', { name: 'Manage Environments' })).toBeHidden(); await page.getByLabel('Manage collection environments').press('Escape'); await insomnia.navigationSidebar.clickRequestOrFolder('New Request'); await page.getByRole('button', { name: 'Send' }).click(); await page.getByRole('tab', { name: 'Console' }).click(); // check new environment value + await expect.soft(page.getByText('kvstring')).toBeVisible(); await page.getByText('kvstring').click(); await page.getByText('kvAnotherStr').click(); await page.getByText('12345').click(); }); + + test('disabled environment variable falls back to base environment', async ({ page, app, insomnia }) => { + const text = await loadFixture('environments.yaml'); + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text); + await page.getByLabel('Import').click(); + await page.locator('[data-test-id="import-from-clipboard"]').click(); + await page.getByRole('button', { name: 'Scan' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); + + // Activate ExampleA environment + await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByRole('option', { name: 'ExampleA' }).press('Enter'); + await page.getByRole('option', { name: 'ExampleA' }).press('Escape'); + + // Send request and verify ExampleA overrides are active + await insomnia.navigationSidebar.clickRequestOrFolder('New Request'); + await page.getByRole('button', { name: 'Send' }).click(); + await page.getByRole('tab', { name: 'Console' }).click(); + await expect.soft(page.getByText('subenvA0')).toBeVisible(); + + // Open env editor, select ExampleA, switch to table view, disable exampleString + await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByRole('button', { name: 'Manage collection environments' }).click(); + await page.getByLabel('Environments', { exact: true }).getByText('ExampleA').click(); + await page.getByRole('button', { name: 'Table Edit' }).click(); + const kvTable = page.getByRole('listbox', { name: 'Environment Key Value Pair' }); + // Find and disable the exampleString row + const exampleStringRow = kvTable.getByRole('option').filter({ hasText: 'exampleString' }); + await exampleStringRow.getByRole('button', { name: 'Disable Row' }).click(); + await expect.soft(exampleStringRow).toHaveCSS('opacity', '0.4'); + + // Close the editor and wait for it to disappear + await page.getByRole('button', { name: 'Close', exact: true }).click(); + await expect.soft(page.getByRole('heading', { name: 'Manage Environments' })).toBeHidden(); + await page.getByLabel('Manage collection environments').press('Escape'); + + // Send request — disabled sub-env variable should fall back to base environment + await insomnia.navigationSidebar.clickRequestOrFolder('New Request'); + await page.getByRole('button', { name: 'Send' }).click(); + await page.getByRole('tab', { name: 'Console' }).click(); + await expect.soft(page.getByText('baseenv0')).toBeVisible(); + await expect.soft(page.getByText('subenvA0')).toBeHidden(); + }); }); diff --git a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx index a35ab21798..0a30a2c3e3 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx @@ -304,6 +304,16 @@ export const OneLineEditor = forwardRef return () => codeMirror.current?.off('changes', fn); }, [onChange]); + useEffect(() => { + const flushOnBlur = (doc: CodeMirror.Editor) => { + if (onChange) { + onChange(doc.getValue() || ''); + } + }; + codeMirror.current?.on('blur', flushOnBlur); + return () => codeMirror.current?.off('blur', flushOnBlur); + }, [onChange]); + useEffect(() => { const unsubscribe = window.main.on( 'nunjucks-context-menu-command', diff --git a/packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx b/packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx index c13667aca7..41447b1e60 100644 --- a/packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; import { Button } from 'react-aria-components'; -import { CodeEditor } from '~/ui/components/.client/codemirror/code-editor'; +import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; import { CopyButton } from '../base/copy-button'; import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown'; @@ -40,6 +40,7 @@ export interface CodePromptModalHandle { } export const CodePromptModal = forwardRef((_, ref) => { const modalRef = useRef(null); + const codeEditorRef = useRef(null); const [error, setError] = useState(''); const [state, setState] = useState({ title: 'Not Set', @@ -111,6 +112,7 @@ export const CodePromptModal = forwardRef((_, ) : (
((_, )} +
+ + {showDisconnectConfirm ? ( + <> +

+ Disconnecting will remove your Personal Access Token and delete related project data. This action + cannot be undone. Are you sure? +

+
+ + +
+ + ) : ( + <> +
+ +

+ Enter a Personal Access Token (PAT) to sync your Konnect control planes into Insomnia projects. +

+ + +
+ { + setPat(e.target.value); + if (status !== 'idle') { + setStatus('idle'); + setValidationError(null); + } + }} + autoComplete="off" + /> + +
+ + {status === 'invalid' && ( +

+ {validationError ?? 'Invalid PAT. Check your input and try again.'} +

+ )} + {(status === 'valid' || (isConnected && status === 'idle')) && ( +

Connected

+ )} +
+ +
+ + {isConnected && ( + + )} +
+ + )} + + )} + + + + ); +}; diff --git a/packages/insomnia/src/ui/components/modals/settings-modal.tsx b/packages/insomnia/src/ui/components/modals/settings-modal.tsx index 59861feae8..119bcee210 100644 --- a/packages/insomnia/src/ui/components/modals/settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/settings-modal.tsx @@ -1,15 +1,12 @@ -import { getOrganizationFeatures } from 'insomnia-api'; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; import { useParams } from 'react-router'; -import { AI_PLUGIN_NAME, isKonnectSyncEnabled } from '~/common/constants'; -import { models } from '~/insomnia-data'; +import { AI_PLUGIN_NAME } from '~/common/constants'; import { useRootLoaderData } from '~/root'; import { AnalyticsEvent } from '~/ui/analytics'; import { AISettings } from '~/ui/components/settings/ai-settings'; import { CredentialsSettings } from '~/ui/components/settings/credentials'; -import { KonnectSettings } from '~/ui/components/settings/konnect-settings'; import { ScriptingSettings } from '~/ui/components/settings/scripting-settings'; import { getAppVersion, getProductName } from '../../../common/constants'; @@ -32,7 +29,16 @@ export interface SettingsModalHandle { show: (options?: { tab?: SettingsModalTabKey }) => void; } -type SettingsModalTabKey = 'data' | 'keyboard' | 'themes' | 'plugins' | 'general' | 'proxy' | 'credentials' | 'ai' | 'scripting' | 'konnect'; +type SettingsModalTabKey = + | 'data' + | 'keyboard' + | 'themes' + | 'plugins' + | 'general' + | 'proxy' + | 'credentials' + | 'ai' + | 'scripting'; export const SettingsModal = forwardRef((props, ref) => { const [defaultTabKey, setDefaultTabKey] = useState('general'); @@ -42,29 +48,12 @@ export const SettingsModal = forwardRef((props, const { organizationId } = useParams() as { organizationId?: string }; const [shouldShowAiSettingsTab, setShouldShowAiSettingsTab] = useState(false); - const [shouldShowKonnectTab, setShouldShowKonnectTab] = useState(false); useEffect(() => { const checkFeatures = async () => { const plugins = await pluginsBridge.getBundlePlugins(); const aiPlugin = plugins.find(p => p.name === AI_PLUGIN_NAME); setShouldShowAiSettingsTab(!!aiPlugin && !!userSession.id); - - if ( - isKonnectSyncEnabled() && - userSession.id && - organizationId && - !models.organization.isScratchpadOrganizationId(organizationId) - ) { - try { - const res = await getOrganizationFeatures({ organizationId, sessionId: userSession.id }); - setShouldShowKonnectTab(res?.features?.konnectSync?.enabled ?? false); - } catch { - setShouldShowKonnectTab(false); - } - } else { - setShouldShowKonnectTab(false); - } }; checkFeatures(); }, [userSession.id, organizationId]); @@ -173,14 +162,6 @@ export const SettingsModal = forwardRef((props, AI Settings )} - {shouldShowKonnectTab && ( - - Konnect - - )} @@ -244,11 +225,6 @@ export const SettingsModal = forwardRef((props, )} - {shouldShowKonnectTab && ( - - - - )} diff --git a/packages/insomnia/src/ui/components/project/project-list-sidebar.tsx b/packages/insomnia/src/ui/components/project/project-list-sidebar.tsx deleted file mode 100644 index 801a369b06..0000000000 --- a/packages/insomnia/src/ui/components/project/project-list-sidebar.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import type { StorageRules } from 'insomnia-api'; -import type React from 'react'; -import { Button, GridList, GridListItem, Input, SearchField } from 'react-aria-components'; -import { useNavigate } from 'react-router'; -import * as reactUse from 'react-use'; - -import type { GitRepository, Project } from '~/insomnia-data'; -import { models } from '~/insomnia-data'; -import type { SyncResult } from '~/konnect/sync'; -import { AnalyticsEvent } from '~/ui/analytics'; - -import { useKonnectSync } from '../../hooks/use-konnect-sync'; -import { AvatarGroup } from '../avatar'; -import { ProjectDropdown } from '../dropdowns/project-dropdown'; -import { Icon } from '../icon'; -import { showModal } from '../modals'; -import { AlertModal } from '../modals/alert-modal'; -import { AskModal } from '../modals/ask-modal'; - -export type ProjectWithPresence = Project & { - gitRepository?: GitRepository; - presence: { - key: string; - alt: string; - src: string; - }[]; -}; - -interface ProjectListSidebarProps { - organizationId: string; - activeProjectId?: string; - projects: ProjectWithPresence[]; - storageRules: StorageRules; - onCreateProject: () => void; - konnectSyncEnabled: boolean; -} - -const TAB_CLASS_ACTIVE = 'border-b-2 border-solid border-b-(--color-surprise) px-3 py-1 text-xs uppercase text-(--color-font)'; -const TAB_CLASS_INACTIVE = 'px-3 py-1 text-xs uppercase text-(--hl) hover:bg-(--hl-xs)'; - -const ROW_CLASS = 'relative flex h-(--line-height-xs) w-full items-center gap-2 overflow-hidden px-4 text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font)'; - -const ACTION_BUTTON_CLASS = 'flex h-full items-center gap-1 rounded-xs 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)'; - -const ProjectGridList = ({ label, items, activeProjectId, organizationId, children }: { - label: string; - items: ProjectWithPresence[]; - activeProjectId?: string; - organizationId: string; - children: (item: ProjectWithPresence) => React.ReactNode; -}) => { - const navigate = useNavigate(); - return ( - { - if (keys !== 'all') { - const [value] = keys.values(); - navigate({ pathname: `/organization/${organizationId}/project/${value}` }); - } - }} - > - {item => ( - -
- - {children(item)} -
-
- )} -
- ); -}; - -const FilterSearchField = ({ label, value, onChange, isDisabled }: { label: string; value: string; onChange: (value: string) => void; isDisabled?: boolean }) => ( - - -
- -
-
-); - -const filterByName = (items: ProjectWithPresence[], query: string | undefined) => - query ? items.filter(p => p.name?.toLowerCase().includes(query.toLowerCase())) : items; - -function showSkippedRoutesModal(result: SyncResult | null) { - if (!result?.success || !result.skippedRoutes.length) { return; } - const byService = new Map(); - for (const { serviceName, routeName, reason } of result.skippedRoutes) { - const list = byService.get(serviceName) ?? []; - list.push(`${routeName} — ${reason}`); - byService.set(serviceName, list); - } - showModal(AlertModal, { - title: 'Skipped Routes', - message: ( -
-

{result.skippedRoutes.length} route(s) were skipped because they cannot be represented in Insomnia:

- {[...byService.entries()].map(([service, routes]) => ( -
- {service} -
    - {routes.map(r =>
  • {r}
  • )} -
-
- ))} -
- ), - }); -} - -export const ProjectListSidebar = ({ - organizationId, - activeProjectId, - projects, - storageRules, - onCreateProject, - konnectSyncEnabled, -}: ProjectListSidebarProps) => { - - const [storedTab, setActiveTab] = reactUse.useLocalStorage<'projects' | 'konnect'>( - `${organizationId}:sidebar-tab`, - 'projects', - ); - const activeTab = !konnectSyncEnabled ? 'projects' : (storedTab ?? 'projects'); - - const [projectListFilter, setProjectListFilter] = reactUse.useLocalStorage( - `${organizationId}:project-list-filter`, - '', - ); - - const [konnectFilter, setKonnectFilter] = reactUse.useLocalStorage( - `${organizationId}:konnect-filter`, - '', - ); - - const { syncing, progress, error: syncError, startSync, cancelSync } = useKonnectSync(); - - const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId); - const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null); - - const filteredProjects = filterByName(nonKonnectProjects, projectListFilter); - const filteredKonnectProjects = filterByName(konnectProjects, konnectFilter); - - const handleSync = async () => { - if (!konnectSyncEnabled) { - return; - } - - const runAndNotify = async () => { - const result = await startSync(organizationId); - showSkippedRoutesModal(result); - }; - - const isResync = konnectProjects.length > 0; - if (isResync) { - showModal(AskModal, { - title: 'Re-sync Konnect', - message: ( -
-

Re-syncing will make the following changes:

-
    -
  • Reset — request method, URL, name, and Konnect-managed headers
  • -
  • Delete — requests added manually or no longer in Konnect
  • -
  • Preserve — body, auth, query params, scripts, description, and user-added headers
  • -
-

This cannot be undone. Continue?

-
- ), - yesText: 'Re-sync', - noText: 'Cancel', - color: 'warning', - onDone: async (confirmed: boolean) => { - if (confirmed) { - await runAndNotify(); - } - }, - }); - } else { - await runAndNotify(); - } - }; - - const tabBar = ( -
- - {konnectSyncEnabled && ( - - )} -
- ); - - if (activeTab === 'konnect' && konnectSyncEnabled) { - return ( -
- {tabBar} -
- - {syncing ? ( - - ) : ( - - )} -
- - {syncing && ( -

{progress}

- )} - {syncError && ( -

{syncError}

- )} - - - {item => { - return ( - <> - - {item.name} - - ); - }} - - - {konnectProjects.length === 0 && !syncing && ( -

- No Konnect projects yet. Click sync to import your control planes. -

- )} -
- ); - } - - return ( -
- {tabBar} -
- { - setProjectListFilter(value); - if (value.trim() !== '') { - window.main.trackAnalyticsEvent({ event: AnalyticsEvent.filterCreatedProjects }); - } - }} - /> - -
- - - {item => ( - <> - - {item.name} - - {item.presence.length > 0 && } - {item._id !== models.project.SCRATCHPAD_PROJECT_ID && ( - - )} - - )} - -
- ); -}; diff --git a/packages/insomnia/src/ui/components/settings/konnect-settings.tsx b/packages/insomnia/src/ui/components/settings/konnect-settings.tsx deleted file mode 100644 index 2663581764..0000000000 --- a/packages/insomnia/src/ui/components/settings/konnect-settings.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useState } from 'react'; -import { Button } from 'react-aria-components'; - -import { validatePat } from '~/konnect/api'; -import { useRootLoaderData } from '~/root'; -import { AnalyticsEvent } from '~/ui/analytics'; - -import { useSettingsPatcher } from '../../hooks/use-request'; - -export const KonnectSettings = () => { - const { settings } = useRootLoaderData()!; - const patchSettings = useSettingsPatcher(); - - const [pat, setPat] = useState(''); - const [status, setStatus] = useState<'idle' | 'validating' | 'valid' | 'invalid'>('idle'); - const [validationError, setValidationError] = useState(null); - - const handleValidate = async () => { - const trimmed = pat.trim(); - if (!trimmed) { - return; - } - setStatus('validating'); - setValidationError(null); - const result = await validatePat(trimmed); - setStatus(result.valid ? 'valid' : 'invalid'); - if (result.valid) { - await window.main.secretStorage.setSecret('konnectPat', trimmed); - patchSettings({ hasKonnectPat: true }); - setPat(''); - window.main.trackAnalyticsEvent({ event: AnalyticsEvent.kongKonnectPatValidated }); - } else { - setValidationError(result.error ?? 'PAT is invalid or could not connect to Konnect.'); - } - }; - - const handleClear = async () => { - await window.main.secretStorage.deleteSecret('konnectPat'); - patchSettings({ hasKonnectPat: false }); - setPat(''); - setStatus('idle'); - }; - - return ( -
-

Kong Konnect

-

- Enter a Personal Access Token (PAT) to sync your Konnect control planes into Insomnia projects. - Generate one at{' '} - { - e.preventDefault(); - window.main.openInBrowser('https://cloud.konghq.com/global/account/tokens'); - }} - > - https://cloud.konghq.com/global/account/tokens - - . -

- -
- - {settings.hasKonnectPat ? ( -
- kpat_•••••••• - Saved -
- ) : null} - { - setPat(e.target.value); - setStatus('idle'); - setValidationError(null); - }} - autoComplete="off" - /> - {status === 'valid' &&

PAT is valid and saved.

} - {status === 'invalid' &&

{validationError}

} -
- -
- - {settings.hasKonnectPat && ( - - )} -
-
- ); -}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/bg.png b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/bg.png new file mode 100644 index 0000000000000000000000000000000000000000..7b0242efb6930379241d9dd35faae3e257dda395 GIT binary patch literal 21038 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 literal 0 HcmV?d00001 diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro.tsx new file mode 100644 index 0000000000..e879d4d3eb --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro.tsx @@ -0,0 +1,30 @@ +import { Button } from 'react-aria-components'; + +import bgUrl from './bg.png'; + +interface KonnectSyncIntroProps { + onConfigure: () => void; +} + +export const KonnectSyncIntro = ({ onConfigure }: KonnectSyncIntroProps) => { + return ( +
+ +
+ NEW +
+

Auto-sync your gateway service routes

+

+ Get right into testing your gateway configuration in Insomnia with the new Konnect platform integration. +

+
+ +
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-header.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-header.tsx deleted file mode 100644 index 38e4f4bc84..0000000000 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-header.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { ReactNode } from 'react'; -import { Button, Input, SearchField } from 'react-aria-components'; - -import { Icon } from '../../icon'; - -interface SidebarHeaderProps { - isScratchPad: boolean; - tabs: { name: 'projects' | 'konnect'; label: ReactNode }[]; - activeTab: 'projects' | 'konnect'; - onTabChange: (tab: 'projects' | 'konnect') => void; - filterValue: string; - onFilterChange: (value: string) => void; - isFilterDisabled: boolean; - actionButton: ReactNode; -} - -// Sidebar header component including project & konnect tabs, filter input, and action button. -export const SidebarHeader = ({ - isScratchPad, - tabs, - activeTab, - onTabChange, - filterValue, - onFilterChange, - isFilterDisabled, - actionButton, -}: SidebarHeaderProps) => ( - <> -
- {!isScratchPad && - tabs.map(({ name, label }) => ( - - ))} -
-
- - -
- -
-
- {actionButton} -
- -); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index e00e98f798..0af1265ce8 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -1,10 +1,11 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import type { StorageRules } from 'insomnia-api'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Button, GridList, GridListItem } from 'react-aria-components'; +import { Button, GridList, GridListItem, Input, SearchField, Tab, TabList, Tabs } from 'react-aria-components'; import { useNavigate, useParams, useSearchParams } from 'react-router'; import * as reactUse from 'react-use'; +import { Button as BasicButton } from '~/basic-components/button'; import type { SortOrder } from '~/common/constants'; import { fuzzyMatchAll } from '~/common/misc'; import { @@ -20,19 +21,20 @@ import { useProjectLoaderData } from '~/routes/organization.$organizationId.proj import { AnalyticsEvent } 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 { KonnectSettingsModal } from '~/ui/components/modals/konnect-settings-modal'; import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node'; +import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro'; 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'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useKonnectSync } from '~/ui/hooks/use-konnect-sync'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; +import insomniaLogo from '~/ui/images/insomnia-logo.svg'; import { isPrimaryClickModifier } from '~/ui/utils'; import { Icon } from '../../icon'; -import { SidebarHeader } from './project-navigation-sidebar-header'; import { type AllRequestsAndMetaInWorkspace, filterCollection, @@ -48,45 +50,97 @@ import { useSidebarDragAndDrop } from './use-sidebar-drag-and-drop'; import { WorkspaceNode } from './workspace-node'; interface ProjectNavigationSidebarProps { + storageRules: StorageRules; activeNodeId?: string; konnectSyncEnabled: boolean; - storageRules: StorageRules; onCreateProject: () => void; } -function showSkippedRoutesModal(result: SyncResult | null) { - if (!result?.success || !result.skippedRoutes.length) { - return; - } - const byService = new Map(); - for (const { serviceName, routeName, reason } of result.skippedRoutes) { - const list = byService.get(serviceName) ?? []; - list.push(`${routeName} — ${reason}`); - byService.set(serviceName, list); - } - showModal(AlertModal, { - title: 'Skipped Routes', - message: ( -
-

{result.skippedRoutes.length} route(s) were skipped because they cannot be represented in Insomnia:

- {[...byService.entries()].map(([service, routes]) => ( -
- {service} -
    - {routes.map(r => ( -
  • {r}
  • - ))} -
-
- ))} -
- ), - }); -} +const SidebarSearchField = ({ + value, + isDisabled, + onChange, +}: { + value: string; + isDisabled: boolean; + onChange: (value: string) => void; +}) => ( + + +
+ +
+
+); + +const SideBarTabList = ({ + konnectSyncEnabled, + isScratchPad, + nonKonnectProjectLength, + konnectProjectsLength, +}: { + konnectSyncEnabled: boolean; + isScratchPad: boolean; + nonKonnectProjectLength: number; + konnectProjectsLength: number; +}) => { + return ( + + + {isScratchPad ? 'Projects' : `Projects (${nonKonnectProjectLength})`} + + {konnectSyncEnabled && !isScratchPad && ( + + + + Konnect ({konnectProjectsLength}) + + + )} + + ); +}; + +const NewProjectButton = ({ onPress, isDisabled }: { onPress: () => void; isDisabled?: boolean }) => ( + + + New Project + +); export const ProjectNavigationSidebar = ({ - konnectSyncEnabled, storageRules, + konnectSyncEnabled, onCreateProject, }: ProjectNavigationSidebarProps) => { const navigate = useNavigate(); @@ -97,7 +151,7 @@ export const ProjectNavigationSidebar = ({ requestId?: string; requestGroupId?: string; }; - const { userSession } = useRootLoaderData()!; + const { userSession, settings } = useRootLoaderData()!; const projectLoaderData = useProjectLoaderData()!; const { projects, projectsSyncStatusPromise } = projectLoaderData; const [checkAllProjectSyncStatus] = useLoaderDeferData>( @@ -133,7 +187,7 @@ export const ProjectNavigationSidebar = ({ ); const activeTab = !konnectSyncEnabled ? 'projects' : (storedTab ?? 'projects'); const isProjectTabActive = activeTab === 'projects'; - const { syncing, progress, error: syncError, startSync, cancelSync } = useKonnectSync(); + const { syncing, progress, startSync, cancelSync } = useKonnectSync(); const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId); const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null); @@ -153,6 +207,8 @@ export const ProjectNavigationSidebar = ({ // ref to track whether we are currently fetching unsynced files for cloud sync projects to avoid duplicate requests const isFetchingUnsyncedFilesRef = useRef(false); + const syncKonnectProjectsAndNotifyRef = useRef<() => Promise>(async () => {}); + const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID; const projectsWithPresence = useMemo( @@ -232,48 +288,66 @@ export const ProjectNavigationSidebar = ({ return setUnsyncedFilesByProjectId(result); }, [organizationId, cloudSyncProjectIdsKey]); + const syncKonnectProjectsAndNotify = async () => { + const result = await startSync(organizationId); + setLastSyncResult(result ?? null); + setShowSyncDetails(false); + setCopiedReason(null); + }; + syncKonnectProjectsAndNotifyRef.current = syncKonnectProjectsAndNotify; + const handleSync = async () => { if (!konnectSyncEnabled) { return; } - const runAndNotify = async () => { - const result = await startSync(organizationId); - showSkippedRoutesModal(result); - }; - const isResync = konnectProjects.length > 0; if (isResync) { showModal(AskModal, { - title: 'Re-sync Konnect', + title: 'Sync updates from Konnect', message: ( -
-

Re-syncing will make the following changes:

-
    -
  • - Reset — request method, URL, name, and Konnect-managed headers +
    +
    +
    + + Konnect +
    + +
    + Insomnia + Insomnia +
    +
    +

    + Sync the latest changes from your Konnect organization into Insomnia. This will: +

    +
      +
    • + + Keep your local custom changes (never pushed to Konnect)
    • -
    • - Delete — requests added manually or no longer in Konnect +
    • + + Update existing resources to match Konnect
    • -
    • - Preserve — body, auth, query params, scripts, description, and user-added headers +
    • + + Remove collections or environments tied to control planes, services, or routes deleted in Konnect
    -

    This cannot be undone. Continue?

    ), - yesText: 'Re-sync', + yesText: 'Sync Now', noText: 'Cancel', - color: 'warning', + color: 'surprise', onDone: async (confirmed: boolean) => { if (confirmed) { - await runAndNotify(); + await syncKonnectProjectsAndNotify(); } }, }); } else { - await runAndNotify(); + await syncKonnectProjectsAndNotify(); } }; @@ -705,191 +779,319 @@ export const ProjectNavigationSidebar = ({ ? [selectedItemId] : []; - const projectsActionButton = !isScratchPad ? ( - - ) : null; - - const konnectActionButton = syncing ? ( - - ) : ( - - ); + const { hasKonnectPat } = settings; + const showKonnectSyncIntro = konnectSyncEnabled && !isProjectTabActive && !hasKonnectPat; + const [showKonnectConfigModal, setShowKonnectConfigModal] = useState(false); + const [lastSyncResult, setLastSyncResult] = useState(null); + const [showSyncDetails, setShowSyncDetails] = useState(false); + const [copiedReason, setCopiedReason] = useState(null); + const skippedRoutesByReason = useMemo(() => { + const map = new Map(); + for (const { routeName, reason, serviceName } of lastSyncResult?.skippedRoutes ?? []) { + const list = map.get(reason) ?? []; + list.push(`${routeName} — ${serviceName}`); + map.set(reason, list); + } + return map; + }, [lastSyncResult]); return (
    - - - Konnect ({konnectProjects.length}) - - ), - }, - ]} - activeTab={activeTab} - onTabChange={tab => setActiveTab(tab)} - filterValue={isProjectTabActive ? filterInputValue : konnectFilter || ''} - onFilterChange={isProjectTabActive ? setFilterInputValue : setKonnectFilter} - isFilterDisabled={false} - actionButton={isProjectTabActive ? projectsActionButton : konnectActionButton} - /> - - {!isProjectTabActive && syncing &&

    {progress}

    } - {!isProjectTabActive && syncError &&

    {syncError}

    } - -
    - - {virtualItem => { - const item = visibleFlatItems[virtualItem.index]; - if (!item) return null; - - return ( - { - if (e.button === 1 && item.kind === 'collectionChild') { - e.preventDefault(); - tabNavigate( - { - organization: organizationId, - project: item.project, - workspace: item.workspace, - item: item.doc, - }, - { withTab: true, shouldNavigate: true, searchParams }, - ); - } - }} - onPress={async e => { - const docId = item.doc._id; - if (item.kind === 'project') { - if (routeInfo?.resourceId === docId) { - toggleProjectOrWorkspace(docId); - } else { - !isScratchPad && window.main.trackAnalyticsEvent({ event: AnalyticsEvent.projectSwitched }); - !isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`); - } - } else if (item.kind === 'workspace') { - if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') { - toggleProjectOrWorkspace(docId); - } else { - tabNavigate( - { - organization: organizationId, - project: item.project, - workspace: item.doc, - item: item.doc, - }, - { withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams }, - ); - } - } else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') { - if ( - routeInfo?.resourceId === docId && - models.requestGroup.isRequestGroupId(docId) && - routeInfo?.routeId !== 'runner' - ) { - toggleRequestGroups([docId], item.workspace); - } else { - tabNavigate( - { - organization: organizationId, - project: item.project, - workspace: item.workspace, - item: item.doc, - }, - { withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams }, - ); - } - } - }} - className="group outline-hidden select-none" - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: `${virtualItem.size}px`, - transform: `translateY(${virtualItem.start}px)`, - }} - > - {item.kind === 'project' && ( - + setActiveTab(key as 'projects' | 'konnect')}> + + + {showKonnectSyncIntro ? ( + setShowKonnectConfigModal(true)} /> + ) : ( + <> +
    + + {isProjectTabActive ? ( + !isScratchPad && + ) : ( +
    + {syncing ? ( + + ) : ( + )} + +
    + )} +
    - {item.kind === 'workspace' && ( - { - if (item.doc.scope === 'collection') { - setCollectionSortOrders(prev => { - const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder }; - return newCollectionSortOrders; - }); + {!isProjectTabActive && syncing && ( +

    {progress}

    + )} + +
    + + {virtualItem => { + const item = visibleFlatItems[virtualItem.index]; + if (!item) return null; + + return ( + { + if (e.button === 1 && item.kind === 'collectionChild') { + e.preventDefault(); + tabNavigate( + { + organization: organizationId, + project: item.project, + workspace: item.workspace, + item: item.doc, + }, + { withTab: true, shouldNavigate: true, searchParams }, + ); } }} - /> - )} + onPress={async e => { + const docId = item.doc._id; + if (item.kind === 'project') { + if (routeInfo?.resourceId === docId) { + toggleProjectOrWorkspace(docId); + } else { + !isScratchPad && window.main.trackAnalyticsEvent({ event: AnalyticsEvent.projectSwitched }); + !isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`); + } + } else if (item.kind === 'workspace') { + if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') { + toggleProjectOrWorkspace(docId); + } else { + tabNavigate( + { + organization: organizationId, + project: item.project, + workspace: item.doc, + item: item.doc, + }, + { withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams }, + ); + } + } else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') { + if ( + routeInfo?.resourceId === docId && + models.requestGroup.isRequestGroupId(docId) && + routeInfo?.routeId !== 'runner' + ) { + toggleRequestGroups([docId], item.workspace); + } else { + tabNavigate( + { + organization: organizationId, + project: item.project, + workspace: item.workspace, + item: item.doc, + }, + { withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams }, + ); + } + } + }} + className="group outline-hidden select-none" + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)`, + }} + > + {item.kind === 'project' && ( + + )} - {item.kind === 'pinnedHeader' && } + {item.kind === 'workspace' && ( + { + if (item.doc.scope === 'collection') { + setCollectionSortOrders(prev => { + const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder }; + return newCollectionSortOrders; + }); + } + }} + /> + )} - {item.kind === 'collectionChild' && } + {item.kind === 'pinnedHeader' && } - {item.kind === 'pinnedRequest' && } + {item.kind === 'collectionChild' && ( + + )} - {item.kind === 'unsyncedWorkspace' && } + {item.kind === 'pinnedRequest' && } - {item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? ( - - ) : null} - - ); - }} - -
    + {item.kind === 'unsyncedWorkspace' && } + + {item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? ( + + ) : null} +
    + ); + }} +
    +
    + {!isProjectTabActive && lastSyncResult && ( +
    0 + ? 'bg-[rgba(250,173,20,0.15)]' + : 'bg-[rgba(82,196,26,0.15)]' + }`} + > +
    + +
    +

    + {lastSyncResult.success + ? lastSyncResult.skippedRoutes.length > 0 + ? 'Sync complete, with warnings' + : 'Sync complete' + : 'Sync failed'} +

    +

    + {!lastSyncResult.success + ? lastSyncResult.error + : lastSyncResult.routes.created === 0 && + lastSyncResult.routes.updated === 0 && + lastSyncResult.routes.deleted === 0 && + lastSyncResult.routes.skipped === 0 + ? 'Already up-to-date with Konnect.' + : [ + lastSyncResult.routes.created > 0 && `${lastSyncResult.routes.created} request(s) added`, + lastSyncResult.routes.updated > 0 && `${lastSyncResult.routes.updated} request(s) updated`, + lastSyncResult.routes.deleted > 0 && `${lastSyncResult.routes.deleted} request(s) deleted`, + lastSyncResult.routes.skipped > 0 && `${lastSyncResult.routes.skipped} route(s) skipped`, + ] + .filter(Boolean) + .join(', ') + '.'} +

    + {lastSyncResult.success && lastSyncResult.skippedRoutes.length > 0 && ( + <> + + {showSyncDetails && ( +
    + {[...skippedRoutesByReason.entries()].map(([reason, routes]) => { + const MAX_SHOW = 5; + const visible = routes.slice(0, MAX_SHOW); + const extra = routes.length - MAX_SHOW; + return ( +
    +

    {reason} for the following routes:

    +
      + {visible.map(r => ( +
    • + {r} +
    • + ))} +
    + {extra > 0 && ( +
    + + {extra} more + +
    + )} +
    + ); + })} +
    + )} + + )} +
    +
    + +
    + )} + + )} + + {showKonnectConfigModal && ( + setShowKonnectConfigModal(false)} + syncKonnectProjectsAndNotifyRef={syncKonnectProjectsAndNotifyRef} + /> + )}
    ); }; @@ -900,27 +1102,18 @@ export const EmptyProjectNavigationSidebar = ({ onCreateProject }: { onCreatePro return (
    - {}} - filterValue="" - onFilterChange={() => {}} - isFilterDisabled - actionButton={ - !isScratchPad ? ( - - ) : null - } - /> + + + +
    + {}} /> + {!isScratchPad && } +
    ); }; diff --git a/packages/insomnia/src/ui/components/tabs/tab-list.tsx b/packages/insomnia/src/ui/components/tabs/tab-list.tsx index 0444529ade..c56b5b3dcb 100644 --- a/packages/insomnia/src/ui/components/tabs/tab-list.tsx +++ b/packages/insomnia/src/ui/components/tabs/tab-list.tsx @@ -20,7 +20,6 @@ import { useInsomniaTab } from '~/ui/hooks/use-insomnia-tab'; import { type ChangeBufferEvent, type ChangeType, database } from '../../../common/database'; import { debounce } from '../../../common/misc'; -import { INSOMNIA_TAB_HEIGHT } from '../../constant'; import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context'; import { type Size, useResizeObserver } from '../../hooks/use-resize-observer'; import { Icon } from '../icon'; @@ -373,7 +372,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = '' if (!tabList.length) return null; return ( -
    +
    - - Preferences - - - - {!isScratchpadWorkspace && hasUntrackedData && !isMinimal ? ( -
    - -
    - ) : null} - {!isScratchpadWorkspace && hasUntrackedData && isMinimal ? ( - - - We have detected orphaned projects on your computer, click here to view them. + Preferences + - ) : null} - {isMinimal && ( - - )} -
    -
    - {isMinimal && } -
    -
    -
    - {!isMinimal && ( + {!isScratchpadWorkspace && hasUntrackedData && !isMinimal ? ( +
    + +
    + ) : null} + {!isScratchpadWorkspace && hasUntrackedData && isMinimal ? ( + + + + We have detected orphaned projects on your computer, click here to view them. + + + ) : null} + {isMinimal && ( )} - {!isMinimal && ( - - - Made with - by Kong - - - )} +
    +
    + {isMinimal && } +
    +
    +
    + {!isMinimal && ( + + )} + {!isMinimal && ( + + + Made with + by Kong + + + )} +
    -
-
- {user ? ( - - - - - - - ) : ( - - - Login - - - Sign up for free - - - )} +
+ {user ? ( + + + + + + + ) : ( + + + Login + + + Sign up for free + + + )} +
- + ); diff --git a/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx similarity index 67% rename from packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx rename to packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx index 5be0008b23..afe830189c 100644 --- a/packages/insomnia/src/ui/components/dropdowns/project-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx @@ -10,12 +10,16 @@ import { MenuSection, MenuTrigger, Popover, + SubmenuTrigger, Tooltip, TooltipTrigger, } from 'react-aria-components'; import { useParams } from 'react-router'; import * as reactUse from 'react-use'; +import type { SORT_ORDERS } from '~/common/constants'; +import { sortOrderName } from '~/common/constants'; +import { scopeToBgColorMap, scopeToTextColorMap } from '~/common/get-workspace-label'; import type { GitRepository, Project, WorkspaceScope } from '~/insomnia-data'; import { models } from '~/insomnia-data'; import { useProjectDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.delete'; @@ -27,20 +31,36 @@ import { AlertModal } from '../modals/alert-modal'; import { AskModal } from '../modals/ask-modal'; import { ProjectModal } from '../modals/project-modal'; +export const ICON_CLASS = 'h-3 w-3 shrink-0'; + +export type WorkspaceSortOrder = Exclude<(typeof SORT_ORDERS)[number], 'http-method' | 'type-desc' | 'type-asc'>; +const workspaceSortOrder: WorkspaceSortOrder[] = [ + 'type-manual', + 'created-asc', + 'created-desc', + 'name-asc', + 'name-desc', +]; + interface Props { project: Project & { hasUncommittedOrUnpushedChanges?: boolean; gitRepository?: GitRepository }; organizationId: string; storageRules: StorageRules; + sortOrder: WorkspaceSortOrder; + onSortOrderChange: (newOrder: WorkspaceSortOrder) => void; } interface ProjectActionItem { id: string; name: string; icon: IconProp; + scope?: WorkspaceScope; action: (projectId: string, projectName: string) => void; + hasSubmenu?: boolean; + submenuItems?: Omit[]; } -export const ProjectDropdown: FC = ({ project, organizationId, storageRules }) => { +export const ProjectDropdown: FC = ({ project, organizationId, storageRules, sortOrder, onSortOrderChange }) => { const [isProjectSettingsModalOpen, setIsProjectSettingsModalOpen] = useState(false); const [newWorkspaceModalState, setNewWorkspaceModalState] = useState<{ scope: WorkspaceScope; @@ -74,6 +94,18 @@ export const ProjectDropdown: FC = ({ project, organizationId, storageRul icon: 'gear', action: () => setIsProjectSettingsModalOpen(true), }, + { + id: 'Sort', + name: 'Sort', + icon: 'sort' as IconName, + action: () => {}, + hasSubmenu: true, + submenuItems: workspaceSortOrder.map(order => ({ + id: order, + name: sortOrderName[order], + action: () => onSortOrderChange(order), + })), + }, { id: 'delete', name: 'Delete', @@ -108,18 +140,21 @@ export const ProjectDropdown: FC = ({ project, organizationId, storageRul { id: 'new-collection', name: 'Request collection', + scope: 'collection', icon: 'bars', action: createNewCollection, }, { id: 'new-document', name: 'Design document', + scope: 'design', icon: 'file', action: createNewDocument, }, { id: 'new-mcp-client', name: 'MCP Client', + scope: 'mcp', icon: ['fac', 'mcp'] as unknown as IconProp, action: createNewMcpClient, }, @@ -128,6 +163,7 @@ export const ProjectDropdown: FC = ({ project, organizationId, storageRul { id: 'new-mock-server', name: 'Mock Server', + scope: 'mock-server' as WorkspaceScope, icon: 'server' as IconName, action: createNewMockServer, }, @@ -136,6 +172,7 @@ export const ProjectDropdown: FC = ({ project, organizationId, storageRul { id: 'new-environment', name: 'Environment', + scope: 'environment', icon: 'code', action: createNewGlobalEnvironment, }, @@ -228,17 +265,61 @@ export const ProjectDropdown: FC = ({ project, organizationId, storageRul {section.name} - {item => ( - - - {item.name} - - )} + {item => + !item.hasSubmenu ? ( + + {section.name === 'CREATE' && item.scope ? ( +
+ +
+ ) : ( + + )} + {item.name} +
+ ) : ( + + + + {item.name} + + + + + item.submenuItems?.find(s => s.id === key)?.action(project._id, project.name) + } + 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 => ( + + {subItem.name} + {sortOrder === subItem.id && ( + + )} + + )} + + + + ) + }
)} diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx index 173ff27e66..6956fb9408 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx @@ -348,7 +348,7 @@ export const SidebarWorkspaceDropdown = ({ @@ -358,7 +358,7 @@ export const SidebarWorkspaceDropdown = ({ ) : ( diff --git a/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx b/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx index 6600c49d08..fc769db7cc 100644 --- a/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx +++ b/packages/insomnia/src/ui/components/mcp/mcp-pane.tsx @@ -15,7 +15,6 @@ import { type ImperativePanelGroupHandle, Panel, PanelGroup, PanelResizeHandle } import { useParams } from 'react-router'; import { useLocalStorage } from 'react-use'; -import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; import { getDefaultServerCapabilities, type McpServerData, @@ -41,7 +40,6 @@ import { AnalyticsEvent, trackOnceDaily } from '~/ui/analytics'; import { McpActionsDropdown } from '~/ui/components/dropdowns/mcp-actions-dropdown'; import { ErrorBoundary } from '~/ui/components/error-boundary'; import { Icon } from '~/ui/components/icon'; -import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; import { McpRequestPane, type RequestPaneTabs } from '~/ui/components/mcp/mcp-request-pane'; import { type PrimitiveSubItem, @@ -255,23 +253,6 @@ export const McpPane = () => { }, }); - const toggleSidebar = () => { - const layout = sidebarPanelRef.current?.getLayout(); - - if (!layout) { - return; - } - - layout[0] = layout && layout[0] > 0 ? 0 : DEFAULT_SIDEBAR_SIZE; - - sidebarPanelRef.current?.setLayout(layout); - }; - - useEffect(() => { - const unsubscribe = window.main.on('toggle-sidebar', toggleSidebar); - return unsubscribe; - }, []); - useEffect(() => { if (settings.forceVerticalLayout) { setDirection('vertical'); @@ -355,10 +336,6 @@ export const McpPane = () => { } }, [activeResponse?._id, readyState]); - useDocBodyKeyboardShortcuts({ - sidebar_toggle: toggleSidebar, - }); - return (
diff --git a/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx index 863954df37..7569605c70 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx @@ -1,6 +1,6 @@ import React, { type FC, type MouseEventHandler, useEffect, useRef, useState } from 'react'; import { OverlayContainer } from 'react-aria'; -import { href, useParams } from 'react-router'; +import { href, useNavigate, useParams } from 'react-router'; import type { BaseModel, Project, Workspace } from '~/insomnia-data'; import { models } from '~/insomnia-data'; @@ -32,6 +32,8 @@ export const WorkspaceDuplicateModal: FC = ({ work const [projectOptions, setProjectOptions] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(''); const [newWorkspaceName, setNewWorkspaceName] = useState(workspace.name); + const navigate = useNavigate(); + useEffect(() => { (async () => { const organizationProjects = await database.find(models.project.type, { @@ -48,6 +50,27 @@ export const WorkspaceDuplicateModal: FC = ({ work modalRef.current?.show(); }, []); + useEffect(() => { + const fetcherResult = fetcher.data; + if ( + fetcherResult && + !('error' in fetcherResult) && + fetcherResult.workspaceId && + fetcherResult.projectId && + fetcherResult.organizationId && + fetcherResult.workspaceScope + ) { + navigate( + `${href('/organization/:organizationId/project/:projectId/workspace/:workspaceId', { + organizationId: fetcherResult.organizationId, + projectId: fetcherResult.projectId, + workspaceId: fetcherResult.workspaceId, + })}/${models.workspace.scopeToActivity(fetcherResult.workspaceScope)}`, + ); + onHide(); + } + }, [fetcher.data, navigate, onHide]); + const isBtnDisabled = fetcher.state !== 'idle' || !selectedProjectId || !newWorkspaceName; return ( diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx index af723acddf..4b6a76678d 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx @@ -223,13 +223,15 @@ export const EmptyNode = ({ item, storageRules }: EmptyNodeProps) => { style={{ left: `${i + 1.5}em` }} /> ))} - {getLabel()} + + {getLabel()} + >({}); + const [flatItems, setFlatItems] = useState([]); + const [projectWorkspaceSortOrder, setProjectWorkspaceSortOrder] = useState>({}); const [unsyncedFilesByProjectId, setUnsyncedFilesByProjectId] = useState>(new Map()); + // Customized workspace sort orders by projectId + const [localWorkspaceOrders, setLocalWorkspaceOrders] = reactUse.useLocalStorage>( + `${organizationId}:local-workspace-orders`, + {}, + ); const [projectNavigationSidebarFilter, setProjectNavigationSidebarFilter] = reactUse.useLocalStorage( `${organizationId}:project-navigation-sidebar-filter`, '', ); - - useEffect(() => { - if (projectNavigationSidebarFilter) { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.projectListFiltered, - }); - } - }, [projectNavigationSidebarFilter]); - const [konnectFilter, setKonnectFilter] = reactUse.useLocalStorage( `${organizationId}:project-navigation-konnect-filter`, '', @@ -185,21 +185,19 @@ export const ProjectNavigationSidebar = ({ `${organizationId}:sidebar-tab`, 'projects', ); + const [expandedProjectAndWorkspaceIds, setExpandedProjectAndWorkspaceIds] = reactUse.useLocalStorage( + `${organizationId}:nav-expanded-projects-and-workspaces`, + [], + ); const activeTab = !konnectSyncEnabled ? 'projects' : (storedTab ?? 'projects'); const isProjectTabActive = activeTab === 'projects'; const { syncing, progress, startSync, cancelSync } = useKonnectSync(); const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId); const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null); - const [filterInputValue, setFilterInputValue] = useState(projectNavigationSidebarFilter || ''); // Debounce update filter reactUse.useDebounce(() => setProjectNavigationSidebarFilter(filterInputValue), 300, [filterInputValue]); - const [expandedProjectAndWorkspaceIds, setExpandedProjectAndWorkspaceIds] = reactUse.useLocalStorage( - `${organizationId}:nav-expanded-projects-and-workspaces`, - [], - ); - const [flatItems, setFlatItems] = useState([]); // ref to cache queried workspaces by project id const cachedWorkspacesRef = useRef>(new Map()); // ref to cache queried collection children (request & requestGroups) data and meta by workspace id @@ -351,6 +349,14 @@ export const ProjectNavigationSidebar = ({ } }; + useEffect(() => { + if (projectNavigationSidebarFilter) { + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.projectListFiltered, + }); + } + }, [projectNavigationSidebarFilter]); + useEffect(() => { getAllRemoteFilesByProjectId(); const updateUnsyncedFiles = () => { @@ -421,8 +427,24 @@ export const ProjectNavigationSidebar = ({ hidden: false, }); const workspaces = workspacesByProject.get(projectId) || []; - // TODO workspace sort - const sortedWorkspaces = [...workspaces].sort((a, b) => a.name.localeCompare(b.name)); + const workspaceOrder = projectWorkspaceSortOrder[projectId] || 'type-manual'; + let sortedWorkspaces: Workspace[] = []; + if (workspaceOrder === 'type-manual') { + const localOrder = localWorkspaceOrders?.[projectId]; + if (localOrder) { + const orderIndexByWorkspaceId = new Map(localOrder.map((workspaceId, index) => [workspaceId, index])); + sortedWorkspaces = [...workspaces].sort((a, b) => { + const ai = orderIndexByWorkspaceId.get(a._id) ?? Infinity; + const bi = orderIndexByWorkspaceId.get(b._id) ?? Infinity; + return ai - bi; + }); + } else { + sortedWorkspaces = [...workspaces].sort((a, b) => sortMethodMap['created-asc'](a, b)); + } + } else { + sortedWorkspaces = [...workspaces].sort((a, b) => sortMethodMap[workspaceOrder](a, b)); + } + const unsyncedWorkspaces = models.project.isRemoteProject(project) ? getUnsyncedRemoteWorkspaces(unsyncedFilesByProjectId.get(projectId) || [], sortedWorkspaces) : []; @@ -498,7 +520,13 @@ export const ProjectNavigationSidebar = ({ // If workspace or any of its collection child matches the filter, show the workspace; otherwise hide items.find(i => i.kind === 'workspace' && i.doc._id === workspaceId)!.hidden = shouldHide; } - const pinnedCollectionChildren = collectionChildren.filter(child => child.pinned && !child.hidden); + // Show pinned collection children when the workspace is expanded + const pinnedCollectionChildren = shouldHideCollectionChildren + ? [] + : // Filter out pinned requests by pinned attribute. Besides, when there is an active filter, also filter out un-matched requests. + collectionChildren.filter( + child => child.pinned && !(projectNavigationSidebarFilter ? child.hidden : false), + ); if (pinnedCollectionChildren.length > 0) { items.push({ @@ -518,7 +546,7 @@ export const ProjectNavigationSidebar = ({ ancestors: child.ancestors, doc: child.doc, collapsed: child.collapsed, - hidden: child.hidden, + hidden: false, level: child.level, pinned: child.pinned, isFirstPinned: idx === 0, @@ -598,15 +626,61 @@ export const ProjectNavigationSidebar = ({ }; buildWorkspaceAndCollectionData(); }, [ + collectionSortOrders, + projectWorkspaceSortOrder, expandedProjectAndWorkspaceIds, isProjectTabActive, + localWorkspaceOrders, organizationId, projectNavigationSidebarFilter, projectsWithPresence, unsyncedFilesByProjectId, - collectionSortOrders, ]); + const handleLocalWorkspaceReorder = useCallback( + ( + sourceProjectId: string, + targetProjectId: string, + draggedId: string, + targetWorkspaceId: string | null, + dropPosition: 'before' | 'after', + ) => { + setLocalWorkspaceOrders(prev => { + const isMoveToDifferentProject = sourceProjectId !== targetProjectId; + const workspaces = cachedWorkspacesRef.current.get(targetProjectId) || []; + const currentWorkspaceSortOrder = projectWorkspaceSortOrder[targetProjectId] || 'type-manual'; + // Get the base order of workspace before re-order + const baseOrder = + // if current order is manual, use the current order in local state or default sort by created time; otherwise use current order to sort + currentWorkspaceSortOrder === 'type-manual' + ? prev?.[targetProjectId] || + [...workspaces].sort((a, b) => sortMethodMap['created-asc'](a, b)).map(w => w._id) + : [...workspaces].sort((a, b) => sortMethodMap[currentWorkspaceSortOrder](a, b)).map(w => w._id); + const reordered = (baseOrder as string[]).filter((id: string) => id !== draggedId); + + if (targetWorkspaceId === null) { + // Drop workspace into a project, add it to the start of the workspace list + reordered.unshift(draggedId); + } else { + const targetIdx = reordered.indexOf(targetWorkspaceId); + if (!isMoveToDifferentProject && targetIdx === -1) return prev; + reordered.splice( + targetIdx === -1 ? reordered.length : dropPosition === 'before' ? targetIdx : targetIdx + 1, + 0, + draggedId, + ); + } + + if (isMoveToDifferentProject || currentWorkspaceSortOrder !== 'type-manual') { + // If the current order is not manual, set the order to manual after re-order to persist the custom order + setProjectWorkspaceSortOrder(prev => ({ ...prev, [targetProjectId]: 'type-manual' })); + } + return { ...prev, [targetProjectId]: reordered }; + }); + }, + [projectWorkspaceSortOrder, setLocalWorkspaceOrders], + ); + const toggleProjectOrWorkspace = useCallback( (projectOrWorkspaceId: string) => { // Do not update toggle state if there is an active filter @@ -643,7 +717,7 @@ export const ProjectNavigationSidebar = ({ return; } - if (projectNavigationSidebarFilter && collapsed === undefined) { + if (projectNavigationSidebarFilter) { return; } @@ -766,6 +840,7 @@ export const ProjectNavigationSidebar = ({ flatItems, organizationId, virtualizer, + onWorkspaceReorder: handleLocalWorkspaceReorder, }); const { selectedItemId, routeInfo } = useProjectNavigationSidebarNavigation({ setActiveTab, @@ -945,7 +1020,18 @@ export const ProjectNavigationSidebar = ({ }} > {item.kind === 'project' && ( - + + setProjectWorkspaceSortOrder(prev => { + const newProjectWorkspaceSortOrder = { ...prev, [item.doc._id]: newSortOrder }; + return newProjectWorkspaceSortOrder; + }) + } + /> )} {item.kind === 'workspace' && ( diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx index 5fd18d1ed0..aa1f0c4c16 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-node.tsx @@ -2,7 +2,7 @@ import type { StorageRules } from 'insomnia-api'; import { Button } from 'react-aria-components'; import { models } from '~/insomnia-data'; -import { ProjectDropdown } from '~/ui/components/dropdowns/project-dropdown'; +import { ProjectDropdown, type WorkspaceSortOrder } from '~/ui/components/dropdowns/sidebar-project-dropdown'; import { AvatarGroup } from '../../avatar'; import { Icon } from '../../icon'; @@ -13,9 +13,11 @@ interface ProjectNodeProps { item: ProjectFlatItem; storageRules: StorageRules; onToggle: (projectId: string) => void; + sortOrder: WorkspaceSortOrder; + onSortOrderChange: (newSortOrder: WorkspaceSortOrder) => void; } -export const ProjectNode = ({ item, storageRules, onToggle }: ProjectNodeProps) => { +export const ProjectNode = ({ item, storageRules, onToggle, sortOrder, onSortOrderChange }: ProjectNodeProps) => { const { doc, collapsed, organizationId } = item; const { name: projectName, presence, _id: projectId } = doc; return ( @@ -43,7 +45,13 @@ export const ProjectNode = ({ item, storageRules, onToggle }: ProjectNodeProps)
{presence.length > 0 && } {projectId !== models.project.SCRATCHPAD_PROJECT_ID && ( - + )} ); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/request-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/request-node.tsx index 4a62cbd2fa..03365008d3 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/request-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/request-node.tsx @@ -153,13 +153,15 @@ export const RequestNode = ({ item, onToggleFolder, className }: RequestNodeProp const content = ( <> + {!isPinnedRequest && ( + + )}
{isFolder ? : } {isPinnedRequest ? (
{content}
@@ -294,9 +296,9 @@ export const PinnedHeaderNode = () => { + + + {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!`} +

+
+
+
+