mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-06 05:19:05 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8ec36474 | ||
|
|
52d1602d35 | ||
|
|
e5731ceb1f | ||
|
|
3ed5a47a83 | ||
|
|
262a29ca5d | ||
|
|
4a3e599128 | ||
|
|
7ebe844643 | ||
|
|
a49b72eebc | ||
|
|
bba3afa0b7 | ||
|
|
221e768b33 | ||
|
|
c2dc7e0f4a | ||
|
|
9e065c34ee | ||
|
|
2f91d541c5 | ||
|
|
948fd487ab | ||
|
|
ed6a5386a2 | ||
|
|
8a24c48fd3 | ||
|
|
d726a6f5bf | ||
|
|
8d2a2a8532 | ||
|
|
b838a6ffc1 |
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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use log::{debug, warn};
|
||||
use log::{warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::types::JsonValue;
|
||||
@@ -182,7 +182,7 @@ pub async fn track_event(
|
||||
|
||||
// Disable analytics actual sending in dev
|
||||
if is_dev() {
|
||||
debug!("track: {} {} {:?}", event, attributes_json, params);
|
||||
// debug!("track: {} {} {:?}", event, attributes_json, params);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,21 @@ 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") {
|
||||
@@ -253,12 +268,13 @@ pub async fn send_http_request(
|
||||
.unwrap_or(empty_bool)
|
||||
.as_bool()
|
||||
.unwrap_or(false);
|
||||
let name = p
|
||||
let name_raw = p
|
||||
.get("name")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
if !enabled || name.is_empty() {
|
||||
|
||||
if !enabled || name_raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -267,24 +283,40 @@ pub async fn send_http_request(
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
let value = p
|
||||
let value_raw = p
|
||||
.get("value")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
multipart_form = multipart_form.part(
|
||||
render::render(name, &workspace, environment_ref),
|
||||
match !file.is_empty() {
|
||||
true => {
|
||||
multipart::Part::bytes(fs::read(file).map_err(|e| e.to_string())?)
|
||||
|
||||
let name = render::render(name_raw, &workspace, environment_ref);
|
||||
let part = if file.is_empty() {
|
||||
multipart::Part::text(render::render(
|
||||
value_raw,
|
||||
&workspace,
|
||||
environment_ref,
|
||||
))
|
||||
} else {
|
||||
match fs::read(file) {
|
||||
Ok(f) => multipart::Part::bytes(f),
|
||||
Err(e) => {
|
||||
return response_err(response, e.to_string(), window).await;
|
||||
}
|
||||
false => multipart::Part::text(render::render(
|
||||
value,
|
||||
&workspace,
|
||||
environment_ref,
|
||||
)),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let ct_raw = p
|
||||
.get("contentType")
|
||||
.unwrap_or(empty_string)
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
|
||||
multipart_form = multipart_form.part(name, if ct_raw.is_empty() {
|
||||
part
|
||||
} else {
|
||||
let ct = render::render(ct_raw, &workspace, environment_ref);
|
||||
part.mime_str(ct.as_str()).map_err(|e| e.to_string())?
|
||||
});
|
||||
}
|
||||
}
|
||||
headers.remove("Content-Type"); // reqwest will add this automatically
|
||||
@@ -307,11 +339,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() => {
|
||||
|
||||
@@ -42,21 +42,7 @@ use window_ext::TrafficLightWindowExt;
|
||||
use crate::analytics::{AnalyticsAction, AnalyticsResource};
|
||||
use crate::grpc::metadata_to_map;
|
||||
use crate::http::send_http_request;
|
||||
use crate::models::{
|
||||
cancel_pending_grpc_connections, cancel_pending_responses, CookieJar,
|
||||
create_http_response, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar,
|
||||
delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request,
|
||||
delete_http_request, delete_http_response, delete_workspace, duplicate_grpc_request,
|
||||
duplicate_http_request, Environment, EnvironmentVariable, Folder, get_cookie_jar,
|
||||
get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request,
|
||||
get_http_response, get_key_value_raw, get_or_create_settings, get_workspace,
|
||||
get_workspace_export_resources, GrpcConnection, GrpcEvent, GrpcEventType, GrpcRequest,
|
||||
HttpRequest, HttpResponse, KeyValue, list_cookie_jars, list_environments,
|
||||
list_folders, list_grpc_connections, list_grpc_events, list_grpc_requests,
|
||||
list_requests, list_responses, list_workspaces, set_key_value_raw, Settings,
|
||||
update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection,
|
||||
upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, Workspace, WorkspaceExportResources,
|
||||
};
|
||||
use crate::models::{cancel_pending_grpc_connections, cancel_pending_responses, CookieJar, create_http_response, delete_all_grpc_connections, delete_all_http_responses, delete_cookie_jar, delete_environment, delete_folder, delete_grpc_connection, delete_grpc_request, delete_http_request, delete_http_response, delete_workspace, duplicate_grpc_request, duplicate_http_request, Environment, EnvironmentVariable, Folder, get_cookie_jar, get_environment, get_folder, get_grpc_connection, get_grpc_request, get_http_request, get_http_response, get_key_value_raw, get_or_create_settings, get_workspace, get_workspace_export_resources, GrpcConnection, GrpcEvent, GrpcEventType, GrpcRequest, HttpRequest, HttpRequestHeader, HttpResponse, KeyValue, list_cookie_jars, list_environments, list_folders, list_grpc_connections, list_grpc_events, list_grpc_requests, list_requests, list_responses, list_workspaces, set_key_value_raw, Settings, update_response_if_id, update_settings, upsert_cookie_jar, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, Workspace, WorkspaceExportResources};
|
||||
use crate::plugin::ImportResult;
|
||||
use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater};
|
||||
|
||||
@@ -1054,6 +1040,7 @@ async fn cmd_create_http_request(
|
||||
sort_priority: f64,
|
||||
folder_id: Option<&str>,
|
||||
method: Option<&str>,
|
||||
headers: Option<Vec<HttpRequestHeader>>,
|
||||
body_type: Option<&str>,
|
||||
w: Window,
|
||||
) -> Result<HttpRequest, String> {
|
||||
@@ -1065,6 +1052,7 @@ async fn cmd_create_http_request(
|
||||
folder_id: folder_id.map(|s| s.to_string()),
|
||||
body_type: body_type.map(|s| s.to_string()),
|
||||
method: method.map(|s| s.to_string()).unwrap_or("GET".to_string()),
|
||||
headers: Json(headers.unwrap_or_default()),
|
||||
sort_priority,
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Yaak",
|
||||
"version": "2024.3.2"
|
||||
"version": "2024.3.6"
|
||||
},
|
||||
"tauri": {
|
||||
"windows": [],
|
||||
@@ -28,7 +28,7 @@
|
||||
"scope": [
|
||||
"$RESOURCE/*",
|
||||
"$APPDATA/responses/*"
|
||||
]
|
||||
]
|
||||
},
|
||||
"shell": {
|
||||
"all": false,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createBrowserRouter, Navigate, Outlet, RouterProvider, useParams } from 'react-router-dom';
|
||||
import { createBrowserRouter, Navigate, RouterProvider, useParams } from 'react-router-dom';
|
||||
import { routePaths, useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { DialogProvider } from './DialogContext';
|
||||
import { GlobalHooks } from './GlobalHooks';
|
||||
import { DefaultLayout } from './DefaultLayout';
|
||||
import { RedirectToLatestWorkspace } from './RedirectToLatestWorkspace';
|
||||
import RouteError from './RouteError';
|
||||
import Workspace from './Workspace';
|
||||
import { RedirectToLatestWorkspace } from './RedirectToLatestWorkspace';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -58,7 +57,7 @@ function RedirectLegacyEnvironmentURLs() {
|
||||
}>();
|
||||
const environmentId = rawEnvironmentId === '__default__' ? undefined : rawEnvironmentId;
|
||||
|
||||
let to = '/';
|
||||
let to;
|
||||
if (workspaceId != null && requestId != null) {
|
||||
to = routes.paths.request({ workspaceId, environmentId, requestId });
|
||||
} else if (workspaceId != null) {
|
||||
@@ -69,12 +68,3 @@ function RedirectLegacyEnvironmentURLs() {
|
||||
|
||||
return <Navigate to={to} />;
|
||||
}
|
||||
|
||||
function DefaultLayout() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<Outlet />
|
||||
<GlobalHooks />
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
12
src-web/components/DefaultLayout.tsx
Normal file
12
src-web/components/DefaultLayout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { DialogProvider } from './DialogContext';
|
||||
import { GlobalHooks } from './GlobalHooks';
|
||||
|
||||
export function DefaultLayout() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<Outlet />
|
||||
<GlobalHooks />
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
|
||||
import { useDeleteEnvironment } from '../hooks/useDeleteEnvironment';
|
||||
import { useEnvironments } from '../hooks/useEnvironments';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
import { useUpdateEnvironment } from '../hooks/useUpdateEnvironment';
|
||||
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
|
||||
@@ -59,14 +60,16 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
|
||||
<SidebarButton
|
||||
active={selectedEnvironment?.id == null}
|
||||
onClick={() => setSelectedEnvironmentId(null)}
|
||||
className="group"
|
||||
environment={null}
|
||||
rightSlot={
|
||||
<IconButton
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
color="custom"
|
||||
title="Add sub environment"
|
||||
icon="plusCircle"
|
||||
iconClassName="text-gray-500 group-hover:text-gray-700"
|
||||
className="group"
|
||||
onClick={handleCreateEnvironment}
|
||||
/>
|
||||
}
|
||||
@@ -113,6 +116,11 @@ const EnvironmentEditor = function ({
|
||||
workspace: Workspace;
|
||||
className?: string;
|
||||
}) {
|
||||
const valueVisibility = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: 'environmentValueVisibility',
|
||||
fallback: true,
|
||||
});
|
||||
const environments = useEnvironments();
|
||||
const updateEnvironment = useUpdateEnvironment(environment?.id ?? null);
|
||||
const updateWorkspace = useUpdateWorkspace(workspace.id);
|
||||
@@ -164,15 +172,26 @@ const EnvironmentEditor = function ({
|
||||
return (
|
||||
<VStack space={4} className={classNames(className, 'pl-4')}>
|
||||
<HStack space={2} className="justify-between">
|
||||
<Heading className="w-full flex items-center">
|
||||
<Heading className="w-full flex items-center gap-1">
|
||||
<div>{environment?.name ?? 'Global Variables'}</div>
|
||||
<IconButton
|
||||
iconClassName="text-gray-600"
|
||||
size="sm"
|
||||
icon={valueVisibility.value ? 'eye' : 'eyeClosed'}
|
||||
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}
|
||||
onClick={() => {
|
||||
return valueVisibility.set((v) => !v);
|
||||
}}
|
||||
/>
|
||||
</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}
|
||||
@@ -216,8 +235,8 @@ function SidebarButton({
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center',
|
||||
'px-1', // Padding to show focus border
|
||||
'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-0.5',
|
||||
'px-2', // Padding to show focus border
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
@@ -225,7 +244,7 @@ function SidebarButton({
|
||||
size="xs"
|
||||
className={classNames(
|
||||
'w-full',
|
||||
active ? 'text-gray-800' : 'text-gray-600 hover:text-gray-700',
|
||||
active ? 'text-gray-800 bg-highlightSecondary' : 'text-gray-600 hover:text-gray-700',
|
||||
)}
|
||||
justify="start"
|
||||
onClick={onClick}
|
||||
|
||||
@@ -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) {
|
||||
@@ -16,6 +16,7 @@ export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {
|
||||
enabled: p.enabled,
|
||||
name: p.name,
|
||||
value: p.file ?? p.value,
|
||||
contentType: p.contentType,
|
||||
isFile: !!p.file,
|
||||
})),
|
||||
[body.form],
|
||||
@@ -27,6 +28,7 @@ export function FormMultipartEditor({ body, forceUpdateKey, onChange }: Props) {
|
||||
form: pairs.map((p) => ({
|
||||
enabled: p.enabled,
|
||||
name: p.name,
|
||||
contentType: p.contentType,
|
||||
file: p.isFile ? p.value : undefined,
|
||||
value: p.isFile ? undefined : p.value,
|
||||
})),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { appWindow } from '@tauri-apps/api/window';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
|
||||
import { useGlobalCommands } from '../hooks/useGlobalCommands';
|
||||
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
|
||||
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
|
||||
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
|
||||
@@ -18,7 +19,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';
|
||||
@@ -33,8 +33,8 @@ export function GlobalHooks() {
|
||||
useRecentRequests();
|
||||
|
||||
useSyncAppearance();
|
||||
|
||||
useSyncWindowTitle();
|
||||
useGlobalCommands();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { wasUpdatedExternally } = useRequestUpdateKey(null);
|
||||
@@ -142,7 +142,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;
|
||||
};
|
||||
|
||||
@@ -195,7 +195,6 @@ export function GrpcConnectionSetupPane({
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
extraItems={[
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Refresh',
|
||||
type: 'default',
|
||||
|
||||
@@ -3,17 +3,18 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { getRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { getRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
|
||||
export function RedirectToLatestWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const routes = useAppRoutes();
|
||||
const workspaces = useWorkspaces();
|
||||
const recentWorkspaces = useRecentWorkspaces();
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
const workspaceId = (await getRecentWorkspaces())[0] ?? workspaces[0]?.id ?? 'n/a';
|
||||
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? 'n/a';
|
||||
const environmentId = (await getRecentEnvironments(workspaceId))[0];
|
||||
const requestId = (await getRecentRequests(workspaceId))[0];
|
||||
|
||||
@@ -23,7 +24,7 @@ export function RedirectToLatestWorkspace() {
|
||||
navigate(routes.paths.workspace({ workspaceId, environmentId }));
|
||||
}
|
||||
})();
|
||||
}, [navigate, routes.paths, workspaces, workspaces.length]);
|
||||
}, [navigate, recentWorkspaces, routes.paths, workspaces, workspaces.length]);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -420,6 +419,19 @@ export function Sidebar({ className }: Props) {
|
||||
],
|
||||
);
|
||||
|
||||
const [showMainContextMenu, setShowMainContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMainContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowMainContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const mainContextMenuItems = useCreateDropdownItems();
|
||||
|
||||
// Not ready to render yet
|
||||
if (tree == null || collapsed.value == null) {
|
||||
return null;
|
||||
@@ -432,11 +444,17 @@ export function Sidebar({ className }: Props) {
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={hidden ? -1 : 0}
|
||||
onContextMenu={handleMainContextMenu}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-full pb-3 overflow-y-scroll overflow-x-visible hide-scrollbars pt-2',
|
||||
)}
|
||||
>
|
||||
<ContextMenu
|
||||
show={showMainContextMenu}
|
||||
items={mainContextMenuItems}
|
||||
onClose={() => setShowMainContextMenu(null)}
|
||||
/>
|
||||
<SidebarItems
|
||||
treeParentMap={treeParentMap}
|
||||
selectedId={selectedId}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -3,7 +3,7 @@ import classNames from 'classnames';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useCommand } from '../hooks/useCommands';
|
||||
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
|
||||
import { usePrompt } from '../hooks/usePrompt';
|
||||
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
@@ -30,7 +30,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
||||
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
||||
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
|
||||
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
|
||||
const createWorkspace = useCreateWorkspace({ navigateAfter: true });
|
||||
const createWorkspace = useCommand('workspace.create');
|
||||
const dialog = useDialog();
|
||||
const prompt = usePrompt();
|
||||
const routes = useAppRoutes();
|
||||
@@ -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;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
@@ -57,7 +56,7 @@ export interface EditorProps {
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
{
|
||||
readOnly,
|
||||
type = 'text',
|
||||
@@ -179,7 +178,9 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
doc: `${defaultValue ?? ''}`,
|
||||
extensions: [
|
||||
languageCompartment.of(langExt),
|
||||
placeholderCompartment.current.of([]),
|
||||
placeholderCompartment.current.of(
|
||||
placeholderExt(placeholderElFromText(placeholder ?? '')),
|
||||
),
|
||||
wrapLinesCompartment.current.of([]),
|
||||
...getExtensions({
|
||||
container,
|
||||
@@ -293,8 +294,6 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
);
|
||||
});
|
||||
|
||||
export const Editor = memo(_Editor);
|
||||
|
||||
function getExtensions({
|
||||
container,
|
||||
readOnly,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import classNames from 'classnames';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useStateSyncDefault } from '../../hooks/useStateSyncDefault';
|
||||
import type { EditorProps } from './Editor';
|
||||
import { Editor } from './Editor';
|
||||
import { IconButton } from './IconButton';
|
||||
@@ -69,7 +70,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
}: InputProps,
|
||||
ref,
|
||||
) {
|
||||
const [obscured, setObscured] = useState(type === 'password');
|
||||
const [obscured, setObscured] = useStateSyncDefault(type === 'password');
|
||||
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
@@ -181,9 +182,10 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
<IconButton
|
||||
title={obscured ? `Show ${label}` : `Obscure ${label}`}
|
||||
size="xs"
|
||||
className="mr-0.5"
|
||||
className="mr-0.5 group/obscure !h-auto my-0.5"
|
||||
iconClassName="text-gray-500 group-hover/obscure:text-gray-800"
|
||||
iconSize="sm"
|
||||
icon={obscured ? 'eyeClosed' : 'eye'}
|
||||
icon={obscured ? 'eye' : 'eyeClosed'}
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } fro
|
||||
import type { XYCoord } from 'react-dnd';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { usePrompt } from '../../hooks/usePrompt';
|
||||
import { DropMarker } from '../DropMarker';
|
||||
import { Button } from './Button';
|
||||
import { Checkbox } from './Checkbox';
|
||||
@@ -14,6 +15,7 @@ import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { InputProps } from './Input';
|
||||
import { Input } from './Input';
|
||||
import { RadioDropdown } from './RadioDropdown';
|
||||
|
||||
export type PairEditorProps = {
|
||||
pairs: Pair[];
|
||||
@@ -22,6 +24,7 @@ export type PairEditorProps = {
|
||||
className?: string;
|
||||
namePlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
valueType?: 'text' | 'password';
|
||||
nameAutocomplete?: GenericCompletionConfig;
|
||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||
nameAutocompleteVariables?: boolean;
|
||||
@@ -36,6 +39,7 @@ export type Pair = {
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
contentType?: string;
|
||||
isFile?: boolean;
|
||||
};
|
||||
|
||||
@@ -51,6 +55,7 @@ export const PairEditor = memo(function PairEditor({
|
||||
nameAutocompleteVariables,
|
||||
namePlaceholder,
|
||||
nameValidate,
|
||||
valueType,
|
||||
onChange,
|
||||
pairs: originalPairs,
|
||||
valueAutocomplete,
|
||||
@@ -176,6 +181,7 @@ export const PairEditor = memo(function PairEditor({
|
||||
allowFileValues={allowFileValues}
|
||||
nameAutocompleteVariables={nameAutocompleteVariables}
|
||||
valueAutocompleteVariables={valueAutocompleteVariables}
|
||||
valueType={valueType}
|
||||
forceFocusPairId={forceFocusPairId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
@@ -218,6 +224,7 @@ type FormRowProps = {
|
||||
| 'valueAutocomplete'
|
||||
| 'nameAutocompleteVariables'
|
||||
| 'valueAutocompleteVariables'
|
||||
| 'valueType'
|
||||
| 'namePlaceholder'
|
||||
| 'valuePlaceholder'
|
||||
| 'nameValidate'
|
||||
@@ -246,9 +253,11 @@ const FormRow = memo(function FormRow({
|
||||
valueAutocompleteVariables,
|
||||
valuePlaceholder,
|
||||
valueValidate,
|
||||
valueType,
|
||||
}: FormRowProps) {
|
||||
const { id } = pairContainer;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const prompt = usePrompt();
|
||||
const nameInputRef = useRef<EditorView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -278,6 +287,11 @@ const FormRow = memo(function FormRow({
|
||||
[onChange, id, pairContainer.pair],
|
||||
);
|
||||
|
||||
const handleChangeValueContentType = useMemo(
|
||||
() => (contentType: string) => onChange({ id, pair: { ...pairContainer.pair, contentType } }),
|
||||
[onChange, id, pairContainer.pair],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => onFocus?.(pairContainer), [onFocus, pairContainer]);
|
||||
const handleDelete = useCallback(
|
||||
() => onDelete?.(pairContainer, false),
|
||||
@@ -397,39 +411,73 @@ const FormRow = memo(function FormRow({
|
||||
name="value"
|
||||
onChange={handleChangeValueText}
|
||||
onFocus={handleFocus}
|
||||
type={isLast ? 'text' : valueType}
|
||||
placeholder={valuePlaceholder ?? 'value'}
|
||||
autocomplete={valueAutocomplete?.(pairContainer.pair.name)}
|
||||
autocompleteVariables={valueAutocompleteVariables}
|
||||
/>
|
||||
)}
|
||||
{allowFileValues && (
|
||||
<Dropdown
|
||||
items={[
|
||||
{ key: 'text', label: 'Text', onSelect: () => handleChangeValueText('') },
|
||||
{ key: 'file', label: 'File', onSelect: () => handleChangeValueFile('') },
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
icon={isLast ? 'empty' : 'chevronDown'}
|
||||
title="Select form data type"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
aria-hidden={isLast}
|
||||
disabled={isLast}
|
||||
color="custom"
|
||||
icon={!isLast ? 'trash' : 'empty'}
|
||||
size="sm"
|
||||
iconSize="sm"
|
||||
title="Delete header"
|
||||
onClick={!isLast ? handleDelete : undefined}
|
||||
className="ml-0.5 opacity-0 group-hover:!opacity-100 focus-visible:!opacity-100"
|
||||
/>
|
||||
{allowFileValues ? (
|
||||
<RadioDropdown
|
||||
value={pairContainer.pair.isFile ? 'file' : 'text'}
|
||||
onChange={(v) => {
|
||||
if (v === 'file') handleChangeValueFile('');
|
||||
else handleChangeValueText('');
|
||||
}}
|
||||
items={[
|
||||
{ label: 'Text', value: 'text' },
|
||||
{ label: 'File', value: 'file' },
|
||||
]}
|
||||
extraItems={[
|
||||
{
|
||||
key: 'mime',
|
||||
label: 'Set Content-Type',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
onSelect: async () => {
|
||||
const v = await prompt({
|
||||
id: 'content-type',
|
||||
require: false,
|
||||
title: 'Override Content-Type',
|
||||
label: 'Content-Type',
|
||||
placeholder: 'text/plain',
|
||||
defaultValue: pairContainer.pair.contentType ?? '',
|
||||
name: 'content-type',
|
||||
confirmLabel: 'Set',
|
||||
description: 'Leave blank to auto-detect',
|
||||
});
|
||||
handleChangeValueContentType(v);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
onSelect: handleDelete,
|
||||
variant: 'danger',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
icon={isLast ? 'empty' : 'chevronDown'}
|
||||
title="Select form data type"
|
||||
/>
|
||||
</RadioDropdown>
|
||||
) : (
|
||||
<Dropdown
|
||||
items={[{ key: 'delete', label: 'Delete', onSelect: handleDelete, variant: 'danger' }]}
|
||||
>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
icon={isLast ? 'empty' : 'chevronDown'}
|
||||
title="Select form data type"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DropdownItemSeparator, DropdownProps } from './Dropdown';
|
||||
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from './Dropdown';
|
||||
import { Dropdown } from './Dropdown';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
@@ -42,7 +42,7 @@ export function RadioDropdown<T = string | null>({
|
||||
};
|
||||
}
|
||||
}),
|
||||
...(extraItems ?? []),
|
||||
...((extraItems ? [{ type: 'separator' }, ...extraItems] : []) as DropdownItem[]),
|
||||
],
|
||||
[items, extraItems, value, onChange],
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface PromptProps {
|
||||
name: InputProps['name'];
|
||||
defaultValue: InputProps['defaultValue'];
|
||||
placeholder: InputProps['placeholder'];
|
||||
require?: InputProps['require'];
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@ export function Prompt({
|
||||
defaultValue,
|
||||
placeholder,
|
||||
onResult,
|
||||
require = true,
|
||||
confirmLabel = 'Save',
|
||||
}: PromptProps) {
|
||||
const [value, setValue] = useState<string>(defaultValue ?? '');
|
||||
@@ -41,7 +43,7 @@ export function Prompt({
|
||||
>
|
||||
<Input
|
||||
hideLabel
|
||||
require
|
||||
require={require}
|
||||
autoSelect
|
||||
placeholder={placeholder}
|
||||
label={label}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
41
src-web/hooks/useCommands.ts
Normal file
41
src-web/hooks/useCommands.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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 });
|
||||
}
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,12 @@ export function useCreateDropdownItems({
|
||||
label: 'GraphQL Query',
|
||||
leftSlot: hideIcons ? undefined : <Icon icon="plus" />,
|
||||
onSelect: () =>
|
||||
createHttpRequest.mutate({ folderId, bodyType: BODY_TYPE_GRAPHQL, method: 'POST' }),
|
||||
createHttpRequest.mutate({
|
||||
folderId,
|
||||
bodyType: BODY_TYPE_GRAPHQL,
|
||||
method: 'POST',
|
||||
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'create-grpc-request',
|
||||
|
||||
@@ -16,7 +16,9 @@ export function useCreateHttpRequest() {
|
||||
return useMutation<
|
||||
HttpRequest,
|
||||
unknown,
|
||||
Partial<Pick<HttpRequest, 'name' | 'sortPriority' | 'folderId' | 'bodyType' | 'method'>>
|
||||
Partial<
|
||||
Pick<HttpRequest, 'name' | 'sortPriority' | 'folderId' | 'bodyType' | 'method' | 'headers'>
|
||||
>
|
||||
>({
|
||||
mutationFn: (patch) => {
|
||||
if (workspaceId === null) {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type { Workspace } from '../lib/models';
|
||||
import { useAppRoutes } from './useAppRoutes';
|
||||
import { useRegisterCommand } from './useCommands';
|
||||
import { usePrompt } from './usePrompt';
|
||||
|
||||
export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }) {
|
||||
const routes = useAppRoutes();
|
||||
export function useGlobalCommands() {
|
||||
const prompt = usePrompt();
|
||||
return useMutation<Workspace, unknown, Partial<Pick<Workspace, 'name'>>>({
|
||||
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 ??
|
||||
@@ -23,11 +27,5 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
|
||||
}));
|
||||
return invoke('cmd_create_workspace', { name });
|
||||
},
|
||||
onSettled: () => trackEvent('workspace', 'create'),
|
||||
onSuccess: async (workspace) => {
|
||||
if (navigateAfter) {
|
||||
routes.navigate('workspace', { workspaceId: workspace.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -14,6 +14,7 @@ export function usePrompt() {
|
||||
defaultValue,
|
||||
placeholder,
|
||||
confirmLabel,
|
||||
require,
|
||||
}: Pick<DialogProps, 'title' | 'description'> &
|
||||
Omit<PromptProps, 'onResult' | 'onHide'> & { id: string }) =>
|
||||
new Promise((onResult: PromptProps['onResult']) => {
|
||||
@@ -24,7 +25,16 @@ export function usePrompt() {
|
||||
hideX: true,
|
||||
size: 'sm',
|
||||
render: ({ hide }) =>
|
||||
Prompt({ onHide: hide, onResult, name, label, defaultValue, placeholder, confirmLabel }),
|
||||
Prompt({
|
||||
onHide: hide,
|
||||
onResult,
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
confirmLabel,
|
||||
require,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
12
src-web/hooks/useStateSyncDefault.ts
Normal file
12
src-web/hooks/useStateSyncDefault.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Like useState, except it will update the value when the default value changes
|
||||
*/
|
||||
export function useStateSyncDefault<T>(defaultValue: T) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
useEffect(() => {
|
||||
setValue(defaultValue);
|
||||
}, [defaultValue]);
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
@@ -1,35 +1,38 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
|
||||
export function trackEvent(
|
||||
resource:
|
||||
| 'app'
|
||||
| 'cookie_jar'
|
||||
| 'dialog'
|
||||
| 'environment'
|
||||
| 'folder'
|
||||
| 'grpc_connection'
|
||||
| 'grpc_event'
|
||||
| 'grpc_request'
|
||||
| 'http_request'
|
||||
| 'http_response'
|
||||
| 'key_value'
|
||||
| 'setting'
|
||||
| 'sidebar'
|
||||
| 'workspace',
|
||||
action:
|
||||
| 'cancel'
|
||||
| 'commit'
|
||||
| 'create'
|
||||
| 'delete'
|
||||
| 'delete_many'
|
||||
| 'duplicate'
|
||||
| 'hide'
|
||||
| 'launch'
|
||||
| 'send'
|
||||
| 'show'
|
||||
| 'toggle'
|
||||
| 'update',
|
||||
export type TrackResource =
|
||||
| 'app'
|
||||
| 'cookie_jar'
|
||||
| 'dialog'
|
||||
| 'environment'
|
||||
| 'folder'
|
||||
| 'grpc_connection'
|
||||
| 'grpc_event'
|
||||
| 'grpc_request'
|
||||
| 'http_request'
|
||||
| 'http_response'
|
||||
| 'key_value'
|
||||
| 'setting'
|
||||
| 'sidebar'
|
||||
| 'workspace';
|
||||
|
||||
export type TrackAction =
|
||||
| 'cancel'
|
||||
| 'commit'
|
||||
| 'create'
|
||||
| 'delete'
|
||||
| 'delete_many'
|
||||
| 'duplicate'
|
||||
| 'hide'
|
||||
| 'launch'
|
||||
| 'send'
|
||||
| 'show'
|
||||
| 'toggle'
|
||||
| 'update';
|
||||
|
||||
export function trackEvent(
|
||||
resource: TrackResource,
|
||||
action: TrackAction,
|
||||
attributes: Record<string, string | number> = {},
|
||||
) {
|
||||
invoke('cmd_track_event', {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-selection;
|
||||
::selection,
|
||||
.cm-selectionBackground {
|
||||
@apply bg-selection !important;
|
||||
}
|
||||
|
||||
/* Disable user selection to make it more "app-like" */
|
||||
@@ -29,7 +30,7 @@
|
||||
}
|
||||
|
||||
a,
|
||||
a * {
|
||||
a[href] * {
|
||||
@apply cursor-pointer !important;
|
||||
}
|
||||
|
||||
@@ -65,6 +66,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
iframe {
|
||||
&::-webkit-scrollbar-corner,
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
Reference in New Issue
Block a user