diff --git a/packages/insomnia/src/common/__tests__/database.test.ts b/packages/insomnia/src/common/__tests__/database.test.ts index 7fcc543773e..82ae96358af 100644 --- a/packages/insomnia/src/common/__tests__/database.test.ts +++ b/packages/insomnia/src/common/__tests__/database.test.ts @@ -37,8 +37,8 @@ describe('onChange()', () => { name: 'bar', }); expect(changesSeen).toEqual([ - [['insert', newDoc, false]], - [['update', updatedDoc, false]], + [['insert', newDoc, false, []]], + [['update', updatedDoc, false, [{ name: 'bar' }]]], ]); db.offChange(callback); await models.request.create(doc); @@ -71,16 +71,16 @@ describe('bufferChanges()', () => { await db.flushChanges(); expect(changesSeen).toEqual([ [ - ['insert', newDoc, false], - ['update', updatedDoc, false], + ['insert', newDoc, false, []], + ['update', updatedDoc, false, [true]], ], ]); // Assert no more changes seen after flush again await db.flushChanges(); expect(changesSeen).toEqual([ [ - ['insert', newDoc, false], - ['update', updatedDoc, false], + ['insert', newDoc, false, []], + ['update', updatedDoc, false, [true]], ], ]); }); @@ -106,8 +106,8 @@ describe('bufferChanges()', () => { await new Promise(resolve => setTimeout(resolve, 1500)); expect(changesSeen).toEqual([ [ - ['insert', newDoc, false], - ['update', updatedDoc, false], + ['insert', newDoc, false, []], + ['update', updatedDoc, false, [true]], ], ]); }); @@ -132,8 +132,8 @@ describe('bufferChanges()', () => { await new Promise(resolve => setTimeout(resolve, 1000)); expect(changesSeen).toEqual([ [ - ['insert', newDoc, false], - ['update', updatedDoc, false], + ['insert', newDoc, false, []], + ['update', updatedDoc, false, [true]], ], ]); }); @@ -166,8 +166,8 @@ describe('bufferChangesIndefinitely()', () => { await db.flushChanges(); expect(changesSeen).toEqual([ [ - ['insert', newDoc, false], - ['update', updatedDoc, false], + ['insert', newDoc, false, []], + ['update', updatedDoc, false, [true]], ], ]); }); diff --git a/packages/insomnia/src/common/database.ts b/packages/insomnia/src/common/database.ts index 79f74a3754d..c493e7ac600 100644 --- a/packages/insomnia/src/common/database.ts +++ b/packages/insomnia/src/common/database.ts @@ -116,7 +116,7 @@ export const database = { }, ...patches, ); - return database.update(doc); + return database.update(doc, false, patches); }, /** duplicate doc and its decendents recursively */ @@ -524,7 +524,7 @@ export const database = { notifyOfChange('remove', doc, fromSync); }, - update: async function(doc: T, fromSync = false) { + update: async function (doc: T, fromSync = false, patches: Patch[] = []) { if (db._empty) { return _send('update', ...arguments); } @@ -550,7 +550,7 @@ export const database = { resolve(docWithDefaults); // NOTE: This needs to be after we resolve - notifyOfChange('update', docWithDefaults, fromSync); + notifyOfChange('update', docWithDefaults, fromSync, patches); }, ); }); @@ -698,7 +698,8 @@ let bufferChangesId = 1; export type ChangeBufferEvent = [ event: ChangeType, doc: T, - fromSync: boolean + fromSync: boolean, + patches: Patch[], ]; let changeBuffer: ChangeBufferEvent[] = []; @@ -709,10 +710,11 @@ let changeListeners: ChangeListener[] = []; /** push changes into the buffer, so that changeListeners can get change contents when database.flushChanges is called, * this method should be called whenever a document change happens */ -async function notifyOfChange(event: ChangeType, doc: T, fromSync: boolean) { +async function notifyOfChange(event: ChangeType, doc: T, fromSync: boolean, patches: Patch[] = []) { const updatedDoc = doc; - changeBuffer.push([event, updatedDoc, fromSync]); + // TODO: Use object is better than array + changeBuffer.push([event, updatedDoc, fromSync, patches]); // Flush right away if we're not buffering if (!bufferingChanges) { diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 206433aa79c..241cb6bc51d 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -57,7 +57,8 @@ export type MainOnChannels = | 'restart' | 'set-hidden-window-busy-status' | 'setMenuBarVisibility' - | 'show-context-menu' + | 'show-nunjucks-context-menu' + | 'showContextMenu' | 'showItemInFolder' | 'showOpenDialog' | 'showSaveDialog' @@ -73,7 +74,8 @@ export type MainOnChannels = export type RendererOnChannels = 'clear-all-models' | 'clear-model' - | 'context-menu-command' + | 'nunjucks-context-menu-command' + | 'contextMenuCommand' | 'grpc.data' | 'grpc.end' | 'grpc.error' @@ -114,10 +116,10 @@ const getTemplateValue = (arg: NunjucksParsedTagArg) => { return arg.defaultValue; }; export function registerElectronHandlers() { - ipcMainOn('show-context-menu', (event, options: { key: string; nunjucksTag: ReturnType }) => { + ipcMainOn('show-nunjucks-context-menu', (event, options: { key: string; nunjucksTag: ReturnType }) => { const { key, nunjucksTag } = options; const sendNunjuckTagContextMsg = (type: NunjucksTagContextMenuAction) => { - event.sender.send('context-menu-command', { key, nunjucksTag: { ...nunjucksTag, type } }); + event.sender.send('nunjucks-context-menu-command', { key, nunjucksTag: { ...nunjucksTag, type } }); }; try { const baseTemplate: MenuItemConstructorOptions[] = nunjucksTag ? @@ -170,7 +172,7 @@ export function registerElectronHandlers() { { click: () => { const tag = `{% ${l.templateTag.name} ${l.templateTag.args?.map(getTemplateValue).join(', ')} %}`; - event.sender.send('context-menu-command', { key, tag }); + event.sender.send('nunjucks-context-menu-command', { key, tag }); }, } : { @@ -179,7 +181,7 @@ export function registerElectronHandlers() { click: () => { const additionalTagFields = additionalArgs.length ? ', ' + additionalArgs.map(getTemplateValue).join(', ') : ''; const tag = `{% ${l.templateTag.name} '${action.value}'${additionalTagFields} %}`; - event.sender.send('context-menu-command', { key, tag }); + event.sender.send('nunjucks-context-menu-command', { key, tag }); }, })), }), @@ -236,4 +238,17 @@ export function registerElectronHandlers() { ipcMainOn('getAppPath', event => { event.returnValue = app.getAppPath(); }); + + ipcMainOn('showContextMenu', (event, options: { key: string; menuItems: MenuItemConstructorOptions[]; extra?: Record }) => { + const menuItems = options.menuItems.map(item => { + return { + ...item, + click: () => { + event.sender.send('contextMenuCommand', { key: options.key, label: item.label, extra: options.extra }); + }, + }; + }); + const menu = Menu.buildFromTemplate(menuItems); + menu.popup(); + }); } diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 216a90bd6e3..4421514de28 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -1,6 +1,6 @@ import * as Sentry from '@sentry/electron/main'; import type { MarkerRange } from 'codemirror'; -import { app, BrowserWindow, type IpcRendererEvent, shell } from 'electron'; +import { app, BrowserWindow, type IpcRendererEvent, type MenuItemConstructorOptions, shell } from 'electron'; import fs from 'fs'; import { APP_START_TIME, LandingPage, SentryMetrics } from '../../common/sentry'; @@ -39,7 +39,9 @@ export interface RendererToMainBridgeAPI { curl: CurlBridgeAPI; trackSegmentEvent: (options: { event: string; properties?: Record }) => void; trackPageView: (options: { name: string }) => void; - showContextMenu: (options: { key: string; nunjucksTag?: { template: string; range: MarkerRange } }) => void; + showNunjucksContextMenu: (options: { key: string; nunjucksTag?: { template: string; range: MarkerRange } }) => void; + showContextMenu: (options: { key: string; menuItems: MenuItemConstructorOptions[]; extra?: Record }) => void; + database: { caCertificate: { create: (options: { parentId: string; path: string }) => Promise; diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index fc994eb4346..cb1203b9fb2 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -69,7 +69,8 @@ const main: Window['main'] = { curl, trackSegmentEvent: options => ipcRenderer.send('trackSegmentEvent', options), trackPageView: options => ipcRenderer.send('trackPageView', options), - showContextMenu: options => ipcRenderer.send('show-context-menu', options), + showNunjucksContextMenu: options => ipcRenderer.send('show-nunjucks-context-menu', options), + showContextMenu: options => ipcRenderer.send('showContextMenu', options), database: { caCertificate: { create: options => ipcRenderer.invoke('database.caCertificate.create', options), diff --git a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx index b63ae0d441e..fe3abd77097 100644 --- a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx @@ -546,7 +546,7 @@ export const CodeEditor = memo(forwardRef(({ } }; useEffect(() => { - const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag }) => { + const unsubscribe = window.main.on('nunjucks-context-menu-command', (_, { key, tag, nunjucksTag }) => { if (id === key) { if (nunjucksTag) { const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions; @@ -657,10 +657,10 @@ export const CodeEditor = memo(forwardRef(({ const nunjucksTag = extractNunjucksTagFromCoords({ left: clientX, top: clientY }, codeMirror); if (nunjucksTag) { // show context menu for nunjucks tag - window.main.showContextMenu({ key: id, nunjucksTag }); + window.main.showNunjucksContextMenu({ key: id, nunjucksTag }); } } else { - window.main.showContextMenu({ key: id }); + window.main.showNunjucksContextMenu({ key: id }); } }} > diff --git a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx index 694d4bd15dd..0ee5767fe9d 100644 --- a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx @@ -247,7 +247,7 @@ export const OneLineEditor = forwardRef }, [onChange]); useEffect(() => { - const unsubscribe = window.main.on('context-menu-command', (_, { key, tag, nunjucksTag }) => { + const unsubscribe = window.main.on('nunjucks-context-menu-command', (_, { key, tag, nunjucksTag }) => { if (id === key) { if (nunjucksTag) { const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions; @@ -310,10 +310,10 @@ export const OneLineEditor = forwardRef const nunjucksTag = extractNunjucksTagFromCoords({ left: clientX, top: clientY }, codeMirror); if (nunjucksTag) { // show context menu for nunjucks tag - window.main.showContextMenu({ key: id, nunjucksTag }); + window.main.showNunjucksContextMenu({ key: id, nunjucksTag }); } } else { - window.main.showContextMenu({ key: id }); + window.main.showNunjucksContextMenu({ key: id }); } }} > diff --git a/packages/insomnia/src/ui/components/document-tab.tsx b/packages/insomnia/src/ui/components/document-tab.tsx new file mode 100644 index 00000000000..ee2d7220744 --- /dev/null +++ b/packages/insomnia/src/ui/components/document-tab.tsx @@ -0,0 +1,34 @@ +import classnames from 'classnames'; +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +interface Props { + organizationId: string; + projectId: string; + workspaceId: string; + className?: string; +} + +export const DocumentTab = ({ organizationId, projectId, workspaceId, className }: Props) => { + return ( + + ); +}; diff --git a/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx b/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx new file mode 100644 index 00000000000..a81e01a3f7c --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx @@ -0,0 +1,157 @@ +import React, { type FC, type MouseEventHandler, useEffect, useRef, useState } from 'react'; +import { OverlayContainer } from 'react-aria'; +import { useFetcher, useParams } from 'react-router-dom'; + +import { database } from '../../../common/database'; +import { strings } from '../../../common/strings'; +import { sortProjects } from '../../../models/helpers/project'; +import * as models from '../../../models/index'; +import type { Project } from '../../../models/project'; +import { Modal, type ModalHandle, type ModalProps } from '../base/modal'; +import { ModalBody } from '../base/modal-body'; +import { ModalFooter } from '../base/modal-footer'; +import { ModalHeader } from '../base/modal-header'; +import { Icon } from '../icon'; + +interface AddRequestModalProps extends ModalProps { + onHide: Function; +} + +export const AddRequestToCollectionModal: FC = ({ onHide }) => { + const { organizationId, projectId: currentProjectId, workspaceId: currentWorkspaceId } = useParams(); + const [projectOptions, setProjectOptions] = useState([]); + const [workspaceOptions, setWorkspaceOptions] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(''); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(''); + + const requestFetcher = useFetcher(); + + useEffect(() => { + (async () => { + const organizationProjects = await database.find(models.project.type, { + parentId: organizationId, + }); + setProjectOptions(sortProjects(organizationProjects)); + setSelectedProjectId(organizationProjects[0]?._id || ''); + })(); + }, [organizationId]); + + useEffect(() => { + (async () => { + const workspaces = await models.workspace.findByParentId(selectedProjectId); + const requestCollections = workspaces.filter(workspace => workspace.scope === 'collection'); + setWorkspaceOptions(requestCollections); + setSelectedWorkspaceId(requestCollections[0]?._id || ''); + })(); + }, [selectedProjectId]); + + const modalRef = useRef(null); + useEffect(() => { + modalRef.current?.show(); + }, []); + + const isBtnDisabled = requestFetcher.state !== 'idle' + || !selectedProjectId || !selectedWorkspaceId; + + const previousRequestFetcherState = useRef('idle'); + + const createNewRequest = async () => { + requestFetcher.submit( + { requestType: 'HTTP', parentId: selectedWorkspaceId }, + { + action: `/organization/${organizationId}/project/${selectedProjectId}/workspace/${selectedWorkspaceId}/debug/request/new`, + method: 'post', + encType: 'application/json', + }, + ); + previousRequestFetcherState.current = 'loading'; + }; + + useEffect(() => { + if (previousRequestFetcherState?.current === 'loading' && requestFetcher.state === 'idle') { + // when action is completed, close the modal + onHide(); + previousRequestFetcherState.current = 'idle'; + } + }, [onHide, requestFetcher.state]); + + return ( + e.stopPropagation()}> + + Add Request + +
+ +
+ {!selectedProjectId && ( +

+ Project is required +

+ )} + +
+ +
+ {!selectedWorkspaceId && ( +

+ Collection is required +

+ )} + {requestFetcher.data?.error && ( +

+ {requestFetcher.data.error} +

+ )} +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/modals/request-group-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/request-group-settings-modal.tsx index 5f4d71eb29d..f2692fe9c97 100644 --- a/packages/insomnia/src/ui/components/modals/request-group-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/request-group-settings-modal.tsx @@ -7,6 +7,7 @@ import type { RequestGroup } from '../../../models/request-group'; import { invariant } from '../../../utils/invariant'; import { useRequestGroupPatcher } from '../../hooks/use-request'; import type { ListWorkspacesLoaderData } from '../../routes/project'; +import { revalidateWorkspaceActiveRequestByFolder } from '../../routes/workspace'; import { Modal, type ModalHandle, type ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalHeader } from '../base/modal-header'; @@ -49,6 +50,8 @@ export const RequestGroupSettingsModal = ({ requestGroup, onHide }: ModalProps & const handleMoveToWorkspace = async () => { invariant(workspaceToCopyTo, 'Workspace ID is required'); patchRequestGroup(requestGroup._id, { parentId: workspaceToCopyTo }); + // if the folder is moved to a different workspace, we need to revalidate the active request + revalidateWorkspaceActiveRequestByFolder(requestGroup, workspaceId); modalRef.current?.hide(); navigate(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceToCopyTo}/debug`); }; diff --git a/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx index 8a376337936..5a8b513d431 100644 --- a/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/request-settings-modal.tsx @@ -11,6 +11,7 @@ import { isWebSocketRequest, type WebSocketRequest } from '../../../models/webso import { invariant } from '../../../utils/invariant'; import { useRequestPatcher } from '../../hooks/use-request'; import type { ListWorkspacesLoaderData } from '../../routes/project'; +import { revalidateWorkspaceActiveRequest } from '../../routes/workspace'; import { Modal, type ModalHandle, type ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; import { ModalHeader } from '../base/modal-header'; @@ -52,6 +53,8 @@ export const RequestSettingsModal = ({ request, onHide }: ModalProps & RequestSe async function handleMoveToWorkspace() { invariant(workspaceToCopyTo, 'Workspace ID is required'); patchRequest(request._id, { parentId: workspaceToCopyTo }); + // if active request is moved, clear the active request in the workspace + revalidateWorkspaceActiveRequest(request._id, workspaceId); modalRef.current?.hide(); navigate(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceToCopyTo}/debug`); } diff --git a/packages/insomnia/src/ui/components/tabs/tab.tsx b/packages/insomnia/src/ui/components/tabs/tab.tsx new file mode 100644 index 00000000000..8695a2b302d --- /dev/null +++ b/packages/insomnia/src/ui/components/tabs/tab.tsx @@ -0,0 +1,159 @@ +import React, { useCallback } from 'react'; +import { Button, GridListItem } from 'react-aria-components'; + +import { scrollElementIntoView } from '../../../utils'; +import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context'; +import { Icon } from '../icon'; +import { Tooltip } from '../tooltip'; +import { TAB_CONTEXT_MENU_COMMAND } from './tabList'; + +export enum TabEnum { + Request = 'request', + Folder = 'folder', + Env = 'environment', + Mock = 'mock-server', + MockRoute = 'mock-route', + Document = 'document', + Collection = 'collection', + Runner = 'runner', + TEST = 'test', + TESTSUITE = 'test-suite', +}; + +export interface BaseTab { + type: TabEnum; + name: string; + url: string; + organizationId: string; + projectId: string; + workspaceId: string; + projectName: string; + workspaceName: string; + id: string; + // tag is used to display the request method in the tab + // method is used to display the tag color + tag?: string; + method?: string; +}; + +const REQUEST_METHOD_STYLE_MAP: Record = { + 'GET': 'text-[--color-font-surprise] bg-[rgba(var(--color-surprise-rgb),0.5)]', + 'POST': 'text-[--color-font-success] bg-[rgba(var(--color-success-rgb),0.5)]', + 'GQL': 'text-[--color-font-success] bg-[rgba(var(--color-success-rgb),0.5)]', + 'HEAD': 'text-[--color-font-info] bg-[rgba(var(--color-info-rgb),0.5)]', + 'OPTIONS': 'text-[--color-font-info] bg-[rgba(var(--color-info-rgb),0.5)]', + 'DELETE': 'text-[--color-font-danger] bg-[rgba(var(--color-danger-rgb),0.5)]', + 'PUT': 'text-[--color-font-warning] bg-[rgba(var(--color-warning-rgb),0.5)]', + 'PATCH': 'text-[--color-font-notice] bg-[rgba(var(--color-notice-rgb),0.5)]', + 'WS': 'text-[--color-font-notice] bg-[rgba(var(--color-notice-rgb),0.5)]', + 'gRPC': 'text-[--color-font-info] bg-[rgba(var(--color-info-rgb),0.5)]', +}; + +const WORKSPACE_TAB_UI_MAP: Record = { + [TabEnum.Collection]: { + icon: 'bars', + bgColor: 'bg-[--color-surprise]', + textColor: 'text-[--color-font-surprise]', + }, + [TabEnum.Env]: { + icon: 'code', + bgColor: 'bg-[--color-font]', + textColor: 'text-[--color-bg]', + }, + [TabEnum.Mock]: { + icon: 'server', + bgColor: 'bg-[--color-warning]', + textColor: 'text-[--color-font-warning]', + }, + [TabEnum.Document]: { + icon: 'file', + bgColor: 'bg-[--color-info]', + textColor: 'text-[--color-font-info]', + }, +}; + +export const InsomniaTab = ({ tab }: { tab: BaseTab }) => { + + const { closeTabById, currentOrgTabs } = useInsomniaTabContext(); + + const renderTabIcon = (type: TabEnum) => { + if (WORKSPACE_TAB_UI_MAP[type]) { + return ( +
+ +
+ ); + } + + if (type === TabEnum.Request || type === TabEnum.MockRoute) { + return ( + {tab.tag} + ); + } + + if (type === TabEnum.Folder) { + return ; + } + if (type === TabEnum.Runner) { + return ; + }; + + if (type === TabEnum.TESTSUITE) { + return ; + } + + return null; + }; + + const handleClose = (id: string) => { + closeTabById(id); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + window.main.showContextMenu({ + key: 'insomniaTab', + menuItems: [ + { + label: TAB_CONTEXT_MENU_COMMAND.CLOSE_ALL, + }, + { + label: TAB_CONTEXT_MENU_COMMAND.CLOSE_OTHERS, + }, + ], + extra: { + currentTabId: tab.id, + }, + }); + }; + + const scrollIntoView = useCallback((node: HTMLDivElement) => { + if (node && currentOrgTabs.activeTabId === tab.id) { + scrollElementIntoView(node, { behavior: 'instant' }); + } + }, [currentOrgTabs.activeTabId, tab.id]); + + return ( + + {({ isSelected, isHovered }) => ( + +
+ {renderTabIcon(tab.type)} + {tab.name} + + + +
+ +
+ + {item => } + +
+ +
+ + + + + {currentPage === 'debug' && ( + + add request to current collection + + )} + + add request to other collection + + + + +
+ {showAddRequestModal && setShowAddRequestModal(false)} />} + + ); +}; diff --git a/packages/insomnia/src/ui/components/tags/method-tag.tsx b/packages/insomnia/src/ui/components/tags/method-tag.tsx index 34e921fa5dd..51983a8f20a 100644 --- a/packages/insomnia/src/ui/components/tags/method-tag.tsx +++ b/packages/insomnia/src/ui/components/tags/method-tag.tsx @@ -1,7 +1,9 @@ import React, { type FC, memo } from 'react'; import { CONTENT_TYPE_GRAPHQL, METHOD_DELETE, METHOD_OPTIONS } from '../../../common/constants'; -import { isEventStreamRequest, type Request } from '../../../models/request'; +import { type GrpcRequest, isGrpcRequest } from '../../../models/grpc-request'; +import { isEventStreamRequest, isRequest, type Request } from '../../../models/request'; +import { isWebSocketRequest, type WebSocketRequest } from '../../../models/websocket-request'; interface Props { method: string; @@ -34,6 +36,25 @@ export function formatMethodName(method: string) { return methodName; } +export const getRequestMethodShortHand = (doc?: Request | WebSocketRequest | GrpcRequest) => { + if (!doc) { + return ''; + } + if (isRequest(doc)) { + return getMethodShortHand(doc); + } + + if (isWebSocketRequest(doc)) { + return 'WS'; + } + + if (isGrpcRequest(doc)) { + return 'gRPC'; + } + + return ''; +}; + export const MethodTag: FC = memo(({ method, override, fullNames }) => { let methodName = method; let overrideName = override; diff --git a/packages/insomnia/src/ui/constant.ts b/packages/insomnia/src/ui/constant.ts new file mode 100644 index 00000000000..ec3ebef8e58 --- /dev/null +++ b/packages/insomnia/src/ui/constant.ts @@ -0,0 +1,3 @@ +// this a constant file just for renderer process + +export const INSOMNIA_TAB_HEIGHT = 40; diff --git a/packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx b/packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx new file mode 100644 index 00000000000..2a1f99baf69 --- /dev/null +++ b/packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx @@ -0,0 +1,343 @@ +import React, { createContext, type FC, type PropsWithChildren, useCallback, useContext, useRef } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useLocalStorage } from 'react-use'; + +import type { BaseTab } from '../../components/tabs/tab'; +import type { OrganizationTabs } from '../../components/tabs/tabList'; + +interface UpdateInsomniaTabParams { + organizationId: string; + tabList: OrganizationTabs['tabList']; + activeTabId?: string; +} + +interface ContextProps { + currentOrgTabs: OrganizationTabs; + appTabsRef?: React.MutableRefObject; + closeTabById: (id: string) => void; + addTab: (tab: BaseTab) => void; + changeActiveTab: (id: string) => void; + closeAllTabsUnderWorkspace?: (workspaceId: string) => void; + closeAllTabsUnderProject?: (projectId: string) => void; + updateProjectName?: (projectId: string, name: string) => void; + updateWorkspaceName?: (projectId: string, name: string) => void; + updateTabById?: (tabId: string, patches: Partial) => void; + batchUpdateTabs?: (updates: { id: string; fields: Partial }[]) => void; + closeAllTabs?: () => void; + closeOtherTabs?: (id: string) => void; + moveBefore?: (targetId: string, movingId: string) => void; + moveAfter?: (targetId: string, movingId: string) => void; +} + +const InsomniaTabContext = createContext({ + currentOrgTabs: { + tabList: [], + activeTabId: '', + }, + closeTabById: () => { }, + addTab: () => { }, + changeActiveTab: () => { }, +}); + +interface InsomniaTabs { + [orgId: string]: OrganizationTabs; +}; + +export const InsomniaTabProvider: FC = ({ children }) => { + const { + organizationId, + projectId, + } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + }; + + const [appTabs, setAppTabs] = useLocalStorage('insomnia-tabs', {}); + + // keep a ref of the appTabs to avoid the function recreated, which will cause the useEffect to run again and cannot delete a tab + // file: packages/insomnia/src/ui/hooks/tab.ts + const appTabsRef = useRef(appTabs); + + const navigate = useNavigate(); + + const updateInsomniaTabs = useCallback(({ organizationId, tabList, activeTabId }: UpdateInsomniaTabParams) => { + const newState = { + ...appTabsRef.current, + [organizationId]: { + tabList, + activeTabId, + }, + }; + appTabsRef.current = newState; + setAppTabs(newState); + }, [setAppTabs]); + + const addTab = useCallback((tab: BaseTab) => { + const currentTabs = appTabsRef?.current?.[organizationId] || { tabList: [], activeTabId: '' }; + + updateInsomniaTabs({ + organizationId, + tabList: [...currentTabs.tabList, tab], + activeTabId: tab.id, + }); + }, [organizationId, updateInsomniaTabs]); + + const closeTabById = useCallback((id: string) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs) { + return; + } + + // If the tab being deleted is the only tab and is active, navigate to the project dashboard + if (currentTabs.activeTabId === id && currentTabs.tabList.length === 1) { + navigate(`/organization/${organizationId}/project/${projectId}`); + updateInsomniaTabs({ + organizationId, + tabList: [], + activeTabId: '', + }); + return; + } + + const index = currentTabs.tabList.findIndex(tab => tab.id === id); + if (index === -1) { + return; + } + const newTabList = currentTabs.tabList.filter(tab => tab.id !== id); + if (currentTabs.activeTabId === id) { + const url = newTabList[Math.max(index - 1, 0)]?.url; + navigate(url); + } + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: currentTabs.activeTabId === id ? newTabList[Math.max(index - 1, 0)]?.id : currentTabs.activeTabId as string, + }); + }, [navigate, organizationId, projectId, updateInsomniaTabs]); + + const closeAllTabsUnderWorkspace = useCallback((workspaceId: string) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs) { + return; + } + const newTabList = currentTabs.tabList.filter(tab => tab.workspaceId !== workspaceId); + + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: '', + }); + }, [organizationId, updateInsomniaTabs]); + + const closeAllTabsUnderProject = useCallback((projectId: string) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs) { + return; + } + const newTabList = currentTabs.tabList.filter(tab => tab.projectId !== projectId); + + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: '', + }); + }, [organizationId, updateInsomniaTabs]); + + const closeAllTabs = useCallback(() => { + navigate(`/organization/${organizationId}/project/${projectId}`); + updateInsomniaTabs({ + organizationId, + tabList: [], + activeTabId: '', + }); + }, [navigate, organizationId, projectId, updateInsomniaTabs]); + + const closeOtherTabs = useCallback((id: string) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs) { + return; + } + const reservedTab = currentTabs.tabList.find(tab => tab.id === id); + if (!reservedTab) { + return; + } + + if (currentTabs.activeTabId !== id) { + navigate(reservedTab.url); + } + updateInsomniaTabs({ + organizationId, + tabList: [reservedTab], + activeTabId: id, + }); + }, [navigate, organizationId, updateInsomniaTabs]); + + const updateTabById = useCallback((tabId: string, patches: Partial) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs) { + return; + } + const newTabList = currentTabs.tabList.map(tab => { + if (tab.id === tabId) { + return { + ...tab, + ...patches, + }; + } + return tab; + }); + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: currentTabs.activeTabId || '', + }); + }, [organizationId, updateInsomniaTabs]); + + const changeActiveTab = useCallback((id: string) => { + const currentTabs = appTabsRef?.current?.[organizationId] || { tabList: [], activeTabId: '' }; + if (!currentTabs) { + return; + } + const tab = currentTabs?.tabList.find(tab => tab.id === id); + // keep the search params when navigate to another tab + tab?.url && navigate(tab.url); + updateInsomniaTabs({ + organizationId, + tabList: currentTabs.tabList, + activeTabId: id, + }); + }, [navigate, organizationId, updateInsomniaTabs]); + + const updateProjectName = useCallback((projectId: string, name: string) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + + if (!currentTabs) { + return; + } + const newTabList = currentTabs.tabList.map(tab => { + if (tab.projectId === projectId) { + return { + ...tab, + projectName: name, + }; + } + return tab; + }); + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: currentTabs.activeTabId || '', + }); + }, [organizationId, updateInsomniaTabs]); + + const updateWorkspaceName = useCallback((workspaceId: string, name: string) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs) { + return; + } + const newTabList = currentTabs.tabList.map(tab => { + if (tab.workspaceId === workspaceId) { + return { + ...tab, + workspaceName: name, + name: tab.id === workspaceId ? name : tab.name, + }; + } + return tab; + }); + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: currentTabs.activeTabId || '', + }); + }, [organizationId, updateInsomniaTabs]); + + const batchUpdateTabs = useCallback((updates: { id: string; fields: Partial }[]) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs) { + return; + } + + const newTabList = currentTabs.tabList.map(tab => { + const update = updates.find(update => update.id === tab.id); + if (update) { + return { + ...tab, + ...update.fields, + }; + } + return tab; + }); + + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: currentTabs.activeTabId || '', + }); + }, [organizationId, updateInsomniaTabs]); + + const moveBefore = useCallback((targetId: string, movingId: string) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs || targetId === movingId) { + return; + } + + const newTabList = [...currentTabs.tabList]; + const movingIndex = newTabList.findIndex(tab => tab.id === movingId); + const [movingTab] = newTabList.splice(movingIndex, 1); + const targetIndex = newTabList.findIndex(tab => tab.id === targetId); + newTabList.splice(targetIndex, 0, movingTab); + + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: currentTabs.activeTabId || '', + }); + }, [organizationId, updateInsomniaTabs]); + + const moveAfter = useCallback((targetId: string, movingId: string) => { + const currentTabs = appTabsRef?.current?.[organizationId]; + if (!currentTabs || targetId === movingId) { + return; + } + + const newTabList = [...currentTabs.tabList]; + const movingIndex = newTabList.findIndex(tab => tab.id === movingId); + const [movingTab] = newTabList.splice(movingIndex, 1); + const targetIndex = newTabList.findIndex(tab => tab.id === targetId); + newTabList.splice(targetIndex + 1, 0, movingTab); + + updateInsomniaTabs({ + organizationId, + tabList: newTabList, + activeTabId: currentTabs.activeTabId || '', + }); + }, [organizationId, updateInsomniaTabs]); + + return ( + + {children} + + ); +}; + +export const useInsomniaTabContext = () => useContext(InsomniaTabContext); diff --git a/packages/insomnia/src/ui/hooks/use-insomnia-tab.ts b/packages/insomnia/src/ui/hooks/use-insomnia-tab.ts new file mode 100644 index 00000000000..7bade7f96c5 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/use-insomnia-tab.ts @@ -0,0 +1,289 @@ +import { useCallback, useEffect } from 'react'; +import { matchPath, useLocation, useSearchParams } from 'react-router-dom'; + +import type { GrpcRequest } from '../../models/grpc-request'; +import type { MockRoute } from '../../models/mock-route'; +import type { Project } from '../../models/project'; +import type { Request } from '../../models/request'; +import type { RequestGroup } from '../../models/request-group'; +import type { UnitTestSuite } from '../../models/unit-test-suite'; +import type { WebSocketRequest } from '../../models/websocket-request'; +import type { Workspace } from '../../models/workspace'; +import { type BaseTab, TabEnum } from '../components/tabs/tab'; +import { TAB_ROUTER_PATH } from '../components/tabs/tabList'; +import { formatMethodName, getRequestMethodShortHand } from '../components/tags/method-tag'; +import { useInsomniaTabContext } from '../context/app/insomnia-tab-context'; + +interface InsomniaTabProps { + organizationId: string; + projectId: string; + workspaceId: string; + activeProject: Project; + activeWorkspace: Workspace; + activeRequest?: Request | GrpcRequest | WebSocketRequest; + activeRequestGroup?: RequestGroup; + activeMockRoute?: MockRoute; + unitTestSuite?: UnitTestSuite; +} + +export const useInsomniaTab = ({ + organizationId, + projectId, + workspaceId, + activeProject, + activeWorkspace, + activeRequest, + activeRequestGroup, + activeMockRoute, + unitTestSuite, +}: InsomniaTabProps) => { + + const { appTabsRef, addTab, changeActiveTab } = useInsomniaTabContext(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + + const generateTabUrl = useCallback((type: TabEnum) => { + if (type === TabEnum.Request) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${activeRequest?._id}`; + } + + if (type === TabEnum.Folder) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request-group/${activeRequestGroup?._id}`; + } + + if (type === TabEnum.Collection) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug?doNotSkipToActiveRequest=true`; + } + + if (type === TabEnum.Env) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/environment`; + } + + if (type === TabEnum.Runner) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/runner${location.search}`; + } + + if (type === TabEnum.Mock) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server`; + } + + if (type === TabEnum.MockRoute) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server/mock-route/${activeMockRoute?._id}`; + } + + if (type === TabEnum.Document) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/spec`; + } + + if (type === TabEnum.TEST) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test`; + } + + if (type === TabEnum.TESTSUITE) { + return `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuite?._id}`; + } + return ''; + }, [activeMockRoute?._id, activeRequest?._id, activeRequestGroup?._id, location.search, organizationId, projectId, unitTestSuite?._id, workspaceId]); + + const getTabType = (pathname: string) => { + for (const type in TAB_ROUTER_PATH) { + const ifMatch = matchPath({ + path: TAB_ROUTER_PATH[type as TabEnum], + end: true, + }, pathname); + if (ifMatch) { + return type as TabEnum; + } + } + + return null; + }; + + const getRunnerTabId = useCallback(() => { + const folderId = searchParams.get('folder'); + if (folderId) { + return `runner_${folderId}`; + } + return `runner_${workspaceId}`; + }, [searchParams, workspaceId]); + + const getCurrentTab = useCallback((type: TabEnum | null) => { + if (!type) { + return undefined; + } + const currentOrgTabs = appTabsRef?.current?.[organizationId]; + if (type === TabEnum.Request) { + return currentOrgTabs?.tabList.find(tab => tab.id === activeRequest?._id); + } + + if (type === TabEnum.Folder) { + return currentOrgTabs?.tabList.find(tab => tab.id === activeRequestGroup?._id); + } + + if (type === TabEnum.Runner) { + // collection runner tab id is prefixed with 'runner_' + const runnerTabId = getRunnerTabId(); + return currentOrgTabs?.tabList.find(tab => tab.id === runnerTabId); + } + + if (type === TabEnum.MockRoute) { + return currentOrgTabs?.tabList.find(tab => tab.id === activeMockRoute?._id); + } + + if (type === TabEnum.TESTSUITE) { + return currentOrgTabs?.tabList.find(tab => tab.id === unitTestSuite?._id); + } + + if ([TabEnum.Collection, TabEnum.Document, TabEnum.Env, TabEnum.Mock, TabEnum.TEST].includes(type)) { + return currentOrgTabs?.tabList.find(tab => tab.id === workspaceId); + } + return undefined; + }, [activeMockRoute?._id, activeRequest?._id, activeRequestGroup?._id, appTabsRef, getRunnerTabId, organizationId, unitTestSuite?._id, workspaceId]); + + const getTabId = useCallback((type: TabEnum | null): string => { + if (!type) { + return ''; + } + if (type === TabEnum.Request) { + return activeRequest?._id || ''; + } + + if (type === TabEnum.Folder) { + return activeRequestGroup?._id || ''; + } + + if (type === TabEnum.Runner) { + const runnerTabId = getRunnerTabId(); + return runnerTabId; + } + + if (type === TabEnum.MockRoute) { + return activeMockRoute?._id || ''; + } + + if (type === TabEnum.TESTSUITE) { + return unitTestSuite?._id || ''; + } + + if ([TabEnum.Collection, TabEnum.Document, TabEnum.Env, TabEnum.Mock, TabEnum.TEST].includes(type)) { + return workspaceId; + } + + return ''; + }, [activeMockRoute?._id, activeRequest?._id, activeRequestGroup?._id, getRunnerTabId, unitTestSuite?._id, workspaceId]); + + const packTabInfo = useCallback((type: TabEnum): BaseTab | undefined => { + if (!type) { + return undefined; + } + if (type === TabEnum.Request) { + return { + type, + name: activeRequest?.name || 'New Request', + url: generateTabUrl(type), + organizationId: organizationId, + projectId: projectId, + workspaceId: workspaceId, + id: getTabId(type), + projectName: activeProject.name, + workspaceName: activeWorkspace.name, + tag: getRequestMethodShortHand(activeRequest), + method: (activeRequest as Request)?.method || '', + }; + } + + if (type === TabEnum.Folder) { + return { + type, + name: activeRequestGroup?.name || 'My Folder', + url: generateTabUrl(type), + organizationId: organizationId, + projectId: projectId, + workspaceId: workspaceId, + id: getTabId(type), + projectName: activeProject.name, + workspaceName: activeWorkspace.name, + }; + } + + if ([TabEnum.Collection, TabEnum.Document, TabEnum.Env, TabEnum.Mock, TabEnum.TEST].includes(type)) { + return { + type, + name: activeWorkspace.name, + url: generateTabUrl(type), + organizationId: organizationId, + projectId: projectId, + workspaceId: workspaceId, + id: getTabId(type), + projectName: activeProject.name, + workspaceName: activeWorkspace.name, + }; + } + + if (type === TabEnum.Runner) { + return { + type, + name: 'Runner', + url: generateTabUrl(type), + organizationId: organizationId, + projectId: projectId, + workspaceId: workspaceId, + id: getTabId(type), + projectName: activeProject.name, + workspaceName: activeWorkspace.name, + }; + } + + if (type === TabEnum.MockRoute) { + return { + type, + name: activeMockRoute?.name || 'Untitled mock route', + url: generateTabUrl(type), + organizationId: organizationId, + projectId: projectId, + workspaceId: workspaceId, + id: getTabId(type), + tag: formatMethodName(activeMockRoute?.method || ''), + projectName: activeProject.name, + workspaceName: activeWorkspace.name, + method: activeMockRoute?.method || '', + }; + } + + if (type === TabEnum.TESTSUITE) { + return { + type, + name: unitTestSuite?.name || 'Untitled test suite', + url: generateTabUrl(type), + organizationId: organizationId, + projectId: projectId, + workspaceId: workspaceId, + id: getTabId(type), + projectName: activeProject.name, + workspaceName: activeWorkspace.name, + }; + } + + return; + }, [activeMockRoute?.method, activeMockRoute?.name, activeProject.name, activeRequest, activeRequestGroup?.name, activeWorkspace.name, generateTabUrl, getTabId, organizationId, projectId, unitTestSuite?.name, workspaceId]); + + useEffect(() => { + const type = getTabType(location.pathname); + const currentTab = getCurrentTab(type); + if (!currentTab && type) { + const tabInfo = packTabInfo(type); + if (tabInfo) { + addTab(tabInfo); + return; + } + } + + // keep active tab in sync with the current route + if (currentTab) { + const currentActiveTabId = appTabsRef?.current?.[organizationId]?.activeTabId; + if (currentActiveTabId !== currentTab.id) { + changeActiveTab(currentTab.id); + } + } + }, [addTab, appTabsRef, changeActiveTab, getCurrentTab, location.pathname, organizationId, packTabInfo]); +}; diff --git a/packages/insomnia/src/ui/hooks/use-resize-observer.tsx b/packages/insomnia/src/ui/hooks/use-resize-observer.tsx new file mode 100644 index 00000000000..ceabf1b58e0 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/use-resize-observer.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from 'react'; + +export interface Size { + width: number | undefined; + height: number | undefined; +} + +export const useResizeObserver = (ref: React.RefObject, onResize: (size: Size) => void) => { + const onResizeRef = useRef<((size: Size) => void) | undefined>(undefined); + onResizeRef.current = onResize; + + useEffect(() => { + if (!ref.current) { + return; + } + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + onResizeRef.current?.({ width, height }); + }); + + observer.observe(ref.current); + + return () => { + observer.disconnect(); + }; + }, [ref]); +}; diff --git a/packages/insomnia/src/ui/hooks/use-runner-request-list.tsx b/packages/insomnia/src/ui/hooks/use-runner-request-list.tsx new file mode 100644 index 00000000000..785bf68b422 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/use-runner-request-list.tsx @@ -0,0 +1,76 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useRouteLoaderData } from 'react-router-dom'; +import { useListData } from 'react-stately'; +import { usePrevious } from 'react-use'; + +import { isRequest, type Request } from '../../models/request'; +import { isRequestGroup } from '../../models/request-group'; +import { invariant } from '../../utils/invariant'; +import type { RequestRow } from '../routes/runner'; +import type { Child, WorkspaceLoaderData } from '../routes/workspace'; + +export const useRunnerRequestList = (workspaceId: string, targetFolderId: string) => { + const { collection } = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData; + const entityMapRef = useRef(new Map()); + + const requestRows: RequestRow[] = useMemo(() => { + return collection + .filter(item => { + entityMapRef.current.set(item.doc._id, item); + return isRequest(item.doc); + }) + .map((item: Child) => { + const ancestorNames: string[] = []; + const ancestorIds: string[] = []; + if (item.ancestors) { + item.ancestors.forEach(ancestorId => { + const ancestor = entityMapRef.current.get(ancestorId); + if (ancestor && isRequestGroup(ancestor?.doc)) { + ancestorNames.push(ancestor?.doc.name); + ancestorIds.push(ancestor?.doc._id); + } + }); + } + + const requestDoc = item.doc as Request; + invariant('method' in item.doc, 'Only Request is supported at the moment'); + return { + id: item.doc._id, + name: item.doc.name, + ancestorNames, + ancestorIds, + method: requestDoc.method, + url: item.doc.url, + parentId: item.doc.parentId, + }; + }) + .filter(item => { + if (targetFolderId) { + return item.ancestorIds.includes(targetFolderId); + } + return true; + }); + }, [collection, targetFolderId]); + + const reqList = useListData({ + initialItems: requestRows, + }); + + const previousWorkspaceId = usePrevious(workspaceId); + const previousTargetFolderId = usePrevious(targetFolderId); + + useEffect(() => { + if ((previousWorkspaceId && previousWorkspaceId !== workspaceId) || (previousTargetFolderId !== undefined && previousTargetFolderId !== targetFolderId)) { + // reset the list when workspace changes + const keys = reqList.items.map(item => item.id); + reqList.remove(...keys); + reqList.append(...requestRows); + } + }, [reqList, requestRows, workspaceId, targetFolderId, previousWorkspaceId, previousTargetFolderId]); + + return { + reqList, + requestRows, + entityMap: entityMapRef.current, + }; +}; diff --git a/packages/insomnia/src/ui/routes/debug.tsx b/packages/insomnia/src/ui/routes/debug.tsx index 7074714771c..a7ba72fc447 100644 --- a/packages/insomnia/src/ui/routes/debug.tsx +++ b/packages/insomnia/src/ui/routes/debug.tsx @@ -1,7 +1,7 @@ import type { IconName } from '@fortawesome/fontawesome-svg-core'; import type { ServiceError, StatusObject } from '@grpc/grpc-js'; import { useVirtualizer } from '@tanstack/react-virtual'; -import React, { type FC, Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { type FC, Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Breadcrumb, Breadcrumbs, @@ -68,10 +68,12 @@ import { isWebSocketRequestId, type WebSocketRequest, } from '../../models/websocket-request'; -import { isScratchpad } from '../../models/workspace'; +import { isDesign, isScratchpad } from '../../models/workspace'; +import { scrollElementIntoView } from '../../utils'; import { getGrpcConnectionErrorDetails, isGrpcConnectionError } from '../../utils/grpc'; import { invariant } from '../../utils/invariant'; import { DropdownHint } from '../components/base/dropdown/dropdown-hint'; +import { DocumentTab } from '../components/document-tab'; import { RequestActionsDropdown } from '../components/dropdowns/request-actions-dropdown'; import { RequestGroupActionsDropdown } from '../components/dropdowns/request-group-actions-dropdown'; import { WorkspaceDropdown } from '../components/dropdowns/workspace-dropdown'; @@ -98,10 +100,13 @@ import { PlaceholderRequestPane } from '../components/panes/placeholder-request- import { RequestGroupPane } from '../components/panes/request-group-pane'; import { RequestPane } from '../components/panes/request-pane'; import { ResponsePane } from '../components/panes/response-pane'; +import { OrganizationTabList } from '../components/tabs/tabList'; import { getMethodShortHand } from '../components/tags/method-tag'; import { RealtimeResponsePane } from '../components/websockets/realtime-response-pane'; import { WebSocketRequestPane } from '../components/websockets/websocket-request-pane'; +import { INSOMNIA_TAB_HEIGHT } from '../constant'; import { useExecutionState } from '../hooks/use-execution-state'; +import { useInsomniaTab } from '../hooks/use-insomnia-tab'; import { useReadyState } from '../hooks/use-ready-state'; import { type CreateRequestType, @@ -115,6 +120,7 @@ import type { RequestLoaderData, WebSocketRequestLoaderData, } from './request'; +import type { RequestGroupLoaderData } from './request-group'; import { useRootLoaderData } from './root'; import Runner from './runner'; import type { Child, WorkspaceLoaderData } from './workspace'; @@ -159,7 +165,8 @@ export const loader: LoaderFunction = async ({ params, request }) => { const startOfQuery = request.url.indexOf('?'); const urlWithoutQuery = startOfQuery > 0 ? request.url.slice(0, startOfQuery) : request.url; const isDisplayingRunner = urlWithoutQuery.includes('/runner'); - if (activeRequest && !isDisplayingRunner) { + const doNotSkipToActiveRequest = request.url.includes('doNotSkipToActiveRequest=true'); + if (activeRequest && !isDisplayingRunner && !doNotSkipToActiveRequest) { return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${activeRequestId}`); } } @@ -214,6 +221,9 @@ export const Debug: FC = () => { requestId?: string; requestGroupId?: string; }; + + const { activeRequestGroup } = useRouteLoaderData('request-group/:requestGroupId') as RequestGroupLoaderData || {}; + const [grpcStates, setGrpcStates] = useState( grpcRequests.map(r => ({ requestId: r._id, @@ -744,13 +754,23 @@ export const Debug: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useInsomniaTab({ + organizationId, + projectId, + workspaceId, + activeWorkspace, + activeProject, + activeRequest, + activeRequestGroup, + }); + return (
-
-
- +
+
+ {
+ {isDesign(activeWorkspace) && ( + + )}
{
- + + { + if (isSelected && node) { + scrollElementIntoView(node, { behavior: 'instant' }); + } + }, [isSelected]); + return (
{ e.preventDefault(); setIsContextMenuOpen(true); diff --git a/packages/insomnia/src/ui/routes/design.tsx b/packages/insomnia/src/ui/routes/design.tsx index 7bfac39a31c..477cc6df02e 100644 --- a/packages/insomnia/src/ui/routes/design.tsx +++ b/packages/insomnia/src/ui/routes/design.tsx @@ -56,6 +56,7 @@ import { type CodeEditorHandle, } from '../components/codemirror/code-editor'; import { DesignEmptyState } from '../components/design-empty-state'; +import { DocumentTab } from '../components/document-tab'; import { WorkspaceDropdown } from '../components/dropdowns/workspace-dropdown'; import { WorkspaceSyncDropdown } from '../components/dropdowns/workspace-sync-dropdown'; import { EnvironmentPicker } from '../components/environment-picker'; @@ -65,8 +66,11 @@ import { useDocBodyKeyboardShortcuts } from '../components/keydown-binder'; import { CookiesModal } from '../components/modals/cookies-modal'; import { CertificatesModal } from '../components/modals/workspace-certificates-modal'; import { WorkspaceEnvironmentsEditModal } from '../components/modals/workspace-environments-edit-modal'; +import { OrganizationTabList } from '../components/tabs/tabList'; import { formatMethodName } from '../components/tags/method-tag'; +import { INSOMNIA_TAB_HEIGHT } from '../constant'; import { useAIContext } from '../context/app/ai-context'; +import { useInsomniaTab } from '../hooks/use-insomnia-tab'; import { useActiveApiSpecSyncVCSVersion, useGitVCSVersion, @@ -189,6 +193,7 @@ const Design: FC = () => { activeCookieJar, caCertificate, clientCertificates, + activeWorkspace, } = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData; const { settings } = useRootLoaderData(); @@ -450,12 +455,19 @@ const Design: FC = () => { } }, [settings.forceVerticalLayout, direction]); + useInsomniaTab({ + organizationId, + projectId, + workspaceId, + activeWorkspace, + activeProject, + }); + return (
-
- + { +
-
+
setEnvironmentModalOpen(true)} />
- - +
-
Spec @@ -982,7 +999,8 @@ const Design: FC = () => {
- + +
diff --git a/packages/insomnia/src/ui/routes/environments.tsx b/packages/insomnia/src/ui/routes/environments.tsx index 19f8ae16292..97596f4d297 100644 --- a/packages/insomnia/src/ui/routes/environments.tsx +++ b/packages/insomnia/src/ui/routes/environments.tsx @@ -17,11 +17,14 @@ import { handleToggleEnvironmentType } from '../components/editors/environment-u import { Icon } from '../components/icon'; import { useDocBodyKeyboardShortcuts } from '../components/keydown-binder'; import { showAlert } from '../components/modals'; +import { OrganizationTabList } from '../components/tabs/tabList'; +import { INSOMNIA_TAB_HEIGHT } from '../constant'; +import { useInsomniaTab } from '../hooks/use-insomnia-tab'; import { useOrganizationPermissions } from '../hooks/use-organization-features'; import type { WorkspaceLoaderData } from './workspace'; const Environments = () => { - const { organizationId, projectId, workspaceId } = useParams<{ organizationId: string; projectId: string; workspaceId: string }>(); + const { organizationId = '', projectId = '', workspaceId = '' } = useParams<{ organizationId: string; projectId: string; workspaceId: string }>(); const routeData = useRouteLoaderData( ':workspaceId' ) as WorkspaceLoaderData; @@ -40,6 +43,7 @@ const Environments = () => { activeEnvironment, subEnvironments, activeWorkspaceMeta, + activeWorkspace, } = routeData; const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(activeEnvironment._id); const isUsingInsomniaCloudSync = Boolean(isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId); @@ -256,10 +260,18 @@ const Environments = () => { sidebar_toggle: toggleSidebar, }); + useInsomniaTab({ + organizationId, + projectId, + workspaceId, + activeWorkspace, + activeProject, + }); + return ( - + { - -
+ + +
diff --git a/packages/insomnia/src/ui/routes/mock-server.tsx b/packages/insomnia/src/ui/routes/mock-server.tsx index 9d80107648d..568aadfad88 100644 --- a/packages/insomnia/src/ui/routes/mock-server.tsx +++ b/packages/insomnia/src/ui/routes/mock-server.tsx @@ -2,7 +2,7 @@ import type { IconName } from '@fortawesome/fontawesome-svg-core'; import React, { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Breadcrumb, Breadcrumbs, Button, GridList, GridListItem, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components'; import { type ImperativePanelGroupHandle, Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -import { type LoaderFunction, NavLink, Route, Routes, useFetcher, useLoaderData, useNavigate, useParams } from 'react-router-dom'; +import { type LoaderFunction, NavLink, Route, Routes, useFetcher, useLoaderData, useNavigate, useParams, useRouteLoaderData } from 'react-router-dom'; import { DEFAULT_SIDEBAR_SIZE } from '../../common/constants'; import * as models from '../../models'; @@ -18,9 +18,13 @@ import { AlertModal } from '../components/modals/alert-modal'; import { AskModal } from '../components/modals/ask-modal'; import { EmptyStatePane } from '../components/panes/empty-state-pane'; import { SvgIcon } from '../components/svg-icon'; +import { OrganizationTabList } from '../components/tabs/tabList'; import { formatMethodName } from '../components/tags/method-tag'; +import { INSOMNIA_TAB_HEIGHT } from '../constant'; +import { useInsomniaTab } from '../hooks/use-insomnia-tab'; import { MockRouteResponse, MockRouteRoute, useMockRoutePatcher } from './mock-route'; import { useRootLoaderData } from './root'; +import type { WorkspaceLoaderData } from './workspace'; export interface MockServerLoaderData { mockServerId: string; mockRoutes: MockRoute[]; @@ -52,6 +56,11 @@ const MockServerRoute = () => { }; const { settings } = useRootLoaderData(); const { mockServerId, mockRoutes } = useLoaderData() as MockServerLoaderData; + + const { activeProject, activeWorkspace } = useRouteLoaderData( + ':workspaceId' + ) as WorkspaceLoaderData; + const fetcher = useFetcher(); const navigate = useNavigate(); const patchMockRoute = useMockRoutePatcher(); @@ -172,11 +181,20 @@ const MockServerRoute = () => { } }, [settings.forceVerticalLayout, direction]); + useInsomniaTab({ + organizationId, + projectId, + workspaceId, + activeWorkspace, + activeProject, + activeMockRoute: mockRoutes.find(s => s._id === mockRouteId), + }); + return (
-
+
@@ -354,7 +372,8 @@ const MockServerRoute = () => {
- + + diff --git a/packages/insomnia/src/ui/routes/organization.tsx b/packages/insomnia/src/ui/routes/organization.tsx index 88134d0b42f..40e413e1c14 100644 --- a/packages/insomnia/src/ui/routes/organization.tsx +++ b/packages/insomnia/src/ui/routes/organization.tsx @@ -35,7 +35,7 @@ import { userSession } from '../../models'; import { updateLocalProjectToRemote } from '../../models/helpers/project'; import { findPersonalOrganization, isOwnerOfOrganization, isPersonalOrganization, isScratchpadOrganizationId, type Organization } from '../../models/organization'; import { type Project, type as ProjectType } from '../../models/project'; -import { isDesign, isScratchpad } from '../../models/workspace'; +import { isScratchpad } from '../../models/workspace'; import { VCSInstance } from '../../sync/vcs/insomnia-sync'; import { migrateProjectsIntoOrganization, shouldMigrateProjectUnderOrganization } from '../../sync/vcs/migrate-projects-into-organization'; import { insomniaFetch } from '../../ui/insomniaFetch'; @@ -57,6 +57,7 @@ import { PresentUsers } from '../components/present-users'; import { Toast } from '../components/toast'; import { useAIContext } from '../context/app/ai-context'; import { InsomniaEventStreamProvider } from '../context/app/insomnia-event-stream-context'; +import { InsomniaTabProvider } from '../context/app/insomnia-tab-context'; import { useOrganizationPermissions } from '../hooks/use-organization-features'; import { syncProjects } from './project'; import { useRootLoaderData } from './root'; @@ -518,7 +519,7 @@ const OrganizationRoute = () => { isScratchpad(workspaceData.activeWorkspace); const isScratchPadBannerVisible = !isScratchPadBannerDismissed && isScratchpadWorkspace; const untrackedProjectsFetcher = useFetcher(); - const { organizationId, projectId, workspaceId } = useParams() as { + const { organizationId, projectId } = useParams() as { organizationId: string; projectId?: string; workspaceId?: string; @@ -603,41 +604,17 @@ const OrganizationRoute = () => { return ( +
-
+
+
+ {!user ? : null}
- {!user ? : null} -
-
- {workspaceData && isDesign(workspaceData?.activeWorkspace) && ( - - )} -
{user ? ( @@ -1085,6 +1062,7 @@ const OrganizationRoute = () => {
+
); }; diff --git a/packages/insomnia/src/ui/routes/project.tsx b/packages/insomnia/src/ui/routes/project.tsx index bf0c6dfb373..895de4b5bec 100644 --- a/packages/insomnia/src/ui/routes/project.tsx +++ b/packages/insomnia/src/ui/routes/project.tsx @@ -57,7 +57,7 @@ import { LandingPage, SentryMetrics } from '../../common/sentry'; import { descendingNumberSort, sortMethodMap } from '../../common/sorting'; import * as models from '../../models'; import { userSession } from '../../models'; -import type { ApiSpec } from '../../models/api-spec'; +import { type ApiSpec } from '../../models/api-spec'; import { sortProjects } from '../../models/helpers/project'; import type { MockServer } from '../../models/mock-server'; import type { Organization } from '../../models/organization'; @@ -86,6 +86,7 @@ import { GitRepositoryCloneModal } from '../components/modals/git-repository-set import { ImportModal } from '../components/modals/import-modal'; import { MockServerSettingsModal } from '../components/modals/mock-server-settings-modal'; import { EmptyStatePane } from '../components/panes/project-empty-state-pane'; +import { OrganizationTabList } from '../components/tabs/tabList'; import { TimeFromNow } from '../components/time-from-now'; import { useInsomniaEventStreamContext } from '../context/app/insomnia-event-stream-context'; import { useLoaderDeferData } from '../hooks/use-loader-defer-data'; @@ -991,7 +992,7 @@ const ProjectRoute: FC = () => {
-
+