Compare commits

..

17 Commits

Author SHA1 Message Date
Gregory Schier
8aeeaa2e09 Bump version 2024-06-12 00:23:50 -07:00
Gregory Schier
57f01d249e Entitlement for v8/Deno 2024-06-12 00:23:32 -07:00
Gregory Schier
6c5a914db6 Variables in pair editor 2024-06-11 12:33:43 -07:00
Gregory Schier
155e51aa74 Put toast on top 2024-06-11 12:04:46 -07:00
Gregory Schier
012a984456 Fix bulk edit label 2024-06-11 09:02:11 -07:00
Gregory Schier
25800202f2 Bump version 2024-06-11 08:35:29 -07:00
Gregory Schier
a058064f1f Adjust fuzzy threshold 2024-06-10 23:25:57 -07:00
Gregory Schier
9f40804532 Fix window drag above cmd+k 2024-06-10 23:24:03 -07:00
Gregory Schier
26cc467858 Fix delete request in sidebar 2024-06-10 23:17:12 -07:00
Gregory Schier
be1cf7bf65 Don't arrow-nav to hidden dropdown items 2024-06-10 23:16:37 -07:00
Gregory Schier
ea4f104ca7 Bulk edit environments 2024-06-10 23:16:21 -07:00
Gregory Schier
32a28a3170 Fix hotkey react keys 2024-06-10 22:29:06 -07:00
Gregory Schier
6215914212 Cmd Palette Improvements (#50)
- Fuzzy matching
- Show hotkeys
- Add actions
2024-06-10 21:37:41 -07:00
Gregory Schier
a2dbd7f849 Download Active Response (#49)
This PR prompts you to save un-previewable file types and adds an option
to save to the response history.
2024-06-10 16:36:09 -07:00
Gregory Schier
5bb9815f4b Remove jump to request hotkey 2024-06-10 09:00:26 -07:00
Gregory Schier
7cd8ac3b21 Response viewer for PDF (#48)
This PR adds a response viewer for PDF files using `react-pdf`
2024-06-10 08:57:08 -07:00
Gregory Schier
456d3aaf52 Don't focus sidebar on cmd+0 2024-06-09 08:36:12 -07:00
43 changed files with 1299 additions and 280 deletions

Binary file not shown.

696
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "yaak-app",
"name": "yaak",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -52,6 +52,7 @@
"codemirror": "^6.0.1",
"codemirror-json-schema": "^0.6.1",
"date-fns": "^3.3.1",
"fast-fuzzy": "^1.12.0",
"focus-trap-react": "^10.1.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
@@ -64,6 +65,7 @@
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"react-pdf": "^9.0.0",
"react-router-dom": "^6.8.1",
"react-use": "^17.4.0",
"slugify": "^1.6.6",
@@ -103,6 +105,7 @@
"tailwindcss": "^3.2.7",
"typescript": "^5.4.5",
"vite": "^5.0.0",
"vite-plugin-static-copy": "^1.0.5",
"vite-plugin-svgr": "^4.2.0",
"vite-plugin-top-level-await": "^1.4.1",
"vitest": "^1.3.0"

2
src-tauri/Cargo.lock generated
View File

@@ -8136,7 +8136,7 @@ dependencies = [
]
[[package]]
name = "yaak-app"
name = "yaak"
version = "0.0.0"
dependencies = [
"anyhow",

View File

@@ -1,10 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
<!-- <key>com.apple.security.app-sandbox</key> <true/>-->
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->
<!-- <key>com.apple.security.network.client</key> <true/>-->
</dict>
<dict>
<!-- Enable for v8 execution -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->
<!-- <key>com.apple.security.app-sandbox</key> <true/>-->
<!-- <key>com.apple.security.files.user-selected.read-write</key> <true/>-->
<!-- <key>com.apple.security.network.client</key> <true/>-->
</dict>
</plist>

View File

@@ -958,6 +958,28 @@ async fn cmd_export_data(
Ok(())
}
#[tauri::command]
async fn cmd_save_response(
window: WebviewWindow,
response_id: &str,
filepath: &str,
) -> Result<(), String> {
let response = get_http_response(&window, response_id)
.await
.map_err(|e| e.to_string())?;
let body_path = match response.body_path {
None => {
return Err("Response does not have a body".to_string());
}
Some(p) => p,
};
fs::copy(body_path, filepath).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn cmd_send_http_request(
window: WebviewWindow,
@@ -1702,6 +1724,7 @@ pub fn run() {
cmd_new_window,
cmd_request_to_curl,
cmd_dismiss_notification,
cmd_save_response,
cmd_send_ephemeral_request,
cmd_send_http_request,
cmd_set_key_value,

View File

@@ -1,6 +1,6 @@
{
"productName": "yaak",
"version": "2024.6.0-beta.1",
"version": "2024.6.2",
"identifier": "app.yaak.desktop",
"build": {
"beforeBuildCommand": "npm run build",

View File

@@ -1,21 +1,40 @@
import { invoke } from '@tauri-apps/api/core';
import classNames from 'classnames';
import { search } from 'fast-fuzzy';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
import { useAppRoutes } from '../hooks/useAppRoutes';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { useEnvironments } from '../hooks/useEnvironments';
import type { HotkeyAction } from '../hooks/useHotKey';
import { useHotKey } from '../hooks/useHotKey';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useRequests } from '../hooks/useRequests';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { CookieDialog } from './CookieDialog';
import { Button } from './core/Button';
import { Heading } from './core/Heading';
import { HotKey } from './core/HotKey';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
import { HStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
interface CommandPaletteGroup {
key: string;
@@ -26,22 +45,120 @@ interface CommandPaletteGroup {
type CommandPaletteItem = {
key: string;
onSelect: () => void;
action?: HotkeyAction;
} & ({ searchText: string; label: ReactNode } | { label: string });
const MAX_PER_GROUP = 4;
const MAX_PER_GROUP = 8;
export function CommandPalette({ onClose }: { onClose: () => void }) {
const [command, setCommand] = useDebouncedState<string>('', 150);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const routes = useAppRoutes();
const activeEnvironmentId = useActiveEnvironmentId();
const activeRequestId = useActiveRequestId();
const activeWorkspaceId = useActiveWorkspaceId();
const active = useActiveWorkspaceId();
const workspaces = useWorkspaces();
const environments = useEnvironments();
const recentEnvironments = useRecentEnvironments();
const recentWorkspaces = useRecentWorkspaces();
const requests = useRequests();
const recentRequests = useRecentRequests();
const [command, setCommand] = useState<string>('');
const openWorkspace = useOpenWorkspace();
const createWorkspace = useCreateWorkspace();
const createHttpRequest = useCreateHttpRequest();
const { activeCookieJar } = useActiveCookieJar();
const createGrpcRequest = useCreateGrpcRequest();
const createEnvironment = useCreateEnvironment();
const dialog = useDialog();
const workspaceId = useActiveWorkspaceId();
const activeEnvironment = useActiveEnvironment();
const [, setSidebarHidden] = useSidebarHidden();
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
const commands: CommandPaletteItem[] = [
{
key: 'settings.open',
label: 'Open Settings',
action: 'settings.show',
onSelect: async () => {
if (workspaceId == null) return;
await invoke('cmd_new_nested_window', {
url: routes.paths.workspaceSettings({ workspaceId }),
label: 'settings',
title: 'Yaak Settings',
});
},
},
{
key: 'app.create',
label: 'Create Workspace',
onSelect: createWorkspace.mutate,
},
{
key: 'http_request.create',
label: 'Create HTTP Request',
onSelect: () => createHttpRequest.mutate({}),
},
{
key: 'cookies.show',
label: 'Show Cookies',
onSelect: async () => {
dialog.show({
id: 'cookies',
title: 'Manage Cookies',
size: 'full',
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
});
},
},
{
key: 'grpc_request.create',
label: 'Create GRPC Request',
onSelect: () => createGrpcRequest.mutate({}),
},
{
key: 'environment.edit',
label: 'Edit Environment',
action: 'environmentEditor.toggle',
onSelect: () => {
dialog.toggle({
id: 'environment-editor',
noPadding: true,
size: 'lg',
className: 'h-[80vh]',
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
});
},
},
{
key: 'environment.create',
label: 'Create Environment',
onSelect: createEnvironment.mutate,
},
{
key: 'sidebar.toggle',
label: 'Toggle Sidebar',
action: 'sidebar.focus',
onSelect: () => setSidebarHidden((h) => !h),
},
];
return commands.sort((a, b) =>
('searchText' in a ? a.searchText : a.label).localeCompare(
'searchText' in b ? b.searchText : b.label,
),
);
}, [
activeCookieJar,
activeEnvironment,
createEnvironment.mutate,
createGrpcRequest,
createHttpRequest,
createWorkspace.mutate,
dialog,
routes.paths,
setSidebarHidden,
workspaceId,
]);
const sortedRequests = useMemo(() => {
return [...requests].sort((a, b) => {
@@ -60,6 +177,23 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
});
}, [recentRequests, requests]);
const sortedEnvironments = useMemo(() => {
return [...environments].sort((a, b) => {
const aRecentIndex = recentEnvironments.indexOf(a.id);
const bRecentIndex = recentEnvironments.indexOf(b.id);
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
return aRecentIndex - bRecentIndex;
} else if (aRecentIndex >= 0 && bRecentIndex === -1) {
return -1;
} else if (aRecentIndex === -1 && bRecentIndex >= 0) {
return 1;
} else {
return a.createdAt.localeCompare(b.createdAt);
}
});
}, [environments, recentEnvironments]);
const sortedWorkspaces = useMemo(() => {
return [...workspaces].sort((a, b) => {
const aRecentIndex = recentWorkspaces.indexOf(a.id);
@@ -78,6 +212,12 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
}, [recentWorkspaces, workspaces]);
const groups = useMemo<CommandPaletteGroup[]>(() => {
const actionsGroup: CommandPaletteGroup = {
key: 'actions',
label: 'Actions',
items: workspaceCommands,
};
const requestGroup: CommandPaletteGroup = {
key: 'requests',
label: 'Requests',
@@ -91,10 +231,10 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
requestGroup.items.push({
key: `switch-request-${r.id}`,
searchText: `${r.method} ${r.name}`,
searchText: fallbackRequestName(r),
label: (
<HStack space={2}>
<HttpMethodTag className="text-fg-subtler" shortNames request={r} />
<HttpMethodTag className="text-fg-subtler" request={r} />
<div className="truncate">{fallbackRequestName(r)}</div>
</HStack>
),
@@ -108,6 +248,23 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
});
}
const environmentGroup: CommandPaletteGroup = {
key: 'environments',
label: 'Environments',
items: [],
};
for (const e of sortedEnvironments) {
if (e.id === activeEnvironment?.id) {
continue;
}
environmentGroup.items.push({
key: `switch-environment-${e.id}`,
label: e.name,
onSelect: () => routes.setEnvironment(e),
});
}
const workspaceGroup: CommandPaletteGroup = {
key: 'workspaces',
label: 'Workspaces',
@@ -115,7 +272,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
};
for (const w of sortedWorkspaces) {
if (w.id === activeWorkspaceId) {
if (w.id === active) {
continue;
}
workspaceGroup.items.push({
@@ -125,30 +282,44 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
});
}
return [requestGroup, workspaceGroup];
return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
}, [
activeEnvironmentId,
activeRequestId,
activeWorkspaceId,
openWorkspace,
routes,
workspaceCommands,
sortedRequests,
activeRequestId,
routes,
activeEnvironmentId,
sortedEnvironments,
activeEnvironment?.id,
sortedWorkspaces,
active,
openWorkspace,
]);
const filteredGroups = useMemo(
() =>
groups
.map((g) => {
g.items = g.items.filter((v) => {
const s = 'searchText' in v ? v.searchText : v.label;
return s.toLowerCase().includes(command.toLowerCase());
});
return g;
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
useEffect(() => {
setSelectedItemKey(null);
}, [command]);
const { filteredGroups, filteredAllItems } = useMemo(() => {
const result = command
? search(command, allItems, {
threshold: 0.5,
keySelector: (v) => ('searchText' in v ? v.searchText : v.label),
})
.filter((g) => g.items.length > 0),
[command, groups],
);
: allItems;
const filteredGroups = groups
.map((g) => {
g.items = result.filter((i) => g.items.includes(i)).slice(0, MAX_PER_GROUP);
return g;
})
.filter((g) => g.items.length > 0);
const filteredAllItems = filteredGroups.flatMap((g) => g.items);
return { filteredAllItems, filteredGroups };
}, [allItems, command, groups]);
const handleSelectAndClose = useCallback(
(cb: () => void) => {
@@ -158,38 +329,37 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
[onClose],
);
const { allItems, selectedItem } = useMemo(() => {
const allItems = filteredGroups.flatMap((g) => g.items);
let selectedItem = allItems.find((i) => i.key === selectedItemKey) ?? null;
const selectedItem = useMemo(() => {
let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
if (selectedItem == null) {
selectedItem = allItems[0] ?? null;
selectedItem = filteredAllItems[0] ?? null;
}
return { selectedItem, allItems };
}, [filteredGroups, selectedItemKey]);
return selectedItem;
}, [filteredAllItems, selectedItemKey]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
const index = allItems.findIndex((v) => v.key === selectedItem?.key);
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
const next = allItems[index + 1];
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
setSelectedItemKey(next?.key ?? null);
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
const prev = allItems[index - 1];
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
setSelectedItemKey(prev?.key ?? null);
} else if (e.key === 'Enter') {
const selected = allItems[index];
const selected = filteredAllItems[index];
setSelectedItemKey(selected?.key ?? null);
if (selected) {
handleSelectAndClose(selected.onSelect);
}
}
},
[allItems, handleSelectAndClose, selectedItem?.key],
[filteredAllItems, handleSelectAndClose, selectedItem?.key],
);
return (
<div className="h-full max-h-[20rem] w-[400px] grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-full w-[400px] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
<div className="px-2 py-2 w-full">
<PlainInput
hideLabel
@@ -209,15 +379,18 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
</div>
<div className="h-full px-1.5 overflow-y-auto pb-1">
{filteredGroups.map((g) => (
<div key={g.key} className="mb-1.5">
<div key={g.key} className="mb-1.5 w-full">
<Heading size={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
{g.label}
</Heading>
{g.items.slice(0, MAX_PER_GROUP).map((v) => (
{g.items.map((v) => (
<CommandPaletteItem
active={v.key === selectedItem?.key}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
rightSlot={
v.action && <CommandPaletteAction action={v.action} onAction={v.onSelect} />
}
>
{v.label}
</CommandPaletteItem>
@@ -233,15 +406,20 @@ function CommandPaletteItem({
children,
active,
onClick,
rightSlot,
}: {
children: ReactNode;
active: boolean;
onClick: () => void;
rightSlot?: ReactNode;
}) {
return (
<button
<Button
onClick={onClick}
tabIndex={active ? undefined : -1}
rightSlot={rightSlot}
color="custom"
justify="start"
className={classNames(
'w-full h-sm flex items-center rounded px-1.5',
'hover:text-fg',
@@ -250,6 +428,17 @@ function CommandPaletteItem({
)}
>
<span className="truncate">{children}</span>
</button>
</Button>
);
}
function CommandPaletteAction({
action,
onAction,
}: {
action: HotkeyAction;
onAction: () => void;
}) {
useHotKey(action, onAction);
return <HotKey className="ml-auto" action={action} />;
}

View File

@@ -21,7 +21,7 @@ import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
@@ -185,18 +185,20 @@ const EnvironmentEditor = function ({
/>
</Heading>
</HStack>
<PairEditor
className="pr-2"
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueVisibility.value ? 'text' : 'password'}
valueAutocompleteVariables={false}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}
pairs={variables}
onChange={handleChange}
/>
<div className="h-full pr-2 pb-2">
<PairOrBulkEditor
preferenceName="environment"
nameAutocomplete={nameAutocomplete}
nameAutocompleteVariables={false}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueVisibility.value ? 'text' : 'password'}
valueAutocompleteVariables={false}
forceUpdateKey={environment?.id ?? workspace?.id ?? 'n/a'}
pairs={variables}
onChange={handleChange}
/>
</div>
</VStack>
);
};

View File

@@ -6,7 +6,6 @@ import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { environmentsQueryKey } from '../hooks/useEnvironments';
import { foldersQueryKey } from '../hooks/useFolders';
import { useGlobalCommands } from '../hooks/useGlobalCommands';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
@@ -42,7 +41,6 @@ export function GlobalHooks() {
// Other useful things
useSyncThemeToDocument();
useGlobalCommands();
useCommandPalette();
useNotificationToast();

View File

@@ -1,6 +1,7 @@
import classNames from 'classnames';
import { useDeleteHttpResponse } from '../hooks/useDeleteHttpResponse';
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
import { useSaveResponse } from '../hooks/useSaveResponse';
import type { HttpResponse } from '../lib/models';
import { pluralize } from '../lib/pluralize';
import { Dropdown } from './core/Dropdown';
@@ -25,30 +26,49 @@ export const RecentResponsesDropdown = function ResponsePane({
const deleteResponse = useDeleteHttpResponse(activeResponse?.id ?? null);
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const latestResponseId = responses[0]?.id ?? 'n/a';
const saveResponse = useSaveResponse(activeResponse);
return (
<Dropdown
items={[
{
key: 'save',
label: 'Save to File',
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{
key: 'clear-single',
label: 'Clear Response',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: deleteResponse.mutate,
disabled: responses.length === 0,
},
{
key: 'unpin',
label: 'Unpin Response',
onSelect: () => onPinnedResponseId(activeResponse.id),
leftSlot: <Icon icon="unpin" />,
hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
{
key: 'clear-all',
label: `Clear ${responses.length} ${pluralize('Response', responses.length)}`,
label: `Delete ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length <= 1,
disabled: responses.length === 0,
},
{ type: 'separator', label: 'History' },
{ type: 'separator' },
...responses.slice(0, 20).map((r: HttpResponse) => ({
key: r.id,
label: (
<HStack space={2}>
<StatusTag className="text-sm" response={r} />
<span>&rarr;</span>{' '}
<span className="text-fg-subtle">&rarr;</span>{' '}
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : 'n/a'}</span>
</HStack>
),

View File

@@ -262,13 +262,15 @@ export const RequestPane = memo(function RequestPane({
options:
requests.length > 0
? [
...requests.map(
(r) =>
({
type: 'constant',
label: r.url,
} as GenericCompletionOption),
),
...requests
.filter((r) => r.id !== activeRequestId)
.map(
(r) =>
({
type: 'constant',
label: r.url,
} as GenericCompletionOption),
),
]
: [
{ label: 'http://', type: 'constant' },

View File

@@ -5,6 +5,7 @@ import { createGlobalState } from 'react-use';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { isBinaryContentType } from '../lib/data/mimetypes';
import type { HttpRequest } from '../lib/models';
import { isResponseLoading } from '../lib/models';
import { Banner } from './core/Banner';
@@ -21,8 +22,10 @@ import { EmptyStateText } from './EmptyStateText';
import { RecentResponsesDropdown } from './RecentResponsesDropdown';
import { ResponseHeaders } from './ResponseHeaders';
import { AudioViewer } from './responseViewers/AudioViewer';
import { BinaryViewer } from './responseViewers/BinaryViewer';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { PdfViewer } from './responseViewers/PdfViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
@@ -158,12 +161,16 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
<AudioViewer response={activeResponse} />
) : contentType?.startsWith('video') ? (
<VideoViewer response={activeResponse} />
) : contentType?.match(/pdf/) ? (
<PdfViewer response={activeResponse} />
) : isBinaryContentType(contentType) ? (
<BinaryViewer response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : activeResponse.contentLength > 2 * 1000 * 1000 ? (
<EmptyStateText>Cannot preview text responses larger than 2MB</EmptyStateText>
) : viewMode === 'pretty' && contentType?.includes('html') ? (
<WebPageViewer response={activeResponse} />
) : contentType?.match(/csv|tab-separated/) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<TextViewer
className="-mr-2" // Pull to the right

View File

@@ -159,20 +159,6 @@ export function Sidebar({ className }: Props) {
return { tree, treeParentMap, selectableRequests, selectedRequest };
}, [activeWorkspace, selectedId, requests, folders, collapsed.value]);
const jumpToRequest = async (index: number) => {
const r = selectableRequests[index];
if (r != null) await handleSelect(r.id);
};
useHotKey('sidebar.jump_1', () => jumpToRequest(0));
useHotKey('sidebar.jump_2', () => jumpToRequest(1));
useHotKey('sidebar.jump_3', () => jumpToRequest(2));
useHotKey('sidebar.jump_4', () => jumpToRequest(3));
useHotKey('sidebar.jump_5', () => jumpToRequest(4));
useHotKey('sidebar.jump_6', () => jumpToRequest(5));
useHotKey('sidebar.jump_7', () => jumpToRequest(6));
useHotKey('sidebar.jump_8', () => jumpToRequest(7));
const focusActiveRequest = useCallback(
(
args: {
@@ -204,7 +190,7 @@ export function Sidebar({ className }: Props) {
);
const handleSelect = useCallback(
async (id: string) => {
async (id: string, opts: { noFocus?: boolean } = {}) => {
const tree = treeParentMap[id ?? 'n/a'] ?? null;
const children = tree?.children ?? [];
const node = children.find((m) => m.item.id === id) ?? null;
@@ -224,7 +210,7 @@ export function Sidebar({ className }: Props) {
});
setSelectedId(id);
setSelectedTree(tree);
focusActiveRequest({ forced: { id, tree } });
if (!opts.noFocus) focusActiveRequest({ forced: { id, tree } });
}
},
[treeParentMap, collapsed, routes, activeEnvironmentId, focusActiveRequest],
@@ -617,7 +603,7 @@ const SidebarItem = forwardRef(function SidebarItem(
) {
const activeRequest = useActiveRequest();
const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(activeRequest ?? null);
const deleteRequest = useDeleteRequest(itemId);
const duplicateHttpRequest = useDuplicateHttpRequest({ id: itemId, navigateAfter: true });
const duplicateGrpcRequest = useDuplicateGrpcRequest({ id: itemId, navigateAfter: true });
const copyAsCurl = useCopyAsCurl(itemId);

View File

@@ -67,7 +67,7 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
<ToastContext.Provider value={state}>
{children}
<Portal name="toasts">
<div className="absolute right-0 bottom-0">
<div className="absolute right-0 bottom-0 z-10">
<AnimatePresence>
{toasts.map((props: PrivateToastEntry) => (
<ToastInstance key={props.id} {...props} />

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCommand } from '../hooks/useCommands';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { usePrompt } from '../hooks/usePrompt';
@@ -28,7 +28,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const activeWorkspaceId = activeWorkspace?.id ?? null;
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const createWorkspace = useCommand('workspace.create');
const createWorkspace = useCreateWorkspace();
const dialog = useDialog();
const prompt = usePrompt();
const settings = useSettings();
@@ -101,7 +101,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
key: 'create-workspace',
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: () => createWorkspace.mutate({}),
onSelect: createWorkspace.mutate,
},
];
}, [

View File

@@ -2,12 +2,7 @@ import { useCallback, useMemo } from 'react';
import { Editor } from './Editor';
import type { PairEditorProps } from './PairEditor';
type Props = Pick<
PairEditorProps,
'onChange' | 'pairs' | 'namePlaceholder' | 'valuePlaceholder'
> & {
foo?: string;
};
type Props = PairEditorProps;
export function BulkPairEditor({ pairs, onChange, namePlaceholder, valuePlaceholder }: Props) {
const pairsText = useMemo(() => {
@@ -30,6 +25,8 @@ export function BulkPairEditor({ pairs, onChange, namePlaceholder, valuePlacehol
return (
<Editor
useTemplating
autocompleteVariables
placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`}
defaultValue={pairsText}
contentType="pairs"

View File

@@ -54,17 +54,12 @@ export function Dialog({
<Overlay open={open} onClose={onClose} portalName="dialog">
<div
className={classNames(
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none my-5',
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none',
vAlign === 'top' && 'justify-start',
vAlign === 'center' && 'justify-center',
)}
>
<div
role="dialog"
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="pointer-events-auto"
>
<div role="dialog" aria-labelledby={titleId} aria-describedby={descriptionId}>
<motion.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
@@ -74,12 +69,12 @@ export function Dialog({
'relative bg-background pointer-events-auto',
'rounded-lg',
'border border-background-highlight-secondary shadow-lg shadow-[rgba(0,0,0,0.1)]',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-6rem)]',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-4rem)]',
size === 'sm' && 'w-[28rem] max-h-[80vh]',
size === 'md' && 'w-[45rem] max-h-[80vh]',
size === 'lg' && 'w-[65rem] max-h-[80vh]',
size === 'full' && 'w-[100vw] h-[100vh]',
size === 'dynamic' && 'min-w-[20rem] max-w-[80vw] max-h-[80vh]',
size === 'dynamic' && 'min-w-[20rem] max-w-[100vw] w-full mt-8',
)}
>
{title ? (

View File

@@ -35,6 +35,7 @@ import { HStack, VStack } from './Stacks';
export type DropdownItemSeparator = {
type: 'separator';
label?: string;
hidden?: boolean;
};
export type DropdownItemDefault = {
@@ -273,7 +274,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
let nextIndex = (currIndex ?? 0) - 1;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.type === 'separator') {
if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') {
nextIndex--;
} else if (nextIndex < 0) {
nextIndex = items.length - 1;
@@ -290,7 +291,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
let nextIndex = (currIndex ?? -1) + 1;
const maxTries = items.length;
for (let i = 0; i < maxTries; i++) {
if (items[nextIndex]?.type === 'separator') {
if (items[nextIndex]?.hidden || items[nextIndex]?.type === 'separator') {
nextIndex++;
} else if (nextIndex >= items.length) {
nextIndex = 0;
@@ -373,7 +374,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
right: onRight ? docRect.width - triggerShape.right : undefined,
left: !onRight ? triggerShape.left : undefined,
minWidth: fullWidth ? triggerWidth : undefined,
maxWidth: '25rem',
maxWidth: '40rem',
};
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight
@@ -456,6 +457,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
<span className="text-fg-subtler text-center px-2 py-1">No matches</span>
)}
{filteredItems.map((item, i) => {
if (item.hidden) {
return null;
}
if (item.type === 'separator') {
return (
<Separator key={i} className={classNames('my-1.5', item.label && 'ml-2')}>
@@ -463,9 +467,6 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
</Separator>
);
}
if (item.hidden) {
return null;
}
return (
<MenuItem
focused={i === selectedIndex}
@@ -538,9 +539,8 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
'h-xs', // More compact
'min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap',
'focus:bg-background-highlight focus:text-fg rounded',
item.variant === 'default' && 'text-fg-subtle',
item.variant === 'danger' && 'text-fg-danger',
item.variant === 'notify' && 'text-fg-primary',
item.variant === 'danger' && '!text-fg-danger',
item.variant === 'notify' && '!text-fg-primary',
)}
{...props}
>

View File

@@ -1,11 +1,13 @@
import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKeyLabel } from '../../hooks/useHotKey';
interface Props {
action: HotkeyAction;
className?: string;
}
export function HotKeyLabel({ action }: Props) {
export function HotKeyLabel({ action, className }: Props) {
const label = useHotKeyLabel(action);
return <span className="text-fg-subtle whitespace-nowrap">{label}</span>;
return <span className={classNames(className, 'text-fg-subtle whitespace-nowrap')}>{label}</span>;
}

View File

@@ -1,26 +1,27 @@
import React from 'react';
import classNames from 'classnames';
import React, { Fragment } from 'react';
import type { HotkeyAction } from '../../hooks/useHotKey';
import { HotKey } from './HotKey';
import { HotKeyLabel } from './HotKeyLabel';
import { HStack, VStack } from './Stacks';
interface Props {
hotkeys: HotkeyAction[];
bottomSlot?: React.ReactNode;
className?: string;
}
export const HotKeyList = ({ hotkeys, bottomSlot }: Props) => {
export const HotKeyList = ({ hotkeys, bottomSlot, className }: Props) => {
return (
<div className="h-full flex items-center justify-center">
<VStack space={2}>
<div className={classNames(className, 'h-full flex items-center justify-center')}>
<div className="px-4 grid gap-2 grid-cols-[auto_auto]">
{hotkeys.map((hotkey) => (
<HStack key={hotkey} className="grid grid-cols-2">
<HotKeyLabel action={hotkey} />
<HotKey className="ml-auto" action={hotkey} />
</HStack>
<Fragment key={hotkey}>
<HotKeyLabel className="truncate" action={hotkey} />
<HotKey className="ml-4" action={hotkey} />
</Fragment>
))}
{bottomSlot}
</VStack>
</div>
</div>
);
};

View File

@@ -54,13 +54,15 @@ const icons = {
plusCircle: lucide.PlusCircleIcon,
question: lucide.ShieldQuestionIcon,
refresh: lucide.RefreshCwIcon,
save: lucide.SaveIcon,
search: lucide.SearchIcon,
sendHorizontal: lucide.SendHorizonalIcon,
settings2: lucide.Settings2Icon,
settings: lucide.SettingsIcon,
sparkles: lucide.SparklesIcon,
sun: lucide.SunIcon,
trash: lucide.TrashIcon,
trash: lucide.Trash2Icon,
unpin: lucide.PinOffIcon,
update: lucide.RefreshCcwIcon,
upload: lucide.UploadIcon,
x: lucide.XIcon,

View File

@@ -6,7 +6,7 @@ export function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanEleme
<code
className={classNames(
className,
'font-mono text-shrink bg-background-highlight-secondary border border-background-highlight',
'font-mono text-shrink bg-background-highlight-secondary border border-background-highlight-secondary',
'px-1.5 py-0.5 rounded text-fg shadow-inner',
)}
{...props}

View File

@@ -23,7 +23,7 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
<IconButton
size="sm"
variant="border"
title={useBulk ? 'Bulk edit' : 'Regular Edit'}
title={useBulk ? 'Enable form edit' : 'Enable bulk edit'}
className={classNames(
'transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow',
'bg-background text-fg-subtle hover:text-fg group-hover/wrapper:opacity-100',

View File

@@ -0,0 +1,27 @@
import { useSaveResponse } from '../../hooks/useSaveResponse';
import type { HttpResponse } from '../../lib/models';
import { getContentTypeHeader } from '../../lib/models';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import { InlineCode } from '../core/InlineCode';
interface Props {
response: HttpResponse;
}
export function BinaryViewer({ response }: Props) {
const saveResponse = useSaveResponse(response);
const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
return (
<Banner color="primary" className="h-full flex flex-col gap-3">
<p>
Content type <InlineCode>{contentType}</InlineCode> cannot be previewed
</p>
<div>
<Button variant="border" size="sm" onClick={() => saveResponse.mutate()}>
Save to File
</Button>
</div>
</Banner>
);
}

View File

@@ -0,0 +1,3 @@
.react-pdf__Document * {
@apply select-text;
}

View File

@@ -0,0 +1,61 @@
import useResizeObserver from '@react-hook/resize-observer';
import 'react-pdf/dist/Page/TextLayer.css';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import { convertFileSrc } from '@tauri-apps/api/core';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import React, { useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useDebouncedState } from '../../hooks/useDebouncedState';
import type { HttpResponse } from '../../lib/models';
import './PdfViewer.css';
interface Props {
response: HttpResponse;
}
const options = {
cMapUrl: '/cmaps/',
standardFontDataUrl: '/standard_fonts/',
};
export function PdfViewer({ response }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useDebouncedState<number>(0, 100);
const [numPages, setNumPages] = useState<number>();
useResizeObserver(containerRef.current ?? null, (v) => {
setContainerWidth(v.contentRect.width);
});
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
};
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
const src = convertFileSrc(response.bodyPath);
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
file={src}
options={options}
onLoadSuccess={onDocumentLoadSuccess}
externalLinkTarget="_blank"
externalLinkRel="noopener noreferrer"
>
{Array.from(new Array(numPages), (_, index) => (
<Page
className="mb-6 select-all"
renderTextLayer
renderAnnotationLayer
key={`page_${index + 1}`}
pageNumber={index + 1}
width={containerWidth}
/>
))}
</Document>
</div>
);
}

View File

@@ -35,21 +35,14 @@ export function TextViewer({ response, pretty, className }: Props) {
);
const contentType = useContentTypeFromHeaders(response.headers);
const rawBody = useResponseBodyText(response) ?? '';
const rawBody = useResponseBodyText(response) ?? null;
const isSearching = filterText != null;
const formattedBody =
pretty && contentType?.includes('json')
? tryFormatJson(rawBody)
: pretty && contentType?.includes('xml')
? tryFormatXml(rawBody)
: rawBody;
const filteredResponse = useFilterResponse({
filter: debouncedFilterText ?? '',
responseId: response.id,
});
const body = isSearching && filterText?.length > 0 ? filteredResponse : formattedBody;
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
@@ -104,6 +97,18 @@ export function TextViewer({ response, pretty, className }: Props) {
return result;
}, [canFilter, filterText, isJson, isSearching, response.id, setFilterText, toggleSearch]);
if (rawBody == null) {
return 'bad';
}
const formattedBody =
pretty && contentType?.includes('json')
? tryFormatJson(rawBody)
: pretty && contentType?.includes('xml')
? tryFormatXml(rawBody)
: rawBody;
const body = isSearching && filterText?.length > 0 ? filteredResponse : formattedBody;
return (
<Editor
readOnly

View File

@@ -18,11 +18,13 @@ export function useClipboardText() {
const setText = useCallback(
(text: string) => {
writeText(text).catch(console.error);
toast.show({
id: 'copied',
variant: 'copied',
message: 'Copied to clipboard',
});
if (text != '') {
toast.show({
id: 'copied',
variant: 'copied',
message: 'Copied to clipboard',
});
}
setValue(text);
},
[setValue, toast],

View File

@@ -9,6 +9,7 @@ export function useCommandPalette() {
id: 'command_palette',
size: 'dynamic',
hideX: true,
className: '!max-h-[min(30rem,calc(100vh-4rem))]',
vAlign: 'top',
noPadding: true,
noScroll: true,

View File

@@ -1,41 +0,0 @@
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { useEffect } from 'react';
import { createGlobalState } from 'react-use';
import type { TrackAction, TrackResource } from '../lib/analytics';
import type { Workspace } from '../lib/models';
interface CommandInstance<T, V> extends UseMutationOptions<V, unknown, T> {
track?: [TrackResource, TrackAction];
name: string;
}
export type Commands = {
'workspace.create': CommandInstance<Partial<Pick<Workspace, 'name'>>, Workspace>;
};
const useCommandState = createGlobalState<Commands>();
export function useRegisterCommand<K extends keyof Commands>(action: K, command: Commands[K]) {
const [, setState] = useCommandState();
useEffect(() => {
setState((commands) => {
return { ...commands, [action]: command };
});
// Remove action when it goes out of scope
return () => {
setState((commands) => {
return { ...commands, [action]: undefined };
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [action]);
}
export function useCommand<K extends keyof Commands>(action: K) {
const [commands] = useCommandState();
const cmd = commands[action];
return useMutation({ ...cmd });
}

View File

@@ -14,7 +14,7 @@ export function useCreateHttpRequest() {
const routes = useAppRoutes();
return useMutation<HttpRequest, unknown, Partial<HttpRequest>>({
mutationFn: (patch) => {
mutationFn: (patch = {}) => {
if (workspaceId === null) {
throw new Error("Cannot create request when there's no active workspace");
}
@@ -28,7 +28,6 @@ export function useCreateHttpRequest() {
}
}
patch.folderId = patch.folderId || activeRequest?.folderId;
console.log('PATCH', patch);
return invoke('cmd_create_http_request', { request: { workspaceId, ...patch } });
},
onSettled: () => trackEvent('http_request', 'create'),

View File

@@ -0,0 +1,27 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
import type { Workspace } from '../lib/models';
import { useAppRoutes } from './useAppRoutes';
import { usePrompt } from './usePrompt';
export function useCreateWorkspace() {
const routes = useAppRoutes();
const prompt = usePrompt();
return useMutation<Workspace, void, void>({
mutationFn: async () => {
const name = await prompt({
id: 'new-workspace',
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
confirmLabel: 'Create',
placeholder: 'My Workspace',
});
return invoke('cmd_create_workspace', { name });
},
onSuccess: async (workspace) => {
routes.navigate('workspace', { workspaceId: workspace.id });
},
});
}

View File

@@ -1,21 +1,17 @@
import { useMutation } from '@tanstack/react-query';
import type { GrpcRequest, HttpRequest } from '../lib/models';
import { useDeleteAnyGrpcRequest } from './useDeleteAnyGrpcRequest';
import { useDeleteAnyHttpRequest } from './useDeleteAnyHttpRequest';
export function useDeleteRequest(request: HttpRequest | GrpcRequest | null) {
export function useDeleteRequest(id: string | null) {
const deleteAnyHttpRequest = useDeleteAnyHttpRequest();
const deleteAnyGrpcRequest = useDeleteAnyGrpcRequest();
return useMutation<void, string>({
mutationFn: async () => {
if (request?.model === 'http_request') {
await deleteAnyHttpRequest.mutateAsync(request.id);
} else if (request?.model === 'grpc_request') {
await deleteAnyGrpcRequest.mutateAsync(request.id);
} else {
// Request is null
}
if (id == null) return;
// We don't know what type it is based on the ID, so just try deleting both
deleteAnyHttpRequest.mutate(id);
deleteAnyGrpcRequest.mutate(id);
},
});
}

View File

@@ -1,31 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { useAppRoutes } from './useAppRoutes';
import { useRegisterCommand } from './useCommands';
import { usePrompt } from './usePrompt';
export function useGlobalCommands() {
const prompt = usePrompt();
const routes = useAppRoutes();
useRegisterCommand('workspace.create', {
name: 'New Workspace',
track: ['workspace', 'create'],
onSuccess: async (workspace) => {
routes.navigate('workspace', { workspaceId: workspace.id });
},
mutationFn: async ({ name: patchName }) => {
const name =
patchName ??
(await prompt({
id: 'new-workspace',
name: 'name',
label: 'Name',
defaultValue: 'My Workspace',
title: 'New Workspace',
confirmLabel: 'Create',
placeholder: 'My Workspace',
}));
return invoke('cmd_create_workspace', { name });
},
});
}

View File

@@ -18,14 +18,6 @@ export type HotkeyAction =
| 'request_switcher.toggle'
| 'settings.show'
| 'sidebar.focus'
| 'sidebar.jump_1'
| 'sidebar.jump_2'
| 'sidebar.jump_3'
| 'sidebar.jump_4'
| 'sidebar.jump_5'
| 'sidebar.jump_6'
| 'sidebar.jump_7'
| 'sidebar.jump_8'
| 'urlBar.focus'
| 'command_palette.toggle'
| 'app.zoom_in'
@@ -44,14 +36,6 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'request_switcher.toggle': ['CmdCtrl+p'],
'settings.show': ['CmdCtrl+,'],
'sidebar.focus': ['CmdCtrl+b'],
'sidebar.jump_1': ['CmdCtrl+1'],
'sidebar.jump_2': ['CmdCtrl+2'],
'sidebar.jump_3': ['CmdCtrl+3'],
'sidebar.jump_4': ['CmdCtrl+4'],
'sidebar.jump_5': ['CmdCtrl+5'],
'sidebar.jump_6': ['CmdCtrl+6'],
'sidebar.jump_7': ['CmdCtrl+7'],
'sidebar.jump_8': ['CmdCtrl+8'],
'urlBar.focus': ['CmdCtrl+l'],
'command_palette.toggle': ['CmdCtrl+k'],
'app.zoom_in': ['CmdCtrl+='],
@@ -71,14 +55,6 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'request_switcher.toggle': 'Toggle Request Switcher',
'settings.show': 'Open Settings',
'sidebar.focus': 'Focus or Toggle Sidebar',
'sidebar.jump_1': 'Jump to request 1',
'sidebar.jump_2': 'Jump to request 2',
'sidebar.jump_3': 'Jump to request 3',
'sidebar.jump_4': 'Jump to request 4',
'sidebar.jump_5': 'Jump to request 5',
'sidebar.jump_6': 'Jump to request 6',
'sidebar.jump_7': 'Jump to request 7',
'sidebar.jump_8': 'Jump to request 8',
'urlBar.focus': 'Focus URL',
'command_palette.toggle': 'Toggle Command Palette',
'app.zoom_in': 'Zoom In',

View File

@@ -5,7 +5,7 @@ import { useLatestHttpResponse } from './useLatestHttpResponse';
export function usePinnedHttpResponse(activeRequest: HttpRequest) {
const latestResponse = useLatestHttpResponse(activeRequest.id);
const { set: setPinnedResponseId, value: pinnedResponseId } = useKeyValue<string | null>({
const { set, value: pinnedResponseId } = useKeyValue<string | null>({
// Key on latest response instead of activeRequest because responses change out of band of active request
key: ['pinned_http_response_id', latestResponse?.id ?? 'n/a'],
fallback: null,
@@ -15,5 +15,13 @@ export function usePinnedHttpResponse(activeRequest: HttpRequest) {
const activeResponse: HttpResponse | null =
responses.find((r) => r.id === pinnedResponseId) ?? latestResponse;
const setPinnedResponseId = async (id: string) => {
if (pinnedResponseId === id) {
await set(null);
} else {
await set(id);
}
};
return { activeResponse, setPinnedResponseId, pinnedResponseId, responses } as const;
}

View File

@@ -0,0 +1,37 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog';
import mime from 'mime';
import slugify from 'slugify';
import { InlineCode } from '../components/core/InlineCode';
import { useToast } from '../components/ToastContext';
import type { HttpResponse } from '../lib/models';
import { getContentTypeHeader } from '../lib/models';
import { getHttpRequest } from '../lib/store';
export function useSaveResponse(response: HttpResponse) {
const toast = useToast();
return useMutation({
mutationFn: async () => {
const request = await getHttpRequest(response.requestId);
if (request == null) return null;
const contentType = getContentTypeHeader(response.headers) ?? 'unknown';
const ext = mime.getExtension(contentType);
const slug = slugify(request.name, { lower: true });
const filepath = await save({
defaultPath: ext ? `${slug}.${ext}` : slug,
title: 'Save Response',
});
await invoke('cmd_save_response', { responseId: response.id, filepath });
toast.show({
message: (
<>
Response saved to <InlineCode>{filepath}</InlineCode>
</>
),
});
},
});
}

View File

@@ -206,3 +206,28 @@ export const mimeTypes = [
'video/x-flv',
'video/x-m4v',
];
export function isBinaryContentType(contentType: string | null) {
const mimeType = contentType?.split(';')[0];
if (mimeType == null) return false;
const [first, second] = mimeType.split('/').map((s) => s.trim().toLowerCase());
if (first == 'text' || second == null) {
return false;
}
if (first != 'application') {
return true;
}
const isTextSubtype =
second === 'json' ||
second === 'ld+json' ||
second === 'x-httpd-php' ||
second === 'x-sh' ||
second === 'x-csh' ||
second === 'xhtml+xml' ||
second === 'xml';
return !isTextSubtype;
}

View File

@@ -220,3 +220,7 @@ export function modelsEq(a: Model, b: Model) {
}
return false;
}
export function getContentTypeHeader(headers: HttpHeader[]): string | null {
return headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null;
}

View File

@@ -26,6 +26,10 @@
@apply select-none cursor-default;
}
.select-all * {
/*@apply select-all;*/
}
a,
a[href] * {
@apply cursor-pointer !important;

View File

@@ -5,6 +5,12 @@ import { createRoot } from 'react-dom/client';
import { attachConsole } from 'tauri-plugin-log-api';
import { App } from './components/App';
import './main.css';
import { pdfjs } from 'react-pdf';
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
// Hide decorations here because it doesn't work in Rust for some reason (bug?)
const osType = await type();

View File

@@ -1,14 +1,35 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { internalIpV4 } from 'internal-ip';
import { createRequire } from 'node:module';
import path from 'node:path';
import { defineConfig, normalizePath } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import svgr from 'vite-plugin-svgr';
import topLevelAwait from 'vite-plugin-top-level-await';
const require = createRequire(import.meta.url);
const cMapsDir = normalizePath(
path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'cmaps'),
);
const standardFontsDir = normalizePath(
path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts'),
);
const mobile = !!/android|ios/.exec(process.env.TAURI_ENV_PLATFORM ?? '');
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [svgr(), react(), topLevelAwait()],
plugins: [
svgr(),
react(),
topLevelAwait(),
viteStaticCopy({
targets: [
{ src: cMapsDir, dest: '' },
{ src: standardFontsDir, dest: '' },
],
}),
],
clearScreen: false,
server: {
port: 1420,