2024.5.0-beta.2 (#38)

This commit is contained in:
Gregory Schier
2024-06-03 14:03:25 -07:00
committed by GitHub
parent 2383e8468f
commit 5b12fad173
29 changed files with 242 additions and 184 deletions

View File

@@ -180,6 +180,7 @@ function importBody(rawBody: any): Pick<HttpRequest, 'body' | 'bodyType' | 'head
f.src != null f.src != null
? { ? {
enabled: !f.disabled, enabled: !f.disabled,
contentType: f.contentType ?? null,
name: f.key ?? '', name: f.key ?? '',
file: f.src ?? '', file: f.src ?? '',
} }
@@ -244,6 +245,7 @@ function convertTemplateSyntax<T>(obj: T): T {
} }
const idCount: Partial<Record<Model['model'], number>> = {}; const idCount: Partial<Record<Model['model'], number>> = {};
function generateId(model: Model['model']): string { function generateId(model: Model['model']): string {
idCount[model] = (idCount[model] ?? -1) + 1; idCount[model] = (idCount[model] ?? -1) + 1;
return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`; return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;

View File

@@ -131,6 +131,7 @@ function j(e) {
form: b(t.formdata).map( form: b(t.formdata).map(
(n) => n.src != null ? { (n) => n.src != null ? {
enabled: !n.disabled, enabled: !n.disabled,
contentType: n.contentType ?? null,
name: n.key ?? "", name: n.key ?? "",
file: n.src ?? "" file: n.src ?? ""
} : { } : {

View File

@@ -89,14 +89,24 @@ pub async fn send_http_request(
let uri = match http::Uri::from_str(url_string.as_str()) { let uri = match http::Uri::from_str(url_string.as_str()) {
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
return response_err(response, e.to_string(), window).await; return response_err(
response,
format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()),
window,
)
.await;
} }
}; };
// Yes, we're parsing both URI and URL because they could return different errors // Yes, we're parsing both URI and URL because they could return different errors
let url = match Url::from_str(uri.to_string().as_str()) { let url = match Url::from_str(uri.to_string().as_str()) {
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
return response_err(response, e.to_string(), window).await; return response_err(
response,
format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()),
window,
)
.await;
} }
}; };

View File

@@ -752,16 +752,16 @@ async fn cmd_import_data(
) -> Result<WorkspaceExportResources, String> { ) -> Result<WorkspaceExportResources, String> {
let mut result: Option<ImportResult> = None; let mut result: Option<ImportResult> = None;
let plugins = vec![ let plugins = vec![
"importer-yaak",
"importer-insomnia",
"importer-postman", "importer-postman",
"importer-insomnia",
"importer-yaak",
"importer-curl", "importer-curl",
]; ];
let file = fs::read_to_string(file_path) let file = read_to_string(file_path)
.unwrap_or_else(|_| panic!("Unable to read file {}", file_path)); .unwrap_or_else(|_| panic!("Unable to read file {}", file_path));
let file_contents = file.as_str(); let file_contents = file.as_str();
for plugin_name in plugins { for plugin_name in plugins {
let v = plugin::run_plugin_import(&w.app_handle(), plugin_name, file_contents) let v = run_plugin_import(&w.app_handle(), plugin_name, file_contents)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if let Some(r) = v { if let Some(r) = v {

View File

@@ -6,7 +6,7 @@
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"productName": "Yaak", "productName": "Yaak",
"version": "2024.5.0-beta.1", "version": "2024.5.0",
"identifier": "app.yaak.desktop", "identifier": "app.yaak.desktop",
"app": { "app": {
"withGlobalTauri": false, "withGlobalTauri": false,

View File

@@ -281,7 +281,7 @@ function SidebarButton({
}, },
}, },
{ {
key: 'delete', key: 'delete-environment',
variant: 'danger', variant: 'danger',
label: 'Delete', label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />, leftSlot: <Icon icon="trash" size="sm" />,

View File

@@ -30,6 +30,8 @@ export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props)
<PairEditor <PairEditor
valueAutocompleteVariables valueAutocompleteVariables
nameAutocompleteVariables nameAutocompleteVariables
namePlaceholder="entry_name"
valuePlaceholder="Value"
pairs={pairs} pairs={pairs}
onChange={handleChange} onChange={handleChange}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}

View File

@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useClipboardText } from '../hooks/useClipboardText'; import { useClipboardText } from '../hooks/useClipboardText';
import { useCommandPalette } from '../hooks/useCommandPalette'; import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars'; import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { environmentsQueryKey } from '../hooks/useEnvironments';
import { foldersQueryKey } from '../hooks/useFolders'; import { foldersQueryKey } from '../hooks/useFolders';
import { useGlobalCommands } from '../hooks/useGlobalCommands'; import { useGlobalCommands } from '../hooks/useGlobalCommands';
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections'; import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
@@ -62,6 +63,8 @@ export function GlobalHooks() {
? httpResponsesQueryKey(model) ? httpResponsesQueryKey(model)
: model.model === 'folder' : model.model === 'folder'
? foldersQueryKey(model) ? foldersQueryKey(model)
: model.model === 'environment'
? environmentsQueryKey(model)
: model.model === 'grpc_connection' : model.model === 'grpc_connection'
? grpcConnectionsQueryKey(model) ? grpcConnectionsQueryKey(model)
: model.model === 'grpc_event' : model.model === 'grpc_event'
@@ -96,10 +99,8 @@ export function GlobalHooks() {
queryClient.setQueryData<Model[]>(queryKey, (values = []) => { queryClient.setQueryData<Model[]>(queryKey, (values = []) => {
const index = values.findIndex((v) => modelsEq(v, model)) ?? -1; const index = values.findIndex((v) => modelsEq(v, model)) ?? -1;
if (index >= 0) { if (index >= 0) {
// console.log('UPDATED', payload);
return [...values.slice(0, index), model, ...values.slice(index + 1)]; return [...values.slice(0, index), model, ...values.slice(index + 1)];
} else { } else {
// console.log('CREATED', payload);
return pushToFront ? [model, ...(values ?? [])] : [...(values ?? []), model]; return pushToFront ? [model, ...(values ?? [])] : [...(values ?? []), model];
} }
}); });
@@ -117,6 +118,8 @@ export function GlobalHooks() {
queryClient.setQueryData(httpResponsesQueryKey(model), removeById(model)); queryClient.setQueryData(httpResponsesQueryKey(model), removeById(model));
} else if (model.model === 'folder') { } else if (model.model === 'folder') {
queryClient.setQueryData(foldersQueryKey(model), removeById(model)); queryClient.setQueryData(foldersQueryKey(model), removeById(model));
} else if (model.model === 'environment') {
queryClient.setQueryData(environmentsQueryKey(model), removeById(model));
} else if (model.model === 'grpc_request') { } else if (model.model === 'grpc_request') {
queryClient.setQueryData(grpcRequestsQueryKey(model), removeById(model)); queryClient.setQueryData(grpcRequestsQueryKey(model), removeById(model));
} else if (model.model === 'grpc_connection') { } else if (model.model === 'grpc_connection') {

View File

@@ -109,11 +109,14 @@ export const RequestPane = memo(function RequestPane({
if (bodyType === BODY_TYPE_NONE) { if (bodyType === BODY_TYPE_NONE) {
newContentType = null; newContentType = null;
} else if ( } else if (
bodyType === BODY_TYPE_FORM_URLENCODED || activeRequest.method.toLowerCase() !== 'put' &&
bodyType === BODY_TYPE_FORM_MULTIPART || activeRequest.method.toLowerCase() !== 'patch' &&
bodyType === BODY_TYPE_JSON || activeRequest.method.toLowerCase() !== 'post' &&
bodyType === BODY_TYPE_OTHER || (bodyType === BODY_TYPE_FORM_URLENCODED ||
bodyType === BODY_TYPE_XML bodyType === BODY_TYPE_FORM_MULTIPART ||
bodyType === BODY_TYPE_JSON ||
bodyType === BODY_TYPE_OTHER ||
bodyType === BODY_TYPE_XML)
) { ) {
patch.method = 'POST'; patch.method = 'POST';
newContentType = bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType; newContentType = bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType;
@@ -181,6 +184,7 @@ export const RequestPane = memo(function RequestPane({
activeRequest.authenticationType, activeRequest.authenticationType,
activeRequest.bodyType, activeRequest.bodyType,
activeRequest.headers, activeRequest.headers,
activeRequest.method,
activeRequest.urlParameters, activeRequest.urlParameters,
handleContentTypeChange, handleContentTypeChange,
updateRequest, updateRequest,

View File

@@ -14,7 +14,7 @@ import { Editor } from '../core/Editor';
import type { IconProps } from '../core/Icon'; import type { IconProps } from '../core/Icon';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton'; import { IconButton } from '../core/IconButton';
import type { SelectOption } from '../core/Select'; import type { SelectProps } from '../core/Select';
import { Select } from '../core/Select'; import { Select } from '../core/Select';
import { Separator } from '../core/Separator'; import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks'; import { HStack, VStack } from '../core/Stacks';
@@ -64,14 +64,14 @@ export function SettingsAppearance() {
return null; return null;
} }
const lightThemes: SelectOption<string>[] = themes const lightThemes: SelectProps<string>['options'] = themes
.filter((theme) => !isThemeDark(theme)) .filter((theme) => !isThemeDark(theme))
.map((theme) => ({ .map((theme) => ({
label: theme.name, label: theme.name,
value: theme.id, value: theme.id,
})); }));
const darkThemes: SelectOption<string>[] = themes const darkThemes: SelectProps<string>['options'] = themes
.filter((theme) => isThemeDark(theme)) .filter((theme) => isThemeDark(theme))
.map((theme) => ({ .map((theme) => ({
label: theme.name, label: theme.name,
@@ -109,7 +109,7 @@ export function SettingsAppearance() {
<Select <Select
name="appearance" name="appearance"
label="Appearance" label="Appearance"
labelPosition="left" labelPosition="top"
size="sm" size="sm"
value={settings.appearance} value={settings.appearance}
onChange={(appearance) => { onChange={(appearance) => {
@@ -123,7 +123,6 @@ export function SettingsAppearance() {
]} ]}
/> />
<HStack space={2}> <HStack space={2}>
<div>Theme</div>
{(settings.appearance === 'system' || settings.appearance === 'light') && ( {(settings.appearance === 'system' || settings.appearance === 'light') && (
<Select <Select
hideLabel hideLabel
@@ -131,8 +130,9 @@ export function SettingsAppearance() {
name="lightTheme" name="lightTheme"
label="Light Theme" label="Light Theme"
size="sm" size="sm"
className="flex-1"
value={activeTheme.light.id} value={activeTheme.light.id}
options={[{ label: 'Light Mode Themes', options: lightThemes }]} options={lightThemes}
onChange={(themeLight) => { onChange={(themeLight) => {
trackEvent('theme', 'update', { theme: themeLight, appearance: 'light' }); trackEvent('theme', 'update', { theme: themeLight, appearance: 'light' });
updateSettings.mutateAsync({ ...settings, themeLight }); updateSettings.mutateAsync({ ...settings, themeLight });
@@ -143,11 +143,12 @@ export function SettingsAppearance() {
<Select <Select
hideLabel hideLabel
name="darkTheme" name="darkTheme"
className="flex-1"
label="Dark Theme" label="Dark Theme"
leftSlot={<Icon icon="moon" />} leftSlot={<Icon icon="moon" />}
size="sm" size="sm"
value={activeTheme.dark.id} value={activeTheme.dark.id}
options={[{ label: 'Dark Mode Themes', options: darkThemes }]} options={darkThemes}
onChange={(themeDark) => { onChange={(themeDark) => {
trackEvent('theme', 'update', { theme: themeDark, appearance: 'dark' }); trackEvent('theme', 'update', { theme: themeDark, appearance: 'dark' });
updateSettings.mutateAsync({ ...settings, themeDark }); updateSettings.mutateAsync({ ...settings, themeDark });

View File

@@ -8,8 +8,8 @@ import { useUpdateWorkspace } from '../../hooks/useUpdateWorkspace';
import { Checkbox } from '../core/Checkbox'; import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading'; import { Heading } from '../core/Heading';
import { IconButton } from '../core/IconButton'; import { IconButton } from '../core/IconButton';
import { Input } from '../core/Input';
import { KeyValueRow, KeyValueRows } from '../core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from '../core/KeyValueRow';
import { PlainInput } from '../core/PlainInput';
import { Select } from '../core/Select'; import { Select } from '../core/Select';
import { Separator } from '../core/Separator'; import { Separator } from '../core/Separator';
import { VStack } from '../core/Stacks'; import { VStack } from '../core/Stacks';
@@ -59,7 +59,7 @@ export function SettingsGeneral() {
</div> </div>
</Heading> </Heading>
<VStack className="mt-1 w-full" space={3}> <VStack className="mt-1 w-full" space={3}>
<Input <PlainInput
size="sm" size="sm"
name="requestTimeout" name="requestTimeout"
label="Request Timeout (ms)" label="Request Timeout (ms)"
@@ -68,6 +68,7 @@ export function SettingsGeneral() {
defaultValue={`${workspace.settingRequestTimeout}`} defaultValue={`${workspace.settingRequestTimeout}`}
validate={(value) => parseInt(value) >= 0} validate={(value) => parseInt(value) >= 0}
onChange={(v) => updateWorkspace.mutate({ settingRequestTimeout: parseInt(v) || 0 })} onChange={(v) => updateWorkspace.mutate({ settingRequestTimeout: parseInt(v) || 0 })}
type="number"
/> />
<Checkbox <Checkbox

View File

@@ -12,6 +12,8 @@ export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange }:
<PairEditor <PairEditor
valueAutocompleteVariables valueAutocompleteVariables
nameAutocompleteVariables nameAutocompleteVariables
namePlaceholder="param_name"
valuePlaceholder="Value"
pairs={urlParameters} pairs={urlParameters}
onChange={onChange} onChange={onChange}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}

View File

@@ -120,7 +120,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{isLoading ? ( {isLoading ? (
<Icon icon="refresh" size={size} className="animate-spin mr-1" /> <Icon icon="refresh" size={size} className="animate-spin mr-1" />
) : leftSlot ? ( ) : leftSlot ? (
<div className="mr-1">{leftSlot}</div> <div className="mr-2">{leftSlot}</div>
) : null} ) : null}
<div <div
className={classNames( className={classNames(

View File

@@ -59,6 +59,7 @@ export interface DropdownProps {
items: DropdownItem[]; items: DropdownItem[];
onOpen?: () => void; onOpen?: () => void;
onClose?: () => void; onClose?: () => void;
fullWidth?: boolean;
hotKeyAction?: HotkeyAction; hotKeyAction?: HotkeyAction;
} }
@@ -73,7 +74,7 @@ export interface DropdownRef {
} }
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown( export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
{ children, items, onOpen, onClose, hotKeyAction }: DropdownProps, { children, items, onOpen, onClose, hotKeyAction, fullWidth }: DropdownProps,
ref, ref,
) { ) {
const [isOpen, _setIsOpen] = useState<boolean>(false); const [isOpen, _setIsOpen] = useState<boolean>(false);
@@ -153,6 +154,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
<Menu <Menu
ref={menuRef} ref={menuRef}
showTriangle showTriangle
fullWidth={fullWidth}
defaultSelectedIndex={defaultSelectedIndex} defaultSelectedIndex={defaultSelectedIndex}
items={items} items={items}
triggerShape={triggerRect ?? null} triggerShape={triggerRect ?? null}
@@ -203,6 +205,7 @@ interface MenuProps {
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null; triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'> | null;
onClose: () => void; onClose: () => void;
showTriangle?: boolean; showTriangle?: boolean;
fullWidth?: boolean;
isOpen: boolean; isOpen: boolean;
} }
@@ -211,6 +214,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
className, className,
isOpen, isOpen,
items, items,
fullWidth,
onClose, onClose,
triggerShape, triggerShape,
defaultSelectedIndex, defaultSelectedIndex,
@@ -359,21 +363,23 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
const heightAbove = triggerShape.top; const heightAbove = triggerShape.top;
const heightBelow = docRect.height - triggerShape.bottom; const heightBelow = docRect.height - triggerShape.bottom;
const hSpaceRemaining = docRect.width - triggerShape.left; const hSpaceRemaining = docRect.width - triggerShape.left;
const top = triggerShape?.bottom + 5; const top = triggerShape.bottom + 5;
const onRight = hSpaceRemaining < 200; const onRight = hSpaceRemaining < 200;
const upsideDown = heightAbove > heightBelow && heightBelow < 200; const upsideDown = heightAbove > heightBelow && heightBelow < 200;
const triggerWidth = triggerShape.right - triggerShape.left;
const containerStyles = { const containerStyles = {
top: !upsideDown ? top : undefined, top: !upsideDown ? top : undefined,
bottom: upsideDown ? docRect.height - top : undefined, bottom: upsideDown ? docRect.height - top : undefined,
right: onRight ? docRect.width - triggerShape?.right : undefined, right: onRight ? docRect.width - triggerShape.right : undefined,
left: !onRight ? triggerShape?.left : undefined, left: !onRight ? triggerShape.left : undefined,
minWidth: fullWidth ? triggerWidth : undefined,
}; };
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' }; const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
const triangleStyles = onRight const triangleStyles = onRight
? { right: width / 2, marginRight: '-0.2rem', ...size } ? { right: width / 2, marginRight: '-0.2rem', ...size }
: { left: width / 2, marginLeft: '-0.2rem', ...size }; : { left: width / 2, marginLeft: '-0.2rem', ...size };
return { containerStyles, triangleStyles }; return { containerStyles, triangleStyles };
}, [triggerShape]); }, [fullWidth, triggerShape]);
const filteredItems = useMemo( const filteredItems = useMemo(
() => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())), () => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),
@@ -521,9 +527,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
onClick={handleClick} onClick={handleClick}
justify="start" justify="start"
leftSlot={ leftSlot={
item.leftSlot && ( item.leftSlot && <div className="pr-2 flex justify-start opacity-70">{item.leftSlot}</div>
<div className="pr-2 flex justify-start text-fg-subtle">{item.leftSlot}</div>
)
} }
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>} rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
innerClassName="!text-left" innerClassName="!text-left"

View File

@@ -266,7 +266,7 @@
} }
&.cm-completionInfo-right { &.cm-completionInfo-right {
@apply ml-1 -mt-0.5; @apply ml-1 -mt-0.5 font-sans;
} }
&.cm-completionInfo-right-narrow { &.cm-completionInfo-right-narrow {
@@ -278,7 +278,7 @@
} }
&.cm-tooltip-autocomplete { &.cm-tooltip-autocomplete {
@apply font-mono text-editor; @apply font-mono;
& > ul { & > ul {
@apply p-1 max-h-[40vh]; @apply p-1 max-h-[40vh];

View File

@@ -195,7 +195,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
placeholderCompartment.current.of( placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '')), placeholderExt(placeholderElFromText(placeholder ?? '')),
), ),
wrapLinesCompartment.current.of([]), wrapLinesCompartment.current.of(wrapLines ? [EditorView.lineWrapping] : []),
...getExtensions({ ...getExtensions({
container, container,
readOnly, readOnly,
@@ -331,7 +331,25 @@ function getExtensions({
undefined; undefined;
return [ return [
// NOTE: These *must* be anonymous functions so the references update properly ...baseExtensions, // Must be first
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
// ------------------------ //
// Things that must be last //
// ------------------------ //
EditorView.updateListener.of((update) => {
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: () => {
onFocus.current?.(); onFocus.current?.();
@@ -346,22 +364,6 @@ function getExtensions({
onPaste.current?.(e.clipboardData?.getData('text/plain') ?? ''); onPaste.current?.(e.clipboardData?.getData('text/plain') ?? '');
}, },
}), }),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
// Handle onChange
EditorView.updateListener.of((update) => {
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
...baseExtensions,
]; ];
} }

View File

@@ -39,7 +39,7 @@ import { text } from './text/extension';
import { twig } from './twig/extension'; import { twig } from './twig/extension';
import { url } from './url/extension'; import { url } from './url/extension';
export const myHighlightStyle = HighlightStyle.define([ export const syntaxHighlightStyle = HighlightStyle.define([
{ {
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment], tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: 'var(--fg-subtler)', color: 'var(--fg-subtler)',
@@ -61,7 +61,7 @@ export const myHighlightStyle = HighlightStyle.define([
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: 'var(--fg-danger)' }, { tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: 'var(--fg-danger)' },
]); ]);
const myTheme = EditorView.theme({}, { dark: true }); const syntaxTheme = EditorView.theme({}, { dark: true });
const syntaxExtensions: Record<string, LanguageSupport> = { const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql': graphqlLanguageSupport(), 'application/graphql': graphqlLanguageSupport(),
@@ -108,8 +108,8 @@ export const baseExtensions = [
return (a.boost ?? 0) - (b.boost ?? 0); return (a.boost ?? 0) - (b.boost ?? 0);
}, },
}), }),
syntaxHighlighting(myHighlightStyle), syntaxHighlighting(syntaxHighlightStyle),
myTheme, syntaxTheme,
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
]; ];

View File

@@ -20,7 +20,11 @@ export interface GenericCompletionConfig {
/** /**
* Complete options, always matching until the start of the line * Complete options, always matching until the start of the line
*/ */
export function genericCompletion({ options, minMatch = 1 }: GenericCompletionConfig) { export function genericCompletion(config?: GenericCompletionConfig) {
if (config == null) return [];
const { minMatch = 1, options } = config;
return function completions(context: CompletionContext) { return function completions(context: CompletionContext) {
const toMatch = context.matchBefore(/.*/); const toMatch = context.matchBefore(/.*/);

View File

@@ -1,13 +1,13 @@
import type { LanguageSupport } from '@codemirror/language'; import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language'; import { LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common'; import { parseMixed } from '@lezer/common';
import type { Environment, Workspace } from '../../../../lib/models';
import type { GenericCompletionConfig } from '../genericCompletion'; import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion'; import { genericCompletion } from '../genericCompletion';
import { placeholders } from './placeholder';
import { textLanguageName } from '../text/extension'; import { textLanguageName } from '../text/extension';
import { twigCompletion } from './completion'; import { twigCompletion } from './completion';
import { placeholders } from './placeholder';
import { parser as twigParser } from './twig'; import { parser as twigParser } from './twig';
import type { Environment, Workspace } from '../../../../lib/models';
export function twig( export function twig(
base: LanguageSupport, base: LanguageSupport,
@@ -15,25 +15,19 @@ export function twig(
workspace: Workspace | null, workspace: Workspace | null,
autocomplete?: GenericCompletionConfig, autocomplete?: GenericCompletionConfig,
) { ) {
const variables =
[...(workspace?.variables ?? []), ...(environment?.variables ?? [])].filter((v) => v.enabled) ??
[];
const completions = twigCompletion({ options: variables });
const language = mixLanguage(base); const language = mixLanguage(base);
const completion = language.data.of({ autocomplete: completions }); const allVariables = [...(workspace?.variables ?? []), ...(environment?.variables ?? [])];
const completionBase = base.language.data.of({ autocomplete: completions }); const variables = allVariables.filter((v) => v.enabled) ?? [];
const additionalCompletion = autocomplete const completions = twigCompletion({ options: variables });
? [base.language.data.of({ autocomplete: genericCompletion(autocomplete) })]
: [];
return [ return [
language, language,
completion,
completionBase,
base.support, base.support,
placeholders(variables), placeholders(variables),
...additionalCompletion, language.data.of({ autocomplete: completions }),
base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
]; ];
} }

View File

@@ -9,7 +9,7 @@ export function FormattedError({ children }: Props) {
return ( return (
<pre <pre
className={classNames( className={classNames(
'w-full select-auto cursor-text bg-gray-100 p-3 rounded', 'w-full select-auto cursor-text bg-background-highlight-secondary p-3 rounded',
'whitespace-pre-wrap border border-fg-danger border-dashed overflow-x-auto', 'whitespace-pre-wrap border border-fg-danger border-dashed overflow-x-auto',
)} )}
> >

View File

@@ -133,7 +133,11 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
> >
<label <label
htmlFor={id} htmlFor={id}
className={classNames(labelClassName, 'text-fg whitespace-nowrap', hideLabel && 'sr-only')} className={classNames(
labelClassName,
'text-fg-subtle whitespace-nowrap',
hideLabel && 'sr-only',
)}
> >
{label} {label}
</label> </label>

View File

@@ -389,7 +389,7 @@ function PairEditorRow({
<Button <Button
size="xs" size="xs"
color="secondary" color="secondary"
className="font-mono text-sm" className="font-mono text-2xs rtl"
onClick={async (e) => { onClick={async (e) => {
e.preventDefault(); e.preventDefault();
const selected = await open({ const selected = await open({
@@ -403,7 +403,9 @@ function PairEditorRow({
handleChangeValueFile(selected.path); handleChangeValueFile(selected.path);
}} }}
> >
{getFileName(pairContainer.pair.value) || 'Select File'} {/* Special character to insert ltr text in rtl element without making things wonky */}
&#x200E;
{pairContainer.pair.value || 'Select File'}
</Button> </Button>
) : ( ) : (
<Input <Input
@@ -494,9 +496,3 @@ const newPairContainer = (initialPair?: Pair): PairContainer => {
const pair = initialPair ?? { name: '', value: '', enabled: true, isFile: false }; const pair = initialPair ?? { name: '', value: '', enabled: true, isFile: false };
return { id, pair }; return { id, pair };
}; };
const getFileName = (path: string | null | undefined): string => {
if (typeof path !== 'string') return '';
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? '';
};

View File

@@ -85,7 +85,11 @@ export const PlainInput = forwardRef<HTMLInputElement, PlainInputProps>(function
> >
<label <label
htmlFor={id} htmlFor={id}
className={classNames(labelClassName, 'text-fg whitespace-nowrap', hideLabel && 'sr-only')} className={classNames(
labelClassName,
'text-fg-subtle whitespace-nowrap',
hideLabel && 'sr-only',
)}
> >
{label} {label}
</label> </label>

View File

@@ -50,5 +50,9 @@ export function RadioDropdown<T = string | null>({
[items, extraItems, value, onChange], [items, extraItems, value, onChange],
); );
return <Dropdown items={dropdownItems}>{children}</Dropdown>; return (
<Dropdown fullWidth items={dropdownItems}>
{children}
</Dropdown>
);
} }

View File

@@ -1,9 +1,14 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { useOsInfo } from '../../hooks/useOsInfo';
import type { ButtonProps } from './Button';
import { Button } from './Button';
import type { RadioDropdownItem } from './RadioDropdown';
import { RadioDropdown } from './RadioDropdown';
import { HStack } from './Stacks'; import { HStack } from './Stacks';
interface Props<T extends string> { export interface SelectProps<T extends string> {
name: string; name: string;
label: string; label: string;
labelPosition?: 'top' | 'left'; labelPosition?: 'top' | 'left';
@@ -11,22 +16,12 @@ interface Props<T extends string> {
hideLabel?: boolean; hideLabel?: boolean;
value: T; value: T;
leftSlot?: ReactNode; leftSlot?: ReactNode;
options: SelectOption<T>[] | SelectOptionGroup<T>[]; options: RadioDropdownItem<T>[];
onChange: (value: T) => void; onChange: (value: T) => void;
size?: 'xs' | 'sm' | 'md' | 'lg'; size?: ButtonProps['size'];
className?: string; className?: string;
} }
export interface SelectOption<T extends string> {
label: string;
value: T;
}
export interface SelectOptionGroup<T extends string> {
label: string;
options: SelectOption<T>[];
}
export function Select<T extends string>({ export function Select<T extends string>({
labelPosition = 'top', labelPosition = 'top',
name, name,
@@ -39,7 +34,8 @@ export function Select<T extends string>({
onChange, onChange,
className, className,
size = 'md', size = 'md',
}: Props<T>) { }: SelectProps<T>) {
const osInfo = useOsInfo();
const [focused, setFocused] = useState<boolean>(false); const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`; const id = `input-${name}`;
return ( return (
@@ -49,55 +45,68 @@ export function Select<T extends string>({
'x-theme-input', 'x-theme-input',
'w-full', 'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent 'pointer-events-auto', // Just in case we're placing in disabled parent
labelPosition === 'left' && 'flex items-center gap-2', labelPosition === 'left' && 'grid grid-cols-[auto_1fr] items-center gap-2',
labelPosition === 'top' && 'flex-row gap-0.5', labelPosition === 'top' && 'flex-row gap-0.5',
)} )}
> >
<label <label
htmlFor={id} htmlFor={id}
className={classNames(labelClassName, 'text-fg whitespace-nowrap', hideLabel && 'sr-only')} className={classNames(
labelClassName,
'text-fg-subtle whitespace-nowrap',
hideLabel && 'sr-only',
)}
> >
{label} {label}
</label> </label>
<HStack {osInfo?.osType === 'macos' ? (
space={2} <HStack
className={classNames( space={2}
'w-full rounded-md text-fg text-sm font-mono', className={classNames(
'pl-2', 'w-full rounded-md text-fg text-sm font-mono',
'border', 'pl-2',
focused ? 'border-border-focus' : 'border-background-highlight', 'border',
size === 'xs' && 'h-xs', focused ? 'border-border-focus' : 'border-background-highlight',
size === 'sm' && 'h-sm', size === 'xs' && 'h-xs',
size === 'md' && 'h-md', size === 'sm' && 'h-sm',
size === 'lg' && 'h-lg', size === 'md' && 'h-md',
)}
>
{leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')}
>
{options.map((o) =>
'options' in o ? (
<optgroup key={o.label} label={o.label}>
{o.options.map(({ label, value }) => (
<option key={label} value={value}>
{label}
</option>
))}
</optgroup>
) : (
<option key={o.label} value={o.value}>
{o.label}
</option>
),
)} )}
</select> >
</HStack> {leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')}
>
{options.map((o) => {
if (o.type === 'separator') return null;
return (
<option key={o.label} value={o.value}>
{o.label}
</option>
);
})}
</select>
</HStack>
) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode
<RadioDropdown value={value} onChange={onChange} items={options}>
<Button
className="w-full text-sm font-mono"
justify="start"
variant="border"
size={size}
leftSlot={leftSlot}
forDropdown
>
{options.find((o) => o.type !== 'separator' && o.value === value)?.label ?? '--'}
</Button>
</RadioDropdown>
)}
</div> </div>
); );
} }

View File

@@ -1,11 +1,11 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders'; import { useContentTypeFromHeaders } from '../../hooks/useContentTypeFromHeaders';
import { useDebouncedState } from '../../hooks/useDebouncedState'; import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useFilterResponse } from '../../hooks/useFilterResponse'; import { useFilterResponse } from '../../hooks/useFilterResponse';
import { useResponseBodyText } from '../../hooks/useResponseBodyText'; import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { useToggle } from '../../hooks/useToggle';
import { tryFormatJson, tryFormatXml } from '../../lib/formatters'; import { tryFormatJson, tryFormatXml } from '../../lib/formatters';
import type { HttpResponse } from '../../lib/models'; import type { HttpResponse } from '../../lib/models';
import { Editor } from '../core/Editor'; import { Editor } from '../core/Editor';
@@ -21,25 +21,42 @@ interface Props {
className?: string; className?: string;
} }
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ response, pretty, className }: Props) { export function TextViewer({ response, pretty, className }: Props) {
const [isSearching, toggleIsSearching] = useToggle(); const [filterTextMap, setFilterTextMap] = useFilterText();
const [filterText, setDebouncedFilterText, setFilterText] = useDebouncedState<string>('', 400); const filterText = filterTextMap[response.id] ?? null;
const debouncedFilterText = useDebouncedValue(filterText, 300);
const setFilterText = useCallback(
(v: string | null) => {
setFilterTextMap((m) => ({ ...m, [response.id]: v }));
},
[setFilterTextMap, response],
);
const contentType = useContentTypeFromHeaders(response.headers); const contentType = useContentTypeFromHeaders(response.headers);
const rawBody = useResponseBodyText(response) ?? ''; const rawBody = useResponseBodyText(response) ?? '';
const isSearching = filterText != null;
const formattedBody = const formattedBody =
pretty && contentType?.includes('json') pretty && contentType?.includes('json')
? tryFormatJson(rawBody) ? tryFormatJson(rawBody)
: pretty && contentType?.includes('xml') : pretty && contentType?.includes('xml')
? tryFormatXml(rawBody) ? tryFormatXml(rawBody)
: rawBody; : rawBody;
const filteredResponse = useFilterResponse({ filter: filterText, responseId: response.id });
const body = filteredResponse ?? formattedBody; const filteredResponse = useFilterResponse({
const clearSearch = useCallback(() => { filter: debouncedFilterText ?? '',
toggleIsSearching(); responseId: response.id,
setFilterText(''); });
}, [setFilterText, toggleIsSearching]);
const body = isSearching && filterText?.length > 0 ? filteredResponse : formattedBody;
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
} else {
setFilterText('');
}
}, [isSearching, setFilterText]);
const isJson = contentType?.includes('json'); const isJson = contentType?.includes('json');
const isXml = contentType?.includes('xml') || contentType?.includes('html'); const isXml = contentType?.includes('xml') || contentType?.includes('html');
@@ -54,16 +71,17 @@ export function TextViewer({ response, pretty, className }: Props) {
result.push( result.push(
<div key="input" className="w-full !opacity-100"> <div key="input" className="w-full !opacity-100">
<Input <Input
key={response.id}
hideLabel hideLabel
autoFocus autoFocus
containerClassName="bg-gray-100 dark:bg-gray-50" containerClassName="bg-background"
size="sm" size="sm"
placeholder={isJson ? 'JSONPath expression' : 'XPath expression'} placeholder={isJson ? 'JSONPath expression' : 'XPath expression'}
label="Filter expression" label="Filter expression"
name="filter" name="filter"
defaultValue={filterText} defaultValue={filterText}
onKeyDown={(e) => e.key === 'Escape' && clearSearch()} onKeyDown={(e) => e.key === 'Escape' && toggleSearch()}
onChange={setDebouncedFilterText} onChange={setFilterText}
/> />
</div>, </div>,
); );
@@ -75,13 +93,16 @@ export function TextViewer({ response, pretty, className }: Props) {
size="sm" size="sm"
icon={isSearching ? 'x' : 'filter'} icon={isSearching ? 'x' : 'filter'}
title={isSearching ? 'Close filter' : 'Filter response'} title={isSearching ? 'Close filter' : 'Filter response'}
onClick={clearSearch} onClick={toggleSearch}
className={classNames(isSearching && '!opacity-100')} className={classNames(
'bg-background border !border-background-highlight',
isSearching && '!opacity-100',
)}
/>, />,
); );
return result; return result;
}, [canFilter, clearSearch, filterText, isJson, isSearching, setDebouncedFilterText]); }, [canFilter, filterText, isJson, isSearching, setFilterText, toggleSearch]);
return ( return (
<Editor <Editor

View File

@@ -18,6 +18,6 @@ export function useFilterResponse({
return (await invoke('cmd_filter_response', { responseId, filter })) as string | null; return (await invoke('cmd_filter_response', { responseId, filter })) as string | null;
}, },
}).data ?? null }).data ?? ''
); );
} }

View File

@@ -1,26 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { getCurrent } from '@tauri-apps/api/webviewWindow'; import { getCurrent } from '@tauri-apps/api/webviewWindow';
import { useEffect, useState } from 'react';
import { useWindowSize } from 'react-use'; import { useWindowSize } from 'react-use';
import { useDebouncedValue } from './useDebouncedValue';
export function useIsFullscreen() { export function useIsFullscreen() {
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const debouncedWindowWidth = useDebouncedValue(windowSize.width);
useEffect(() => { // NOTE: Fullscreen state isn't updated right after resize event on Mac (needs to wait for animation) so
(async function () { // we'll wait for a bit using the debounced window size. Hopefully Tauri eventually adds a way to listen
// Fullscreen state isn't updated right after resize event on Mac (needs to wait for animation) so // for fullscreen change events.
// we'll poll for 10 seconds to see if it changes. Hopefully Tauri eventually adds a way to listen
// for this.
for (let i = 0; i < 100; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
const newIsFullscreen = await getCurrent().isFullscreen();
if (newIsFullscreen !== isFullscreen) {
setIsFullscreen(newIsFullscreen);
break;
}
}
})();
}, [windowSize, isFullscreen]);
return isFullscreen; return (
useQuery({
queryKey: ['is_fullscreen', debouncedWindowWidth],
queryFn: async () => {
return getCurrent().isFullscreen();
},
}).data ?? false
);
} }

View File

@@ -40,12 +40,10 @@ export const colors = {
const moonlightDefault: YaakTheme = { const moonlightDefault: YaakTheme = {
id: 'moonlight', id: 'moonlight',
name: 'Moonlight', name: 'Moonlight',
background: new Color(colors.gray4, 'dark'), background: new Color('#222436', 'dark'),
backgroundHighlight: new Color(colors.gray5, 'dark'), foreground: new Color('#d5def8', 'dark'),
backgroundHighlightSecondary: new Color(colors.gray5, 'dark'), foregroundSubtle: new Color('#828bb8', 'dark'),
foreground: new Color(colors.gray11, 'dark'), foregroundSubtler: new Color('hsl(232,26%,43%)', 'dark'),
foregroundSubtle: new Color(colors.gray7, 'dark'),
foregroundSubtler: new Color(colors.gray6, 'dark'),
colors: { colors: {
primary: new Color(colors.purple, 'dark'), primary: new Color(colors.purple, 'dark'),
secondary: new Color(colors.desaturatedGray, 'dark'), secondary: new Color(colors.desaturatedGray, 'dark'),
@@ -58,13 +56,9 @@ const moonlightDefault: YaakTheme = {
components: { components: {
appHeader: { appHeader: {
background: new Color(colors.gray3, 'dark'), background: new Color(colors.gray3, 'dark'),
backgroundHighlight: new Color(colors.gray5, 'dark'),
backgroundHighlightSecondary: new Color(colors.gray4, 'dark'),
}, },
sidebar: { sidebar: {
background: new Color(colors.gray3, 'dark'), background: new Color(colors.gray3, 'dark'),
backgroundHighlight: new Color(colors.gray5, 'dark'),
backgroundHighlightSecondary: new Color(colors.gray4, 'dark'),
}, },
}, },
}; };