mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-06 05:19:05 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
221e768b33 | ||
|
|
c2dc7e0f4a | ||
|
|
9e065c34ee | ||
|
|
2f91d541c5 | ||
|
|
948fd487ab | ||
|
|
ed6a5386a2 | ||
|
|
8a24c48fd3 | ||
|
|
d726a6f5bf |
15
package-lock.json
generated
15
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"format-graphql": "^1.4.0",
|
||||
"framer-motion": "^9.0.4",
|
||||
"lucide-react": "^0.309.0",
|
||||
"mime": "^4.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"parse-color": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -7208,6 +7209,20 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.1.tgz",
|
||||
"integrity": "sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa"
|
||||
],
|
||||
"bin": {
|
||||
"mime": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"format-graphql": "^1.4.0",
|
||||
"framer-motion": "^9.0.4",
|
||||
"lucide-react": "^0.309.0",
|
||||
"mime": "^4.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"parse-color": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -7,15 +7,15 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, Method};
|
||||
use http::header::{ACCEPT, USER_AGENT};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, Method};
|
||||
use log::{error, info, warn};
|
||||
use reqwest::{multipart, Url};
|
||||
use reqwest::redirect::Policy;
|
||||
use reqwest::{multipart, Url};
|
||||
use sqlx::types::{Json, JsonValue};
|
||||
use tauri::{Manager, Window};
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::watch::{Receiver};
|
||||
use tokio::sync::watch::Receiver;
|
||||
|
||||
use crate::{models, render, response_err};
|
||||
|
||||
@@ -244,6 +244,22 @@ pub async fn send_http_request(
|
||||
}
|
||||
}
|
||||
request_builder = request_builder.form(&form_params);
|
||||
} else if body_type == "binary" && request_body.contains_key("filePath") {
|
||||
let file_path = request_body
|
||||
.get("filePath")
|
||||
.ok_or("filePath not set")?
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
|
||||
match fs::read(file_path).map_err(|e| e.to_string()) {
|
||||
Ok(f) => {
|
||||
request_builder = request_builder.body(f);
|
||||
}
|
||||
Err(e) => {
|
||||
return response_err(response, e, window).await;
|
||||
}
|
||||
}
|
||||
|
||||
} else if body_type == "multipart/form-data" && request_body.contains_key("form") {
|
||||
let mut multipart_form = multipart::Form::new();
|
||||
if let Some(form_definition) = request_body.get("form") {
|
||||
@@ -307,11 +323,11 @@ pub async fn send_http_request(
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = resp_tx.send(client.execute(sendable_req).await);
|
||||
});
|
||||
|
||||
|
||||
let raw_response = tokio::select! {
|
||||
Ok(r) = resp_rx => {r}
|
||||
_ = cancel_rx.changed() => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Yaak",
|
||||
"version": "2024.3.3"
|
||||
"version": "2024.3.4"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [],
|
||||
|
||||
82
src-web/components/BinaryFileEditor.tsx
Normal file
82
src-web/components/BinaryFileEditor.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import mime from 'mime';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
|
||||
type Props = {
|
||||
requestId: string;
|
||||
contentType: string | null;
|
||||
body: HttpRequest['body'];
|
||||
onChange: (body: HttpRequest['body']) => void;
|
||||
onChangeContentType: (contentType: string | null) => void;
|
||||
};
|
||||
|
||||
export function BinaryFileEditor({
|
||||
contentType,
|
||||
body,
|
||||
onChange,
|
||||
onChangeContentType,
|
||||
requestId,
|
||||
}: Props) {
|
||||
const ignoreContentType = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['ignore_content_type', requestId],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
const handleClick = async () => {
|
||||
await ignoreContentType.set(false);
|
||||
const path = await open({
|
||||
title: 'Select File',
|
||||
multiple: false,
|
||||
});
|
||||
if (path) {
|
||||
onChange({ filePath: path });
|
||||
} else {
|
||||
onChange({ filePath: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const filePath = typeof body.filePath === 'string' ? body.filePath : undefined;
|
||||
const mimeType = mime.getType(filePath ?? '') ?? 'application/octet-stream';
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
<HStack space={2} alignItems="center">
|
||||
<Button variant="border" color="gray" size="sm" onClick={handleClick}>
|
||||
Choose File
|
||||
</Button>
|
||||
<div className="text-xs font-mono truncate rtl pr-3 text-gray-800">
|
||||
{/* Special character to insert ltr text in rtl element without making things wonky */}
|
||||
‎
|
||||
{filePath ?? 'Select File'}
|
||||
</div>
|
||||
</HStack>
|
||||
{filePath != null && mimeType !== contentType && !ignoreContentType.value && (
|
||||
<Banner className="mt-3 !py-5">
|
||||
<div className="text-sm mb-4 text-center">
|
||||
<div>Set Content-Type header</div>
|
||||
<InlineCode>{mimeType}</InlineCode> for current request?
|
||||
</div>
|
||||
<HStack space={1.5} justifyContent="center">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="gray"
|
||||
size="xs"
|
||||
onClick={() => onChangeContentType(mimeType)}
|
||||
>
|
||||
Set Header
|
||||
</Button>
|
||||
<Button size="xs" variant="border" onClick={() => ignoreContentType.set(true)}>
|
||||
Ignore
|
||||
</Button>
|
||||
</HStack>
|
||||
</Banner>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { PairEditor } from './core/PairEditor';
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
body: HttpRequest['body'];
|
||||
onChange: (headers: HttpRequest['body']) => void;
|
||||
onChange: (body: HttpRequest['body']) => void;
|
||||
};
|
||||
|
||||
export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { settingsQueryKey } from '../hooks/useSettings';
|
||||
import { useSyncAppearance } from '../hooks/useSyncAppearance';
|
||||
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
|
||||
import { workspacesQueryKey } from '../hooks/useWorkspaces';
|
||||
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||
import type { Model } from '../lib/models';
|
||||
import { modelsEq } from '../lib/models';
|
||||
import { setPathname } from '../lib/persistPathname';
|
||||
@@ -142,7 +141,7 @@ function removeById<T extends { id: string }>(model: T) {
|
||||
|
||||
const shouldIgnoreModel = (payload: Model) => {
|
||||
if (payload.model === 'key_value') {
|
||||
return payload.namespace === NAMESPACE_NO_SYNC;
|
||||
return payload.namespace === 'no_sync';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -6,24 +6,27 @@ import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||
import { useIsResponseLoading } from '../hooks/useIsResponseLoading';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
|
||||
import { useSendRequest } from '../hooks/useSendRequest';
|
||||
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
import type { HttpHeader, HttpRequest, HttpUrlParameter } from '../lib/models';
|
||||
import {
|
||||
BODY_TYPE_OTHER,
|
||||
AUTH_TYPE_BASIC,
|
||||
AUTH_TYPE_BEARER,
|
||||
AUTH_TYPE_NONE,
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
BODY_TYPE_OTHER,
|
||||
BODY_TYPE_XML,
|
||||
} from '../lib/models';
|
||||
import { BasicAuth } from './BasicAuth';
|
||||
import { BearerAuth } from './BearerAuth';
|
||||
import { BinaryFileEditor } from './BinaryFileEditor';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Editor } from './core/Editor';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
@@ -56,6 +59,7 @@ export const RequestPane = memo(function RequestPane({
|
||||
const [activeTab, setActiveTab] = useActiveTab();
|
||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
const contentType = useContentTypeFromHeaders(activeRequest.headers);
|
||||
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
@@ -68,19 +72,19 @@ export const RequestPane = memo(function RequestPane({
|
||||
{ label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED },
|
||||
{ label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART },
|
||||
{ type: 'separator', label: 'Text Content' },
|
||||
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
|
||||
{ label: 'JSON', value: BODY_TYPE_JSON },
|
||||
{ label: 'XML', value: BODY_TYPE_XML },
|
||||
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
|
||||
{ label: 'Other', value: BODY_TYPE_OTHER },
|
||||
{ type: 'separator', label: 'Other' },
|
||||
{ label: 'Binary File', value: BODY_TYPE_BINARY },
|
||||
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
|
||||
],
|
||||
onChange: async (bodyType) => {
|
||||
const patch: Partial<HttpRequest> = { bodyType };
|
||||
let newContentType: string | null | undefined;
|
||||
if (bodyType === BODY_TYPE_NONE) {
|
||||
patch.headers = activeRequest.headers.filter(
|
||||
(h) => h.name.toLowerCase() !== 'content-type',
|
||||
);
|
||||
newContentType = null;
|
||||
} else if (
|
||||
bodyType === BODY_TYPE_FORM_URLENCODED ||
|
||||
bodyType === BODY_TYPE_FORM_MULTIPART ||
|
||||
@@ -89,32 +93,17 @@ export const RequestPane = memo(function RequestPane({
|
||||
bodyType === BODY_TYPE_XML
|
||||
) {
|
||||
patch.method = 'POST';
|
||||
patch.headers = [
|
||||
...(activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
|
||||
[]),
|
||||
{
|
||||
name: 'Content-Type',
|
||||
value: bodyType,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
newContentType = bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType;
|
||||
} else if (bodyType == BODY_TYPE_GRAPHQL) {
|
||||
patch.method = 'POST';
|
||||
patch.headers = [
|
||||
...(activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
|
||||
[]),
|
||||
{
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
newContentType = 'application/json';
|
||||
}
|
||||
|
||||
// Force update header editor so any changed headers are reflected
|
||||
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
||||
await updateRequest.mutateAsync(patch);
|
||||
|
||||
updateRequest.mutate(patch);
|
||||
if (newContentType !== undefined) {
|
||||
await handleContentTypeChange(newContentType);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -171,6 +160,31 @@ export const RequestPane = memo(function RequestPane({
|
||||
(body: HttpRequest['body']) => updateRequest.mutate({ body }),
|
||||
[updateRequest],
|
||||
);
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
async (contentType: string | null) => {
|
||||
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type');
|
||||
|
||||
if (contentType != null) {
|
||||
headers.push({
|
||||
name: 'Content-Type',
|
||||
value: contentType,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
await updateRequest.mutateAsync({ headers });
|
||||
|
||||
// Force update header editor so any changed headers are reflected
|
||||
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
||||
},
|
||||
[activeRequest.headers, updateRequest],
|
||||
);
|
||||
const handleBinaryFileChange = useCallback(
|
||||
(body: HttpRequest['body']) => {
|
||||
updateRequest.mutate({ body });
|
||||
},
|
||||
[updateRequest],
|
||||
);
|
||||
const handleBodyTextChange = useCallback(
|
||||
(text: string) => updateRequest.mutate({ body: { text } }),
|
||||
[updateRequest],
|
||||
@@ -314,6 +328,14 @@ export const RequestPane = memo(function RequestPane({
|
||||
body={activeRequest.body}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
||||
<BinaryFileEditor
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={handleBinaryFileChange}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateText>No Body</EmptyStateText>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { CSSProperties } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { createGlobalState } from 'react-use';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
import { useResponseContentType } from '../hooks/useResponseContentType';
|
||||
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
|
||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||
import type { HttpRequest } from '../lib/models';
|
||||
import { isResponseLoading } from '../lib/models';
|
||||
@@ -37,7 +37,7 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
|
||||
const { activeResponse, setPinnedResponse, responses } = usePinnedHttpResponse(activeRequest);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [activeTab, setActiveTab] = useActiveTab();
|
||||
const contentType = useResponseContentType(activeResponse);
|
||||
const contentType = useContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
|
||||
@@ -31,7 +31,6 @@ import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
|
||||
import { useUpdateGrpcRequest } from '../hooks/useUpdateGrpcRequest';
|
||||
import { useUpdateHttpRequest } from '../hooks/useUpdateHttpRequest';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||
import type { Folder, GrpcRequest, HttpRequest, Workspace } from '../lib/models';
|
||||
import { isResponseLoading } from '../lib/models';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
@@ -87,7 +86,7 @@ export function Sidebar({ className }: Props) {
|
||||
const collapsed = useKeyValue<Record<string, boolean>>({
|
||||
key: ['sidebar_collapsed', activeWorkspace?.id ?? 'n/a'],
|
||||
fallback: {},
|
||||
namespace: NAMESPACE_NO_SYNC,
|
||||
namespace: 'no_sync',
|
||||
});
|
||||
|
||||
useHotKey('http_request.duplicate', async () => {
|
||||
|
||||
@@ -9,13 +9,19 @@ import type {
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
||||
import { useImportData } from '../hooks/useImportData';
|
||||
import { useIsFullscreen } from '../hooks/useIsFullscreen';
|
||||
import { useOsInfo } from '../hooks/useOsInfo';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useSidebarWidth } from '../hooks/useSidebarWidth';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { HotKeyList } from './core/HotKeyList';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { FeedbackLink } from './core/Link';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { CreateDropdown } from './CreateDropdown';
|
||||
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
|
||||
@@ -34,6 +40,9 @@ const drag = { gridArea: 'drag' };
|
||||
const WINDOW_FLOATING_SIDEBAR_WIDTH = 600;
|
||||
|
||||
export default function Workspace() {
|
||||
const workspaces = useWorkspaces();
|
||||
const activeWorkspace = useActiveWorkspace();
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const { setWidth, width, resetWidth } = useSidebarWidth();
|
||||
const { hide, show, hidden } = useSidebarHidden();
|
||||
const activeRequest = useActiveRequest();
|
||||
@@ -119,6 +128,11 @@ export default function Workspace() {
|
||||
);
|
||||
}
|
||||
|
||||
// We're loading still
|
||||
if (workspaces.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={styles}
|
||||
@@ -163,7 +177,15 @@ export default function Workspace() {
|
||||
<HeaderSize data-tauri-drag-region style={head}>
|
||||
<WorkspaceHeader className="pointer-events-none" />
|
||||
</HeaderSize>
|
||||
{activeRequest == null ? (
|
||||
{activeWorkspace == null ? (
|
||||
<div className="m-auto">
|
||||
<Banner color="warning" className="max-w-[30rem]">
|
||||
The active workspace{' '}
|
||||
<InlineCode className="text-orange-800">{activeWorkspaceId}</InlineCode> was not found.
|
||||
Select a workspace from the header menu or report this bug to <FeedbackLink />
|
||||
</Banner>
|
||||
</div>
|
||||
) : activeRequest == null ? (
|
||||
<HotKeyList
|
||||
hotkeys={['http_request.create', 'sidebar.toggle', 'settings.show']}
|
||||
bottomSlot={
|
||||
|
||||
@@ -167,10 +167,14 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
<Dropdown items={items}>
|
||||
<Button
|
||||
size="sm"
|
||||
className={classNames(className, 'text-gray-800 !px-2 truncate')}
|
||||
className={classNames(
|
||||
className,
|
||||
'text-gray-800 !px-2 truncate',
|
||||
activeWorkspace === null && 'italic opacity-disabled',
|
||||
)}
|
||||
{...buttonProps}
|
||||
>
|
||||
{activeWorkspace?.name}
|
||||
{activeWorkspace?.name ?? 'Workspace'}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
color?: 'danger' | 'success' | 'gray';
|
||||
color?: 'danger' | 'warning' | 'success' | 'gray';
|
||||
}
|
||||
export function Banner({ children, className, color = 'gray' }: Props) {
|
||||
return (
|
||||
@@ -14,6 +14,7 @@ export function Banner({ children, className, color = 'gray' }: Props) {
|
||||
className,
|
||||
'border border-dashed italic px-3 py-2 rounded select-auto cursor-text',
|
||||
color === 'gray' && 'border-gray-500/60 bg-gray-300/10 text-gray-800',
|
||||
color === 'warning' && 'border-orange-500/60 bg-orange-300/10 text-orange-800',
|
||||
color === 'danger' && 'border-red-500/60 bg-red-300/10 text-red-800',
|
||||
color === 'success' && 'border-green-500/60 bg-green-300/10 text-green-800',
|
||||
)}
|
||||
|
||||
14
src-web/components/core/Editor/BetterMatchDecorator.ts
Normal file
14
src-web/components/core/Editor/BetterMatchDecorator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { type DecorationSet, MatchDecorator, type ViewUpdate } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
|
||||
*/
|
||||
export class BetterMatchDecorator extends MatchDecorator {
|
||||
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
|
||||
if (!update.startState.selection.eq(update.state.selection)) {
|
||||
return super.createDeco(update.view);
|
||||
} else {
|
||||
return super.updateDeco(update, deco);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,14 @@
|
||||
@apply text-red-700 dark:text-red-800 bg-red-300/30 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/40;
|
||||
}
|
||||
}
|
||||
|
||||
.hyperlink-widget {
|
||||
& > * {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
-webkit-text-security: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-singleline {
|
||||
@@ -103,10 +111,10 @@
|
||||
@apply font-mono text-[0.75rem];
|
||||
|
||||
/*
|
||||
* Round corners or they'll stick out of the editor bounds of editor is rounded.
|
||||
* Could potentially be pushed up from the editor like we do with bg color but this
|
||||
* is probably fine.
|
||||
*/
|
||||
* Round corners or they'll stick out of the editor bounds of editor is rounded.
|
||||
* Could potentially be pushed up from the editor like we do with bg color but this
|
||||
* is probably fine.
|
||||
*/
|
||||
@apply rounded-lg;
|
||||
}
|
||||
}
|
||||
@@ -167,8 +175,8 @@
|
||||
@apply h-full flex items-center;
|
||||
|
||||
/* Break characters on line wrapping mode, useful for URL field.
|
||||
* We can make this dynamic if we need it to be configurable later
|
||||
*/
|
||||
* We can make this dynamic if we need it to be configurable later
|
||||
*/
|
||||
|
||||
&.cm-lineWrapping {
|
||||
@apply break-all;
|
||||
@@ -176,9 +184,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip.cm-tooltip-hover {
|
||||
@apply shadow-lg bg-gray-200 rounded text-gray-700 border border-gray-500 z-50 pointer-events-auto text-xs;
|
||||
@apply px-2 py-1;
|
||||
|
||||
a {
|
||||
@apply text-yellow-500 font-bold;
|
||||
|
||||
&:hover {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply text-yellow-600 bg-yellow-600 h-3 w-3 ml-1;
|
||||
content: '';
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
|
||||
-webkit-mask-size: contain;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE: Extra selector required to override default styles */
|
||||
.cm-tooltip.cm-tooltip {
|
||||
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-[0.75rem];
|
||||
.cm-tooltip.cm-tooltip-autocomplete {
|
||||
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-300 z-50 pointer-events-auto text-xs;
|
||||
|
||||
.cm-completionIcon {
|
||||
@apply italic font-mono;
|
||||
|
||||
@@ -122,11 +122,8 @@ export const baseExtensions = [
|
||||
history(),
|
||||
dropCursor(),
|
||||
drawSelection(),
|
||||
// TODO: Figure out how to debounce showing of autocomplete in a good way
|
||||
// debouncedAutocompletionDisplay({ millis: 1000 }),
|
||||
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
|
||||
autocompletion({
|
||||
closeOnBlur: false, // For debugging in devtools without closing it
|
||||
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
|
||||
compareCompletions: (a, b) => {
|
||||
// Don't sort completions at all, only on boost
|
||||
return (a.boost ?? 0) - (b.boost ?? 0);
|
||||
|
||||
98
src-web/components/core/Editor/hyperlink/extension.ts
Normal file
98
src-web/components/core/Editor/hyperlink/extension.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view';
|
||||
import { EditorView } from 'codemirror';
|
||||
|
||||
const REGEX =
|
||||
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))/g;
|
||||
|
||||
const tooltip = hoverTooltip(
|
||||
(view, pos, side) => {
|
||||
const { from, text } = view.state.doc.lineAt(pos);
|
||||
let match;
|
||||
let found: { start: number; end: number } | null = null;
|
||||
|
||||
while ((match = REGEX.exec(text))) {
|
||||
const start = from + match.index;
|
||||
const end = start + match[0].length;
|
||||
|
||||
if (pos >= start && pos <= end) {
|
||||
found = { start, end };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((found.start == pos && side < 0) || (found.end == pos && side > 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pos: found.start,
|
||||
end: found.end,
|
||||
create() {
|
||||
const dom = document.createElement('a');
|
||||
dom.textContent = 'Open in browser';
|
||||
dom.href = text.substring(found!.start - from, found!.end - from);
|
||||
dom.target = '_blank';
|
||||
dom.rel = 'noopener noreferrer';
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
hoverTime: 100,
|
||||
},
|
||||
);
|
||||
|
||||
const decorator = function () {
|
||||
const placeholderMatcher = new MatchDecorator({
|
||||
regexp: REGEX,
|
||||
decoration(match, view, matchStartPos) {
|
||||
const matchEndPos = matchStartPos + match[0].length - 1;
|
||||
|
||||
// Don't decorate if the cursor is inside the match
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.from > matchStartPos && r.to <= matchEndPos) {
|
||||
return Decoration.replace({});
|
||||
}
|
||||
}
|
||||
|
||||
const groupMatch = match[1];
|
||||
if (groupMatch == null) {
|
||||
// Should never happen, but make TS happy
|
||||
console.warn('Group match was empty', match);
|
||||
return Decoration.replace({});
|
||||
}
|
||||
|
||||
return Decoration.mark({
|
||||
class: 'hyperlink-widget',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
placeholders: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (instance) => instance.placeholders,
|
||||
provide: (plugin) =>
|
||||
EditorView.bidiIsolatedRanges.of((view) => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none;
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const hyperlink = [tooltip, decorator()];
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
|
||||
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
|
||||
import { BetterMatchDecorator } from '../BetterMatchDecorator';
|
||||
|
||||
class PlaceholderWidget extends WidgetType {
|
||||
constructor(
|
||||
readonly name: string,
|
||||
readonly isExistingVariable: boolean,
|
||||
) {
|
||||
constructor(readonly name: string, readonly isExistingVariable: boolean) {
|
||||
super();
|
||||
}
|
||||
eq(other: PlaceholderWidget) {
|
||||
@@ -25,19 +23,6 @@ class PlaceholderWidget extends WidgetType {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it
|
||||
*/
|
||||
class BetterMatchDecorator extends MatchDecorator {
|
||||
updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {
|
||||
if (!update.startState.selection.eq(update.state.selection)) {
|
||||
return super.createDeco(update.view);
|
||||
} else {
|
||||
return super.updateDeco(update, deco);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const placeholders = function (variables: { name: string }[]) {
|
||||
const placeholderMatcher = new BetterMatchDecorator({
|
||||
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
|
||||
|
||||
@@ -33,3 +33,7 @@ export function Link({ href, children, className, ...other }: Props) {
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedbackLink() {
|
||||
return <Link href="https://yaak.canny.io">Feedback</Link>;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
|
||||
import { useDebouncedState } from '../../hooks/useDebouncedState';
|
||||
import { useFilterResponse } from '../../hooks/useFilterResponse';
|
||||
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
|
||||
import { useResponseContentType } from '../../hooks/useResponseContentType';
|
||||
import { useToggle } from '../../hooks/useToggle';
|
||||
import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
|
||||
import type { HttpResponse } from '../../lib/models';
|
||||
import { Editor } from '../core/Editor';
|
||||
import { hyperlink } from '../core/Editor/hyperlink/extension';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { Input } from '../core/Input';
|
||||
|
||||
const extraExtensions = [hyperlink];
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
pretty: boolean;
|
||||
@@ -21,7 +24,7 @@ export function TextViewer({ response, pretty }: Props) {
|
||||
const [isSearching, toggleIsSearching] = useToggle();
|
||||
const [filterText, setDebouncedFilterText, setFilterText] = useDebouncedState<string>('', 400);
|
||||
|
||||
const contentType = useResponseContentType(response);
|
||||
const contentType = useContentTypeFromHeaders(response.headers);
|
||||
const rawBody = useResponseBodyText(response) ?? '';
|
||||
const formattedBody =
|
||||
pretty && contentType?.includes('json')
|
||||
@@ -87,6 +90,7 @@ export function TextViewer({ response, pretty }: Props) {
|
||||
defaultValue={body}
|
||||
contentType={contentType}
|
||||
actions={actions}
|
||||
extraExtensions={extraExtensions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { NAMESPACE_GLOBAL } from '../lib/keyValueStore';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useCookieJars } from './useCookieJars';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
@@ -9,7 +8,7 @@ export function useActiveCookieJar() {
|
||||
const cookieJars = useCookieJars();
|
||||
|
||||
const kv = useKeyValue<string | null>({
|
||||
namespace: NAMESPACE_GLOBAL,
|
||||
namespace: 'global',
|
||||
key: ['activeCookieJar', workspaceId ?? 'n/a'],
|
||||
fallback: null,
|
||||
});
|
||||
|
||||
9
src-web/hooks/useContentTypeFromHeaders.ts
Normal file
9
src-web/hooks/useContentTypeFromHeaders.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { HttpHeader } from '../lib/models';
|
||||
|
||||
export function useContentTypeFromHeaders(headers: HttpHeader[] | null): string | null {
|
||||
return useMemo(
|
||||
() => headers?.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null,
|
||||
[headers],
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NAMESPACE_GLOBAL } from '../lib/keyValueStore';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
|
||||
export function protoFilesArgs(requestId: string | null) {
|
||||
return {
|
||||
namespace: NAMESPACE_GLOBAL,
|
||||
namespace: 'global' as const,
|
||||
key: ['proto_files', requestId ?? 'n/a'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,10 +80,14 @@ export function useIntrospectGraphQL(baseRequest: HttpRequest) {
|
||||
setRefetchKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
const schema = useMemo(
|
||||
() => (introspection ? buildClientSchema(introspection) : undefined),
|
||||
[introspection],
|
||||
);
|
||||
const schema = useMemo(() => {
|
||||
try {
|
||||
return introspection ? buildClientSchema(introspection) : undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
setError('message' in e ? e.message : String(e));
|
||||
}
|
||||
}, [introspection]);
|
||||
|
||||
return { schema, isLoading, error, refetch };
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useKeyValue<T extends Object | null>({
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
namespace?: string;
|
||||
namespace?: 'app' | 'no_sync' | 'global';
|
||||
key: string | string[];
|
||||
fallback: T;
|
||||
}) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore';
|
||||
import { getKeyValue } from '../lib/keyValueStore';
|
||||
import { useActiveEnvironmentId } from './useActiveEnvironmentId';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useEnvironments } from './useEnvironments';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
|
||||
const kvKey = (workspaceId: string) => 'recent_environments::' + workspaceId;
|
||||
const namespace = NAMESPACE_GLOBAL;
|
||||
const namespace = 'global';
|
||||
const fallback: string[] = [];
|
||||
|
||||
export function useRecentEnvironments() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore';
|
||||
import { getKeyValue } from '../lib/keyValueStore';
|
||||
import { useActiveRequestId } from './useActiveRequestId';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useGrpcRequests } from './useGrpcRequests';
|
||||
@@ -7,7 +7,7 @@ import { useHttpRequests } from './useHttpRequests';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
|
||||
const kvKey = (workspaceId: string) => 'recent_requests::' + workspaceId;
|
||||
const namespace = NAMESPACE_GLOBAL;
|
||||
const namespace = 'global';
|
||||
const fallback: string[] = [];
|
||||
|
||||
export function useRecentRequests() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { getKeyValue, NAMESPACE_GLOBAL } from '../lib/keyValueStore';
|
||||
import { getKeyValue } from '../lib/keyValueStore';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
import { useWorkspaces } from './useWorkspaces';
|
||||
|
||||
const kvKey = () => 'recent_workspaces';
|
||||
const namespace = NAMESPACE_GLOBAL;
|
||||
const namespace = 'global';
|
||||
const fallback: string[] = [];
|
||||
|
||||
export function useRecentWorkspaces() {
|
||||
@@ -25,7 +25,7 @@ export function useRecentWorkspaces() {
|
||||
return [activeWorkspaceId, ...withoutCurrent];
|
||||
}).catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [activeWorkspaceId]);
|
||||
|
||||
const onlyValidIds = useMemo(
|
||||
() => kv.value?.filter((id) => workspaces.some((w) => w.id === id)) ?? [],
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { HttpResponse } from '../lib/models';
|
||||
|
||||
export function useResponseContentType(response: HttpResponse | null): string | null {
|
||||
return useMemo(
|
||||
() => response?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? null,
|
||||
[response],
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
|
||||
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
|
||||
import { useKeyValue } from './useKeyValue';
|
||||
|
||||
export function useSidebarHidden() {
|
||||
const activeWorkspaceId = useActiveWorkspaceId();
|
||||
const { set, value } = useKeyValue<boolean>({
|
||||
namespace: NAMESPACE_NO_SYNC,
|
||||
namespace: 'no_sync',
|
||||
key: ['sidebar_hidden', activeWorkspaceId ?? 'n/a'],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import type { KeyValue } from './models';
|
||||
|
||||
export const NAMESPACE_GLOBAL = 'global';
|
||||
export const NAMESPACE_NO_SYNC = 'no_sync';
|
||||
|
||||
export async function setKeyValue<T>({
|
||||
namespace = NAMESPACE_GLOBAL,
|
||||
namespace = 'global',
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
@@ -21,7 +18,7 @@ export async function setKeyValue<T>({
|
||||
}
|
||||
|
||||
export async function getKeyValue<T>({
|
||||
namespace = NAMESPACE_GLOBAL,
|
||||
namespace = 'global',
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const BODY_TYPE_NONE = null;
|
||||
export const BODY_TYPE_GRAPHQL = 'graphql';
|
||||
export const BODY_TYPE_JSON = 'application/json';
|
||||
export const BODY_TYPE_BINARY = 'binary';
|
||||
export const BODY_TYPE_OTHER = 'other';
|
||||
export const BODY_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded';
|
||||
export const BODY_TYPE_FORM_MULTIPART = 'multipart/form-data';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { appWindow } from '@tauri-apps/api/window';
|
||||
import { NAMESPACE_NO_SYNC, getKeyValue, setKeyValue } from './keyValueStore';
|
||||
import { getKeyValue, setKeyValue } from './keyValueStore';
|
||||
|
||||
const key = ['window_pathname', appWindow.label];
|
||||
const namespace = NAMESPACE_NO_SYNC;
|
||||
const namespace = 'no_sync';
|
||||
const fallback = undefined;
|
||||
|
||||
export async function setPathname(value: string) {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
|
||||
a,
|
||||
a * {
|
||||
a[href] * {
|
||||
@apply cursor-pointer !important;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
iframe {
|
||||
&::-webkit-scrollbar-corner,
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
Reference in New Issue
Block a user