This commit is contained in:
Gregory Schier
2025-01-26 13:13:45 -08:00
committed by GitHub
parent 82b1ad35ff
commit f678593903
99 changed files with 3492 additions and 1583 deletions

View File

@@ -153,9 +153,10 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
label: 'Send Request',
onSelect: () => sendRequest(activeRequest.id),
});
for (const a of httpRequestActions) {
for (let i = 0; i < httpRequestActions.length; i++) {
const a = httpRequestActions[i]!;
commands.push({
key: a.key,
key: `http_request_action.${i}`,
label: a.label,
onSelect: () => a.call(activeRequest),
});

View File

@@ -73,7 +73,6 @@ export const CookieDropdown = memo(function CookieDropdown() {
...(((cookieJars ?? []).length > 1 // Never delete the last one
? [
{
key: 'delete',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
color: 'danger',

View File

@@ -7,6 +7,7 @@ import type {
FormInputHttpRequest,
FormInputSelect,
FormInputText,
JsonPrimitive,
} from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { useCallback } from 'react';
@@ -15,51 +16,93 @@ import { useFolders } from '../hooks/useFolders';
import { useHttpRequests } from '../hooks/useHttpRequests';
import { capitalize } from '../lib/capitalize';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Banner } from './core/Banner';
import { Checkbox } from './core/Checkbox';
import { Editor } from './core/Editor/Editor';
import { Input } from './core/Input';
import { Label } from './core/Label';
import { Select } from './core/Select';
import { VStack } from './core/Stacks';
import { Markdown } from './Markdown';
import { SelectFile } from './SelectFile';
// eslint-disable-next-line react-refresh/only-export-components
export const DYNAMIC_FORM_NULL_ARG = '__NULL__';
const INPUT_SIZE = 'sm';
export function DynamicForm<T extends Record<string, string | boolean>>({
config,
data,
onChange,
useTemplating,
autocompleteVariables,
stateKey,
}: {
config: FormInput[];
interface Props<T> {
inputs: FormInput[] | undefined | null;
onChange: (value: T) => void;
data: T;
useTemplating?: boolean;
autocompleteVariables?: boolean;
stateKey: string;
}) {
disabled?: boolean;
}
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
inputs,
data,
onChange,
useTemplating,
autocompleteVariables,
stateKey,
disabled,
}: Props<T>) {
const setDataAttr = useCallback(
(name: string, value: string | boolean | null) => {
(name: string, value: JsonPrimitive) => {
onChange({ ...data, [name]: value == DYNAMIC_FORM_NULL_ARG ? undefined : value });
},
[data, onChange],
);
return (
<FormInputs
disabled={disabled}
inputs={inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
useTemplating={useTemplating}
autocompleteVariables={autocompleteVariables}
data={data}
/>
);
}
function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs,
autocompleteVariables,
stateKey,
useTemplating,
setDataAttr,
data,
disabled,
}: Pick<Props<T>, 'inputs' | 'useTemplating' | 'autocompleteVariables' | 'stateKey' | 'data'> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
}) {
return (
<VStack space={3} className="h-full overflow-auto">
{config.map((a, i) => {
switch (a.type) {
{inputs?.map((input, i) => {
if ('hidden' in input && input.hidden) {
return null;
}
if ('disabled' in input && disabled != null) {
input.disabled = disabled;
}
switch (input.type) {
case 'select':
return (
<SelectArg
key={i + stateKey}
arg={a}
onChange={(v) => setDataAttr(a.name, v)}
arg={input}
onChange={(v) => setDataAttr(input.name, v)}
value={
data[a.name] ? String(data[a.name]) : (a.defaultValue ?? DYNAMIC_FORM_NULL_ARG)
data[input.name]
? String(data[input.name])
: (input.defaultValue ?? DYNAMIC_FORM_NULL_ARG)
}
/>
);
@@ -68,11 +111,13 @@ export function DynamicForm<T extends Record<string, string | boolean>>({
<TextArg
key={i}
stateKey={stateKey}
arg={a}
arg={input}
useTemplating={useTemplating || false}
autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(a.name, v)}
value={data[a.name] ? String(data[a.name]) : (a.defaultValue ?? '')}
onChange={(v) => setDataAttr(input.name, v)}
value={
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '')
}
/>
);
case 'editor':
@@ -80,40 +125,79 @@ export function DynamicForm<T extends Record<string, string | boolean>>({
<EditorArg
key={i}
stateKey={stateKey}
arg={a}
arg={input}
useTemplating={useTemplating || false}
autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(a.name, v)}
value={data[a.name] ? String(data[a.name]) : (a.defaultValue ?? '')}
onChange={(v) => setDataAttr(input.name, v)}
value={
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '')
}
/>
);
case 'checkbox':
return (
<CheckboxArg
key={i + stateKey}
arg={a}
onChange={(v) => setDataAttr(a.name, v)}
value={data[a.name] !== undefined ? data[a.name] === true : false}
arg={input}
onChange={(v) => setDataAttr(input.name, v)}
value={data[input.name] != null ? data[input.name] === true : false}
/>
);
case 'http_request':
return (
<HttpRequestArg
key={i + stateKey}
arg={a}
onChange={(v) => setDataAttr(a.name, v)}
value={data[a.name] ? String(data[a.name]) : DYNAMIC_FORM_NULL_ARG}
arg={input}
onChange={(v) => setDataAttr(input.name, v)}
value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG}
/>
);
case 'file':
return (
<FileArg
key={i + stateKey}
arg={a}
onChange={(v) => setDataAttr(a.name, v)}
filePath={data[a.name] ? String(data[a.name]) : DYNAMIC_FORM_NULL_ARG}
arg={input}
onChange={(v) => setDataAttr(input.name, v)}
filePath={
data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG
}
/>
);
case 'accordion':
return (
<Banner key={i} className={classNames('!p-0', disabled && 'opacity-disabled')}>
<details>
<summary className="px-3 py-1.5 text-text-subtle">{input.label}</summary>
<div className="mb-3 px-3">
<FormInputs
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
/>
</div>
</details>
</Banner>
);
case 'banner':
return (
<Banner
key={i}
color={input.color}
className={classNames(disabled && 'opacity-disabled')}
>
<FormInputs
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
/>
</Banner>
);
case 'markdown':
return <Markdown>{input.content}</Markdown>;
}
})}
</VStack>
@@ -145,13 +229,17 @@ function TextArg({
return (
<Input
name={arg.name}
multiLine={arg.multiLine}
onChange={handleChange}
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
required={!arg.optional}
disabled={arg.disabled}
type={arg.password ? 'password' : 'text'}
label={arg.label ?? arg.name}
size={INPUT_SIZE}
hideLabel={arg.label == null}
placeholder={arg.placeholder ?? arg.defaultValue ?? ''}
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
useTemplating={useTemplating}
autocompleteVariables={autocompleteVariables}
stateKey={stateKey}
@@ -184,23 +272,29 @@ function EditorArg({
const id = `input-${arg.name}`;
// Read-only editor force refresh for every defaultValue change
// Should this be built into the <Editor/> component?
const forceUpdateKey = arg.readOnly ? arg.defaultValue + stateKey : stateKey;
return (
<div className=" w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
<Label
htmlFor={id}
optional={arg.optional}
required={!arg.optional}
visuallyHidden={arg.hideLabel}
otherTags={arg.language ? [capitalize(arg.language)] : undefined}
tags={arg.language ? [capitalize(arg.language)] : undefined}
>
{arg.label}
</Label>
<Editor
id={id}
className={classNames(
'border border-border rounded-md overflow-hidden px-2 py-1.5',
'border border-border rounded-md overflow-hidden px-2 py-1',
'focus-within:border-border-focus',
'max-h-[15rem]', // So it doesn't take up too much space
)}
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
disabled={arg.disabled}
language={arg.language}
onChange={handleChange}
heightMode="auto"
@@ -209,7 +303,7 @@ function EditorArg({
useTemplating={useTemplating}
autocompleteVariables={autocompleteVariables}
stateKey={stateKey}
forceUpdateKey={stateKey}
forceUpdateKey={forceUpdateKey}
hideGutter
/>
</div>
@@ -232,12 +326,9 @@ function SelectArg({
onChange={onChange}
hideLabel={arg.hideLabel}
value={value}
options={[
...arg.options.map((a) => ({
label: a.name,
value: a.value,
})),
]}
size={INPUT_SIZE}
disabled={arg.disabled}
options={arg.options}
/>
);
}
@@ -253,6 +344,7 @@ function FileArg({
}) {
return (
<SelectFile
disabled={arg.disabled}
onChange={({ filePath }) => onChange(filePath)}
filePath={filePath === '__NULL__' ? null : filePath}
directory={!!arg.directory}
@@ -278,6 +370,7 @@ function HttpRequestArg({
name={arg.name}
onChange={onChange}
value={value}
disabled={arg.disabled}
options={[
...httpRequests.map((r) => {
return {
@@ -323,6 +416,7 @@ function CheckboxArg({
<Checkbox
onChange={onChange}
checked={value}
disabled={arg.disabled}
title={arg.label ?? arg.name}
hideLabel={arg.label == null}
/>

View File

@@ -1,4 +1,5 @@
import type { Environment } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
@@ -11,10 +12,7 @@ import { showPrompt } from '../lib/prompt';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { ContextMenu } from './core/Dropdown';
import type {
GenericCompletionConfig,
GenericCompletionOption,
} from './core/Editor/genericCompletion';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
@@ -255,7 +253,6 @@ function SidebarButton({
onClose={() => setShowContextMenu(null)}
items={[
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" size="sm" />,
onSelect: async () => {
@@ -277,7 +274,6 @@ function SidebarButton({
},
},
{
key: 'delete-environment',
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" size="sm" />,

View File

@@ -1,5 +1,5 @@
import { emit } from '@tauri-apps/api/event';
import type { PromptTextRequest, PromptTextResponse } from '@yaakapp-internal/plugins';
import type { InternalEvent } from '@yaakapp-internal/plugins';
import type { ShowToastRequest } from '@yaakapp/api';
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
@@ -12,6 +12,7 @@ import { useSyncModelStores } from '../hooks/useSyncModelStores';
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
import { useSyncZoomSetting } from '../hooks/useSyncZoomSetting';
import { useSubscribeTemplateFunctions } from '../hooks/useTemplateFunctions';
import { generateId } from '../lib/generateId';
import { showPrompt } from '../lib/prompt';
import { showToast } from '../lib/toast';
@@ -36,15 +37,24 @@ export function GlobalHooks() {
showToast({ ...event.payload });
});
// Listen for prompts
useListenToTauriEvent<{ replyId: string; args: PromptTextRequest }>(
'show_prompt',
async (event) => {
const value = await showPrompt(event.payload.args);
const result: PromptTextResponse = { value };
await emit(event.payload.replyId, result);
},
);
// Listen for plugin events
useListenToTauriEvent<InternalEvent>('plugin_event', async ({ payload: event }) => {
if (event.payload.type === 'prompt_text_request') {
const value = await showPrompt(event.payload);
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
windowContext: event.windowContext,
payload: {
type: 'prompt_text_response',
value,
},
};
await emit(event.id, result);
}
});
return null;
}

View File

@@ -69,13 +69,11 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
<Dropdown
items={[
{
key: 'refresh',
label: 'Refetch',
leftSlot: <Icon icon="refresh" />,
onSelect: refetch,
},
{
key: 'clear',
label: 'Clear',
onSelect: clear,
hidden: !schema,
@@ -84,7 +82,6 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
},
{ type: 'separator', label: 'Setting' },
{
key: 'auto_fetch',
label: 'Automatic Introspection',
onSelect: () => {
setAutoIntrospectDisabled({

View File

@@ -6,7 +6,7 @@ import type { CSSProperties } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { useContainerSize } from '../hooks/useContainerQuery';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { useHttpAuthentication } from '../hooks/useHttpAuthentication';
import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { fallbackRequestName } from '../lib/fallbackRequestName';
@@ -69,7 +69,7 @@ export function GrpcConnectionSetupPane({
onSend,
}: Props) {
const updateRequest = useUpdateAnyGrpcRequest();
const authentication = useHttpAuthentication();
const authentication = useHttpAuthenticationSummaries();
const [activeTabs, setActiveTabs] = useAtom(tabsAtom);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
@@ -237,7 +237,6 @@ export function GrpcConnectionSetupPane({
{
label: 'Refresh',
type: 'default',
key: 'custom',
leftSlot: <Icon className="text-text-subtlest" size="sm" icon="refresh" />,
},
]}

View File

@@ -1,4 +1,5 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import { charsets } from '../lib/data/charsets';
import { connections } from '../lib/data/connections';
import { encodings } from '../lib/data/encodings';
@@ -44,7 +45,7 @@ const headerOptionsMap: Record<string, string[]> = {
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionConfig['options'] =
const options: GenericCompletionOption[] =
headerOptionsMap[name]?.map((o) => ({
label: o,
type: 'constant',

View File

@@ -1,8 +1,14 @@
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
import React, { useCallback } from 'react';
import { useHttpAuthentication } from '../hooks/useHttpAuthentication';
import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { Checkbox } from './core/Checkbox';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { DynamicForm } from './DynamicForm';
import { EmptyStateText } from './EmptyStateText';
@@ -13,11 +19,15 @@ interface Props {
export function HttpAuthenticationEditor({ request }: Props) {
const updateHttpRequest = useUpdateAnyHttpRequest();
const updateGrpcRequest = useUpdateAnyGrpcRequest();
const auths = useHttpAuthentication();
const auth = auths.find((a) => a.name === request.authenticationType);
const auth = useHttpAuthenticationConfig(
request.authenticationType,
request.authentication,
request.id,
);
const handleChange = useCallback(
(authentication: Record<string, boolean>) => {
console.log('UPDATE', authentication);
if (request.model === 'http_request') {
updateHttpRequest.mutate({
id: request.id,
@@ -33,18 +43,42 @@ export function HttpAuthenticationEditor({ request }: Props) {
[request.id, request.model, updateGrpcRequest, updateHttpRequest],
);
if (auth == null) {
if (auth.data == null) {
return <EmptyStateText>No Authentication {request.authenticationType}</EmptyStateText>;
}
return (
<DynamicForm
autocompleteVariables
useTemplating
stateKey={`auth.${request.id}.${request.authenticationType}`}
config={auth.config}
data={request.authentication}
onChange={handleChange}
/>
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<HStack space={2} className="mb-1" alignItems="center">
<Checkbox
className="w-full"
checked={!request.authentication.disabled}
onChange={(disabled) => handleChange({ ...request.authentication, disabled: !disabled })}
title="Enabled"
/>
{auth.data.actions && (
<Dropdown
items={auth.data.actions.map(
(a): DropdownItem => ({
label: a.label,
leftSlot: a.icon ? <Icon icon={a.icon} /> : null,
onSelect: () => a.call(request),
}),
)}
>
<IconButton title="Authentication Actions" icon="settings" size="xs" />
</Dropdown>
)}
</HStack>
<DynamicForm
disabled={request.authentication.disabled}
autocompleteVariables
useTemplating
stateKey={`auth.${request.id}.${request.authenticationType}`}
inputs={auth.data.args}
data={request.authentication}
onChange={handleChange}
/>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import remarkGfm from 'remark-gfm';
import ReactMarkdown, { type Components } from 'react-markdown';
import { Prose } from './Prose';
export function Markdown({ children, className }: { children: string; className?: string }) {
return (
<Prose className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
</Prose>
);
}
const markdownComponents: Partial<Components> = {
// Ensure links open in external browser by adding target="_blank"
a: ({ href, children, ...rest }) => {
if (href && !href.match(/https?:\/\//)) {
href = `http://${href}`;
}
return (
<a target="_blank" rel="noreferrer noopener" href={href} {...rest}>
{children}
</a>
);
},
};

View File

@@ -1,12 +1,9 @@
import classNames from 'classnames';
import { useRef, useState } from 'react';
import type { Components } from 'react-markdown';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { EditorProps } from './core/Editor/Editor';
import { Editor } from './core/Editor/Editor';
import { Prose } from './Prose';
import { SegmentedControl } from './core/SegmentedControl';
import { Markdown } from './Markdown';
type ViewMode = 'edit' | 'preview';
@@ -47,11 +44,9 @@ export function MarkdownEditor({
defaultValue.length === 0 ? (
<p className="text-text-subtlest">No description</p>
) : (
<Prose className="max-w-xl overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
<Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{defaultValue}
</Markdown>
</Prose>
<Markdown className="max-w-xl overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
{defaultValue}
</Markdown>
);
const contents = viewMode === 'preview' ? preview : editor;
@@ -88,17 +83,3 @@ export function MarkdownEditor({
</div>
);
}
const markdownComponents: Partial<Components> = {
// Ensure links open in external browser by adding target="_blank"
a: ({ href, children, ...rest }) => {
if (href && !href.match(/https?:\/\//)) {
href = `http://${href}`;
}
return (
<a target="_blank" rel="noreferrer noopener" href={href} {...rest}>
{children}
</a>
);
},
};

View File

@@ -5,6 +5,10 @@
@apply mt-0;
}
& > :last-child {
@apply mb-0;
}
img,
video,
p,
@@ -107,6 +111,7 @@
ul code {
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
@apply px-1.5 py-0.5 rounded not-italic;
@apply select-text;
}
pre {

View File

@@ -27,13 +27,11 @@ export function RecentConnectionsDropdown({
<Dropdown
items={[
{
key: 'clear-single',
label: 'Clear Connection',
onSelect: deleteConnection.mutate,
disabled: connections.length === 0,
},
{
key: 'clear-all',
label: `Clear ${pluralizeCount('Connection', connections.length)}`,
onSelect: deleteAllConnections.mutate,
hidden: connections.length <= 1,
@@ -41,7 +39,6 @@ export function RecentConnectionsDropdown({
},
{ type: 'separator', label: 'History' },
...connections.slice(0, 20).map((c) => ({
key: c.id,
label: (
<HStack space={2}>
{formatDistanceToNowStrict(c.createdAt + 'Z')} ago &bull;{' '}

View File

@@ -58,7 +58,6 @@ export function RecentRequestsDropdown({ className }: Props) {
if (request === undefined) continue;
recentRequestItems.push({
key: request.id,
label: fallbackRequestName(request),
// leftSlot: <CountBadge className="!ml-0 px-0 w-5" count={recentRequestItems.length} />,
leftSlot: <HttpMethodTag className="text-right" shortNames request={request} />,

View File

@@ -32,7 +32,6 @@ export const RecentResponsesDropdown = function ResponsePane({
<Dropdown
items={[
{
key: 'save',
label: 'Save to File',
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
@@ -40,7 +39,6 @@ export const RecentResponsesDropdown = function ResponsePane({
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100,
},
{
key: 'copy',
label: 'Copy Body',
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
@@ -48,13 +46,11 @@ export const RecentResponsesDropdown = function ResponsePane({
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100,
},
{
key: 'clear-single',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: deleteResponse.mutate,
},
{
key: 'unpin',
label: 'Unpin Response',
onSelect: () => onPinnedResponseId(activeResponse.id),
leftSlot: <Icon icon="unpin" />,
@@ -63,7 +59,6 @@ export const RecentResponsesDropdown = function ResponsePane({
},
{ type: 'separator', label: 'History' },
{
key: 'clear-all',
label: `Delete ${responses.length} ${pluralize('Response', responses.length)}`,
onSelect: deleteAllResponses.mutate,
hidden: responses.length === 0,
@@ -71,7 +66,6 @@ export const RecentResponsesDropdown = function ResponsePane({
},
{ type: 'separator' },
...responses.slice(0, 20).map((r: HttpResponse) => ({
key: r.id,
label: (
<HStack space={2}>
<StatusTag className="text-sm" response={r} />

View File

@@ -1,4 +1,5 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import type {GenericCompletionOption} from "@yaakapp-internal/plugins";
import classNames from 'classnames';
import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
@@ -8,7 +9,7 @@ import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
import { useHttpAuthentication } from '../hooks/useHttpAuthentication';
import { useHttpAuthenticationSummaries } from '../hooks/useHttpAuthentication';
import { httpRequestsAtom } from '../hooks/useHttpRequests';
import { useImportCurl } from '../hooks/useImportCurl';
import { useImportQuerystring } from '../hooks/useImportQuerystring';
@@ -36,10 +37,7 @@ import { showToast } from '../lib/toast';
import { BinaryFileEditor } from './BinaryFileEditor';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor/Editor';
import type {
GenericCompletionConfig,
GenericCompletionOption,
} from './core/Editor/genericCompletion';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput';
@@ -93,7 +91,7 @@ export const RequestPane = memo(function RequestPane({
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }] = useRequestEditor();
const contentType = useContentTypeFromHeaders(activeRequest.headers);
const authentication = useHttpAuthentication();
const authentication = useHttpAuthenticationSummaries();
const handleContentTypeChange = useCallback(
async (contentType: string | null) => {

View File

@@ -28,14 +28,12 @@ export function SettingsDropdown() {
ref={dropdownRef}
items={[
{
key: 'settings',
label: 'Settings',
hotKeyAction: 'settings.show',
leftSlot: <Icon icon="settings" />,
onSelect: openSettings.mutate,
},
{
key: 'hotkeys',
label: 'Keyboard shortcuts',
hotKeyAction: 'hotkeys.showHelp',
leftSlot: <Icon icon="keyboard" />,
@@ -49,33 +47,28 @@ export function SettingsDropdown() {
},
},
{
key: 'import-data',
label: 'Import Data',
leftSlot: <Icon icon="folder_input" />,
onSelect: () => importData.mutate(),
},
{
key: 'export-data',
label: 'Export Data',
leftSlot: <Icon icon="folder_output" />,
onSelect: () => exportData.mutate(),
},
{ type: 'separator', label: `Yaak v${appInfo.version}` },
{
key: 'update-check',
label: 'Check for Updates',
leftSlot: <Icon icon="update" />,
onSelect: () => checkForUpdates.mutate(),
},
{
key: 'feedback',
label: 'Feedback',
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" />,
onSelect: () => openUrl('https://yaak.app/roadmap'),
},
{
key: 'changelog',
label: 'Changelog',
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" />,

View File

@@ -46,13 +46,11 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
if (child.model === 'folder') {
return [
{
key: 'send-all',
label: 'Send All',
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.id)),
},
{
key: 'folder-settings',
label: 'Settings',
leftSlot: <Icon icon="settings" />,
onSelect: () =>
@@ -64,13 +62,11 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
}),
},
{
key: 'duplicateFolder',
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => duplicateFolder.mutate(),
},
{
key: 'delete-folder',
label: 'Delete',
color: 'danger',
leftSlot: <Icon icon="trash" />,
@@ -84,7 +80,6 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
child.model === 'http_request'
? [
{
key: 'send-request',
label: 'Send',
hotKeyAction: 'http_request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
@@ -92,7 +87,6 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
onSelect: () => sendRequest.mutate(child.id),
},
...httpRequestActions.map((a) => ({
key: a.key,
label: a.label,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
@@ -107,13 +101,11 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
return [
...requestItems,
{
key: 'rename-request',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: renameRequest.mutate,
},
{
key: 'duplicate-request',
label: 'Duplicate',
hotKeyAction: 'http_request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
@@ -124,14 +116,12 @@ export function SidebarItemContextMenu({ child, show, close }: Props) {
: duplicateGrpcRequest.mutate(),
},
{
key: 'move-workspace',
label: 'Move',
leftSlot: <Icon icon="arrow_right_circle" />,
hidden: workspaces.length <= 1,
onSelect: moveToWorkspace.mutate,
},
{
key: 'delete-request',
color: 'danger',
label: 'Delete',
hotKeyAction: 'http_request.delete',

View File

@@ -25,6 +25,10 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
? initialTokens.tokens[0]?.val.args
: [];
for (const arg of templateFunction.args) {
if (!('name' in arg)) {
// Skip visual-only args
continue;
}
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
@@ -79,7 +83,7 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
<VStack className="pb-3" space={4}>
<h1 className="font-mono !text-base">{templateFunction.name}()</h1>
<DynamicForm
config={templateFunction.args}
inputs={templateFunction.args}
data={argValues}
onChange={setArgValues}
stateKey={`template_function.${templateFunction.name}`}

View File

@@ -46,7 +46,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const extraItems: DropdownItem[] = [
{
key: 'workspace-settings',
label: 'Workspace Settings',
leftSlot: <Icon icon="settings" />,
hotKeyAction: 'workspace_settings.show',
@@ -62,7 +61,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
},
},
{
key: 'reveal-workspace-sync-dir',
label: revealInFinderText,
hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,
leftSlot: <Icon icon="folder_open" />,
@@ -72,7 +70,6 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
},
},
{
key: 'delete-responses',
label: 'Clear Send History',
color: 'warning',
leftSlot: <Icon icon="history" />,
@@ -80,13 +77,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
},
{ type: 'separator' },
{
key: 'create-workspace',
label: 'New Workspace',
leftSlot: <Icon icon="plus" />,
onSelect: createWorkspace,
},
{
key: 'open-workspace',
label: 'Open Workspace',
leftSlot: <Icon icon="folder" />,
onSelect: openWorkspaceFromSyncDir.mutate,

View File

@@ -7,7 +7,7 @@ interface Props {
color?: 'primary' | 'secondary' | 'success' | 'notice' | 'warning' | 'danger' | 'info';
}
export function Banner({ children, className, color = 'secondary' }: Props) {
export function Banner({ children, className, color }: Props) {
return (
<div>
<div
@@ -16,7 +16,7 @@ export function Banner({ children, className, color = 'secondary' }: Props) {
`x-theme-banner--${color}`,
'whitespace-pre-wrap',
'border border-dashed border-border bg-surface',
'px-3 py-2 rounded select-auto cursor-text',
'px-3 py-2 rounded select-auto',
'overflow-x-auto text-text',
)}
>

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { type ReactNode } from 'react';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon';
import { HStack } from './Stacks';
@@ -26,17 +26,15 @@ export function Checkbox({
event,
}: CheckboxProps) {
return (
<HStack
as="label"
space={2}
className={classNames(className, 'text-text mr-auto', disabled && 'opacity-disabled')}
>
<HStack as="label" space={2} className={classNames(className, 'text-text mr-auto')}>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
<input
aria-hidden
className={classNames(
'appearance-none w-4 h-4 flex-shrink-0 border border-border',
'rounded hocus:border-border-focus hocus:bg-focus/[5%] outline-none ring-0',
'rounded outline-none ring-0',
!disabled && 'hocus:border-border-focus hocus:bg-focus/[5%] ',
disabled && 'border-dotted',
)}
type="checkbox"
disabled={disabled}
@@ -54,7 +52,7 @@ export function Checkbox({
/>
</div>
</div>
{!hideLabel && title}
<span className={classNames(disabled && 'opacity-disabled')}>{!hideLabel && title}</span>
</HStack>
);
}

View File

@@ -44,7 +44,6 @@ export type DropdownItemSeparator = {
};
export type DropdownItemDefault = {
key: string;
type?: 'default';
label: ReactNode;
keepOpen?: boolean;
@@ -465,11 +464,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
return (
<>
{items.map(
(item) =>
(item, i) =>
item.type !== 'separator' &&
!item.hotKeyLabelOnly && (
!item.hotKeyLabelOnly &&
item.hotKeyAction && (
<MenuItemHotKey
key={item.key}
key={`${item.hotKeyAction}::${i}`}
onSelect={handleSelect}
item={item}
action={item.hotKeyAction}
@@ -542,7 +542,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
focused={i === selectedIndex}
onFocus={handleFocus}
onSelect={handleSelect}
key={item.key}
key={`item_${i}`}
item={item}
/>
);

View File

@@ -5,6 +5,7 @@
@apply w-full block text-base;
/* Regular cursor */
.cm-cursor {
@apply border-text !important;
/* Widen the cursor a bit */
@@ -12,6 +13,7 @@
}
/* Vim-mode cursor */
.cm-fat-cursor {
@apply outline-0 bg-text !important;
@apply text-surface !important;
@@ -181,7 +183,7 @@
@apply hidden !important;
}
&.cm-singleline .cm-line {
&.cm-singleline * {
@apply cursor-default;
}
}

View File

@@ -1,4 +1,4 @@
import { defaultKeymap, historyField } from '@codemirror/commands';
import { defaultKeymap, historyField, indentWithTab } from '@codemirror/commands';
import { foldState, forceParsing } from '@codemirror/language';
import type { EditorStateConfig, Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
@@ -34,14 +34,22 @@ import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import {
baseExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
} from './extensions';
import type { GenericCompletionConfig } from './genericCompletion';
import { singleLineExtensions } from './singleLine';
// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== 'Tab');
const keymapExtensions: Record<EditorKeymap, Extension> = {
vim: vim(),
emacs: emacs(),
vscode: keymap.of(vscodeKeymap),
vscode: keymap.of(vsCodeWithoutTab),
default: [],
};
@@ -68,6 +76,7 @@ export interface EditorProps {
onKeyDown?: (e: KeyboardEvent) => void;
singleLine?: boolean;
wrapLines?: boolean;
disableTabIndent?: boolean;
format?: (v: string) => Promise<string>;
autocomplete?: GenericCompletionConfig;
autocompleteVariables?: boolean;
@@ -85,9 +94,9 @@ const emptyExtension: Extension = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
type = 'text',
type,
heightMode,
language = 'text',
language,
autoFocus,
autoSelect,
placeholder,
@@ -101,6 +110,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onBlur,
onKeyDown,
className,
disabled,
singleLine,
format,
autocomplete,
@@ -108,6 +118,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
autocompleteVariables,
actions,
wrapLines,
disableTabIndent,
hideGutter,
stateKey,
}: EditorProps,
@@ -122,6 +133,20 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
wrapLines = settings.editorSoftWrap;
}
if (disabled) {
readOnly = true;
}
if (
singleLine ||
language == null ||
language === 'text' ||
language === 'url' ||
language === 'pairs'
) {
disableTabIndent = true;
}
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view, []);
@@ -166,7 +191,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
useEffect(
function configurePlaceholder() {
if (cm.current === null) return;
const ext = placeholderExt(placeholderElFromText(placeholder ?? '', type));
const ext = placeholderExt(placeholderElFromText(placeholder, type));
const effects = placeholderCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
@@ -209,6 +234,23 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[wrapLines],
);
// Update tab indent
const tabIndentCompartment = useRef(new Compartment());
useEffect(
function configureTabIndent() {
if (cm.current === null) return;
const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (disableTabIndent && current !== emptyExtension) return; // Nothing to do
if (!disableTabIndent && current === emptyExtension) return; // Nothing to do
const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;
const effects = tabIndentCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[disableTabIndent],
);
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
@@ -342,9 +384,12 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder ?? '', type)),
placeholderExt(placeholderElFromText(placeholder, type)),
),
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
tabIndentCompartment.current.of(
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
),
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions['default'],
),
@@ -475,6 +520,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
className={classNames(
className,
'cm-wrapper text-base',
disabled && 'opacity-disabled',
type === 'password' && 'cm-obscure-text',
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
singleLine ? 'cm-singleline' : 'cm-multiline',
@@ -557,10 +603,8 @@ function getExtensions({
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? [multiLineExtensions({ hideGutter })] : []),
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
// ------------------------ //
// Things that must be last //
@@ -580,13 +624,15 @@ function getExtensions({
];
}
const placeholderElFromText = (text: string, type: EditorProps['type']) => {
const placeholderElFromText = (text: string | undefined, type: EditorProps['type']) => {
const el = document.createElement('div');
if (type === 'password') {
// Will be obscured (dots) so just needs to be something to take up space
el.innerHTML = 'something-cool';
el.setAttribute('aria-hidden', 'true');
} else {
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
}
return el;
@@ -596,8 +642,8 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
// Save state in sessionStorage by removing doc and saving the hash of it instead
// This will be checked on restore and put back in if it matches
// Save state in sessionStorage by removing doc and saving the hash of it instead.
// This will be checked on restore and put back in if it matches.
stateObj.docHash = md5(stateObj.doc);
delete stateObj.doc;

View File

@@ -4,7 +4,7 @@ import {
closeBracketsKeymap,
completionKeymap,
} from '@codemirror/autocomplete';
import { history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { history, historyKeymap } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
@@ -142,6 +142,11 @@ export const baseExtensions = [
keymap.of([...historyKeymap, ...completionKeymap]),
];
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: '-1' }),
];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
hideGutter
? []
@@ -208,5 +213,5 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
rectangularSelection(),
crosshairCursor(),
highlightActiveLineGutter(),
keymap.of([indentWithTab, ...closeBracketsKeymap, ...searchKeymap, ...foldKeymap, ...lintKeymap]),
keymap.of([...closeBracketsKeymap, ...searchKeymap, ...foldKeymap, ...lintKeymap]),
];

View File

@@ -1,16 +1,5 @@
import type { CompletionContext } from '@codemirror/autocomplete';
export interface GenericCompletionOption {
label: string;
type: 'constant' | 'variable';
detail?: string;
info?: string;
/** When given, should be a number from -99 to 99 that adjusts
* how this completion is ranked compared to other completions
* that match the input as well as this one. A negative number
* moves it down the list, a positive number moves it up. */
boost?: number;
}
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
export interface GenericCompletionConfig {
minMatch?: number;

View File

@@ -16,6 +16,7 @@ export type InputProps = Pick<
| 'useTemplating'
| 'autocomplete'
| 'forceUpdateKey'
| 'disabled'
| 'autoFocus'
| 'autoSelect'
| 'autocompleteVariables'
@@ -75,6 +76,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
readOnly,
stateKey,
multiLine,
disabled,
...props
}: InputProps,
ref,
@@ -82,18 +84,26 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [stateKey, forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
const handleFocus = useCallback(() => {
if (readOnly) return;
setFocused(true);
// Select all text on focus
editorRef.current?.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
onFocus?.();
}, [onFocus, readOnly]);
const handleBlur = useCallback(() => {
setFocused(false);
editorRef.current?.dispatch({ selection: { anchor: 0 } });
// Move selection to the end on blur
editorRef.current?.dispatch({
selection: { anchor: editorRef.current.state.doc.length },
});
onBlur?.();
}, [onBlur]);
@@ -114,13 +124,14 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
(value: string) => {
setCurrentValue(value);
onChange?.(value);
setHasChanged(true);
},
[onChange],
[onChange, setHasChanged],
);
const wrapperRef = useRef<HTMLDivElement>(null);
// Submit nearest form on Enter key press
// Submit the nearest form on Enter key press
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
@@ -145,7 +156,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
>
<Label
htmlFor={id.current}
optional={!required}
required={required}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
@@ -158,8 +169,9 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
'x-theme-input',
'relative w-full rounded-md text',
'border',
focused ? 'border-border-focus' : 'border-border',
!isValid && '!border-danger',
focused && !disabled ? 'border-border-focus' : 'border-border',
disabled && 'border-dotted',
!isValid && hasChanged && '!border-danger',
size === 'md' && 'min-h-md',
size === 'sm' && 'min-h-sm',
size === 'xs' && 'min-h-xs',
@@ -190,7 +202,12 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
onChange={handleChange}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
className={classNames(editorClassName, multiLine && 'py-1.5')}
disabled={disabled}
className={classNames(
editorClassName,
multiLine && size === 'md' && 'py-1.5',
multiLine && size === 'sm' && 'py-1',
)}
onFocus={handleFocus}
onBlur={handleBlur}
readOnly={readOnly}
@@ -201,7 +218,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className="mr-0.5 group/obscure !h-auto my-0.5"
className={classNames("mr-0.5 group/obscure !h-auto my-0.5", disabled && 'opacity-disabled')}
iconClassName="text-text-subtle group-hover/obscure:text"
iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'}

View File

@@ -4,30 +4,32 @@ import type { HTMLAttributes } from 'react';
export function Label({
htmlFor,
className,
optional,
children,
visuallyHidden,
otherTags = [],
tags = [],
required,
...props
}: HTMLAttributes<HTMLLabelElement> & {
htmlFor: string;
optional?: boolean;
otherTags?: string[];
required?: boolean;
tags?: string[];
visuallyHidden?: boolean;
}) {
const tags = optional ? ['optional', ...otherTags] : otherTags;
return (
<label
htmlFor={htmlFor}
className={classNames(
className,
visuallyHidden && 'sr-only',
'flex-shrink-0',
'flex-shrink-0 text-sm',
'text-text-subtle whitespace-nowrap flex items-center gap-1',
)}
htmlFor={htmlFor}
{...props}
>
{children}
<span>
{children}
{required === true && <span className="text-text-subtlest">*</span>}
</span>
{tags.map((tag, i) => (
<span key={i} className="text-xs text-text-subtlest">
({tag})

View File

@@ -168,7 +168,8 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
);
const handleChange = useCallback(
(pair: PairWithId) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
(pair: PairWithId) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
[setPairsAndSave],
);
@@ -344,7 +345,6 @@ function PairEditorRow({
const deleteItems = useMemo(
(): DropdownItem[] => [
{
key: 'delete',
label: 'Delete',
onSelect: handleDelete,
color: 'danger',
@@ -570,7 +570,6 @@ function FileActionsDropdown({
const extraItems = useMemo<DropdownItem[]>(
() => [
{
key: 'mime',
label: 'Set Content-Type',
leftSlot: <Icon icon="pencil" />,
hidden: !pair.isFile,
@@ -589,7 +588,6 @@ function FileActionsDropdown({
},
},
{
key: 'clear-file',
label: 'Unset File',
leftSlot: <Icon icon="x" />,
hidden: pair.isFile,
@@ -598,7 +596,6 @@ function FileActionsDropdown({
},
},
{
key: 'delete',
label: 'Delete',
onSelect: onDelete,
variant: 'danger',

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { HTMLAttributes, FocusEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import type { FocusEvent, HTMLAttributes } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
@@ -41,8 +41,8 @@ export function PlainInput({
onFocusRaw,
}: PlainInputProps) {
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -71,19 +71,19 @@ export function PlainInput({
'px-2 text-xs font-mono cursor-text',
);
const isValid = useMemo(() => {
if (required && !validateRequire(currentValue)) return false;
if (typeof validate === 'boolean') return validate;
if (typeof validate === 'function' && !validate(currentValue)) return false;
return true;
}, [required, currentValue, validate]);
const handleChange = useCallback(
(value: string) => {
setCurrentValue(value);
onChange?.(value);
setHasChanged(true);
const isValid = (value: string) => {
if (required && !validateRequire(value)) return false;
if (typeof validate === 'boolean') return validate;
if (typeof validate === 'function' && !validate(value)) return false;
return true;
};
inputRef.current?.setCustomValidity(isValid(value) ? '' : 'Invalid value');
},
[onChange],
[onChange, required, validate],
);
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -98,12 +98,7 @@ export function PlainInput({
labelPosition === 'top' && 'flex-row gap-0.5',
)}
>
<Label
htmlFor={id}
className={labelClassName}
visuallyHidden={hideLabel}
optional={!required}
>
<Label htmlFor={id} className={labelClassName} visuallyHidden={hideLabel} required={required}>
{label}
</Label>
<HStack
@@ -114,7 +109,7 @@ export function PlainInput({
'relative w-full rounded-md text',
'border',
focused ? 'border-border-focus' : 'border-border-subtle',
!isValid && '!border-danger',
hasChanged && 'has-[:invalid]:border-danger', // For built-in HTML validation
size === 'md' && 'min-h-md',
size === 'sm' && 'min-h-sm',
size === 'xs' && 'min-h-xs',

View File

@@ -2,7 +2,7 @@ import type { PromptTextRequest } from '@yaakapp-internal/plugins';
import type { FormEvent, ReactNode } from 'react';
import { useCallback, useState } from 'react';
import { Button } from './Button';
import { Input } from './Input';
import { PlainInput } from './PlainInput';
import { HStack } from './Stacks';
export type PromptProps = Omit<PromptTextRequest, 'id' | 'title' | 'description'> & {
@@ -35,7 +35,7 @@ export function Prompt({
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
onSubmit={handleSubmit}
>
<Input
<PlainInput
hideLabel
autoSelect
required={required}
@@ -43,7 +43,6 @@ export function Prompt({
label={label}
defaultValue={defaultValue}
onChange={setValue}
stateKey={null}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">

View File

@@ -23,12 +23,14 @@ export interface SelectProps<T extends string> {
size?: ButtonProps['size'];
className?: string;
event?: string;
disabled?: boolean;
}
export function Select<T extends string>({
labelPosition = 'top',
name,
labelClassName,
disabled,
hideLabel,
label,
value,
@@ -72,7 +74,8 @@ export function Select<T extends string>({
'w-full rounded-md text text-sm font-mono',
'pl-2',
'border',
focused ? 'border-border-focus' : 'border-border',
focused && !disabled ? 'border-border-focus' : 'border-border',
disabled && 'border-dotted',
isInvalidSelection && 'border-danger',
size === 'xs' && 'h-xs',
size === 'sm' && 'h-sm',
@@ -86,7 +89,8 @@ export function Select<T extends string>({
onChange={(e) => handleChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')}
className={classNames('pr-7 w-full outline-none bg-transparent disabled:opacity-disabled')}
disabled={disabled}
>
{isInvalidSelection && <option value={'__NONE__'}>-- Select an Option --</option>}
{options.map((o) => {
@@ -109,6 +113,7 @@ export function Select<T extends string>({
variant="border"
size={size}
leftSlot={leftSlot}
disabled={disabled}
forDropdown
>
{options.find((o) => o.type !== 'separator' && o.value === value)?.label ?? '--'}

View File

@@ -20,9 +20,8 @@ export interface ToastProps {
color?: ShowToastRequest['color'];
}
const ICONS: Record<NonNullable<ToastProps['color']>, IconProps['icon'] | null> = {
const ICONS: Record<NonNullable<ToastProps['color'] | 'custom'>, IconProps['icon'] | null> = {
custom: null,
default: 'info',
danger: 'alert_triangle',
info: 'info',
notice: 'alert_triangle',
@@ -42,9 +41,8 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
{},
[open],
);
color = color ?? 'default';
const toastIcon = icon ?? (color in ICONS && ICONS[color]);
const toastIcon = icon ?? (color && color in ICONS && ICONS[color]);
return (
<motion.div