Skip to content

Commit

Permalink
feat(command-palette): Add global search for unsynced files (#7405)
Browse files Browse the repository at this point in the history
* use the user/files api to fetch the unsynced files in global search

* naming

* fix e2e test

* fail gracefully if pulling a remote project doesn't work out

* fix popover error

* clean up loading and error states ui

* fix e2e test
  • Loading branch information
gatzjames authored May 14, 2024
1 parent 1f49c3f commit aae0a9c
Show file tree
Hide file tree
Showing 5 changed files with 276 additions and 168 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ test('Command palette - can switch between requests and workspaces', async ({ ap
await page.locator('body').press(requestSwitchKeyboardShortcut);
await page.getByPlaceholder('Search and switch between').press('ArrowUp');
await page.getByPlaceholder('Search and switch between').press('ArrowUp');
await page.getByPlaceholder('Search and switch between').press('ArrowUp');
await page.getByPlaceholder('Search and switch between').press('Enter');
await expect(page.getByTestId('workspace-context-dropdown').locator('span')).toContainText('E2E testing specification - swagger 2 1.0.0');
});
243 changes: 129 additions & 114 deletions packages/insomnia/src/ui/components/command-palette.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import React, { useEffect, useRef } from 'react';
import React, { memo, useEffect, useRef } from 'react';
import { useState } from 'react';
import { Button, Collection, ComboBox, Dialog, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxItem, Modal, ModalOverlay, Section, Text } from 'react-aria-components';
import { Button, Collection, ComboBox, Dialog, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxItem, Modal, ModalOverlay, Popover, Section, Text } from 'react-aria-components';
import { useFetcher, useNavigate, useParams, useRouteLoaderData } from 'react-router-dom';

import { constructKeyCombinationDisplay, getPlatformKeyCombinations } from '../../common/hotkeys';
import { fuzzyMatch } from '../../common/misc';
import { isGrpcRequest } from '../../models/grpc-request';
import { isScratchpadOrganizationId } from '../../models/organization';
import { isRequest } from '../../models/request';
import { isRequestGroup } from '../../models/request-group';
import { isWebSocketRequest } from '../../models/websocket-request';
import { scopeToActivity } from '../../models/workspace';
import { useInsomniaEventStreamContext } from '../context/app/insomnia-event-stream-context';
import { LoaderResult } from '../routes/commands';
import { ProjectLoaderData, scopeToBgColorMap, scopeToIconMap, scopeToLabelMap, scopeToTextColorMap } from '../routes/project';
import { LoaderResult, RemoteFilesLoaderResult } from '../routes/commands';
import { scopeToBgColorMap, scopeToIconMap, scopeToLabelMap, scopeToTextColorMap } from '../routes/project';
import { RootLoaderData } from '../routes/root';
import { AvatarGroup } from './avatar';
import { Icon } from './icon';
import { useDocBodyKeyboardShortcuts } from './keydown-binder';
import { showAlert } from './modals';
import { getMethodShortHand } from './tags/method-tag';

export const CommandPalette = () => {
export const CommandPalette = memo(function CommandPalette() {
const [isOpen, setIsOpen] = useState(false);
const { settings } = useRouteLoaderData('root') as RootLoaderData;

Expand All @@ -45,8 +44,8 @@ export const CommandPalette = () => {
</Keyboard>}
</Button>
<ModalOverlay isDismissable className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex pt-20 justify-center bg-black/30">
<Modal className="max-w-3xl h-max w-full rounded-md flex flex-col overflow-hidden border border-solid border-[--hl-sm] max-h-[80vh] bg-[--color-bg] text-[--color-font]">
<Dialog className="outline-none h-max overflow-hidden flex flex-col">
<Modal className="max-w-3xl w-full">
<Dialog aria-label='Command palette dialog' className="outline-none">
{({ close }) => (
<CommandPaletteCombobox close={close} />
)}
Expand All @@ -55,7 +54,7 @@ export const CommandPalette = () => {
</ModalOverlay>
</DialogTrigger>
);
};
});

const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
const {
Expand All @@ -76,18 +75,11 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
const setActiveEnvironmentFetcher = useFetcher();
const navigate = useNavigate();

const projectDataLoader = useFetcher<ProjectLoaderData>();
const accountId = userSession.accountId;

useEffect(() => {
if (projectId && !projectDataLoader.data && projectDataLoader.state === 'idle' && !isScratchpadOrganizationId(organizationId)) {
projectDataLoader.load(`/organization/${organizationId}/project/${projectId}?index`);
}
}, [organizationId, projectDataLoader, projectId]);

const commandsLoader = useFetcher<LoaderResult>();

const projectData = projectDataLoader.data;
const remoteFilesLoader = useFetcher<RemoteFilesLoaderResult>();

useEffect(() => {
if (!commandsLoader.data && commandsLoader.state === 'idle') {
Expand All @@ -100,6 +92,12 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
}
}, [commandsLoader, organizationId, projectId, workspaceId]);

useEffect(() => {
if (!remoteFilesLoader.data && remoteFilesLoader.state === 'idle') {
remoteFilesLoader.load('/remote-files');
}
}, [remoteFilesLoader]);

const comboboxSections: {
id: string;
name: string;
Expand All @@ -124,21 +122,25 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
},
})) || [];

const currentFiles = projectData?.files?.map(file => ({
const remoteFiles = remoteFilesLoader.data?.files || [];

const currentFilesData = commandsLoader.data?.current.files || [];
const currentRemoteFilesData = remoteFiles.filter(file => file.item.teamProjectLocalId === projectId).filter(file => !currentFilesData.some(f => f.id === file.item.id));

console.log({ currentFilesData });

const currentFiles = [...currentFilesData, ...currentRemoteFilesData]?.map(file => ({
...file,
action: () => {
if (file.scope === 'unsynced') {
if (!projectData || !projectData.activeProject || !file.remoteId) {
return null;
}
pullFileFetcher.submit({ backendProjectId: file.remoteId, remoteId: projectData?.activeProject.remoteId }, {
if ('pullUrl' in file && file.pullUrl) {
pullFileFetcher.submit({ backendProjectId: file.item.projectId, remoteId: file.item.teamProjectId }, {
method: 'POST',
action: `/organization/${organizationId}/project/${projectId}/remote-collections/pull`,
action: file.pullUrl,
});

return true;
} else {
navigate(`/organization/${organizationId}/project/${projectId}/workspace/${file.id}/${scopeToActivity(file.scope)}`);
navigate(file.url);
return null;
}
},
Expand Down Expand Up @@ -169,10 +171,23 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
},
})) || [];

const otherFiles = commandsLoader.data?.other.files.map(file => ({
const otherFilesData = commandsLoader.data?.other.files || [];
const otherRemoteFilesData = remoteFiles.filter(file => file.item.teamProjectLocalId !== projectId).filter(file => !otherFilesData.some(f => f.id === file.item.id));

const otherFiles = [...otherFilesData, ...otherRemoteFilesData].map(file => ({
...file,
action: () => {
navigate(file.url);
if ('pullUrl' in file && file.pullUrl) {
pullFileFetcher.submit({ backendProjectId: file.item.projectId, remoteId: file.item.teamProjectId }, {
method: 'POST',
action: file.pullUrl,
});

return true;
} else {
navigate(file.url);
return null;
}
},
})) || [];

Expand Down Expand Up @@ -219,13 +234,12 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
name: 'Collections and documents',
children: currentFiles.map(file => ({
id: file.id,
icon: <span className={`${scopeToBgColorMap[file.scope]} ${scopeToTextColorMap[file.scope]} rounded aspect-square h-6 flex items-center justify-center`}><Icon icon={scopeToIconMap[file.scope]} className="w-4" /></span>,
icon: <span className={`${scopeToBgColorMap[file.item.scope]} ${scopeToTextColorMap[file.item.scope]} rounded aspect-square h-6 flex items-center justify-center`}><Icon icon={scopeToIconMap[file.item.scope]} className="w-4" /></span>,
name: file.name,
description: <span className='flex items-center gap-1'><span className='px-2 text-[--hl]'>{scopeToLabelMap[file.scope]}</span></span>,
textValue: file.name + ' ' + scopeToLabelMap[file.scope],
loading: Boolean(pullFileFetcher.formData?.get('backendProjectId') && pullFileFetcher.formData?.get('backendProjectId') === file.remoteId),
description: <span className='flex items-center gap-1'><span className='px-2 text-[--hl]'>{scopeToLabelMap[file.item.scope]}</span></span>,
textValue: file.name + ' ' + scopeToLabelMap[file.item.scope],
presence: presence
.filter(p => p.project === projectData?.activeProject?.remoteId && p.file === file.id)
.filter(p => p.project === file.item.teamProjectId && p.file === file.id)
.filter(p => p.acct !== accountId)
.map(user => {
return {
Expand Down Expand Up @@ -300,13 +314,13 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
id: 'other-collections-and-documents',
name: 'Other collections and documents',
children: otherFiles.map(file => ({
id: file.item._id,
id: file.id,
icon: <span className={`${scopeToBgColorMap[file.item.scope]} ${scopeToTextColorMap[file.item.scope]} rounded aspect-square h-6 flex items-center justify-center`}><Icon icon={scopeToIconMap[file.item.scope]} className="w-4" /></span>,
name: file.name,
description: <span className='flex items-center gap-1'><span className='px-2 text-[--hl]'>{scopeToLabelMap[file.item.scope]}</span>{file.organizationName}<span>/</span>{file.projectName}</span>,
textValue: file.name + ' ' + scopeToLabelMap[file.item.scope],
presence: presence
.filter(p => p.project === projectData?.activeProject?.remoteId && p.file === file.id)
.filter(p => p.project === file.item.teamProjectId && p.file === file.id)
.filter(p => p.acct !== accountId)
.map(user => {
return {
Expand All @@ -321,11 +335,18 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
const prevPullFetcherState = useRef(pullFileFetcher.state);
useEffect(() => {
if (pullFileFetcher.state === 'idle' && prevPullFetcherState.current !== 'idle') {
if (pullFileFetcher.data?.error) {
showAlert({
title: 'Error',
message: pullFileFetcher.data.error,
});
}

close();
}

prevPullFetcherState.current = pullFileFetcher.state;
}, [close, pullFileFetcher.state]);
}, [close, pullFileFetcher]);

// Close the dialog when the environment is set
// If we close the dialog when fetcher.submit() is done then the dialog will close before the environment is set
Expand All @@ -339,11 +360,15 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
prevEnvFetcherState.current = setActiveEnvironmentFetcher.state;
}, [close, setActiveEnvironmentFetcher.state]);

const isPullingFile = pullFileFetcher.state !== 'idle';
const pullingFileBackedProjectId = pullFileFetcher.formData?.get('backendProjectId');
const pullingFile = remoteFiles.find(file => file.item.projectId === pullingFileBackedProjectId);

return (
<ComboBox
aria-label='Quick switcher'
className='flex flex-col divide-y divide-solid divide-[--hl-sm] overflow-hidden'
isDisabled={pullFileFetcher.state !== 'idle'}
className='group overflow-hidden'
isDisabled={isPullingFile}
autoFocus
allowsCustomValue={false}
menuTrigger='focus'
Expand Down Expand Up @@ -385,83 +410,73 @@ const CommandPaletteCombobox = ({ close }: { close: () => void }) => {
}
}}
>
<Label
aria-label="Filter"
className="group relative flex items-center gap-2 p-2 flex-1"
>
<Icon icon="search" className="text-[--color-font] pl-2" />
<Input
placeholder="Search and switch between requests, collections and documents"
className="py-1 w-full pl-2 pr-7 bg-[--color-bg] text-[--color-font]"
/>
</Label>
{pullFileFetcher.state === 'idle' && (
<ListBox
className="outline-none relative overflow-y-auto flex-1"
items={comboboxSections}
>
{section => (
<Section className='flex-1 flex flex-col'>
<Header className='p-2 text-xs uppercase text-[--hl] select-none'>{section.name}</Header>
<Collection items={section.children}>
{item => (
<ListBoxItem textValue={item.textValue} className="group outline-none select-none">
<div
className={`flex select-none outline-none ${item.id === workspaceId || item.id === requestId ? 'text-[--color-font] font-bold' : 'text-[--hl]'} group-aria-selected:text-[--color-font] relative group-hover:bg-[--hl-xs] group-data-[focused]:bg-[--hl-sm] group-focus:bg-[--hl-sm] transition-colors gap-2 px-4 items-center h-[--line-height-xs] w-full overflow-hidden`}
>
{item.icon}
<Text className="flex-shrink-0 px-1 truncate" slot="label">{item.name}</Text>
{item.presence.length > 0 && (
<span className='w-[70px]'>
<AvatarGroup
size="small"
maxAvatars={3}
items={item.presence}
/>
</span>
)}
<Text className="flex-1 px-1 truncate text-sm text-[--hl-md]" slot="description">{item.description}</Text>
</div>
</ListBoxItem>
)}
</Collection>
</Section>
)}
</ListBox>
)}
{pullFileFetcher.state !== 'idle' && (
<div
className="flex-1 overflow-y-auto outline-none flex flex-col data-[empty]:hidden"
>
{comboboxSections.map(section => (
<div className='flex-1 flex flex-col' key={section.id}>
<Header className='p-2 text-xs uppercase text-[--hl] select-none'>{section.name}</Header>
<div>
{section.children.map(item => (
<div key={item.id} className="group cursor-not-allowed outline-none select-none">
<div
className={`flex select-none outline-none ${item.id === workspaceId || item.id === requestId ? 'text-[--color-font] font-bold' : 'text-[--hl]'} group-aria-selected:text-[--color-font] relative transition-colors gap-2 px-4 items-center h-[--line-height-xs] w-full overflow-hidden`}
>
{item.icon}
<span className="flex-1 px-1 truncate">{item.name}</span>
<span className="flex-1 px-1 truncate">{item.description}</span>
<span className='w-[70px]'>
{item.presence.length > 0 && (
<AvatarGroup
size="small"
maxAvatars={3}
items={item.presence}
/>
)}
</span>
</div>
{({ isOpen }) => {
return (
<>
<Label
aria-label="Filter"
className="group relative flex items-center flex-1 pt-0"
>
{isPullingFile ? (
<>
<Icon icon="spinner" className="text-[--color-font] absolute left-4 animate-spin" />
<div
slot='input'
className="py-3 pl-10 pr-7 w-full bg-[--color-bg] transition-none text-[--color-font] rounded-md group-data-[open]:rounded-b-none border border-solid border-[--hl-sm]"
>
Pulling: {pullingFile?.name}
</div>
))}
</div>
</div>
))}
</div>
)}
</>
) : (
<>
<Icon icon="search" className="text-[--color-font] absolute left-4" />
<Input
slot='input'
placeholder="Search and switch between requests, collections and documents"
className="py-3 pl-10 pr-7 w-full bg-[--color-bg] transition-none text-[--color-font] rounded-md group-data-[open]:rounded-b-none border border-solid border-[--hl-sm]"
/>
</>
)}
</Label>
<Popover offset={0} className={`outline-none rounded-b-md w-[--trigger-width] bg-[--color-bg] text-[--color-font] relative overflow-y-auto flex-1 border ${isOpen ? 'border-solid' : ''} border-[--hl-sm]`}>
<ListBox
aria-label='Commands'
className="outline-none relative overflow-y-auto flex-1"
items={comboboxSections}
>
{section => (
<Section className='flex-1 flex flex-col'>
<Header className='p-2 text-xs uppercase text-[--hl] select-none'>{section.name}</Header>
<Collection items={section.children}>
{item => (
<ListBoxItem textValue={item.textValue} className="group outline-none select-none">
<div
className={`flex select-none outline-none ${item.id === workspaceId || item.id === requestId ? 'text-[--color-font] font-bold' : 'text-[--hl]'} group-aria-selected:text-[--color-font] relative group-hover:bg-[--hl-xs] group-data-[focused]:bg-[--hl-sm] group-focus:bg-[--hl-sm] transition-colors gap-2 px-4 items-center h-[--line-height-xs] w-full overflow-hidden`}
>
{item.icon}
<Text className="flex-shrink-0 px-1 truncate" slot="label">{item.name}</Text>
{item.presence.length > 0 && (
<span className='w-[70px]'>
<AvatarGroup
size="small"
maxAvatars={3}
items={item.presence}
/>
</span>
)}
<Text className="flex-1 px-1 truncate text-sm text-[--hl-md]" slot="description">{item.description}</Text>
</div>
</ListBoxItem>
)}
</Collection>
</Section>
)}
</ListBox>
</Popover>
</>
);
}}

</ComboBox>
);
};
4 changes: 4 additions & 0 deletions packages/insomnia/src/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ async function renderApp() {
path: 'commands',
loader: async (...args) => (await import('./routes/commands')).loader(...args),
},
{
path: 'remote-files',
loader: async (...args) => (await import('./routes/commands')).remoteFilesLoader(...args),
},
{
path: 'import',
children: [
Expand Down
Loading

0 comments on commit aae0a9c

Please sign in to comment.