Setting to colorize HTTP methods

https://feedback.yaak.app/p/support-colors-for-http-method-in-sidebar
This commit is contained in:
Gregory Schier
2025-06-04 10:59:40 -07:00
parent 58873ea606
commit 2562cf7c55
17 changed files with 93 additions and 66 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN colored_methods BOOLEAN DEFAULT FALSE;

View File

@@ -62,7 +62,7 @@ export type ProxySetting = { "type": "enabled", disabled: boolean, http: string,
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, editorFontSize: number, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, editorKeymap: EditorKeymap, coloredMethods: boolean, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };

View File

@@ -1,9 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment } from "./gen_models";
import type { Folder } from "./gen_models";
import type { GrpcRequest } from "./gen_models";
import type { HttpRequest } from "./gen_models";
import type { WebsocketRequest } from "./gen_models";
import type { Workspace } from "./gen_models";
import type { Environment } from "./gen_models.js";
import type { Folder } from "./gen_models.js";
import type { GrpcRequest } from "./gen_models.js";
import type { HttpRequest } from "./gen_models.js";
import type { WebsocketRequest } from "./gen_models.js";
import type { Workspace } from "./gen_models.js";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };

View File

@@ -114,6 +114,7 @@ pub struct Settings {
pub theme_light: String,
pub update_channel: String,
pub editor_keymap: EditorKeymap,
pub colored_methods: bool,
}
impl UpsertModelInfo for Settings {
@@ -160,6 +161,7 @@ impl UpsertModelInfo for Settings {
(ThemeDark, self.theme_dark.as_str().into()),
(ThemeLight, self.theme_light.as_str().into()),
(UpdateChannel, self.update_channel.into()),
(ColoredMethods, self.colored_methods.into()),
(Proxy, proxy.into()),
])
}
@@ -179,6 +181,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::ThemeDark,
SettingsIden::ThemeLight,
SettingsIden::UpdateChannel,
SettingsIden::ColoredMethods,
]
}
@@ -205,6 +208,7 @@ impl UpsertModelInfo for Settings {
theme_light: row.get("theme_light")?,
hide_window_controls: row.get("hide_window_controls")?,
update_channel: row.get("update_channel")?,
colored_methods: row.get("colored_methods")?,
})
}
}

View File

@@ -29,6 +29,7 @@ impl<'a> DbContext<'a> {
theme_dark: "yaak-dark".to_string(),
theme_light: "yaak-light".to_string(),
update_channel: "stable".to_string(),
colored_methods: false,
};
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
}

View File

@@ -236,7 +236,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
return workspaces;
}
const r = [...workspaces].sort((a, b) => {
return [...workspaces].sort((a, b) => {
const aRecentIndex = recentWorkspaces?.indexOf(a.id);
const bRecentIndex = recentWorkspaces?.indexOf(b.id);
@@ -250,7 +250,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
return a.createdAt.localeCompare(b.createdAt);
}
});
return r;
}, [recentWorkspaces, workspaces]);
const groups = useMemo<CommandPaletteGroup[]>(() => {
@@ -272,7 +271,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
searchText: resolvedModelNameWithFolders(r),
label: (
<HStack space={2}>
<HttpMethodTag className="text-text-subtlest" request={r} />
<HttpMethodTag short className="text-xs" request={r} />
<div className="truncate">{resolvedModelNameWithFolders(r)}</div>
</HStack>
),

View File

@@ -41,8 +41,7 @@ import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { TabContent, TabItem, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
@@ -50,6 +49,7 @@ import { GraphQLEditor } from './GraphQLEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
import { RequestMethodDropdown } from './RequestMethodDropdown';
import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
@@ -138,10 +138,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ||
activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART
) {
const n = Array.isArray(activeRequest.body?.form)
numParams = Array.isArray(activeRequest.body?.form)
? activeRequest.body.form.filter((p) => p.name).length
: 0;
numParams = n;
}
const tabs = useMemo<TabItem[]>(
@@ -314,11 +313,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest.id, sendRequest],
);
const handleMethodChange = useCallback(
(method: string) => patchModel(activeRequest, { method }),
[activeRequest],
);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
[activeRequest],
@@ -335,14 +329,17 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
method={activeRequest.method}
placeholder="https://example.com"
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={handleSend}
onCancel={cancelResponse}
onMethodChange={handleMethodChange}
onUrlChange={handleUrlChange}
leftSlot={
<div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" />
</div>
}
forceUpdateKey={updateKey}
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
/>

View File

@@ -67,7 +67,7 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
...responses.map((r: HttpResponse) => ({
label: (
<HStack space={2}>
<HttpStatusTag className="text-sm" response={r} />
<HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtle">&rarr;</span>{' '}
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : 'n/a'}</span>
</HStack>

View File

@@ -58,7 +58,7 @@ export function RecentRequestsDropdown({ className }: Props) {
recentRequestItems.push({
label: resolvedModelName(request),
leftSlot: <HttpMethodTag request={request} />,
leftSlot: <HttpMethodTag short className="text-xs" request={request} />,
onSelect: async () => {
await router.navigate({
to: '/workspaces/$workspaceId',

View File

@@ -1,16 +1,17 @@
import { HttpRequest, patchModel } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { showPrompt } from '../lib/prompt';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown';
type Props = {
method: string;
request: HttpRequest;
className?: string;
onChange: (method: string) => void;
};
const radioItems: RadioDropdownItem<string>[] = [
@@ -28,10 +29,13 @@ const radioItems: RadioDropdownItem<string>[] = [
}));
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
method,
onChange,
request,
className,
}: Props) {
const handleChange = useCallback(async (method: string) => {
await patchModel(request, { method });
}, []);
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
@@ -49,17 +53,22 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
placeholder: 'CUSTOM',
});
if (newMethod == null) return;
onChange(newMethod);
await handleChange(newMethod);
},
},
],
[onChange],
[],
);
return (
<RadioDropdown value={method} items={radioItems} itemsAfter={itemsAfter} onChange={onChange}>
<RadioDropdown
value={request.method}
items={radioItems}
itemsAfter={itemsAfter}
onChange={handleChange}
>
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}>
{method.toUpperCase()}
<HttpMethodTag request={request}/>
</Button>
</RadioDropdown>
);

View File

@@ -122,6 +122,11 @@ export function SettingsAppearance() {
title="Wrap Editor Lines"
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
/>
<Checkbox
checked={settings.coloredMethods}
title="Colorize Request Methods"
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
/>
{type() !== 'macos' && (
<Checkbox

View File

@@ -9,11 +9,9 @@ import { IconButton } from './core/IconButton';
import type { InputProps } from './core/Input';
import { Input } from './core/Input';
import { HStack } from './core/Stacks';
import { RequestMethodDropdown } from './RequestMethodDropdown';
type Props = Pick<HttpRequest, 'url'> & {
className?: string;
method: HttpRequest['method'] | null;
placeholder: string;
onSend: () => void;
onUrlChange: (url: string) => void;
@@ -21,10 +19,10 @@ type Props = Pick<HttpRequest, 'url'> & {
onPasteOverwrite?: InputProps['onPasteOverwrite'];
onCancel: () => void;
submitIcon?: IconProps['icon'] | null;
onMethodChange?: (method: string) => void;
isLoading: boolean;
forceUpdateKey: string;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
autocomplete?: InputProps['autocomplete'];
stateKey: InputProps['stateKey'];
};
@@ -33,16 +31,15 @@ export const UrlBar = memo(function UrlBar({
forceUpdateKey,
onUrlChange,
url,
method,
placeholder,
className,
onSend,
onCancel,
onMethodChange,
onPaste,
onPasteOverwrite,
submitIcon = 'send_horizontal',
autocomplete,
leftSlot,
rightSlot,
isLoading,
stateKey,
@@ -87,18 +84,7 @@ export const UrlBar = memo(function UrlBar({
onChange={onUrlChange}
defaultValue={url}
placeholder={placeholder}
leftSlot={
method != null &&
onMethodChange != null && (
<div className="py-0.5">
<RequestMethodDropdown
method={method}
onChange={onMethodChange}
className="ml-0.5 !h-full"
/>
</div>
)
}
leftSlot={leftSlot}
rightSlot={
<HStack space={0.5}>
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}

View File

@@ -642,7 +642,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
justify="start"
leftSlot={
(isLoading || item.leftSlot) && (
<div className={classNames('pr-2 flex justify-start opacity-70')}>
<div className={classNames('pr-2 flex justify-start [&_svg]:opacity-70')}>
{isLoading ? <LoadingIcon /> : item.leftSlot}
</div>
)

View File

@@ -1,10 +1,11 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
import { GrpcRequest, HttpRequest, settingsAtom, WebsocketRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
interface Props {
request: HttpRequest | GrpcRequest | WebsocketRequest;
className?: string;
shortNames?: boolean;
short?: boolean;
}
const methodNames: Record<string, string> = {
@@ -18,7 +19,8 @@ const methodNames: Record<string, string> = {
query: 'QURY',
};
export function HttpMethodTag({ request, className }: Props) {
export function HttpMethodTag({ request, className, short }: Props) {
const settings = useAtomValue(settingsAtom);
const method =
request.model === 'http_request' && request.bodyType === 'graphql'
? 'GQL'
@@ -26,19 +28,34 @@ export function HttpMethodTag({ request, className }: Props) {
? 'GRPC'
: request.model === 'websocket_request'
? 'WS'
: (methodNames[request.method.toLowerCase()] ?? request.method.slice(0, 4));
: request.method;
let label = method.toUpperCase();
const paddedMethod = method.padStart(4, ' ').toUpperCase();
if (short) {
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
label = label.padStart(4, ' ');
}
return (
<span
className={classNames(
className,
'text-xs font-mono text-text-subtle flex-shrink-0 whitespace-pre',
!settings.coloredMethods && 'text-text-subtle',
settings.coloredMethods && method === 'GQL' && 'text-info',
settings.coloredMethods && method === 'WS' && 'text-info',
settings.coloredMethods && method === 'GRPC' && 'text-info',
settings.coloredMethods && method === 'OPTIONS' && 'text-info',
settings.coloredMethods && method === 'HEAD' && 'text-info',
settings.coloredMethods && method === 'GET' && 'text-primary',
settings.coloredMethods && method === 'PUT' && 'text-warning',
settings.coloredMethods && method === 'PATCH' && 'text-notice',
settings.coloredMethods && method === 'POST' && 'text-success',
settings.coloredMethods && method === 'DELETE' && 'text-danger',
'font-mono flex-shrink-0 whitespace-pre',
'pt-[0.25em]', // Fix for monospace font not vertically centering
)}
>
{paddedMethod}
{label}
</span>
);
}

View File

@@ -5,19 +5,20 @@ interface Props {
response: HttpResponse;
className?: string;
showReason?: boolean;
short?: boolean;
}
export function HttpStatusTag({ response, className, showReason }: Props) {
export function HttpStatusTag({ response, className, showReason, short }: Props) {
const { status, state } = response;
let colorClass;
let label = `${status}`;
if (state === 'initialized') {
label = 'CONNECTING';
label = short ? 'CONN' : 'CONNECTING';
colorClass = 'text-text-subtle';
} else if (status < 100) {
label = 'ERROR';
label = short ? 'ERR' : 'ERROR';
colorClass = 'text-danger';
} else if (status < 200) {
colorClass = 'text-info';
@@ -33,8 +34,7 @@ export function HttpStatusTag({ response, className, showReason }: Props) {
return (
<span className={classNames(className, 'font-mono', colorClass)}>
{label}{' '}
{showReason && 'statusReason' in response ? response.statusReason : null}
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
</span>
);
}

View File

@@ -53,11 +53,11 @@ export type InputProps = Pick<
> & {
className?: string;
containerClassName?: string;
inputWrapperClassName?: string;
defaultValue?: string | null;
disableObscureToggle?: boolean;
fullHeight?: boolean;
hideLabel?: boolean;
inputWrapperClassName?: string;
help?: ReactNode;
label: ReactNode;
labelClassName?: string;

View File

@@ -12,7 +12,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from '
import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
import { activeRequestAtom } from '../../hooks/useActiveRequest';
import {allRequestsAtom} from "../../hooks/useAllRequests";
import { allRequestsAtom } from '../../hooks/useAllRequests';
import { useScrollIntoView } from '../../hooks/useScrollIntoView';
import { useSidebarItemCollapsed } from '../../hooks/useSidebarItemCollapsed';
import { jotaiStore } from '../../lib/jotai';
@@ -214,10 +214,13 @@ export const SidebarItem = memo(function SidebarItem({
return null;
}
const opacitySubtle = 'opacity-80';
const itemPrefix = item.model !== 'folder' && (
<HttpMethodTag
short
request={item}
className={classNames(!(active || selected) && 'text-text-subtlest')}
className={classNames('text-xs', !(active || selected) && opacitySubtle)}
/>
);
@@ -287,7 +290,11 @@ export const SidebarItem = memo(function SidebarItem({
{latestHttpResponse.state !== 'closed' ? (
<LoadingIcon size="sm" className="text-text-subtlest" />
) : (
<HttpStatusTag className="text-xs" response={latestHttpResponse} />
<HttpStatusTag
short
className={classNames('text-xs', !(active || selected) && opacitySubtle)}
response={latestHttpResponse}
/>
)}
</div>
) : null}