GraphQL autocomplete and duplicate request

This commit is contained in:
Gregory Schier
2023-03-21 23:54:45 -07:00
parent 9b8961c23d
commit 168dfb9f6b
31 changed files with 299 additions and 157 deletions

View File

@@ -58,25 +58,24 @@ async fn migrate_db(
}
#[tauri::command]
async fn send_request(
async fn send_ephemeral_request(
request: models::HttpRequest,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<String, String> {
) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await;
let response = models::HttpResponse::default();
return actually_send_ephemeral_request(request, response, app_handle, pool).await;
}
let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let mut response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
.await
.expect("Failed to create response");
app_handle.emit_all("updated_response", &response).unwrap();
async fn actually_send_ephemeral_request(
request: models::HttpRequest,
mut response: models::HttpResponse,
app_handle: AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<models::HttpResponse, String> {
let start = std::time::Instant::now();
let mut url_string = req.url.to_string();
let mut url_string = request.url.to_string();
let mut variables = HashMap::new();
variables.insert("PROJECT_ID", "project_123");
@@ -108,7 +107,7 @@ async fn send_request(
headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
for h in req.headers.0 {
for h in request.headers.0 {
if h.name.is_empty() && h.value.is_empty() {
continue;
}
@@ -133,10 +132,10 @@ async fn send_request(
}
let m =
Method::from_bytes(req.method.to_uppercase().as_bytes()).expect("Failed to create method");
Method::from_bytes(request.method.to_uppercase().as_bytes()).expect("Failed to create method");
let builder = client.request(m, url_string.to_string()).headers(headers);
let sendable_req_result = match (req.body, req.body_type) {
let sendable_req_result = match (request.body, request.body_type) {
(Some(b), Some(_)) => builder.body(b).build(),
_ => builder.build(),
};
@@ -173,28 +172,49 @@ async fn send_request(
response.url = v.url().to_string();
response.body = v.text().await.expect("Failed to get body");
response.elapsed = start.elapsed().as_millis() as i64;
response = models::update_response(response, pool)
response = models::update_response_if_id(response, pool)
.await
.expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id)
Ok(response)
}
Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
}
}
#[tauri::command]
async fn send_request(
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str,
) -> Result<(), String> {
let pool = &*db_instance.lock().await;
let req = models::get_request(request_id, pool)
.await
.expect("Failed to get request");
let response = models::create_response(&req.id, 0, "", 0, None, "", vec![], pool)
.await
.expect("Failed to create response");
app_handle.emit_all("updated_response", &response).unwrap();
actually_send_ephemeral_request(req, response, app_handle, pool).await?;
Ok(())
}
async fn response_err(
mut response: models::HttpResponse,
error: String,
app_handle: AppHandle<Wry>,
pool: &Pool<Sqlite>,
) -> Result<String, String> {
) -> Result<models::HttpResponse, String> {
response.error = Some(error.clone());
response = models::update_response(response, pool)
response = models::update_response_if_id(response, pool)
.await
.expect("Failed to update response");
app_handle.emit_all("updated_response", &response).unwrap();
Ok(response.id)
Ok(response)
}
#[tauri::command]
@@ -268,6 +288,20 @@ async fn create_request(
Ok(created_request.id)
}
#[tauri::command]
async fn duplicate_request(
id: &str,
app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<String, String> {
let pool = &*db_instance.lock().await;
let request = models::duplicate_request(id, pool).await.expect("Failed to duplicate request");
app_handle
.emit_all("updated_request", &request)
.unwrap();
Ok(request.id)
}
#[tauri::command]
async fn update_request(
request: models::HttpRequest,
@@ -458,7 +492,6 @@ fn main() {
let p = dir.join("db.sqlite");
let p_string = p.to_string_lossy().replace(' ', "%20");
let url = format!("sqlite://{}?mode=rwc", p_string);
println!("DB PATH: {}", p_string);
tauri::async_runtime::block_on(async move {
let pool = SqlitePoolOptions::new()
.connect(url.as_str())
@@ -501,7 +534,7 @@ fn main() {
} else {
event.window().open_devtools();
}
},
}
_ => {}
};
})
@@ -525,6 +558,8 @@ fn main() {
get_request,
requests,
send_request,
send_ephemeral_request,
duplicate_request,
create_request,
create_workspace,
delete_workspace,

View File

@@ -48,7 +48,7 @@ pub struct HttpResponseHeader {
pub value: String,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HttpResponse {
pub id: String,
@@ -180,6 +180,35 @@ pub async fn create_workspace(
get_workspace(&id, pool).await
}
pub async fn duplicate_request(
id: &str,
pool: &Pool<Sqlite>,
) -> Result<HttpRequest, sqlx::Error> {
let existing = get_request(id, pool).await.expect("Failed to get request to duplicate");
// TODO: Figure out how to make this better
let b2;
let body = match existing.body {
Some(b) => {
b2 = b;
Some(b2.as_str())
}
None => None,
};
upsert_request(
None,
existing.workspace_id.as_str(),
existing.name.as_str(),
existing.method.as_str(),
body,
existing.body_type,
existing.url.as_str(),
existing.headers.0,
existing.sort_priority,
pool,
).await
}
pub async fn upsert_request(
id: Option<&str>,
workspace_id: &str,
@@ -350,6 +379,16 @@ pub async fn create_response(
get_response(&id, pool).await
}
pub async fn update_response_if_id(
response: HttpResponse,
pool: &Pool<Sqlite>,
) -> Result<HttpResponse, sqlx::Error> {
if response.id == "" {
return Ok(response);
}
return update_response(response, pool).await;
}
pub async fn update_response(
response: HttpResponse,
pool: &Pool<Sqlite>,

View File

@@ -16,7 +16,6 @@ import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { DEFAULT_FONT_SIZE } from '../lib/constants';
import { extractKeyValue } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, KeyValue, Workspace } from '../lib/models';
import { convertDates } from '../lib/models';
import { AppRouter } from './AppRouter';
const queryClient = new QueryClient({
@@ -52,13 +51,13 @@ await listen('updated_request', ({ payload: request }: { payload: HttpRequest })
for (const r of requests) {
if (r.id === request.id) {
found = true;
newRequests.push(convertDates(request));
newRequests.push(request);
} else {
newRequests.push(r);
}
}
if (!found) {
newRequests.push(convertDates(request));
newRequests.push(request);
}
return newRequests;
},
@@ -74,13 +73,13 @@ await listen('updated_response', ({ payload: response }: { payload: HttpResponse
for (const r of responses) {
if (r.id === response.id) {
found = true;
newResponses.push(convertDates(response));
newResponses.push(response);
} else {
newResponses.push(r);
}
}
if (!found) {
newResponses.push(convertDates(response));
newResponses.push(response);
}
return newResponses;
},
@@ -94,13 +93,13 @@ await listen('updated_workspace', ({ payload: workspace }: { payload: Workspace
for (const w of workspaces) {
if (w.id === workspace.id) {
found = true;
newWorkspaces.push(convertDates(workspace));
newWorkspaces.push(workspace);
} else {
newWorkspaces.push(w);
}
}
if (!found) {
newWorkspaces.push(convertDates(workspace));
newWorkspaces.push(workspace);
}
return newWorkspaces;
});

View File

@@ -1,11 +1,18 @@
import type { Extension } from '@codemirror/state';
import { graphql } from 'cm6-graphql';
import { formatSdl } from 'format-graphql';
import { useMemo } from 'react';
import { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
import { useEffect, useMemo, useState } from 'react';
import { useUniqueKey } from '../hooks/useUniqueKey';
import { Separator } from './core/Separator';
import type { HttpRequest } from '../lib/models';
import { sendEphemeralRequest } from '../lib/sendEphemeralRequest';
import type { EditorProps } from './core/Editor';
import { Editor } from './core/Editor';
import { Separator } from './core/Separator';
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'defaultValue' | 'className'>;
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'defaultValue' | 'className'> & {
baseRequest: HttpRequest;
};
interface GraphQLBody {
query: string;
@@ -13,7 +20,7 @@ interface GraphQLBody {
operationName?: string;
}
export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: Props) {
export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEditorProps }: Props) {
const queryKey = useUniqueKey();
const { query, variables } = useMemo<GraphQLBody>(() => {
if (!defaultValue) {
@@ -46,12 +53,29 @@ export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: P
}
};
const [graphqlExtension, setGraphqlExtension] = useState<Extension>();
useEffect(() => {
const body = JSON.stringify({
query: getIntrospectionQuery(),
operationName: 'IntrospectionQuery',
});
const req: HttpRequest = { ...baseRequest, body, id: '' };
sendEphemeralRequest(req).then((response) => {
console.log('RESPONSE', response.body);
const { data } = JSON.parse(response.body);
const schema = buildClientSchema(data);
setGraphqlExtension(graphql(schema, {}));
});
}, [baseRequest.url]);
return (
<div className="pb-2 h-full grid grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
key={queryKey.key}
heightMode="auto"
defaultValue={query ?? ''}
languageExtension={graphqlExtension}
onChange={handleChangeQuery}
contentType="application/graphql"
placeholder="..."
@@ -59,7 +83,6 @@ export function GraphQLEditor({ defaultValue, onChange, ...extraEditorProps }: P
{...extraEditorProps}
/>
<Separator variant="primary" />
{/*<Separator variant="secondary" />*/}
<p className="pt-1 text-gray-500 text-sm">Variables</p>
<Editor
useTemplating

View File

@@ -39,17 +39,21 @@ const headerOptionsMap: Record<string, string[]> = {
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionConfig['options'] =
headerOptionsMap[name]?.map((o, i) => ({
headerOptionsMap[name]?.map((o) => ({
label: o,
type: 'constant',
boost: 99 - i, // Max boost is 99
boost: 1, // Put above other completions
})) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
minMatch: MIN_MATCH,
options: headerNames.map((t, i) => ({ label: t, type: 'constant', boost: 99 - i })),
options: headerNames.map((t) => ({
label: t,
type: 'constant',
boost: 1, // Put above other completions
})),
};
const validateHttpHeader = (v: string) => {

View File

@@ -1,5 +1,5 @@
import classnames from 'classnames';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useKeyValue } from '../hooks/useKeyValue';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
@@ -23,6 +23,7 @@ export function RequestPane({ fullHeight, className }: Props) {
const activeRequest = useActiveRequest();
const activeRequestId = activeRequest?.id ?? null;
const updateRequest = useUpdateRequest(activeRequestId);
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const activeTab = useKeyValue<string>({
key: ['active_request_body_tab'],
defaultValue: 'body',
@@ -34,12 +35,24 @@ export function RequestPane({ fullHeight, className }: Props) {
value: 'body',
label: activeRequest?.bodyType ?? 'No Body',
options: {
onChange: (bodyType: HttpRequest['bodyType']) => {
onChange: async (bodyType: HttpRequest['bodyType']) => {
const patch: Partial<HttpRequest> = { bodyType };
if (bodyType == HttpRequestBodyType.GraphQL) {
patch.method = 'POST';
patch.headers = [
...(activeRequest?.headers.filter((h) => h.name.toLowerCase() !== 'content-type') ??
[]),
{
name: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
setTimeout(() => {
setForceUpdateHeaderEditorKey((u) => u + 1);
}, 100);
}
updateRequest.mutate(patch);
await updateRequest.mutate(patch);
},
value: activeRequest?.bodyType ?? null,
items: [
@@ -54,7 +67,7 @@ export function RequestPane({ fullHeight, className }: Props) {
{ value: 'headers', label: 'Headers' },
{ value: 'auth', label: 'Auth' },
],
[activeRequest?.bodyType ?? 'n/a'],
[activeRequest?.bodyType, activeRequest?.headers],
);
const handleBodyChange = useCallback((body: string) => updateRequest.mutate({ body }), []);
@@ -88,7 +101,7 @@ export function RequestPane({ fullHeight, className }: Props) {
</TabContent>
<TabContent value="headers">
<HeaderEditor
key={activeRequestId}
key={`${activeRequest.id}::${forceUpdateHeaderEditorKey}`}
headers={activeRequest.headers}
onChange={handleHeadersChange}
/>
@@ -123,6 +136,7 @@ export function RequestPane({ fullHeight, className }: Props) {
) : activeRequest.bodyType === HttpRequestBodyType.GraphQL ? (
<GraphQLEditor
key={activeRequest.id}
baseRequest={activeRequest}
className="!bg-gray-50"
defaultValue={activeRequest?.body ?? ''}
onChange={handleBodyChange}

View File

@@ -1,33 +1,34 @@
import { useActiveRequestId } from '../hooks/useActiveRequestId';
import type { HTMLAttributes, ReactElement } from 'react';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useDuplicateRequest } from '../hooks/useDuplicateRequest';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
interface Props {
className?: string;
requestId: string;
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
}
export function RequestSettingsDropdown({ className }: Props) {
const activeRequestId = useActiveRequestId();
const deleteRequest = useDeleteRequest(activeRequestId ?? null);
export function RequestSettingsDropdown({ requestId, children }: Props) {
const deleteRequest = useDeleteRequest(requestId ?? null);
const duplicateRequest = useDuplicateRequest({ id: requestId, navigateAfter: true });
return (
<Dropdown
items={[
{
label: 'Something Else',
onSelect: () => null,
leftSlot: <Icon icon="camera" />,
label: 'Duplicate',
onSelect: duplicateRequest.mutate,
leftSlot: <Icon icon="copy" />,
},
'-----',
{
label: 'Delete Request',
label: 'Delete',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
]}
>
<IconButton className={className} size="sm" title="Request Options" icon="gear" />
{children}
</Dropdown>
);
}

View File

@@ -5,19 +5,17 @@ import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCreateRequest } from '../hooks/useCreateRequest';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useRequests } from '../hooks/useRequests';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest';
import { useUpdateRequest } from '../hooks/useUpdateRequest';
import type { HttpRequest } from '../lib/models';
import { Button } from './core/Button';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack, VStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
import { DropMarker } from './DropMarker';
import { RequestSettingsDropdown } from './RequestSettingsDropdown';
import { ToggleThemeButton } from './ToggleThemeButton';
interface Props {
@@ -204,7 +202,6 @@ const _SidebarItem = forwardRef(function SidebarItem(
{ className, requestName, requestId, workspaceId, active, sidebarWidth }: SidebarItemProps,
ref: ForwardedRef<HTMLLIElement>,
) {
const deleteRequest = useDeleteRequest(requestId);
const updateRequest = useUpdateRequest(requestId);
const [editing, setEditing] = useState<boolean>(false);
@@ -244,17 +241,6 @@ const _SidebarItem = forwardRef(function SidebarItem(
[active],
);
const actionItems = useMemo(
() => [
{
label: 'Delete Request',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
],
[],
);
return (
<li
ref={ref}
@@ -295,19 +281,18 @@ const _SidebarItem = forwardRef(function SidebarItem(
</span>
)}
</Button>
<Dropdown items={actionItems}>
<RequestSettingsDropdown requestId={requestId}>
<IconButton
color="custom"
size="sm"
title="Request Options"
icon="dotsH"
className={classnames(
'absolute right-0 top-0 transition-opacity opacity-0',
'group-hover/item:opacity-100 focus-visible:opacity-100',
)}
color="custom"
size="sm"
iconSize="sm"
title="Delete request"
icon="dotsH"
/>
</Dropdown>
</RequestSettingsDropdown>
</div>
</li>
);

View File

@@ -39,7 +39,7 @@ export const UrlBar = memo(function UrlBar({ id: requestId, url, method, classNa
className="px-0"
name="url"
label="Enter URL"
containerClassName="shadow shadow-gray-100 dark:shadow-gray-0"
containerClassName="shadow shadow-gray-100 dark:shadow-gray-50"
onChange={handleUrlChange}
defaultValue={url}
placeholder="https://example.com"

View File

@@ -50,7 +50,14 @@ export default function Workspace() {
</div>
<div className="flex-1 flex justify-end -mr-2 pointer-events-none">
<IconButton size="sm" title="" icon="magnifyingGlass" />
<RequestSettingsDropdown className="pointer-events-auto" />
<RequestSettingsDropdown>
<IconButton
size="sm"
title="Request Options"
icon="gear"
className="pointer-events-auto"
/>
</RequestSettingsDropdown>
</div>
</HStack>
<div

View File

@@ -162,6 +162,7 @@ function Menu({ className, items, onClose, triggerRect }: MenuProps) {
>
{containerStyles && (
<VStack
space={0.5}
ref={initMenu}
style={menuStyles}
tabIndex={-1}

View File

@@ -151,7 +151,15 @@
/* NOTE: Extra selector required to override default styles */
.cm-tooltip.cm-tooltip {
@apply shadow-lg bg-gray-50 rounded overflow-hidden text-gray-900 border border-gray-200 z-50 pointer-events-auto;
@apply shadow-lg bg-gray-50 rounded text-gray-700 border border-gray-200 z-50 pointer-events-auto text-sm;
&.cm-completionInfo-right {
@apply ml-1;
}
&.cm-completionInfo-right-narrow {
@apply ml-1;
}
* {
@apply transition-none;
@@ -159,7 +167,7 @@
&.cm-tooltip-autocomplete {
& > ul {
@apply p-1 max-h-[20rem];
@apply p-1 max-h-[40vh];
}
& > ul > li {
@@ -177,5 +185,18 @@
.cm-completionIcon {
@apply text-sm flex items-center pb-0.5;
}
.cm-completionLabel {
}
.cm-completionDetail {
@apply ml-auto;
}
}
}
/* Add default icon. Needs low priority so it can be overwritten */
.cm-completionIcon::after {
content: '𝑥';
}

View File

@@ -1,11 +1,11 @@
import { defaultKeymap } from '@codemirror/commands';
import type { Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import classnames from 'classnames';
import { EditorView } from 'codemirror';
import type { MutableRefObject } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useUnmount } from 'react-use';
import { IconButton } from '../IconButton';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
@@ -18,6 +18,7 @@ export interface _EditorProps {
className?: string;
heightMode?: 'auto' | 'full';
contentType?: string;
languageExtension?: Extension;
autoFocus?: boolean;
defaultValue?: string;
placeholder?: string;
@@ -38,6 +39,7 @@ export function _Editor({
placeholder,
useTemplating,
defaultValue,
languageExtension,
onChange,
onFocus,
className,
@@ -48,12 +50,6 @@ export function _Editor({
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
// Unmount the editor
useUnmount(() => {
cm.current?.view.destroy();
cm.current = null;
});
// Use ref so we can update the onChange handler without re-initializing the editor
const handleChange = useRef<_EditorProps['onChange']>(onChange);
useEffect(() => {
@@ -87,9 +83,11 @@ export function _Editor({
// Initialize the editor when ref mounts
useEffect(() => {
if (wrapperRef.current === null || cm.current !== null) return;
let view: EditorView;
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete });
const langExt =
languageExtension ?? getLanguageExtension({ contentType, useTemplating, autocomplete });
const state = EditorState.create({
doc: `${defaultValue ?? ''}`,
extensions: [
@@ -106,18 +104,18 @@ export function _Editor({
}),
],
});
const view = new EditorView({ state, parent: wrapperRef.current });
view = new EditorView({ state, parent: wrapperRef.current });
cm.current = { view, languageCompartment };
syncGutterBg({ parent: wrapperRef.current, className });
if (autoFocus) view.focus();
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
}, [wrapperRef.current]);
useEffect(() => {
if (wrapperRef.current === null) return;
syncGutterBg({ parent: wrapperRef.current, className });
}, [className]);
return () => {
view.destroy();
cm.current = null;
};
}, [wrapperRef.current, languageExtension]);
const cmContainer = useMemo(
() => (

View File

@@ -32,7 +32,8 @@ import {
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { graphqlLanguageSupport } from 'cm6-graphql';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { render } from 'react-dom';
import type { EditorProps } from './index';
import { text } from './text/extension';
import { twig } from './twig/extension';
@@ -97,6 +98,9 @@ export function getLanguageExtension({
useTemplating = false,
autocomplete,
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
if (contentType === 'application/graphql') {
return graphql();
}
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? text();
if (!useTemplating) {
@@ -115,7 +119,14 @@ export const baseExtensions = [
// 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: true, interactionDelay: 200 }),
autocompletion({
// closeOnBlur: false,
interactionDelay: 200,
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
},
}),
syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true),
];

View File

@@ -24,6 +24,6 @@ export function genericCompletion({ options, minMatch = 1 }: GenericCompletionCo
if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text);
return { from: toMatch.from, options: optionsWithoutExactMatches };
return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello' };
};
}

View File

@@ -7,6 +7,7 @@ import {
ClockIcon,
CodeIcon,
ColorWheelIcon,
CopyIcon,
Cross2Icon,
DividerHorizontalIcon,
DotsHorizontalIcon,
@@ -40,8 +41,11 @@ const icons = {
check: CheckIcon,
checkbox: CheckboxIcon,
clock: ClockIcon,
chevronDown: ChevronDownIcon,
code: CodeIcon,
colorWheel: ColorWheelIcon,
copy: CopyIcon,
dividerH: DividerHorizontalIcon,
dotsH: DotsHorizontalIcon,
dotsV: DotsVerticalIcon,
drag: DragHandleDots2Icon,
@@ -50,12 +54,10 @@ const icons = {
home: HomeIcon,
listBullet: ListBulletIcon,
magicWand: MagicWandIcon,
chevronDown: ChevronDownIcon,
magnifyingGlass: MagnifyingGlassIcon,
moon: MoonIcon,
paperPlane: PaperPlaneIcon,
plus: PlusIcon,
dividerH: DividerHorizontalIcon,
plusCircle: PlusCircledIcon,
question: QuestionMarkIcon,
rows: RowsIcon,

View File

@@ -127,7 +127,7 @@ export const PairEditor = memo(function PairEditor({
'@container',
'pb-2 grid',
// NOTE: Add padding to top so overflow doesn't hide drop marker
'pt-1',
'pt-1 -my-1',
)}
>
{pairs.map((p, i) => {

View File

@@ -4,6 +4,7 @@ import { forwardRef } from 'react';
const gapClasses = {
0: 'gap-0',
0.5: 'gap-0.5',
1: 'gap-1',
2: 'gap-2',
3: 'gap-3',

View File

@@ -1,12 +1,12 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useNavigate } from 'react-router-dom';
import type { HttpRequest } from '../lib/models';
import { useActiveWorkspace } from './useActiveWorkspace';
import { useRoutes } from './useRoutes';
export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean }) {
const workspace = useActiveWorkspace();
const navigate = useNavigate();
const routes = useRoutes();
return useMutation<string, unknown, Pick<HttpRequest, 'name' | 'sortPriority'>>({
mutationFn: (patch) => {
@@ -16,8 +16,8 @@ export function useCreateRequest({ navigateAfter }: { navigateAfter: boolean })
return invoke('create_request', { ...patch, workspaceId: workspace.id });
},
onSuccess: async (requestId) => {
if (navigateAfter) {
navigate(`/workspaces/${workspace?.id}/requests/${requestId}`);
if (navigateAfter && workspace !== null) {
routes.navigate('request', { workspaceId: workspace.id, requestId });
}
},
});

View File

@@ -8,6 +8,7 @@ export function useDeleteRequest(id: string | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
console.log('DELETE REQUEST2', id, workspaceId);
if (id === null) return;
await invoke('delete_request', { requestId: id });
},

View File

@@ -0,0 +1,26 @@
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useRoutes } from './useRoutes';
export function useDuplicateRequest({
id,
navigateAfter,
}: {
id: string | null;
navigateAfter: boolean;
}) {
const workspaceId = useActiveWorkspaceId();
const routes = useRoutes();
return useMutation<string, string>({
mutationFn: async () => {
if (id === null) throw new Error("Can't duplicate a null request");
return invoke('duplicate_request', { id });
},
onSuccess: async (newId: string) => {
if (navigateAfter && workspaceId !== null) {
routes.navigate('request', { workspaceId, requestId: newId });
}
},
});
}

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpRequest } from '../lib/models';
import { convertDates } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
export function requestsQueryKey(workspaceId: string) {
@@ -16,8 +15,7 @@ export function useRequests() {
queryKey: requestsQueryKey(workspaceId ?? 'n/a'),
queryFn: async () => {
if (workspaceId == null) return [];
const requests = (await invoke('requests', { workspaceId })) as HttpRequest[];
return requests.map(convertDates);
return (await invoke('requests', { workspaceId })) as HttpRequest[];
},
}).data ?? []
);

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { HttpResponse } from '../lib/models';
import { convertDates } from '../lib/models';
export function responsesQueryKey(requestId: string) {
return ['http_responses', { requestId }];
@@ -14,10 +13,9 @@ export function useResponses(requestId: string | null) {
initialData: [],
queryKey: responsesQueryKey(requestId ?? 'n/a'),
queryFn: async () => {
const responses = (await invoke('responses', {
return (await invoke('responses', {
requestId,
})) as HttpResponse[];
return responses.map(convertDates);
},
}).data ?? []
);

View File

@@ -36,7 +36,6 @@ export function useRoutes() {
// outside caller perspective.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedPath = routePaths[path](...(params as any));
console.log('NAVIGATE TO', resolvedPath, 'WITH PARAMS', params, 'AND PATH', path);
navigate(resolvedPath);
},
paths: routePaths,

View File

@@ -1,17 +1,11 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { responsesQueryKey } from './useResponses';
export function useSendRequest(id: string | null) {
const queryClient = useQueryClient();
return useMutation<void, string>({
mutationFn: async () => {
if (id === null) return;
await invoke('send_request', { requestId: id });
},
onSuccess: async () => {
if (id === null) return;
await queryClient.invalidateQueries(responsesQueryKey(id));
},
}).mutate;
}

View File

@@ -14,11 +14,7 @@ export function useUpdateRequest(id: string | null) {
const updatedRequest = { ...request, ...patch };
await invoke('update_request', {
request: {
...updatedRequest,
createdAt: updatedRequest.createdAt.toISOString().replace('Z', ''),
updatedAt: updatedRequest.updatedAt.toISOString().replace('Z', ''),
},
request: updatedRequest,
});
},
});

View File

@@ -1,6 +1,5 @@
import { invoke } from '@tauri-apps/api';
import type { Workspace } from '../lib/models';
import { convertDates } from '../lib/models';
import { useQuery } from '@tanstack/react-query';
export function workspacesQueryKey() {
@@ -10,8 +9,7 @@ export function workspacesQueryKey() {
export function useWorkspaces() {
return (
useQuery(workspacesQueryKey(), async () => {
const workspaces = (await invoke('workspaces')) as Workspace[];
return workspaces.map(convertDates);
return (await invoke('workspaces')) as Workspace[];
}).data ?? []
);
}

View File

@@ -53,20 +53,3 @@ export interface HttpResponse extends BaseModel {
readonly url: string;
readonly headers: HttpHeader[];
}
export function convertDates<T extends Pick<BaseModel, 'createdAt' | 'updatedAt'>>(m: T): T {
return {
...m,
createdAt: convertDate(m.createdAt),
updatedAt: convertDate(m.updatedAt),
};
}
function convertDate(d: string | Date): Date {
if (typeof d !== 'string') {
return d;
}
const date = new Date(d);
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - userTimezoneOffset);
}

View File

@@ -0,0 +1,6 @@
import { invoke } from '@tauri-apps/api';
import type { HttpRequest, HttpResponse } from './models';
export function sendEphemeralRequest(request: HttpRequest): Promise<HttpResponse> {
return invoke('send_ephemeral_request', { request });
}

View File

@@ -1,5 +1,4 @@
import { invoke } from '@tauri-apps/api';
import { convertDates } from './models';
import type { HttpRequest } from './models';
export async function getRequest(id: string | null): Promise<HttpRequest | null> {
@@ -8,5 +7,5 @@ export async function getRequest(id: string | null): Promise<HttpRequest | null>
if (request == null) {
return null;
}
return convertDates(request);
return request;
}

View File

@@ -1,9 +1,3 @@
const height = {
"xs": "1.5rem",
"sm": "2rem",
"md": "2.5rem"
};
/** @type {import("tailwindcss").Config} */
module.exports = {
darkMode: ["class", "[data-appearance=\"dark\"]"],
@@ -16,8 +10,17 @@ module.exports = {
opacity: {
"disabled": "0.3"
},
height,
lineHeight: height
height: {
"xs": "1.5rem",
"sm": "2.00rem",
"md": "2.5rem"
},
lineHeight: {
// HACK: Minus 2 to account for borders inside inputs
"xs": "calc(1.5rem - 2px)",
"sm": "calc(2.0rem - 2px)",
"md": "calc(2.5rem - 2px)"
}
},
fontFamily: {
"mono": ["JetBrains Mono", "Menlo", "monospace"],