snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import mime from "mime";
import { useKeyValue } from "../hooks/useKeyValue";
import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile";
type Props = {
requestId: string;
contentType: string | null;
body: HttpRequest["body"];
onChange: (body: HttpRequest["body"]) => void;
onChangeContentType: (contentType: string | null) => void;
};
export function BinaryFileEditor({
contentType,
body,
onChange,
onChangeContentType,
requestId,
}: Props) {
const ignoreContentType = useKeyValue<boolean>({
namespace: "global",
key: ["ignore_content_type", requestId],
fallback: false,
});
const handleChange = async ({ filePath }: { filePath: string | null }) => {
await ignoreContentType.set(false);
onChange({ filePath: filePath ?? undefined });
};
const filePath = typeof body.filePath === "string" ? body.filePath : null;
const mimeType = mime.getType(filePath ?? "") ?? "application/octet-stream";
return (
<VStack space={2}>
<SelectFile onChange={handleChange} filePath={filePath} />
{filePath != null && mimeType !== contentType && !ignoreContentType.value && (
<Banner className="mt-3 py-5!">
<div className="mb-4 text-center">
<div>Set Content-Type header</div>
<InlineCode>{mimeType}</InlineCode> for current request?
</div>
<HStack space={1.5} justifyContent="center">
<Button size="sm" variant="border" onClick={() => ignoreContentType.set(true)}>
Ignore
</Button>
<Button
variant="solid"
color="primary"
size="sm"
onClick={() => onChangeContentType(mimeType)}
>
Set Header
</Button>
</HStack>
</Banner>
)}
</VStack>
);
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { appInfo } from "../lib/appInfo";
interface Props {
children: ReactNode;
feature: "updater" | "license";
}
const featureMap: Record<Props["feature"], boolean> = {
updater: appInfo.featureUpdater,
license: appInfo.featureLicense,
};
export function CargoFeature({ children, feature }: Props) {
if (featureMap[feature]) {
return <>{children}</>;
}
return null;
}

View File

@@ -0,0 +1,163 @@
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
import { platform } from "@yaakapp-internal/platform";
interface Props {
hide: () => void;
}
// Detect path separator from an existing path (defaults to /)
function getPathSeparator(path: string): string {
return path.includes("\\") ? "\\" : "/";
}
export function CloneGitRepositoryDialog({ hide }: Props) {
const [url, setUrl] = useState<string>("");
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
const [hasSubdirectory, setHasSubdirectory] = useState(false);
const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
const directory = directoryOverride ?? computedDirectory;
const workspaceDirectory =
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
const handleSelectDirectory = async () => {
const dir = await platform.dialog.open({
title: "Select Directory",
directory: true,
multiple: false,
});
if (dir != null) {
setBaseDirectory(dir);
setDirectoryOverride(null);
}
};
const handleClone = async (e: React.FormEvent) => {
e.preventDefault();
if (!url || !directory) return;
setIsCloning(true);
setError(null);
try {
const result = await gitClone(url, directory, promptCredentials);
if (result.type === "needs_credentials") {
setError(
result.error ?? "Authentication failed. Please check your credentials and try again.",
);
return;
}
// Open the workspace from the cloned directory (or subdirectory)
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
hide();
} catch (err) {
setError(String(err));
showErrorToast({
id: "git-clone-error",
title: "Clone Failed",
message: String(err),
});
} finally {
setIsCloning(false);
}
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
</Banner>
)}
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
<PlainInput
required
label="Repository URL"
placeholder="https://github.com/user/repo.git"
defaultValue={url}
onChange={setUrl}
/>
<PlainInput
label="Directory"
placeholder={appInfo.defaultProjectDir}
defaultValue={directory}
onChange={setDirectoryOverride}
rightSlot={
<IconButton
size="xs"
className="mr-0.5 h-auto! my-0.5"
icon="folder"
title="Browse"
onClick={handleSelectDirectory}
/>
}
/>
<Checkbox
checked={hasSubdirectory}
onChange={setHasSubdirectory}
title="Workspace is in a subdirectory"
help="Enable if the Yaak workspace files are not at the root of the repository"
/>
{hasSubdirectory && (
<PlainInput
label="Subdirectory"
placeholder="path/to/workspace"
defaultValue={subdirectory}
onChange={setSubdirectory}
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? "Cloning..." : "Clone Repository"}
</Button>
</VStack>
);
}
function extractRepoName(url: string): string {
// Handle various Git URL formats:
// https://github.com/user/repo.git
// git@github.com:user/repo.git
// https://github.com/user/repo
const match = url.match(/\/([^/]+?)(\.git)?$/);
if (match?.[1]) {
return match[1];
}
// Fallback for SSH-style URLs
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
if (sshMatch?.[1]) {
return sshMatch[1];
}
return "";
}

View File

@@ -0,0 +1,28 @@
import classNames from "classnames";
import type { CSSProperties } from "react";
interface Props {
color: string | null;
onClick?: () => void;
className?: string;
}
export function ColorIndicator({ color, onClick, className }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined };
const finalClassName = classNames(
className,
"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent shrink-0",
);
if (onClick) {
return (
<button
type="button"
onClick={onClick}
style={style}
className={classNames(finalClassName, "hover:border-text")}
/>
);
}
return <span style={style} className={finalClassName} />;
}

View File

@@ -0,0 +1,507 @@
import { workspacesAtom } from "@yaakapp-internal/models";
import { Heading, Icon, useDebouncedState } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { fuzzyFilter } from "fuzzbunny";
import { useAtomValue } from "jotai";
import {
Fragment,
type KeyboardEvent,
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { createFolder } from "../commands/commands";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { openSettings } from "../commands/openSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { switchWorkspace } from "../commands/switchWorkspace";
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { useAllRequests } from "../hooks/useAllRequests";
import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { useGrpcRequestActions } from "../hooks/useGrpcRequestActions";
import type { HotkeyAction } from "../hooks/useHotKey";
import { useHttpRequestActions } from "../hooks/useHttpRequestActions";
import { useRecentEnvironments } from "../hooks/useRecentEnvironments";
import { useRecentRequests } from "../hooks/useRecentRequests";
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { useScrollIntoView } from "../hooks/useScrollIntoView";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
import { appInfo } from "../lib/appInfo";
import { copyToClipboard } from "../lib/copy";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { editEnvironment } from "../lib/editEnvironment";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import {
resolvedModelNameWithFolders,
resolvedModelNameWithFoldersArray,
} from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { CookieDialog } from "./CookieDialog";
import { Button } from "./core/Button";
import { Hotkey } from "./core/Hotkey";
import { HttpMethodTag } from "./core/HttpMethodTag";
import { PlainInput } from "./core/PlainInput";
interface CommandPaletteGroup {
key: string;
label: ReactNode;
items: CommandPaletteItem[];
}
type CommandPaletteItem = {
key: string;
onSelect: () => void;
action?: HotkeyAction;
} & ({ searchText: string; label: ReactNode } | { label: string });
const MAX_PER_GROUP = 8;
export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [command, setCommand] = useDebouncedState<string>("", 150);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const activeEnvironment = useActiveEnvironment();
const httpRequestActions = useHttpRequestActions();
const grpcRequestActions = useGrpcRequestActions();
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const workspaces = useAtomValue(workspacesAtom);
const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
const createWorkspace = useCreateWorkspace();
const recentEnvironments = useRecentEnvironments();
const recentWorkspaces = useRecentWorkspaces();
const requests = useAllRequests();
const activeRequest = useActiveRequest();
const activeCookieJar = useActiveCookieJar();
const [recentRequests] = useRecentRequests();
const [, setSidebarHidden] = useSidebarHidden();
const { mutate: sendRequest } = useSendAnyHttpRequest();
const handleSetCommand = (command: string) => {
setCommand(command);
setSelectedItemKey(null);
};
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
if (workspaceId == null) return [];
const commands: CommandPaletteItem[] = [
{
key: "settings.open",
label: "Open Settings",
action: "settings.show",
onSelect: () => openSettings.mutate(null),
},
{
key: "workspace_settings.open",
label: "Open Workspace Settings",
action: "workspace_settings.show",
onSelect: () => openWorkspaceSettings(),
},
{
key: "app.create",
label: "Create Workspace",
onSelect: createWorkspace,
},
{
key: "model.create",
label: "Create HTTP Request",
onSelect: () => createRequestAndNavigate({ model: "http_request", workspaceId }),
},
{
key: "grpc_request.create",
label: "Create GRPC Request",
onSelect: () => createRequestAndNavigate({ model: "grpc_request", workspaceId }),
},
{
key: "websocket_request.create",
label: "Create Websocket Request",
onSelect: () => createRequestAndNavigate({ model: "websocket_request", workspaceId }),
},
{
key: "folder.create",
label: "Create Folder",
onSelect: () => createFolder.mutate({}),
},
{
key: "cookies.show",
label: "Show Cookies",
action: "cookies_editor.show",
onSelect: async () => {
CookieDialog.show(activeCookieJar?.id ?? null);
},
},
{
key: "environment.edit",
label: "Edit Environment",
action: "environment_editor.toggle",
onSelect: () => editEnvironment(activeEnvironment),
},
{
key: "environment.create",
label: "Create Environment",
onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),
},
{
key: "sidebar.toggle",
label: "Toggle Sidebar",
action: "sidebar.focus",
onSelect: () => setSidebarHidden((h) => !h),
},
];
if (activeRequest?.model === "http_request") {
commands.push({
key: "request.send",
action: "request.send",
label: "Send Request",
onSelect: () => sendRequest(activeRequest.id),
});
if (appInfo.cliVersion != null) {
commands.push({
key: "request.copy_cli_send",
searchText: `copy cli send yaak request send ${activeRequest.id}`,
label: "Copy CLI Send Command",
onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
});
}
httpRequestActions.forEach((a, i) => {
commands.push({
key: `http_request_action.${i}`,
label: a.label,
onSelect: () => a.call(activeRequest),
});
});
}
if (activeRequest?.model === "grpc_request") {
grpcRequestActions.forEach((a, i) => {
commands.push({
key: `grpc_request_action.${i}`,
label: a.label,
onSelect: () => a.call(activeRequest),
});
});
}
if (activeRequest != null) {
commands.push({
key: "http_request.rename",
label: "Rename Request",
onSelect: () => renameModelWithPrompt(activeRequest),
});
commands.push({
key: "sidebar.selected.delete",
label: "Delete Request",
onSelect: () => deleteModelWithConfirm(activeRequest),
});
}
return commands.sort((a, b) =>
("searchText" in a ? a.searchText : a.label).localeCompare(
"searchText" in b ? b.searchText : b.label,
),
);
}, [
activeCookieJar?.id,
activeEnvironment,
activeRequest,
baseEnvironment,
createWorkspace,
grpcRequestActions,
httpRequestActions,
sendRequest,
setSidebarHidden,
workspaceId,
]);
const sortedRequests = useMemo(() => {
return [...requests].sort((a, b) => {
const aRecentIndex = recentRequests.indexOf(a.id);
const bRecentIndex = recentRequests.indexOf(b.id);
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
return aRecentIndex - bRecentIndex;
}
if (aRecentIndex >= 0 && bRecentIndex === -1) {
return -1;
}
if (aRecentIndex === -1 && bRecentIndex >= 0) {
return 1;
}
return a.createdAt.localeCompare(b.createdAt);
});
}, [recentRequests, requests]);
const sortedEnvironments = useMemo(() => {
return [...subEnvironments].sort((a, b) => {
const aRecentIndex = recentEnvironments.indexOf(a.id);
const bRecentIndex = recentEnvironments.indexOf(b.id);
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
return aRecentIndex - bRecentIndex;
}
if (aRecentIndex >= 0 && bRecentIndex === -1) {
return -1;
}
if (aRecentIndex === -1 && bRecentIndex >= 0) {
return 1;
}
return a.createdAt.localeCompare(b.createdAt);
});
}, [subEnvironments, recentEnvironments]);
const sortedWorkspaces = useMemo(() => {
if (recentWorkspaces == null) {
// Should never happen
return workspaces;
}
return [...workspaces].sort((a, b) => {
const aRecentIndex = recentWorkspaces?.indexOf(a.id);
const bRecentIndex = recentWorkspaces?.indexOf(b.id);
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
return aRecentIndex - bRecentIndex;
}
if (aRecentIndex >= 0 && bRecentIndex === -1) {
return -1;
}
if (aRecentIndex === -1 && bRecentIndex >= 0) {
return 1;
}
return a.createdAt.localeCompare(b.createdAt);
});
}, [recentWorkspaces, workspaces]);
const groups = useMemo<CommandPaletteGroup[]>(() => {
const actionsGroup: CommandPaletteGroup = {
key: "actions",
label: "Actions",
items: workspaceCommands,
};
const requestGroup: CommandPaletteGroup = {
key: "requests",
label: "Switch Request",
items: [],
};
for (const r of sortedRequests) {
requestGroup.items.push({
key: `switch-request-${r.id}`,
searchText: resolvedModelNameWithFolders(r),
label: (
<div className="flex items-center gap-x-0.5">
<HttpMethodTag short className="text-xs mr-2" request={r} />
{resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
<Fragment key={name}>
{i !== 0 && <Icon icon="chevron_right" className="opacity-80" />}
<div className={classNames(i < all.length - 1 && "truncate")}>{name}</div>
</Fragment>
))}
</div>
),
onSelect: async () => {
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: r.workspaceId },
search: (prev) => ({ ...prev, request_id: r.id }),
});
},
});
}
const environmentGroup: CommandPaletteGroup = {
key: "environments",
label: "Switch Environment",
items: [],
};
for (const e of sortedEnvironments) {
if (e.id === activeEnvironment?.id) {
continue;
}
environmentGroup.items.push({
key: `switch-environment-${e.id}`,
label: e.name,
onSelect: () => setWorkspaceSearchParams({ environment_id: e.id }),
});
}
const workspaceGroup: CommandPaletteGroup = {
key: "workspaces",
label: "Switch Workspace",
items: [],
};
for (const w of sortedWorkspaces) {
workspaceGroup.items.push({
key: `switch-workspace-${w.id}`,
label: w.name,
onSelect: () => switchWorkspace.mutate({ workspaceId: w.id, inNewWindow: false }),
});
}
return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
}, [
workspaceCommands,
sortedRequests,
sortedEnvironments,
activeEnvironment?.id,
sortedWorkspaces,
]);
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
const { filteredGroups, filteredAllItems } = useMemo(() => {
const result = command
? fuzzyFilter(
allItems.map((i) => ({
...i,
filterBy: "searchText" in i ? i.searchText : i.label,
})),
command,
{ fields: ["filterBy"] },
)
.sort((a, b) => b.score - a.score)
.map((v) => v.item)
: allItems;
const filteredGroups = groups
.map((g) => {
const items = result
.filter((i) => g.items.find((i2) => i2.key === i.key))
.slice(0, MAX_PER_GROUP);
return { ...g, items };
})
.filter((g) => g.items.length > 0);
const filteredAllItems = filteredGroups.flatMap((g) => g.items);
return { filteredAllItems, filteredGroups };
}, [allItems, command, groups]);
const handleSelectAndClose = useCallback(
(cb: () => void) => {
onClose();
cb();
},
[onClose],
);
const selectedItem = useMemo(() => {
let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
if (selectedItem == null) {
selectedItem = filteredAllItems[0] ?? null;
}
return selectedItem;
}, [filteredAllItems, selectedItemKey]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) {
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
setSelectedItemKey(next?.key ?? null);
} else if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
setSelectedItemKey(prev?.key ?? null);
} else if (e.key === "Enter") {
const selected = filteredAllItems[index];
setSelectedItemKey(selected?.key ?? null);
if (selected) {
handleSelectAndClose(selected.onSelect);
}
}
},
[filteredAllItems, handleSelectAndClose, selectedItem?.key],
);
return (
<div className="h-full w-[min(700px,80vw)] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2">
<div className="px-2 w-full">
<PlainInput
autoFocus
hideLabel
leftSlot={
<div className="h-md w-10 flex justify-center items-center">
<Icon icon="search" color="secondary" />
</div>
}
name="command"
label="Command"
placeholder="Search or type a command"
className="font-sans text-base!"
defaultValue={command}
onChange={handleSetCommand}
onKeyDownCapture={handleKeyDown}
/>
</div>
<div className="h-full px-1.5 overflow-y-auto pt-2 pb-1">
{filteredGroups.map((g) => (
<div key={g.key} className="mb-1.5 w-full">
<Heading level={2} className="text-xs! uppercase px-1.5 h-sm flex items-center">
{g.label}
</Heading>
{g.items.map((v) => (
<CommandPaletteItem
active={v.key === selectedItem?.key}
key={v.key}
onClick={() => handleSelectAndClose(v.onSelect)}
rightSlot={v.action && <CommandPaletteAction action={v.action} />}
>
{v.label}
</CommandPaletteItem>
))}
</div>
))}
</div>
</div>
);
}
function CommandPaletteItem({
children,
active,
onClick,
rightSlot,
}: {
children: ReactNode;
active: boolean;
onClick: () => void;
rightSlot?: ReactNode;
}) {
const ref = useRef<HTMLButtonElement | null>(null);
useScrollIntoView(ref.current, active);
return (
<Button
ref={ref}
onClick={onClick}
tabIndex={active ? undefined : -1}
rightSlot={rightSlot}
color="custom"
justify="start"
className={classNames(
"w-full h-sm flex items-center rounded-sm px-1.5",
"hover:text-text",
active && "bg-surface-highlight",
!active && "text-text-subtle",
)}
>
<span className="truncate">{children}</span>
</Button>
);
}
function CommandPaletteAction({ action }: { action: HotkeyAction }) {
return <Hotkey className="ml-auto" action={action} />;
}

View File

@@ -0,0 +1,129 @@
import { checkLicense } from "@yaakapp-internal/license";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { platform } from "@yaakapp-internal/platform";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
"Personal use of Yaak is free. If you’re using Yaak at work, please purchase a license.";
export function CommercialUseBanner({
source,
title,
}: {
source: string;
title: string;
}) {
const [visible, setVisible] = useState(false);
const snoozeStartedRef = useRef(false);
const {
isLoading: isSnoozeLoading,
set: setSnoozedAt,
value: snoozedAt,
} = useKeyValue<string | null>({
namespace: "global",
key: "commercial-use-banner-snoozed-at",
fallback: null,
});
useEffect(() => {
let canceled = false;
shouldShowCommercialUsePrompt()
.then((shouldShow) => {
if (!canceled) setVisible(shouldShow);
})
.catch(console.error);
return () => {
canceled = true;
};
}, [source]);
const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS);
const handleShow = useCallback(() => {
if (snoozeStartedRef.current || snoozed) {
return;
}
snoozeStartedRef.current = true;
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
}, [setSnoozedAt, snoozed, source]);
if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
return null;
}
return (
<div className="w-full">
<DismissibleBanner
id={`commercial-use:${source}`}
color="info"
className="w-full"
onDismiss={() =>
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
}
onShow={handleShow}
actions={[
{
label: "Purchase License",
color: "info",
variant: "solid",
onClick: () => {
openCommercialUsePricing(source).catch(console.error);
},
},
]}
>
<div className="text-sm">
<p className="font-semibold text-text">{title}</p>
<p className="mt-0.5 text-text-subtle">{COMMERCIAL_USE_BANNER_MESSAGE}</p>
</div>
</DismissibleBanner>
</div>
);
}
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
if (appInfo.featureLicense !== true) {
return false;
}
try {
const license = await checkLicense();
return license.status === "personal_use";
} catch (err) {
console.log("Failed to check license before commercial-use prompt", err);
return false;
}
}
async function openCommercialUsePricing(source: string): Promise<void> {
await platform.openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
}
function isSnoozed(value: string | null, ms: number): boolean {
if (value == null) return false;
try {
const snooze = JSON.parse(value) as { at?: unknown };
const at = typeof snooze.at === "string" ? snooze.at : null;
return isWithinMs(at, ms);
} catch {
// Older builds stored only the timestamp, so keep respecting that as a global snooze.
return isWithinMs(value, ms);
}
}
function isWithinMs(date: string | null, ms: number): boolean {
if (date == null) return false;
const time = new Date(date).getTime();
if (Number.isNaN(time)) return false;
return Date.now() - time < ms;
}

View File

@@ -0,0 +1,74 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { useToggle } from "../hooks/useToggle";
import { showConfirm } from "../lib/confirm";
import { Button } from "./core/Button";
import { Link } from "./core/Link";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
request: HttpRequest;
}
const LARGE_TEXT_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeRequestBody({ children, request }: Props) {
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
if (request.body?.text == null) {
return children;
}
const contentLength = request.body.text.length ?? 0;
const tooLargeBytes = LARGE_TEXT_BYTES;
const isLarge = contentLength > tooLargeBytes;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Rendering content over{" "}
<InlineCode>
<SizeTag contentLength={tooLargeBytes} />
</InlineCode>{" "}
may impact performance.
</p>
<p>
See{" "}
<Link href="https://feedback.yaak.app/en/help/articles/1198684-working-with-large-values">
Working With Large Values
</Link>{" "}
for tips.
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Body
</Button>
<Button
color="danger"
size="xs"
variant="border"
onClick={async () => {
const confirm = await showConfirm({
id: `delete-body-${request.id}`,
confirmText: "Delete Body",
title: "Delete Body Text",
description: "Are you sure you want to delete the request body text?",
color: "danger",
});
if (confirm) {
await patchModel(request, { body: { ...request.body, text: "" } });
}
}}
>
Delete Body
</Button>
</HStack>
</Banner>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,61 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useToggle } from "../hooks/useToggle";
import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { getResponseBodyText } from "../lib/responseBody";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponse({ children, response }: Props) {
const { mutate: saveResponse } = useSaveResponse(response);
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.contentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing responses over{" "}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{" "}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Response
</Button>
<Button color="secondary" variant="border" size="xs" onClick={() => saveResponse()}>
Save to File
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getResponseBodyText({ response, filter: null })}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,56 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
import { useToggle } from "../hooks/useToggle";
import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponseRequest({ children, response }: Props) {
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.requestContentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing content over{" "}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{" "}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Request Body
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,26 @@
import { useAtomValue } from "jotai";
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
import { ContextMenu } from "./core/Dropdown";
import { ErrorBoundary } from "./ErrorBoundary";
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
export function ContextMenus() {
const menus = useAtomValue(contextMenusAtom);
return (
<>
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
<ContextMenu
items={items}
triggerPosition={triggerPosition}
triggerRect={triggerRect}
// The trigger isn't a React component here, so it arrives as an element and gets
// wrapped to look like the ref the menu expects
triggerRef={{ current: triggerEl ?? null }}
onClose={() => hideContextMenu(id)}
/>
</ErrorBoundary>
))}
</>
);
}

View File

@@ -0,0 +1,731 @@
import type { Cookie } from "@yaakapp-internal/models";
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
import { formatDate } from "date-fns/format";
import { useAtomValue } from "jotai";
import {
type ComponentProps,
type CSSProperties,
type FormEvent,
type ReactNode,
type RefObject,
useMemo,
useRef,
useState,
} from "react";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { cookieDomain } from "../lib/model_util";
import {
Icon,
SplitLayout,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox";
import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { showAlert } from "../lib/alert";
interface Props {
cookieJarId: string | null;
}
export const CookieDialog = ({ cookieJarId }: Props) => {
const cookieJars = useAtomValue(cookieJarsAtom);
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
const [filter, setFilter] = useState("");
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
const [draftExpiresInput, setDraftExpiresInput] = useState("");
const editorFormRef = useRef<HTMLFormElement>(null);
const filteredCookies = useMemo(() => {
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
}, [cookieJar?.cookies, filter]);
const selectedCookie = useMemo(
() =>
selectedCookieKey == null
? null
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
[filteredCookies, selectedCookieKey],
);
const detailCookie = draftCookie ?? selectedCookie;
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
const isEditingCookie = draftCookie != null;
const handleAddCookie = () => {
setSelectedCookieKey(null);
setEditingCookieKey(NEW_COOKIE_KEY);
setDraftCookie(newCookieDraft());
setDraftExpiresInput("");
};
const handleEditCookie = () => {
if (selectedCookie == null) {
return;
}
setEditingCookieKey(cookieKey(selectedCookie));
setDraftCookie(selectedCookie);
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
};
const handleCancelEdit = () => {
if (isCreatingCookie) {
setSelectedCookieKey(null);
}
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
const handleCloseDetails = () => {
if (isEditingCookie) {
handleCancelEdit();
return;
}
setSelectedCookieKey(null);
};
const handleSaveCookie = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (cookieJar == null || draftCookie == null) {
return;
}
let nextCookie = normalizeCookie(draftCookie);
if (nextCookie.expires !== "SessionEnd") {
const expires = cookieExpiresFromInput(draftExpiresInput);
if (expires == null) {
showAlert({
id: "invalid-cookie-expires",
title: "Invalid Cookie",
body: "Cookie expiration must be a valid date.",
});
return;
}
nextCookie = { ...nextCookie, expires };
}
const nextCookieKey = cookieKey(nextCookie);
const nextCookies = cookieJar.cookies.filter((cookie) => {
const key = cookieKey(cookie);
if (editingCookieKey != null && key === editingCookieKey) {
return false;
}
return key !== nextCookieKey;
});
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
setSelectedCookieKey(nextCookieKey);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
if (cookieJar == null) {
return <div>No cookie jar selected</div>;
}
return (
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<PlainInput
name="cookie-filter"
label="Filter cookies"
hideLabel
placeholder="Filter cookies"
defaultValue={filter}
forceUpdateKey={filterUpdateKey}
onChange={setFilter}
rightSlot={
filter.length > 0 && (
<IconButton
className="bg-transparent! h-auto! min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={() => {
setFilter("");
setFilterUpdateKey((key) => key + 1);
}}
/>
)
}
/>
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
</div>
{cookieJar.cookies.length === 0 && detailCookie == null ? (
<EmptyStateText>
Cookies will appear when a response includes a Set-Cookie header.
</EmptyStateText>
) : filteredCookies.length === 0 && detailCookie == null ? (
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
) : (
<SplitLayout
layout="vertical"
storageKey="cookie-dialog-details"
defaultRatio={0.5}
className="-mx-2"
minHeightPx={10}
firstSlot={({ style }) =>
filteredCookies.length === 0 ? (
<div style={style}>
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
</div>
) : (
<Table scrollable style={style} className="pr-0.5">
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Value</TableHeaderCell>
<TableHeaderCell>Domain</TableHeaderCell>
<TableHeaderCell>Path</TableHeaderCell>
<TableHeaderCell>Expires</TableHeaderCell>
<TableHeaderCell>Size</TableHeaderCell>
<TableHeaderCell>HTTP Only</TableHeaderCell>
<TableHeaderCell>Secure</TableHeaderCell>
<TableHeaderCell>Same Site</TableHeaderCell>
<TableHeaderCell>
<IconButton
icon="list_x"
size="sm"
className="text-text-subtle"
title="Clear all cookies"
onClick={() => {
setSelectedCookieKey(null);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
void patchModel(cookieJar, { cookies: [] });
}}
/>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
{filteredCookies.map((c: Cookie) => {
const key = cookieKey(c);
const isSelected = key === selectedCookieKey;
return (
<TableRow
key={key}
className={classNames(
"group/tr cursor-default",
isSelected && "[&_td]:bg-surface-highlight",
!isSelected && "hover:[&_td]:bg-surface-hover",
)}
onClick={() => {
setSelectedCookieKey(key);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}}
>
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
{c.name}
</TableCell>
<TruncatedWideTableCell className="min-w-40">
{c.value}
</TruncatedWideTableCell>
<TableCell>{cookieDomain(c)}</TableCell>
<TableCell>{c.path}</TableCell>
<TableCell>{cookieExpires(c)}</TableCell>
<TableCell>{cookieSize(c)}</TableCell>
<TableCell>
<Icon
icon={c.httpOnly ? "check" : "x"}
className={classNames(!c.httpOnly && "opacity-10")}
/>
</TableCell>
<TableCell>
<Icon
icon={c.secure ? "check" : "x"}
className={classNames(!c.secure && "opacity-10")}
/>
</TableCell>
<TableCell>{c.sameSite}</TableCell>
<TableCell className="rounded-r pr-2">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
onClick={(event) => {
event.stopPropagation();
if (isSelected) {
setSelectedCookieKey(null);
}
if (editingCookieKey === key) {
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}
void patchModel(cookieJar, {
cookies: cookieJar.cookies.filter(
(c2: Cookie) => cookieKey(c2) !== key,
),
});
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)
}
secondSlot={
detailCookie == null
? null
: ({ style }) => (
<CookieDetailsPane
formRef={editorFormRef}
isEditing={isEditingCookie}
onSubmit={handleSaveCookie}
style={style}
>
<EventDetailHeader
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
copyText={isEditingCookie ? undefined : detailCookie.value}
actions={
isEditingCookie
? [
{
key: "save",
label: isCreatingCookie ? "Create" : "Save",
onClick: () => editorFormRef.current?.requestSubmit(),
},
{
key: "cancel",
label: "Cancel",
onClick: handleCancelEdit,
},
]
: [
{
key: "edit",
label: "Edit",
onClick: handleEditCookie,
},
]
}
onClose={handleCloseDetails}
/>
{isEditingCookie ? (
<CookieEditor
cookie={detailCookie}
expiresInputValue={draftExpiresInput}
onChange={setDraftCookie}
onExpiresInputChange={setDraftExpiresInput}
/>
) : (
<CookieDetails cookie={detailCookie} />
)}
</CookieDetailsPane>
)
}
/>
)}
</div>
);
};
function CookieDetailsPane({
children,
formRef,
isEditing,
onSubmit,
style,
}: {
children: ReactNode;
formRef: RefObject<HTMLFormElement | null>;
isEditing: boolean;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
style: CSSProperties;
}) {
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
if (isEditing) {
return (
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
{children}
</form>
);
}
return (
<div style={style} className={className}>
{children}
</div>
);
}
CookieDialog.show = (cookieJarId: string | null) => {
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
if (cookieJar == null) {
showAlert({
id: "invalid-jar",
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
title: "Invalid Cookie Jar",
});
return;
}
showDialog({
id: "cookies",
title: `${cookieJar.name} Cookies`,
size: "full",
render: () => <CookieDialog cookieJarId={cookieJarId} />,
});
};
function CookieDetails({ cookie }: { cookie: Cookie }) {
return (
<div className="overflow-y-auto">
<KeyValueRows selectable>
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
</CookieKeyValueRow>
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
{cookie.sameSite && (
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
)}
</KeyValueRows>
</div>
);
}
function CookieEditor({
cookie,
expiresInputValue,
onChange,
onExpiresInputChange,
}: {
cookie: Cookie;
expiresInputValue: string;
onChange: (cookie: Cookie) => void;
onExpiresInputChange: (value: string) => void;
}) {
const sessionCookie = cookie.expires === "SessionEnd";
return (
<div className="overflow-y-auto">
<KeyValueRows>
<CookieKeyValueRow align="middle" label="Name">
<CookieTextInput
required
autoFocus
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookie.name}
onChange={(name) => onChange({ ...cookie, name })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Value">
<CookieTextarea
value={cookie.value}
onChange={(value) => onChange({ ...cookie, value })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Domain">
<CookieTextInput
required
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookieDomainInputValue(cookie)}
placeholder="example.com"
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Path">
<CookieTextInput
value={cookie.path}
placeholder="/"
onChange={(path) => onChange({ ...cookie, path })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">
<div className="grid gap-1">
<Checkbox
checked={sessionCookie}
title="Session cookie"
onChange={(checked) => {
if (checked) {
onChange({ ...cookie, expires: "SessionEnd" });
return;
}
const expiresInput =
cookieExpiresFromInput(expiresInputValue) == null
? defaultCookieExpiresInputValue()
: expiresInputValue;
onExpiresInputChange(expiresInput);
onChange({
...cookie,
expires: cookieExpiresFromInput(expiresInput)!,
});
}}
/>
<CookieTextInput
value={sessionCookie ? "" : expiresInputValue}
disabled={sessionCookie}
onChange={(value) => {
onExpiresInputChange(value);
const expires = cookieExpiresFromInput(value);
if (expires != null) {
onChange({ ...cookie, expires });
}
}}
/>
</div>
</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="HTTP Only">
<Checkbox
hideLabel
title="HTTP Only"
checked={cookie.httpOnly}
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Secure">
<Checkbox
hideLabel
title="Secure"
checked={cookie.secure}
onChange={(secure) => onChange({ ...cookie, secure })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Same Site">
<Select
hideLabel
name="cookie-same-site"
label="Same Site"
value={cookie.sameSite ?? ""}
size="xs"
className="w-full"
options={[
{ label: "n/a", value: "" },
{ label: "Lax", value: "Lax" },
{ label: "Strict", value: "Strict" },
{ label: "None", value: "None" },
]}
onChange={(sameSite) =>
onChange({
...cookie,
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
})
}
/>
</CookieKeyValueRow>
</KeyValueRows>
</div>
);
}
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
return <KeyValueRow labelClassName={classNames("w-28", labelClassName)} {...props} />;
}
function CookieTextInput({
autoFocus,
disabled,
onChange,
pattern,
placeholder,
required,
value,
}: {
autoFocus?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
pattern?: string;
placeholder?: string;
required?: boolean;
value: string;
}) {
return (
<input
autoFocus={autoFocus}
autoCapitalize="off"
autoCorrect="off"
className={cookieInputClassName}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
pattern={pattern}
placeholder={placeholder}
required={required}
type="text"
value={value}
/>
);
}
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
return (
<textarea
autoCapitalize="off"
autoCorrect="off"
className={classNames(cookieInputClassName, "min-h-20 resize-y")}
onChange={(event) => onChange(event.target.value)}
value={value}
/>
);
}
const NEW_COOKIE_KEY = "__new-cookie__";
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
const cookieInputClassName = classNames(
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
"border border-border-subtle outline-hidden",
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
"focus:border-border-focus invalid:border-danger",
"disabled:opacity-disabled disabled:border-dotted",
);
function cookieSize(cookie: Cookie) {
const encoder = new TextEncoder();
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
}
function newCookieDraft(): Cookie {
return {
name: "",
value: "",
domain: "NotPresent",
expires: "SessionEnd",
path: "/",
secure: false,
httpOnly: false,
sameSite: null,
};
}
function normalizeCookie(cookie: Cookie): Cookie {
return {
...cookie,
domain: normalizeCookieDomain(cookie.domain),
name: cookie.name.trim(),
path: cookie.path.trim() || "/",
};
}
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
if (domain === "NotPresent" || domain === "Empty") {
return domain;
}
if ("Suffix" in domain) {
return { Suffix: domain.Suffix.trim() };
}
return { HostOnly: domain.HostOnly.trim() };
}
function cookieDomainInputValue(cookie: Cookie) {
const domain = cookieDomain(cookie);
return domain === "n/a" ? "" : domain;
}
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
const trimmedDomain = domain.trim();
if (trimmedDomain.length === 0) {
return { ...cookie, domain: "NotPresent" };
}
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
return { ...cookie, domain: { Suffix: trimmedDomain } };
}
return { ...cookie, domain: { HostOnly: trimmedDomain } };
}
function cookieExpires(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "Session";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return cookie.expires.AtUtc;
}
const date = new Date(expiresSeconds * 1000);
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
}
function cookieExpiresInputValue(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return "";
}
return new Date(expiresSeconds * 1000).toISOString();
}
function defaultCookieExpiresInputValue() {
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
}
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
return null;
}
return { AtUtc: `${Math.floor(time / 1000)}` };
}
function cookieMatchesFilter(cookie: Cookie, filter: string) {
const query = filter.trim().toLowerCase();
if (query.length === 0) {
return true;
}
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
value.toLowerCase().includes(query),
);
}
function cookieKey(cookie: Cookie) {
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
}
function cookieDomainKey(domain: Cookie["domain"]) {
if (typeof domain !== "string" && "HostOnly" in domain) {
return `HostOnly:${domain.HostOnly}`;
}
if (typeof domain !== "string" && "Suffix" in domain) {
return `Suffix:${domain.Suffix}`;
}
return domain;
}

View File

@@ -0,0 +1,92 @@
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { memo, useMemo } from "react";
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showPrompt } from "../lib/prompt";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { CookieDialog } from "./CookieDialog";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { Icon, InlineCode } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
export const CookieDropdown = memo(function CookieDropdown() {
const activeCookieJar = useActiveCookieJar();
const createCookieJar = useCreateCookieJar();
const cookieJars = useAtomValue(cookieJarsAtom);
const items = useMemo((): DropdownItem[] => {
return [
...(cookieJars ?? []).map((j) => ({
key: j.id,
label: j.name,
leftSlot: <Icon icon={j.id === activeCookieJar?.id ? "check" : "empty"} />,
onSelect: () => {
setWorkspaceSearchParams({ cookie_jar_id: j.id });
},
})),
...(((cookieJars ?? []).length > 0 && activeCookieJar != null
? [
{ type: "separator", label: activeCookieJar.name },
{
key: "manage",
label: "Manage Cookies",
leftSlot: <Icon icon="cookie" />,
onSelect: () => {
if (activeCookieJar == null) return;
CookieDialog.show(activeCookieJar.id);
},
},
{
key: "rename",
label: "Rename",
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await showPrompt({
id: "rename-cookie-jar",
title: "Rename Cookie Jar",
description: (
<>
Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode>
</>
),
label: "Name",
confirmText: "Save",
placeholder: "New name",
defaultValue: activeCookieJar?.name,
});
if (name == null) return;
await patchModel(activeCookieJar, { name });
},
},
...(((cookieJars ?? []).length > 1 // Never delete the last one
? [
{
label: "Delete",
leftSlot: <Icon icon="trash" />,
color: "danger",
onSelect: async () => {
await deleteModelWithConfirm(activeCookieJar);
},
},
]
: []) as DropdownItem[]),
]
: []) as DropdownItem[]),
{ type: "separator" },
{
key: "create-cookie-jar",
label: "New Cookie Jar",
leftSlot: <Icon icon="plus" />,
onSelect: () => createCookieJar.mutate(),
},
];
}, [activeCookieJar, cookieJars, createCookieJar]);
return (
<Dropdown items={items}>
<IconButton size="sm" icon="cookie" iconColor="secondary" title="Cookie Jar" />
</Dropdown>
);
});

View File

@@ -0,0 +1,33 @@
import { useTimedBoolean } from "@yaakapp-internal/ui";
import { copyToClipboard } from "../lib/copy";
import { showToast } from "../lib/toast";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
interface Props extends Omit<ButtonProps, "onClick"> {
text: string | (() => Promise<string | null>);
}
export function CopyButton({ text, ...props }: Props) {
const [copied, setCopied] = useTimedBoolean();
return (
<Button
{...props}
onClick={async () => {
const content = typeof text === "function" ? await text() : text;
if (content == null) {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
} else {
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
>
{copied ? "Copied" : "Copy"}
</Button>
);
}

View File

@@ -0,0 +1,31 @@
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
import { copyToClipboard } from "../lib/copy";
import { showToast } from "../lib/toast";
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
text: string | (() => Promise<string | null>);
}
export function CopyIconButton({ text, ...props }: Props) {
const [copied, setCopied] = useTimedBoolean();
return (
<IconButton
{...props}
icon={copied ? "check" : "copy"}
showConfirm
onClick={async () => {
const content = typeof text === "function" ? await text() : text;
if (content == null) {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
} else {
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
/>
);
}

View File

@@ -0,0 +1,21 @@
import { useCreateDropdownItems } from "../hooks/useCreateDropdownItems";
import type { DropdownProps } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
interface Props extends Omit<DropdownProps, "items"> {
hideFolder?: boolean;
}
export function CreateDropdown({ hideFolder, children, ...props }: Props) {
const getItems = useCreateDropdownItems({
hideFolder,
hideIcons: true,
folderId: "active-folder",
});
return (
<Dropdown items={getItems} {...props}>
{children}
</Dropdown>
);
}

View File

@@ -0,0 +1,68 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useState } from "react";
import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from "./ColorIndicator";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
interface Props {
onCreate: (id: string) => void;
hide: () => void;
workspaceId: string;
}
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
return (
<form
className="pb-3 flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
model: "environment",
name,
color,
variables: [],
public: sharable,
workspaceId,
parentModel: "environment",
});
hide();
onCreate(id);
}}
>
<PlainInput
label="Name"
required
defaultValue={name}
onChange={setName}
placeholder="Production"
/>
<Checkbox
checked={sharable}
title="Share this environment"
help="Sharable environments are included in data export and directory sync."
onChange={toggleSharable}
/>
<div>
<Label
htmlFor="color"
className="mb-1.5"
help="Select a color to be displayed when this environment is active, to help identify it."
>
Color
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
</form>
);
}

View File

@@ -0,0 +1,97 @@
import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
import { gitCallbacks } from "./git/callbacks";
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
interface Props {
hide: () => void;
}
export function CreateWorkspaceDialog({ hide }: Props) {
const [name, setName] = useState<string>("");
const [syncConfig, setSyncConfig] = useState<{
filePath: string | null;
initGit?: boolean;
}>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
return (
<VStack
as="form"
space={3}
alignItems="start"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: "workspace", name });
if (workspaceId == null) return;
// Do getWorkspaceMeta instead of naively creating one because it might have
// been created already when the store refreshes the workspace meta after
const workspaceMeta = await rpc<WorkspaceMeta>("cmd_get_workspace_meta", {
workspaceId,
});
await updateModel({
...workspaceMeta,
settingSyncDir: syncConfig.filePath,
});
if (syncConfig.initGit && syncConfig.filePath) {
gitMutations(syncConfig.filePath, gitCallbacks(syncConfig.filePath))
.init.mutateAsync()
.catch((err) => {
showErrorToast({
id: "git-init-error",
title: "Error initializing Git",
message: String(err),
});
});
}
// Navigate to workspace
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId },
});
hide();
if (setupEncryption) {
setupOrConfigureEncryption();
}
}}
>
<PlainInput required label="Name" defaultValue={name} onChange={setName} />
<SyncToFilesystemSetting
onChange={setSyncConfig}
onCreateNewWorkspace={hide}
value={syncConfig}
/>
<div>
<Label htmlFor={null} help={<EncryptionHelp />}>
Workspace encryption
</Label>
<Checkbox
checked={setupEncryption}
onChange={setSetupEncryption}
title="Enable Encryption"
/>
</div>
<Button type="submit" color="primary" className="w-full mt-3">
Create Workspace
</Button>
</VStack>
);
}

View File

@@ -0,0 +1,41 @@
import { useAtomValue } from "jotai";
import type { ComponentType } from "react";
import { useCallback } from "react";
import { dialogsAtom, hideDialog } from "../lib/dialog";
import { Dialog, type DialogProps } from "./core/Dialog";
import { ErrorBoundary } from "./ErrorBoundary";
export type DialogInstance = {
id: string;
render: ComponentType<{ hide: () => void }>;
} & Omit<DialogProps, "open" | "children">;
export function Dialogs() {
const dialogs = useAtomValue(dialogsAtom);
return (
<>
{dialogs.map(({ id, ...props }) => (
<DialogInstance key={id} id={id} {...props} />
))}
</>
);
}
function DialogInstance({ render: Component, onClose, id, ...props }: DialogInstance) {
const hide = useCallback(() => {
hideDialog(id);
}, [id]);
const handleClose = useCallback(() => {
onClose?.();
hideDialog(id);
}, [id, onClose]);
return (
<Dialog open onClose={handleClose} {...props}>
<ErrorBoundary name={`Dialog ${id}`}>
<Component hide={hide} {...props} />
</ErrorBoundary>
</Dialog>
);
}

View File

@@ -0,0 +1,190 @@
import type { DnsOverride, Workspace } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { fireAndForget } from "../lib/fireAndForget";
import {
HStack,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
VStack,
} from "@yaakapp-internal/ui";
import { useCallback, useId, useMemo } from "react";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
interface Props {
workspace: Workspace;
}
interface DnsOverrideWithId extends DnsOverride {
_id: string;
}
export function DnsOverridesEditor({ workspace }: Props) {
const reactId = useId();
// Ensure each override has an internal ID for React keys
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
return workspace.settingDnsOverrides.map((override, index) => ({
...override,
_id: `${reactId}-${index}`,
}));
}, [workspace.settingDnsOverrides, reactId]);
const handleChange = useCallback(
(overrides: DnsOverride[]) => {
fireAndForget(patchModel(workspace, { settingDnsOverrides: overrides }));
},
[workspace],
);
const handleAdd = useCallback(() => {
const newOverride: DnsOverride = {
hostname: "",
ipv4: [""],
ipv6: [],
enabled: true,
};
handleChange([...workspace.settingDnsOverrides, newOverride]);
}, [workspace.settingDnsOverrides, handleChange]);
const handleUpdate = useCallback(
(index: number, update: Partial<DnsOverride>) => {
const updated = workspace.settingDnsOverrides.map((o, i) =>
i === index ? { ...o, ...update } : o,
);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
const handleDelete = useCallback(
(index: number) => {
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
handleChange(updated);
},
[workspace.settingDnsOverrides, handleChange],
);
return (
<VStack space={3} className="pb-3">
<div className="text-text-subtle text-sm">
Override DNS resolution for specific hostnames. This works like{" "}
<code className="text-text-subtlest bg-surface-highlight px-1 rounded-sm">/etc/hosts</code> but
only for requests made from this workspace.
</div>
{overridesWithIds.length > 0 && (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell className="w-8" />
<TableHeaderCell>Hostname</TableHeaderCell>
<TableHeaderCell>IPv4 Address</TableHeaderCell>
<TableHeaderCell>IPv6 Address</TableHeaderCell>
<TableHeaderCell className="w-10" />
</TableRow>
</TableHead>
<TableBody>
{overridesWithIds.map((override, index) => (
<DnsOverrideRow
key={override._id}
override={override}
onUpdate={(update) => handleUpdate(index, update)}
onDelete={() => handleDelete(index)}
/>
))}
</TableBody>
</Table>
)}
<HStack>
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
Add DNS Override
</Button>
</HStack>
</VStack>
);
}
interface DnsOverrideRowProps {
override: DnsOverride;
onUpdate: (update: Partial<DnsOverride>) => void;
onDelete: () => void;
}
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
const ipv4Value = override.ipv4.join(", ");
const ipv6Value = override.ipv6.join(", ");
return (
<TableRow>
<TableCell>
<Checkbox
hideLabel
title={override.enabled ? "Disable override" : "Enable override"}
checked={override.enabled ?? true}
onChange={(enabled) => onUpdate({ enabled })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="Hostname"
placeholder="api.example.com"
defaultValue={override.hostname}
onChange={(hostname) => onUpdate({ hostname })}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv4 addresses"
placeholder="127.0.0.1"
defaultValue={ipv4Value}
onChange={(value) =>
onUpdate({
ipv4: value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<PlainInput
size="sm"
hideLabel
label="IPv6 addresses"
placeholder="::1"
defaultValue={ipv6Value}
onChange={(value) =>
onUpdate({
ipv6: value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</TableCell>
<TableCell>
<IconButton
size="xs"
iconSize="sm"
icon="trash"
title="Delete override"
onClick={onDelete}
/>
</TableCell>
</TableRow>
);
}

View File

@@ -0,0 +1,34 @@
import classNames from "classnames";
import type { CSSProperties } from "react";
import { memo } from "react";
interface Props {
className?: string;
style?: CSSProperties;
orientation?: "horizontal" | "vertical";
}
export const DropMarker = memo(
function DropMarker({ className, style, orientation = "horizontal" }: Props) {
return (
<div
style={style}
className={classNames(
className,
"absolute pointer-events-none z-50",
orientation === "horizontal" && "w-full",
orientation === "vertical" && "w-0 top-0 bottom-0",
)}
>
<div
className={classNames(
"absolute bg-primary rounded-full",
orientation === "horizontal" && "left-2 right-2 bottom-[-0.1rem] h-[0.2rem]",
orientation === "vertical" && "left-[-0.1rem] top-0 bottom-0 w-[0.2rem]",
)}
/>
</div>
);
},
() => true,
);

View File

@@ -0,0 +1,628 @@
import type { Folder, HttpRequest } from "@yaakapp-internal/models";
import { foldersAtom, httpRequestsAtom } from "@yaakapp-internal/models";
import type {
FormInput,
FormInputCheckbox,
FormInputEditor,
FormInputFile,
FormInputHttpRequest,
FormInputKeyValue,
FormInputSelect,
FormInputText,
JsonPrimitive,
} from "@yaakapp-internal/plugins";
import { Banner, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useRandomKey } from "../hooks/useRandomKey";
import { capitalize } from "../lib/capitalize";
import { showDialog } from "../lib/dialog";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { InputProps } from "./core/Input";
import { Input } from "./core/Input";
import { Label } from "./core/Label";
import type { Pair } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { Markdown } from "./Markdown";
import { SelectFile } from "./SelectFile";
export const DYNAMIC_FORM_NULL_ARG = "__NULL__";
const INPUT_SIZE = "sm";
interface Props<T> {
inputs: FormInput[] | undefined | null;
onChange: (value: T) => void;
data: T;
autocompleteFunctions?: boolean;
autocompleteVariables?: boolean;
stateKey: string;
className?: string;
disabled?: boolean;
}
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
inputs,
data,
onChange,
autocompleteVariables,
autocompleteFunctions,
stateKey,
className,
disabled,
}: Props<T>) {
const setDataAttr = useCallback(
(name: string, value: JsonPrimitive) => {
onChange({ ...data, [name]: value === DYNAMIC_FORM_NULL_ARG ? undefined : value });
},
[data, onChange],
);
return (
<FormInputsStack
disabled={disabled}
inputs={inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
data={data}
className={classNames(className, "pb-4")} // Pad the bottom to look nice
/>
);
}
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
className,
...props
}: FormInputsProps<T> & { className?: string }) {
return (
<VStack
space={3}
className={classNames(
className,
"h-full overflow-auto",
"pr-1", // A bit of space between inputs and scrollbar
)}
>
<FormInputs {...props} />
</VStack>
);
}
type FormInputsProps<T> = Pick<
Props<T>,
"inputs" | "autocompleteFunctions" | "autocompleteVariables" | "stateKey" | "data"
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
};
function FormInputs<T extends Record<string, JsonPrimitive>>({
inputs,
autocompleteFunctions,
autocompleteVariables,
stateKey,
setDataAttr,
data,
disabled,
}: FormInputsProps<T>) {
return (
<>
{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={input}
onChange={(v) => setDataAttr(input.name, v)}
value={
data[input.name]
? String(data[input.name])
: (input.defaultValue ?? DYNAMIC_FORM_NULL_ARG)
}
/>
);
case "text":
return (
<TextArg
key={i + stateKey}
stateKey={stateKey}
arg={input}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables || false}
onChange={(v) => setDataAttr(input.name, v)}
value={
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
}
/>
);
case "editor":
return (
<EditorArg
key={i + stateKey}
stateKey={stateKey}
arg={input}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables || false}
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={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={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={input}
onChange={(v) => setDataAttr(input.name, v)}
filePath={
data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG
}
/>
);
case "accordion":
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<div key={i + stateKey}>
<DetailsBanner
summary={input.label}
className={classNames("mb-auto!", disabled && "opacity-disabled")}
>
<div className="mt-3">
<FormInputsStack
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</div>
</DetailsBanner>
</div>
);
case "h_stack":
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
<FormInputs
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</div>
);
case "banner":
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<Banner
key={i + stateKey}
color={input.color}
className={classNames(disabled && "opacity-disabled")}
>
<FormInputsStack
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</Banner>
);
case "markdown":
return <Markdown key={i + stateKey}>{input.content}</Markdown>;
case "key_value":
return (
<KeyValueArg
key={i + stateKey}
arg={input}
stateKey={stateKey}
onChange={(v) => setDataAttr(input.name, v)}
value={
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "[]")
}
/>
);
default:
// @ts-expect-error
throw new Error(`Invalid input type: ${input.type}`);
}
})}
</>
);
}
function TextArg({
arg,
onChange,
value,
autocompleteFunctions,
autocompleteVariables,
stateKey,
}: {
arg: FormInputText;
value: string;
onChange: (v: string) => void;
autocompleteFunctions: boolean;
autocompleteVariables: boolean;
stateKey: string;
}) {
const props: InputProps = {
onChange,
name: arg.name,
multiLine: arg.multiLine,
className: arg.multiLine ? "min-h-16" : undefined,
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
required: !arg.optional,
disabled: arg.disabled,
help: arg.description,
type: arg.password ? "password" : "text",
label: arg.label ?? arg.name,
size: INPUT_SIZE,
hideLabel: arg.hideLabel ?? arg.label == null,
placeholder: arg.placeholder ?? undefined,
forceUpdateKey: stateKey,
autocomplete: arg.completionOptions ? { options: arg.completionOptions } : undefined,
stateKey,
autocompleteFunctions,
autocompleteVariables,
};
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
return <Input {...props} />;
}
return <PlainInput {...props} />;
}
function EditorArg({
arg,
onChange,
value,
autocompleteFunctions,
autocompleteVariables,
stateKey,
}: {
arg: FormInputEditor;
value: string;
onChange: (v: string) => void;
autocompleteFunctions: boolean;
autocompleteVariables: boolean;
stateKey: string;
}) {
const id = `input-${arg.name}`;
// Read-only editor force refresh for every defaultValue change
// Should this be built into the <Editor/> component?
const [popoutKey, regeneratePopoutKey] = useRandomKey();
const forceUpdateKey = popoutKey + (arg.readOnly ? arg.defaultValue + stateKey : stateKey);
return (
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
<Label
htmlFor={id}
required={!arg.optional}
visuallyHidden={arg.hideLabel}
help={arg.description}
tags={arg.language ? [capitalize(arg.language)] : undefined}
>
{arg.label}
</Label>
<div
className={classNames(
"border border-border rounded-md overflow-hidden px-2 py-1",
"focus-within:border-border-focus",
!arg.rows && "max-h-40", // So it doesn't take up too much space
)}
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
>
<Editor
id={id}
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
disabled={arg.disabled}
language={arg.language}
readOnly={arg.readOnly}
onChange={onChange}
hideGutter
heightMode="auto"
className="min-h-12"
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined}
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
stateKey={stateKey}
forceUpdateKey={forceUpdateKey}
actions={
<div>
<IconButton
variant="border"
size="sm"
className="my-0.5 opacity-60 group-hover:opacity-100"
icon="expand"
title="Pop out to large editor"
onClick={() => {
showDialog({
id: "id",
size: "full",
title: arg.readOnly ? "View Value" : "Edit Value",
className: "max-w-200! max-h-240!",
description: arg.label && (
<Label
htmlFor={id}
required={!arg.optional}
visuallyHidden={arg.hideLabel}
help={arg.description}
tags={arg.language ? [capitalize(arg.language)] : undefined}
>
{arg.label}
</Label>
),
onClose() {
// Force the main editor to update on close
regeneratePopoutKey();
},
render() {
return (
<Editor
id={id}
autocomplete={
arg.completionOptions ? { options: arg.completionOptions } : undefined
}
disabled={arg.disabled}
language={arg.language}
readOnly={arg.readOnly}
onChange={onChange}
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined}
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
stateKey={stateKey}
forceUpdateKey={forceUpdateKey}
/>
);
},
});
}}
/>
</div>
}
/>
</div>
</div>
);
}
function SelectArg({
arg,
value,
onChange,
}: {
arg: FormInputSelect;
value: string;
onChange: (v: string) => void;
}) {
return (
<Select
label={arg.label ?? arg.name}
name={arg.name}
help={arg.description}
onChange={onChange}
defaultValue={arg.defaultValue}
hideLabel={arg.hideLabel}
value={value}
size={INPUT_SIZE}
disabled={arg.disabled}
options={arg.options}
/>
);
}
function FileArg({
arg,
filePath,
onChange,
}: {
arg: FormInputFile;
filePath: string;
onChange: (v: string | null) => void;
}) {
return (
<SelectFile
disabled={arg.disabled}
help={arg.description}
onChange={({ filePath }) => onChange(filePath)}
filePath={filePath === DYNAMIC_FORM_NULL_ARG ? null : filePath}
directory={!!arg.directory}
/>
);
}
function HttpRequestArg({
arg,
value,
onChange,
}: {
arg: FormInputHttpRequest;
value: string;
onChange: (v: string) => void;
}) {
const folders = useAtomValue(foldersAtom);
const httpRequests = useAtomValue(httpRequestsAtom);
const activeHttpRequest = useActiveRequest("http_request");
useEffect(() => {
if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {
onChange(activeHttpRequest.id);
}
}, [activeHttpRequest, onChange, value]);
return (
<Select
label={arg.label ?? arg.name}
name={arg.name}
onChange={onChange}
help={arg.description}
value={value}
disabled={arg.disabled}
options={httpRequests.map((r) => {
return {
label:
buildRequestBreadcrumbs(r, folders).join(" / ") +
(r.id === activeHttpRequest?.id ? " (current)" : ""),
value: r.id,
};
})}
/>
);
}
function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): string[] {
const ancestors: (HttpRequest | Folder)[] = [request];
const next = () => {
const latest = ancestors[0];
if (latest == null) return [];
const parent = folders.find((f) => f.id === latest.folderId);
if (parent == null) return;
ancestors.unshift(parent);
next();
};
next();
return ancestors.map((a) => (a.model === "folder" ? a.name : resolvedModelName(a)));
}
function CheckboxArg({
arg,
onChange,
value,
}: {
arg: FormInputCheckbox;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<Checkbox
onChange={onChange}
checked={value}
help={arg.description}
disabled={arg.disabled}
title={arg.label ?? arg.name}
hideLabel={arg.label == null}
/>
);
}
function KeyValueArg({
arg,
onChange,
value,
stateKey,
}: {
arg: FormInputKeyValue;
value: string;
onChange: (v: string) => void;
stateKey: string;
}) {
const pairs: Pair[] = useMemo(() => {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}, [value]);
const handleChange = useCallback(
(newPairs: Pair[]) => {
onChange(JSON.stringify(newPairs));
},
[onChange],
);
return (
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
<Label
htmlFor={`input-${arg.name}`}
required={!arg.optional}
visuallyHidden={arg.hideLabel}
help={arg.description}
>
{arg.label ?? arg.name}
</Label>
<PairEditor
pairs={pairs}
onChange={handleChange}
stateKey={stateKey}
namePlaceholder="name"
valuePlaceholder="value"
noScroll
/>
</div>
);
}
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
if (!inputs) return false;
for (const input of inputs) {
if ("inputs" in input && !hasVisibleInputs(input.inputs)) {
// Has children, but none are visible
return false;
}
if (!input.hidden) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,24 @@
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
wrapperClassName?: string;
}
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
return (
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div
className={classNames(
className,
"rounded-lg border border-dashed border-border-subtle",
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
)}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { VStack } from "@yaakapp-internal/ui";
export function EncryptionHelp() {
return (
<VStack space={3}>
<p>Encrypt passwords, tokens, and other sensitive info when encryption is enabled.</p>
<p>
Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or
sharing with others.
</p>
</VStack>
);
}

View File

@@ -0,0 +1,85 @@
import classNames from "classnames";
import { memo, useMemo } from "react";
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { editEnvironment } from "../lib/editEnvironment";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
type Props = {
className?: string;
} & Pick<ButtonProps, "forDropdown" | "leftSlot">;
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
className,
...buttonProps
}: Props) {
const { subEnvironments, baseEnvironment } = useEnvironmentsBreakdown();
const activeEnvironment = useActiveEnvironment();
const items: DropdownItem[] = useMemo(
() => [
...subEnvironments.map(
(e) => ({
key: e.id,
label: e.name,
rightSlot: <EnvironmentColorIndicator environment={e} />,
leftSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: async () => {
if (e.id !== activeEnvironment?.id) {
setWorkspaceSearchParams({ environment_id: e.id });
} else {
setWorkspaceSearchParams({ environment_id: null });
}
},
}),
[activeEnvironment?.id],
),
...((subEnvironments.length > 0
? [{ type: "separator", label: "Environments" }]
: []) as DropdownItem[]),
{
label: "Manage Environments",
hotKeyAction: "environment_editor.toggle",
leftSlot: <Icon icon="box" />,
onSelect: () => editEnvironment(activeEnvironment),
},
],
[subEnvironments, activeEnvironment],
);
const hasBaseVars =
(baseEnvironment?.variables ?? []).filter((v) => v.enabled && (v.name || v.value)).length > 0;
return (
<Dropdown items={items}>
<Button
size="sm"
className={classNames(
className,
"text px-2! truncate",
!activeEnvironment && !hasBaseVars && "text-text-subtlest italic",
)}
// If no environments, the button simply opens the dialog.
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
onClick={
subEnvironments.length === 0
? (event) => {
event.preventDefault();
editEnvironment(null);
}
: undefined
}
{...buttonProps}
>
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
{activeEnvironment?.name ?? (hasBaseVars ? "Environment" : "No Environment")}
</Button>
</Dropdown>
);
});

View File

@@ -0,0 +1,23 @@
import type { Environment } from "@yaakapp-internal/models";
import { showColorPicker } from "../lib/showColorPicker";
import { ColorIndicator } from "./ColorIndicator";
export function EnvironmentColorIndicator({
environment,
clickToEdit,
className,
}: {
environment: Environment | null;
clickToEdit?: boolean;
className?: string;
}) {
if (environment?.color == null) return null;
return (
<ColorIndicator
className={className}
color={environment?.color ?? null}
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
/>
);
}

View File

@@ -0,0 +1,33 @@
import { useState } from "react";
import { ColorIndicator } from "./ColorIndicator";
import { Banner } from "@yaakapp-internal/ui";
import { Button } from "./core/Button";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
export function EnvironmentColorPicker({
color: defaultColor,
onChange,
}: {
color: string | null;
onChange: (color: string | null) => void;
}) {
const [color, setColor] = useState<string | null>(defaultColor);
return (
<form
className="flex flex-col items-stretch gap-5 pb-2 w-full"
onSubmit={(e) => {
e.preventDefault();
onChange(color);
}}
>
<Banner color="secondary">
This color will be used to color the interface when this environment is active
</Banner>
<ColorPickerWithThemeColors color={color} onChange={setColor} />
<Button type="submit" color="secondary">
{color != null && <ColorIndicator color={color} />}
Save
</Button>
</form>
);
}

View File

@@ -0,0 +1,444 @@
import type { Environment, Workspace } from "@yaakapp-internal/models";
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai-family";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import {
environmentsBreakdownAtom,
useEnvironmentsBreakdown,
} from "../hooks/useEnvironmentsBreakdown";
import { useHotKey } from "../hooks/useHotKey";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { fireAndForget } from "../lib/fireAndForget";
import { jotaiStore } from "../lib/jotai";
import { isBaseEnvironment, isSubEnvironment } from "../lib/model_util";
import { resolvedModelName } from "../lib/resolvedModelName";
import { showColorPicker } from "../lib/showColorPicker";
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import type { PairEditorHandle } from "./core/PairEditor";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import { EnvironmentEditor } from "./EnvironmentEditor";
import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
const collapsedFamily = atomFamily((treeId: string) => {
const key = ["env_collapsed", treeId ?? "n/a"];
return atomWithKVStorage<Record<string, boolean>>(key, {});
});
interface Props {
initialEnvironmentId: string | null;
setRef?: (ref: PairEditorHandle | null) => void;
}
type TreeModel = Environment | Workspace;
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironmentId ?? null,
);
const selectedEnvironment =
selectedEnvironmentId != null
? allEnvironments.find((e) => e.id === selectedEnvironmentId)
: baseEnvironment;
return (
<SplitLayout
storageKey="env_editor"
defaultRatio={0.75}
layout="horizontal"
className="gap-0"
resizeHandleClassName="-translate-x-px"
firstSlot={() => (
<EnvironmentEditDialogSidebar
selectedEnvironmentId={selectedEnvironment?.id ?? null}
setSelectedEnvironmentId={setSelectedEnvironmentId}
/>
)}
secondSlot={() => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
{baseEnvironments.length > 1 ? (
<div className="p-3">
<Banner color="notice">
There are multiple base environments for this workspace. Please delete the
environments you no longer need.
</Banner>
</div>
) : (
<span />
)}
{selectedEnvironment == null ? (
<div className="p-3 mt-10">
<Banner color="danger">
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
</Banner>
</div>
) : (
<EnvironmentEditor
key={selectedEnvironment.id}
setRef={setRef}
className="pl-4 pt-3"
environment={selectedEnvironment}
/>
)}
</div>
)}
/>
);
}
const sharableTooltip = (
<IconTooltip
tabIndex={-1}
icon="eye"
iconSize="sm"
content="This environment will be included in Directory Sync and data exports"
/>
);
function EnvironmentEditDialogSidebar({
selectedEnvironmentId,
setSelectedEnvironmentId,
}: {
selectedEnvironmentId: string | null;
setSelectedEnvironmentId: (id: string | null) => void;
}) {
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null);
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useLayoutEffect(() => {
if (selectedEnvironmentId == null) return;
treeRef.current?.selectItem(selectedEnvironmentId);
treeRef.current?.focus();
}, []);
const handleDeleteEnvironment = useCallback(
async (environment: Environment) => {
await deleteModelWithConfirm(environment);
if (selectedEnvironmentId === environment.id) {
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
}
},
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback(
() => treeRef.current?.getSelectedItems() as TreeModel[] | undefined,
[],
);
const handleRenameSelected = useCallback(() => {
const items = getSelectedTreeModels();
if (items?.length === 1 && items[0] != null) {
treeRef.current?.renameItem(items[0].id);
}
}, [getSelectedTreeModels]);
const handleDeleteSelected = useCallback(
(items: TreeModel[]) => deleteModelWithConfirm(items),
[],
);
const handleDuplicateSelected = useCallback(
async (items: TreeModel[]) => {
if (items.length === 1 && items[0]) {
const newId = await duplicateModel(items[0]);
setSelectedEnvironmentId(newId);
} else {
await Promise.all(items.map(duplicateModel));
}
},
[setSelectedEnvironmentId],
);
useHotKey("sidebar.selected.rename", handleRenameSelected, {
enable: treeHasFocus,
allowDefault: true,
priority: 100,
});
useHotKey(
"sidebar.selected.delete",
useCallback(() => {
const items = getSelectedTreeModels();
if (items) {
fireAndForget(handleDeleteSelected(items));
}
}, [getSelectedTreeModels, handleDeleteSelected]),
{ enable: treeHasFocus, priority: 100 },
);
useHotKey(
"sidebar.selected.duplicate",
useCallback(async () => {
const items = getSelectedTreeModels();
if (items) await handleDuplicateSelected(items);
}, [getSelectedTreeModels, handleDuplicateSelected]),
{ enable: treeHasFocus, priority: 100 },
);
const getContextMenu = useCallback(
(items: TreeModel[]): ContextMenuProps["items"] => {
const environment = items[0];
const addEnvironmentItem: DropdownItem = {
label: "Create Sub Environment",
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createSubEnvironment();
},
};
if (environment == null || environment.model !== "environment") {
return [addEnvironmentItem];
}
const singleEnvironment = items.length === 1;
const canDeleteEnvironment =
isSubEnvironment(environment) ||
(isBaseEnvironment(environment) && baseEnvironments.length > 1);
const menuItems: DropdownItem[] = [
{
label: "Rename",
leftSlot: <Icon icon="pencil" />,
hidden: isBaseEnvironment(environment) || !singleEnvironment,
hotKeyAction: "sidebar.selected.rename",
hotKeyLabelOnly: true,
onSelect: () => {
// Not sure why this is needed, but without it the
// edit input blurs immediately after opening.
requestAnimationFrame(() => handleRenameSelected());
},
},
{
label: "Duplicate",
leftSlot: <Icon icon="copy" />,
hidden: isBaseEnvironment(environment),
hotKeyAction: "sidebar.selected.duplicate",
hotKeyLabelOnly: true,
onSelect: () => handleDuplicateSelected(items),
},
{
label: environment.color ? "Change Color" : "Assign Color",
leftSlot: <Icon icon="palette" />,
hidden: isBaseEnvironment(environment) || !singleEnvironment,
onSelect: async () => showColorPicker(environment),
},
{
label: `Make ${environment.public ? "Private" : "Sharable"}`,
leftSlot: <Icon icon={environment.public ? "eye_closed" : "eye"} />,
rightSlot: <EnvironmentSharableTooltip />,
hidden: items.length > 1,
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
},
{
color: "danger",
label: "Delete",
hotKeyAction: "sidebar.selected.delete",
hotKeyLabelOnly: true,
hidden: !canDeleteEnvironment,
leftSlot: <Icon icon="trash" />,
onSelect: () => handleDeleteEnvironment(environment),
},
];
// Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment) {
menuItems.push({ type: "separator" });
menuItems.push(addEnvironmentItem);
}
return menuItems;
},
[
baseEnvironments.length,
handleDeleteEnvironment,
handleDuplicateSelected,
handleRenameSelected,
],
);
const handleDragEnd = useCallback(async function handleDragEnd({
items,
children,
insertAt,
}: {
items: TreeModel[];
children: TreeModel[];
insertAt: number;
}) {
const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;
const next = children[insertAt] as Exclude<TreeModel, Workspace>;
const beforePriority = prev?.sortPriority ?? 0;
const afterPriority = next?.sortPriority ?? 0;
const shouldUpdateAll = afterPriority - beforePriority < 1;
try {
if (shouldUpdateAll) {
// Add items to children at insertAt
children.splice(insertAt, 0, ...items);
await Promise.all(children.map((m, i) => patchModel(m, { sortPriority: i * 1000 })));
} else {
const range = afterPriority - beforePriority;
const increment = range / (items.length + 2);
await Promise.all(
items.map((m, i) => {
const sortPriority = beforePriority + (i + 1) * increment;
// Spread item sortPriority out over before/after range
return patchModel(m, { sortPriority });
}),
);
}
} catch (e) {
console.error(e);
}
}, []);
const handleActivate = useCallback(
(item: TreeModel) => {
setSelectedEnvironmentId(item.id);
},
[setSelectedEnvironmentId],
);
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
({ items, position, onClose }) => (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[],
);
const tree = useAtomValue(treeAtom);
return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
{tree != null && (
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
)}
</aside>
);
}
const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
const activeWorkspace = get(activeWorkspaceAtom);
const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom);
if (activeWorkspace == null || baseEnvironment == null) return null;
const root: TreeNode<TreeModel> = {
item: activeWorkspace,
parent: null,
children: [],
depth: 0,
};
for (const item of baseEnvironments) {
root.children?.push({
item,
parent: root,
depth: 0,
draggable: false,
});
}
const parent = root.children?.[0];
if (baseEnvironments.length <= 1 && parent != null) {
parent.children = subEnvironments.map((item) => ({
item,
parent,
depth: 1,
localDrag: true,
}));
}
return root;
});
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return baseEnvironments.length > 1 ? (
<Icon icon="alert_triangle" color="notice" />
) : (
item.model === "environment" && item.color && <EnvironmentColorIndicator environment={item} />
);
}
function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return (
<>
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
<IconButton
size="sm"
color="custom"
iconSize="sm"
icon="plus_circle"
className="opacity-50 hover:opacity-100"
title="Add Sub-Environment"
onClick={createSubEnvironment}
/>
)}
</>
);
}
function ItemInner({ item }: { item: TreeModel }) {
return (
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
{item.model === "environment" && item.public ? (
<div className="mr-2 flex items-center">{sharableTooltip}</div>
) : (
<span aria-hidden />
)}
<div className="truncate min-w-0 text-left">{resolvedModelName(item)}</div>
</div>
);
}
async function createSubEnvironment() {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
return id;
}
function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
defaultValue: item.name,
placeholder: "Name",
async onChange(item, name) {
await patchModel(item, { name });
},
};
return options;
}

View File

@@ -0,0 +1,175 @@
import type { Environment } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { Heading } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useMemo } from "react";
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { useIsEncryptionEnabled } from "../hooks/useIsEncryptionEnabled";
import { useKeyValue } from "../hooks/useKeyValue";
import { useRandomKey } from "../hooks/useRandomKey";
import { analyzeTemplate, convertTemplateToSecure } from "../lib/encryption";
import { isBaseEnvironment } from "../lib/model_util";
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
} from "../lib/setupOrConfigureEncryption";
import { DismissibleBanner } from "./core/DismissibleBanner";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
import { ensurePairId } from "./core/PairEditor.util";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
import { PillButton } from "./core/PillButton";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip";
interface Props {
environment: Environment;
hideName?: boolean;
className?: string;
setRef?: (n: PairEditorHandle | null) => void;
}
export function EnvironmentEditor({ environment, hideName, className, setRef }: Props) {
const workspaceId = environment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
namespace: "global",
key: ["environmentValueVisibility", workspaceId],
fallback: false,
});
const { allEnvironments } = useEnvironmentsBreakdown();
const handleChange = useCallback(
(variables: PairWithId[]) => patchModel(environment, { variables }),
[environment],
);
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
const options: GenericCompletionOption[] = [];
if (isBaseEnvironment(environment)) {
return { options };
}
const allVariables = allEnvironments.flatMap((e) => e?.variables);
const allVariableNames = new Set(allVariables.map((v) => v?.name));
for (const name of allVariableNames) {
const containingEnvs = allEnvironments.filter((e) =>
e.variables.some((v) => v.name === name),
);
const isAlreadyInActive = containingEnvs.find((e) => e.id === environment.id);
if (isAlreadyInActive) {
continue;
}
options.push({
label: name,
type: "constant",
detail: containingEnvs.map((e) => e.name).join(", "),
});
}
return { options };
}, [environment, allEnvironments]);
const validateName = useCallback((name: string) => {
// Empty just means the variable doesn't have a name yet and is unusable
if (name === "") return true;
return name.match(/^[a-z_][a-z0-9_.-]*$/i) != null;
}, []);
const valueType = !isEncryptionEnabled && valueVisibility.value ? "text" : "password";
const allVariableAreEncrypted = useMemo(
() =>
environment.variables.every((v) => v.value === "" || analyzeTemplate(v.value) !== "insecure"),
[environment.variables],
);
const encryptEnvironment = (environment: Environment) => {
withEncryptionEnabled(async () => {
const encryptedVariables: PairWithId[] = [];
for (const variable of environment.variables) {
const value = variable.value ? await convertTemplateToSecure(variable.value) : "";
encryptedVariables.push(ensurePairId({ ...variable, value }));
}
await handleChange(encryptedVariables);
regenerateForceUpdateKey();
});
};
return (
<div
className={classNames(
className,
"h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3",
)}
>
<div className="flex flex-col gap-4">
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorIndicator
className="mr-2"
clickToEdit
environment={environment ?? null}
/>
{!hideName && <div className="mr-2">{environment?.name}</div>}
{isEncryptionEnabled ? (
!allVariableAreEncrypted ? (
<PillButton color="notice" onClick={() => encryptEnvironment(environment)}>
Encrypt All Variables
</PillButton>
) : (
<PillButton color="secondary" onClick={setupOrConfigureEncryption}>
Encryption Settings
</PillButton>
)
) : (
<PillButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
{valueVisibility.value ? "Hide Values" : "Show Values"}
</PillButton>
)}
<PillButton
color="secondary"
rightSlot={<EnvironmentSharableTooltip />}
onClick={async () => {
await patchModel(environment, { public: !environment.public });
}}
>
{environment.public ? "Sharable" : "Private"}
</PillButton>
</Heading>
{environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
<DismissibleBanner
id={`warn-unencrypted-${environment.id}`}
color="notice"
className="mr-3"
actions={[
{
label: "Encrypt Variables",
onClick: () => encryptEnvironment(environment),
color: "success",
},
]}
>
This sharable environment contains plain-text secrets
</DismissibleBanner>
)}
</div>
<PairOrBulkEditor
setRef={setRef}
className="h-full"
allowMultilineValues
preferenceName="environment"
nameAutocomplete={nameAutocomplete}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueType}
valueAutocompleteVariables="environment"
valueAutocompleteFunctions
forceUpdateKey={`${environment.id}::${forceUpdateKey}`}
pairs={environment.variables}
onChange={handleChange}
stateKey={`environment.${environment.id}`}
forcedEnvironmentId={environment.id}
/>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { IconTooltip } from "./core/IconTooltip";
export function EnvironmentSharableTooltip() {
return (
<IconTooltip content="Sharable environments are included in Directory Sync and data export." />
);
}

View File

@@ -0,0 +1,66 @@
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
import type { ErrorInfo, ReactNode } from "react";
import { Component, useEffect } from "react";
import { showDialog } from "../lib/dialog";
import RouteError from "./RouteError";
interface ErrorBoundaryProps {
name: string;
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn("Error caught by ErrorBoundary:", error, info);
}
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => {
showDialog({
id: "error-boundary",
render: () => <RouteError error={this.state.error} />,
});
}}
>
Show
</Button>
</Banner>
);
}
return this.props.children;
}
}
export function ErrorBoundaryTestThrow() {
useEffect(() => {
throw new Error("test error");
});
return <div>Hello</div>;
}

View File

@@ -0,0 +1,169 @@
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
interface Props {
onHide: () => void;
onSuccess: (path: string) => void;
}
export function ExportDataDialog({ onHide, onSuccess }: Props) {
const allWorkspaces = useAtomValue(workspacesAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
if (activeWorkspace == null || allWorkspaces.length === 0) return null;
return (
<ExportDataDialogContent
onHide={onHide}
onSuccess={onSuccess}
allWorkspaces={allWorkspaces}
activeWorkspace={activeWorkspace}
/>
);
}
function ExportDataDialogContent({
onHide,
onSuccess,
activeWorkspace,
allWorkspaces,
}: Props & {
allWorkspaces: Workspace[];
activeWorkspace: Workspace;
}) {
const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState<boolean>(false);
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
[activeWorkspace.id]: true,
});
// Put the active workspace first
const workspaces = useMemo(
() => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)],
[activeWorkspace, allWorkspaces],
);
const handleToggleAll = () => {
setSelectedWorkspaces(
// oxlint-disable-next-line no-accumulating-spread
allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),
);
};
const handleExport = useCallback(async () => {
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
const document = await rpc<string>("cmd_export_data", {
workspaceIds: ids,
includePrivateEnvironments: includePrivateEnvironments,
});
const savedTo = await platform.files.save(
`yaak.${slug}.json`,
new TextEncoder().encode(document),
[{ name: "JSON", extensions: ["json"] }],
);
if (savedTo == null) {
return; // Cancelled
}
onHide();
onSuccess(savedTo);
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="w-6 min-w-0 py-2 text-left pl-1">
<Checkbox
checked={!allSelected && !noneSelected ? "indeterminate" : allSelected}
hideLabel
title="All workspaces"
onChange={handleToggleAll}
/>
</th>
<th className="py-2 text-left pl-4" onClick={handleToggleAll}>
Workspace
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{workspaces.map((w) => (
<tr key={w.id}>
<td className="min-w-0 py-1 pl-1">
<Checkbox
checked={selectedWorkspaces[w.id] ?? false}
title={w.name}
hideLabel
onChange={() =>
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
}
/>
</td>
<td
className="py-1 pl-4 text whitespace-nowrap overflow-x-auto hide-scrollbars"
onClick={() =>
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
}
>
{w.name} {w.id === activeWorkspace.id ? "(current workspace)" : ""}
</td>
</tr>
))}
</tbody>
</table>
<DetailsBanner color="secondary" defaultOpen summary="Extra Settings">
<Checkbox
checked={includePrivateEnvironments}
onChange={setIncludePrivateEnvironments}
title="Include private environments"
help='Environments marked as "sharable" will be exported by default'
/>
</DetailsBanner>
</VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button
</Link>
</div>
<HStack space={2} justifyContent="end">
<Button size="sm" className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
size="sm"
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{" "}
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button>
</HStack>
</footer>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useRef, useState } from "react";
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
import { rpc } from "../lib/rpc";
import { hideToastById, showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Input } from "./core/Input";
interface Props {
feature: FeedbackFeature;
onDone: () => void;
}
export function FeedbackToast({ feature, onDone }: Props) {
const [text, setText] = useState<string>("");
const [sent, setSent] = useState(false);
const sentRef = useRef(false);
const handleDismiss = () => {
onDone();
hideToastById(`feature-feedback-${feature}`);
};
const handleSend = () => {
const trimmedText = text.trim();
if (sentRef.current || trimmedText.length === 0) return;
sentRef.current = true;
setSent(true);
onDone();
// Fire-and-forget; failures are intentionally ignored
rpc("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
showToast({
id: `feature-feedback-${feature}`,
timeout: 3000,
color: "success",
message: "Thanks for the feedback!",
});
};
return (
<VStack space={2}>
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
<div className="h-20">
<Input
size="xs"
// The editor forces its mono font on the scroller, so the override
// has to target it directly
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
label="Feedback"
hideLabel
stateKey={null}
multiLine
fullHeight
placeholder="Your thoughts..."
onChange={setText}
/>
</div>
<HStack space={1.5} justifyContent="end">
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
Dismiss
</Button>
<Button
size="xs"
color="primary"
disabled={sent || text.trim().length === 0}
onClick={handleSend}
>
Send
</Button>
</HStack>
</VStack>
);
}

View File

@@ -0,0 +1,212 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { useFolderActions } from "../hooks/useFolderActions";
import { useLatestHttpResponse } from "../hooks/useLatestHttpResponse";
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { showDialog } from "../lib/dialog";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { Button } from "./core/Button";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { Separator } from "./core/Separator";
import { SizeTag } from "./core/SizeTag";
import { HttpResponsePane } from "./HttpResponsePane";
interface Props {
folder: Folder;
style: CSSProperties;
}
export function FolderLayout({ folder, style }: Props) {
const folders = useAtomValue(foldersAtom);
const requests = useAtomValue(allRequestsAtom);
const folderActions = useFolderActions();
const sendAllAction = useMemo(
() => folderActions.find((a) => a.label === "Send All"),
[folderActions],
);
const children = useMemo(() => {
return [
...folders.filter((f) => f.folderId === folder.id),
...requests.filter((r) => r.folderId === folder.id),
];
}, [folder.id, folders, requests]);
const handleSendAll = useCallback(() => {
void sendAllAction?.call(folder);
}, [sendAllAction, folder]);
return (
<div style={style} className="p-6 pt-4 overflow-y-auto @container">
<HStack space={2} alignItems="center">
<Icon icon="folder" size="xl" color="secondary" />
<Heading level={1}>{resolvedModelName(folder)}</Heading>
<HStack className="ml-auto" alignItems="center">
<Button
rightSlot={<Icon icon="send_horizontal" />}
color="secondary"
size="sm"
variant="border"
onClick={handleSendAll}
disabled={sendAllAction == null}
>
Send All
</Button>
</HStack>
</HStack>
<Separator className="mt-3 mb-8" />
<div className="grid grid-cols-1 @lg:grid-cols-2 @4xl:grid-cols-3 gap-4 min-w-0">
{children.map((child) => (
<ChildCard key={child.id} child={child} />
))}
</div>
</div>
);
}
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
let card: ReactNode;
if (child.model === "folder") {
card = <FolderCard folder={child} />;
} else if (child.model === "http_request") {
card = <HttpRequestCard request={child} />;
} else if (child.model === "grpc_request") {
card = <RequestCard request={child} />;
} else if (child.model === "websocket_request") {
card = <RequestCard request={child} />;
} else {
card = <div>Unknown model</div>;
}
const navigate = useCallback(async () => {
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: child.workspaceId },
search: (prev) => ({ ...prev, request_id: child.id }),
});
}, [child.id, child.workspaceId]);
return (
<div
className={classNames(
"rounded-lg bg-surface-highlight p-3 pt-1 border border-border",
"flex flex-col gap-3",
)}
>
<HStack space={2}>
{child.model === "folder" && <Icon icon="folder" size="lg" />}
<Heading className="truncate" level={2}>
{resolvedModelName(child)}
</Heading>
<HStack space={0.5} className="ml-auto -mr-1.5">
<IconButton
color="custom"
title="Send Request"
size="sm"
icon="external_link"
className="opacity-70 hover:opacity-100"
onClick={navigate}
/>
<IconButton
color="custom"
title="Send Request"
size="sm"
icon="send_horizontal"
className="opacity-70 hover:opacity-100"
onClick={() => {
sendAnyHttpRequest.mutate(child.id);
}}
/>
</HStack>
</HStack>
<div className="text-text-subtle">{card}</div>
</div>
);
}
function FolderCard({ folder }: { folder: Folder }) {
return (
<div>
<Button
color="primary"
onClick={async () => {
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: folder.workspaceId },
search: (prev) => {
return { ...prev, request_id: null, folder_id: folder.id };
},
});
}}
>
Open
</Button>
</div>
);
}
function RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) {
return <div>TODO {request.id}</div>;
}
function HttpRequestCard({ request }: { request: HttpRequest }) {
const latestResponse = useLatestHttpResponse(request.id);
return (
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
<code className="font-mono text-editor text-info border border-info rounded-sm px-2.5 py-0.5 truncate w-full min-w-0">
{request.method} {request.url}
</code>
{latestResponse ? (
<button
className="block mr-auto"
type="button"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
showDialog({
id: "response-preview",
title: "Response Preview",
size: "md",
className: "h-full",
render: () => {
return <HttpResponsePane activeRequestId={request.id} />;
},
});
}}
>
<HStack
space={2}
alignItems="center"
className={classNames(
"cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
"font-mono text-editor border rounded-sm px-1.5 py-0.5 truncate w-full",
)}
>
{latestResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={latestResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={latestResponse} />
<span>&bull;</span>
<SizeTag
contentLength={latestResponse.contentLength ?? 0}
contentLengthCompressed={latestResponse.contentLength}
/>
</HStack>
</button>
) : (
<div>No Responses</div>
)}
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { Fragment, useMemo } from "react";
import { useAuthTab } from "../hooks/useAuthTab";
import { useEnvironmentsBreakdown } from "../hooks/useEnvironmentsBreakdown";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useModelAncestors } from "../hooks/useModelAncestors";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { hideDialog } from "../lib/dialog";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { Input } from "./core/Input";
import { Link } from "./core/Link";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { EnvironmentEditor } from "./EnvironmentEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
interface Props {
folderId: string | null;
tab?: FolderSettingsTab;
}
const TAB_AUTH = "auth";
const TAB_HEADERS = "headers";
const TAB_SETTINGS = "settings";
const TAB_VARIABLES = "variables";
const TAB_GENERAL = "general";
export type FolderSettingsTab =
| typeof TAB_AUTH
| typeof TAB_HEADERS
| typeof TAB_GENERAL
| typeof TAB_SETTINGS
| typeof TAB_VARIABLES;
export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null;
const ancestors = useModelAncestors(folder);
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder);
const environments = useEnvironmentsBreakdown();
const folderEnvironment = environments.allEnvironments.find(
(e) => e.parentModel === "folder" && e.parentId === folderId,
);
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return [];
return [
{
value: TAB_GENERAL,
label: "General",
},
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
...headersTab,
...authTab,
{
value: TAB_VARIABLES,
label: "Variables",
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
},
];
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
if (folder == null) return null;
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => (
<Fragment key={item.id}>
{index > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
)}
<span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name}
</span>
</Fragment>
))}
{breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 shrink-0" />
)}
<span className="whitespace-nowrap" title={folder.name}>
{folder.name}
</span>
</div>
</div>
<Tabs
defaultValue={tab ?? TAB_GENERAL}
label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1 flex-1"
layout="horizontal"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
<HStack alignItems="center" justifyContent="between" className="w-full">
<Button
onClick={async () => {
const didDelete = await deleteModelWithConfirm(folder);
if (didDelete) {
hideDialog("folder-settings");
}
}}
color="danger"
variant="border"
size="xs"
>
Delete Folder
</Button>
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id}
<CopyIconButton
className="opacity-70 text-primary!"
size="2xs"
iconSize="sm"
title="Copy folder ID"
text={folder.id}
/>
</InlineCode>
</HStack>
</div>
</TabContent>
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
headers={folder.headers}
onChange={(headers) => patchModel(folder, { headers })}
stateKey={`headers.${folder.id}`}
/>
</TabContent>
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<ModelSettingsEditor model={folder} />
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
<VStack alignItems="center" space={1.5}>
<p>
Override{" "}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
Variables
</Link>{" "}
for requests within this folder.
</p>
<Button
variant="border"
size="sm"
onClick={async () => {
await createWorkspaceModel({
workspaceId: folder.workspaceId,
parentModel: "folder",
parentId: folder.id,
model: "environment",
name: "Folder Environment",
});
}}
>
Create Folder Environment
</Button>
</VStack>
</EmptyStateText>
) : (
<EnvironmentEditor hideName environment={folderEnvironment} />
)}
</TabContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor";
type Props = {
forceUpdateKey: string;
request: HttpRequest;
onChange: (body: HttpRequest["body"]) => void;
};
export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) {
const pairs = useMemo<Pair[]>(
() =>
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
enabled: p.enabled,
name: p.name,
value: p.file ?? p.value,
contentType: p.contentType,
filename: p.filename,
isFile: !!p.file,
id: p.id,
})),
[request.body.form],
);
const handleChange = useCallback<PairEditorProps["onChange"]>(
(pairs) =>
onChange({
form: pairs.map((p) => ({
enabled: p.enabled,
name: p.name,
contentType: p.contentType,
filename: p.filename,
file: p.isFile ? p.value : undefined,
value: p.isFile ? undefined : p.value,
id: p.id,
})),
}),
[onChange],
);
return (
<PairEditor
valueAutocompleteFunctions
valueAutocompleteVariables
nameAutocompleteVariables
nameAutocompleteFunctions
allowFileValues
allowMultilineValues
pairs={pairs}
onChange={handleChange}
forceUpdateKey={forceUpdateKey}
stateKey={`multipart.${request.id}`}
/>
);
}

View File

@@ -0,0 +1,46 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
request: HttpRequest;
onChange: (headers: HttpRequest["body"]) => void;
};
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
const pairs = useMemo<Pair[]>(
() =>
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
enabled: !!p.enabled,
name: p.name || "",
value: p.value || "",
id: p.id,
})),
[request.body.form],
);
const handleChange = useCallback<PairEditorProps["onChange"]>(
(pairs) =>
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
[onChange],
);
return (
<PairOrBulkEditor
allowMultilineValues
preferenceName="form_urlencoded"
valueAutocompleteFunctions
valueAutocompleteVariables
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="entry_name"
valuePlaceholder="Value"
pairs={pairs}
onChange={handleChange}
forceUpdateKey={forceUpdateKey}
stateKey={`urlencoded.${request.id}`}
/>
);
}

View File

@@ -0,0 +1,45 @@
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
import { usePlatformEvent } from "../hooks/usePlatformEvent";
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
import { fireAndForget } from "../lib/fireAndForget";
import { jotaiStore } from "../lib/jotai";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import { router } from "../lib/router";
export function GlobalHooks() {
useSyncZoomSetting();
useSyncFontSizeSetting();
useSubscribeActiveWorkspaceId();
useSyncWorkspaceChildModels();
useSubscribeTemplateFunctions();
useSubscribeHttpAuthentication();
// Other useful things
useActiveWorkspaceChangedToast();
useSubscribeHotKeys();
usePlatformEvent("show_home", () => {
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
});
useHotKey(
"request.rename",
async () => {
const model = jotaiStore.get(activeRequestAtom);
if (model == null) return;
await renameModelWithPrompt(model);
},
{ allowDefault: true },
);
return null;
}

View File

@@ -0,0 +1,129 @@
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { useEffect, useMemo } from "react";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { activeGrpcConnectionAtom, useGrpcEvents } from "../hooks/usePinnedGrpcConnection";
import { Banner, SplitLayout } from "@yaakapp-internal/ui";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { workspaceLayoutAtom } from "../lib/atoms";
import { HotkeyList } from "./core/HotkeyList";
import { GrpcRequestPane } from "./GrpcRequestPane";
import { GrpcResponsePane } from "./GrpcResponsePane";
interface Props {
style: CSSProperties;
}
const emptyArray: string[] = [];
export function GrpcConnectionLayout({ style }: Props) {
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const wsId = activeWorkspace?.id ?? "n/a";
const activeRequest = useActiveRequest("grpc_request");
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
const grpcEvents = useGrpcEvents(activeConnection?.id ?? null);
const protoFilesKv = useGrpcProtoFiles(activeRequest?.id ?? null);
const protoFiles = protoFilesKv.value ?? emptyArray;
const grpc = useGrpc(activeRequest, activeConnection, protoFiles);
const services = grpc.reflect.data ?? null;
useEffect(() => {
if (services == null || activeRequest == null) return;
const s = services.find((s) => s.name === activeRequest.service);
if (s == null) {
patchModel(activeRequest, {
service: services[0]?.name ?? null,
method: services[0]?.methods[0]?.name ?? null,
}).catch(console.error);
return;
}
const m = s.methods.find((m) => m.name === activeRequest.method);
if (m == null) {
patchModel(activeRequest, {
method: s.methods[0]?.name ?? null,
}).catch(console.error);
return;
}
}, [activeRequest, services]);
const activeMethod = useMemo(() => {
if (services == null || activeRequest == null) return null;
const s = services.find((s) => s.name === activeRequest.service);
if (s == null) return null;
return s.methods.find((m) => m.name === activeRequest.method);
}, [activeRequest, services]);
const methodType:
| "unary"
| "server_streaming"
| "client_streaming"
| "streaming"
| "no-schema"
| "no-method" = useMemo(() => {
if (services == null) return "no-schema";
if (activeMethod == null) return "no-method";
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return "streaming";
if (activeMethod.clientStreaming) return "client_streaming";
if (activeMethod.serverStreaming) return "server_streaming";
return "unary";
}, [activeMethod, services]);
if (activeRequest == null) {
return null;
}
return (
<SplitLayout
storageKey={`grpc_layout::${wsId}`}
className="p-3 gap-1.5"
style={style}
layout={workspaceLayout}
firstSlot={({ style }) => (
<GrpcRequestPane
style={style}
activeRequest={activeRequest}
protoFiles={protoFiles}
methodType={methodType}
isStreaming={grpc.isStreaming}
onGo={grpc.go.mutate}
onCommit={grpc.commit.mutate}
onCancel={grpc.cancel.mutate}
onSend={grpc.send.mutate}
services={services ?? null}
reflectionError={grpc.reflect.error as string | undefined}
reflectionLoading={grpc.reflect.isFetching}
/>
)}
secondSlot={({ style }) =>
!grpc.go.isPending && (
<div
style={style}
className={classNames(
"x-theme-responsePane",
"max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1",
"bg-surface rounded-md border border-border-subtle",
"shadow relative",
)}
>
{grpc.go.error ? (
<Banner color="danger" className="m-2">
{grpc.go.error}
</Banner>
) : grpcEvents.length >= 0 ? (
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
) : (
<HotkeyList hotkeys={["request.send", "sidebar.focus", "url_bar.focus"]} />
)}
</div>
)
}
/>
);
}

View File

@@ -0,0 +1,302 @@
import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc";
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import {
handleRefresh,
jsonCompletion,
jsonSchemaLinter,
stateExtensions,
updateSchema,
} from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
services: ReflectResponseService[] | null;
reflectionError?: string;
reflectionLoading?: boolean;
request: GrpcRequest;
protoFiles: string[];
};
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({
services,
reflectionError,
reflectionLoading,
request,
protoFiles,
...extraEditorProps
}: Props) {
const [editorView, setEditorView] = useState<EditorView | null>(null);
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
setEditorView(h);
}, []);
// Find the schema for the selected service and method
const methodSchema = useMemo<MethodSchema>(() => {
if (services === null || request.service === null || request.method === null) {
return { type: "none" };
}
const s = services.find((s) => s.name === request.service);
if (s == null) {
return {
type: "error",
id: "grpc-find-service-error",
title: "Couldn't Find Service",
body: (
<>
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
log: ["Failed to find service", { service: request.service, services }],
};
}
const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (schema == null) {
return {
type: "error",
id: "grpc-find-schema-error",
title: "Couldn't Find Method",
body: (
<>
Failed to find method <InlineCode>{request.method}</InlineCode> for{" "}
<InlineCode>{request.service}</InlineCode> in schema
</>
),
log: ["Failed to find method", { method: request.method, methods: s.methods }],
};
}
try {
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
} catch (err) {
return {
type: "error",
id: "grpc-parse-schema-error",
title: "Failed to Parse Schema",
body: (
<VStack space={4}>
<p>
For service <InlineCode>{request.service}</InlineCode> and method{" "}
<InlineCode>{request.method}</InlineCode>
</p>
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
log: ["Failed to parse schema", err],
};
}
}, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
const extraExtensions = useMemo(
() => [
linter(jsonSchemaLinter(), {
delay: 200,
needsRefresh: handleRefresh,
}),
jsoncLanguage.data.of({
autocomplete: jsonCompletion(),
}),
stateExtensions({}),
],
[],
);
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo(
() => [
// Matches the GraphQL editor: one always-visible control labelled by schema state,
// with everything schema-related behind it.
<div key="schema" className="opacity-100!">
<Dropdown
items={[
{
// Hidden for servers without reflection, which isn't an error
hidden: !reflectionError,
type: "content",
label: (
<Banner color="danger">
<p className="mb-1">Reflection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={handleShowReflectionError}
>
View Error
</Button>
</Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
>
<Button
size="sm"
variant="border"
title="Schema"
forDropdown
isLoading={reflectionLoading}
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
</div>,
],
[
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
);
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
setRef={handleInitEditorViewRef}
language="json"
autocompleteFunctions
autocompleteVariables
defaultValue={request.message}
heightMode="auto"
placeholder="..."
extraExtensions={extraExtensions}
actions={actions}
stateKey={`grpc_message.${request.id}`}
{...extraEditorProps}
/>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
interface Props {
onDone: () => void;
}
export function GrpcProtoSelectionDialog(props: Props) {
const request = useActiveRequest();
if (request?.model !== "grpc_request") return null;
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
}
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
const protoFilesKv = useGrpcProtoFiles(request.id);
const protoFiles = protoFilesKv.value ?? [];
const grpc = useGrpc(request, null, protoFiles);
const services = grpc.reflect.data;
const serverReflection = protoFiles.length === 0 && services != null;
let reflectError = grpc.reflect.error ?? null;
const reflectionUnimplemented = String(reflectError).match(/unimplemented/i);
if (reflectionUnimplemented) {
reflectError = null;
}
if (request == null) {
return null;
}
return (
<VStack className="flex-col-reverse mb-3" space={3}>
{/* Buttons on top so they get focus first */}
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
<Button
color="primary"
variant="border"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}
>
Add Proto Files
</Button>
<Button
variant="border"
color="primary"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
}}
>
Add Import Folders
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
variant="border"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
{reflectError && (
<Banner color="warning">
<h1 className="font-bold">
Reflection failed on URL <InlineCode>{request.url || "n/a"}</InlineCode>
</h1>
<p>{reflectError.trim()}</p>
</Banner>
)}
{!serverReflection && services != null && services.length > 0 && (
<Banner className="flex flex-col gap-2">
<p>
Found services{" "}
{services?.slice(0, 5).map((s, i) => {
return (
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
<InlineCode>{s.name}</InlineCode>
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
</span>
);
})}
{services?.length > 5 && pluralizeCount("other", services?.length - 5)}
</p>
</Banner>
)}
{serverReflection && services != null && services.length > 0 && (
<Banner className="flex flex-col gap-2">
<p>
Server reflection found services
{services?.map((s, i) => {
return (
<span key={s.name + s.methods.map((m) => m.name).join(",")}>
<InlineCode>{s.name}</InlineCode>
{i === services.length - 1 ? "" : i === services.length - 2 ? " and " : ", "}
</span>
);
})}
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{" "}
files.
</p>
</Banner>
)}
{protoFiles.length > 0 && (
<table className="w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="text-text-subtlest" colSpan={3}>
Added File Paths
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => {
const parts = f.split("/");
// oxlint-disable-next-line no-array-index-key -- none
return (
<tr key={f + i} className="group">
<td>
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
</td>
<td className="pl-1 font-mono text-sm" title={f}>
{parts.length > 3 && ".../"}
{parts.slice(-3).join("/")}
</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
variant="border"
size="xs"
icon="trash"
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
onClick={async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{reflectionUnimplemented && protoFiles.length === 0 && (
<Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
Server Reflection
</Link>{" "}
. Please manually add the <InlineCode>.proto</InlineCode> file to get started.
</Banner>
)}
</VStack>
</VStack>
);
}

View File

@@ -0,0 +1,318 @@
import {
type GrpcRequest,
type HttpRequestHeader,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useAuthTab } from "../hooks/useAuthTab";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { RadioDropdown } from "./core/RadioDropdown";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { GrpcEditor } from "./GrpcEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
protoFiles: string[];
reflectionError?: string;
reflectionLoading?: boolean;
methodType:
| "unary"
| "client_streaming"
| "server_streaming"
| "streaming"
| "no-schema"
| "no-method";
isStreaming: boolean;
onCommit: () => void;
onCancel: () => void;
onSend: (v: { message: string }) => void;
onGo: () => void;
services: ReflectResponseService[] | null;
}
const TAB_MESSAGE = "message";
const TAB_METADATA = "metadata";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
export function GrpcRequestPane({
style,
services,
methodType,
activeRequest,
protoFiles,
reflectionError,
reflectionLoading,
isStreaming,
onGo,
onCommit,
onCancel,
onSend,
}: Props) {
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null);
const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback(
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
const handleChangeMessage = useCallback(
// Not debounced: handleSend reads message from the store, so a pending
// debounced patch would send stale text
(message: string) => patchModel(activeRequest, { message }),
[activeRequest],
);
const select = useMemo(() => {
const options =
services?.flatMap((s) =>
s.methods.map((m) => ({
label: `${s.name.split(".").pop() ?? s.name}/${m.name}`,
value: `${s.name}/${m.name}`,
})),
) ?? [];
const value = `${activeRequest?.service ?? ""}/${activeRequest?.method ?? ""}`;
return { value, options };
}, [activeRequest?.method, activeRequest?.service, services]);
const handleChangeService = useCallback(
async (v: string) => {
const [serviceName, methodName] = v.split("/", 2);
if (serviceName == null || methodName == null) throw new Error("Should never happen");
await patchModel(activeRequest, {
service: serviceName,
method: methodName,
});
},
[activeRequest],
);
const handleConnect = useCallback(async () => {
if (activeRequest == null) return;
if (activeRequest.service == null || activeRequest.method == null) {
alert({
id: "grpc-invalid-service-method",
title: "Error",
body: "Service or method not selected",
});
}
onGo();
}, [activeRequest, onGo]);
const handleSend = useCallback(async () => {
if (activeRequest == null) return;
onSend({ message: activeRequest.message });
}, [activeRequest, onSend]);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs: TabItem[] = useMemo(
() => [
{ value: TAB_MESSAGE, label: "Message" },
...metadataTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[authTab, hasDescription, metadataTab, numSettingsOverrides],
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
[activeRequest],
);
const handleDescriptionChange = useCallback(
(description: string) => patchModelDebounced(activeRequest, { description }),
[activeRequest],
);
return (
<VStack style={style}>
<div
ref={urlContainerEl}
className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5",
paneWidth === 0 && "opacity-0",
paneWidth > 0 && paneWidth < 400 && "grid-cols-1!",
)}
>
<UrlBar
key={forceUpdateKey}
url={activeRequest.url ?? ""}
submitIcon={null}
forceUpdateKey={forceUpdateKey}
placeholder="localhost:50051"
onSend={handleConnect}
onUrlChange={handleChangeUrl}
onCancel={onCancel}
isLoading={isStreaming}
stateKey={`grpc_url.${activeRequest.id}`}
/>
<HStack space={1.5}>
<RadioDropdown
value={select.value}
onChange={handleChangeService}
items={select.options.map((o) => ({
label: o.label,
value: o.value,
type: "default",
shortLabel: o.label,
}))}
>
<Button
size="sm"
variant="border"
rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null}
className={classNames(
"font-mono text-editor min-w-20 ring-0!",
paneWidth < 400 && "flex-1",
)}
>
{select.options.find((o) => o.value === select.value)?.label ?? "No Schema"}
</Button>
</RadioDropdown>
{methodType === "client_streaming" || methodType === "streaming" ? (
<>
{isStreaming && (
<>
<IconButton
variant="border"
size="sm"
title="Cancel"
onClick={onCancel}
icon="x"
/>
<IconButton
variant="border"
size="sm"
title="Commit"
onClick={onCommit}
icon="check"
/>
</>
)}
<IconButton
size="sm"
variant="border"
title={isStreaming ? "Connect" : "Send"}
hotkeyAction="request.send"
onClick={isStreaming ? handleSend : handleConnect}
icon={isStreaming ? "send_horizontal" : "arrow_up_down"}
/>
</>
) : (
<IconButton
size="sm"
variant="border"
title={methodType === "unary" ? "Send" : "Connect"}
hotkeyAction="request.send"
onClick={isStreaming ? onCancel : handleConnect}
disabled={methodType === "no-schema" || methodType === "no-method"}
icon={
isStreaming
? "x"
: methodType.includes("streaming")
? "arrow_up_down"
: "send_horizontal"
}
/>
)}
</HStack>
</div>
<Tabs
label="Request"
tabs={tabs}
tabListClassName="mt-1 mb-1.5!"
storageKey="grpc_request_tabs"
activeTabKey={activeRequest.id}
>
<TabContent value="message">
<GrpcEditor
onChange={handleChangeMessage}
forceUpdateKey={forceUpdateKey}
services={services}
reflectionError={reflectionError}
reflectionLoading={reflectionLoading}
request={activeRequest}
protoFiles={protoFiles}
/>
</TabContent>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_METADATA}>
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={forceUpdateKey}
headers={activeRequest.metadata}
stateKey={`headers.${activeRequest.id}`}
onChange={handleMetadataChange}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
onChange={handleDescriptionChange}
/>
</div>
</TabContent>
</Tabs>
</VStack>
);
}

View File

@@ -0,0 +1,248 @@
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import {
activeGrpcConnectionAtom,
activeGrpcConnections,
pinnedGrpcConnectionIdAtom,
useGrpcEvents,
} from "../hooks/usePinnedGrpcConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
interface Props {
style?: CSSProperties;
className?: string;
activeRequest: GrpcRequest;
methodType:
| "unary"
| "client_streaming"
| "server_streaming"
| "streaming"
| "no-schema"
| "no-method";
}
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const connections = useAtomValue(activeGrpcConnections);
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
const events = useGrpcEvents(activeConnection?.id ?? null);
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
const activeEvent = useMemo(
() => (activeEventIndex != null ? events[activeEventIndex] : null),
[activeEventIndex, events],
);
// Set the active message to the first message received if unary
// oxlint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (events.length === 0 || activeEvent != null || methodType !== "unary") {
return;
}
const firstServerMessageIndex = events.findIndex((m) => m.eventType === "server_message");
if (firstServerMessageIndex !== -1) {
setActiveEventIndex(firstServerMessageIndex);
}
}, [events.length]);
if (activeConnection == null) {
return (
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
<HStack space={2}>
<span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== "closed" && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
</HStack>
<div className="ml-auto">
<RecentGrpcConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedGrpcConnectionId}
/>
</div>
</HStack>
);
return (
<div style={style} className="h-full">
<ErrorBoundary name="GRPC Events">
<EventViewer
events={events}
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutStorageKey="grpc_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, onClose }) => (
<GrpcEventDetail
event={event}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</ErrorBoundary>
</div>
);
}
function GrpcEventRow({
event,
isActive,
onClick,
}: {
event: GrpcEvent;
isActive: boolean;
onClick: () => void;
}) {
const { eventType, status, content, error } = event;
const display = getEventDisplay(eventType, status);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
content={
<span className="text-xs">
{content.slice(0, 1000)}
{error && <span className="text-warning"> ({error})</span>}
</span>
}
timestamp={event.createdAt}
/>
);
}
function GrpcEventDetail({
event,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: GrpcEvent;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
if (event.eventType === "client_message" || event.eventType === "server_message") {
const title = `Message ${event.eventType === "client_message" ? "Sent" : "Received"}`;
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
copyText={event.content}
onClose={onClose}
/>
{!showLarge && event.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<Editor
language="json"
defaultValue={event.content ?? ""}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
);
}
// Error or connection_end - show metadata/trailers
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
{event.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{event.error}
</div>
)}
<div className="py-2 h-full">
{Object.keys(event.metadata).length === 0 ? (
<EmptyStateText>
No {event.eventType === "connection_end" ? "trailers" : "metadata"}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(event.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
</div>
);
}
function getEventDisplay(
eventType: GrpcEvent["eventType"],
status: GrpcEvent["status"],
): { icon: IconProps["icon"]; color: IconProps["color"]; title: string } {
if (eventType === "server_message") {
return { icon: "arrow_big_down_dash", color: "info", title: "Server message" };
}
if (eventType === "client_message") {
return { icon: "arrow_big_up_dash", color: "primary", title: "Client message" };
}
if (eventType === "error" || (status != null && status > 0)) {
return { icon: "alert_triangle", color: "danger", title: "Error" };
}
if (eventType === "connection_end") {
return { icon: "check", color: "success", title: "Connection response" };
}
return { icon: "info", color: undefined, title: "Event" };
}

View File

@@ -0,0 +1,190 @@
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import { HStack } from "@yaakapp-internal/ui";
import { acceptLanguages } from "../lib/data/acceptLanguages";
import { cacheControlDirectives } from "../lib/data/cacheControl";
import { charsets } from "../lib/data/charsets";
import { connections } from "../lib/data/connections";
import { encodings } from "../lib/data/encodings";
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { userAgents } from "../lib/data/userAgents";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditorRow } from "./core/PairEditor";
import { ensurePairId } from "./core/PairEditor.util";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
headers: HttpRequestHeader[];
inheritedHeaders?: HttpRequestHeader[];
inheritedHeadersLabel?: string;
stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void;
label?: string;
};
export function HeadersEditor({
stateKey,
headers,
inheritedHeaders,
inheritedHeadersLabel = "Inherited",
onChange,
forceUpdateKey,
}: Props) {
// Get header names defined at current level (case-insensitive)
const currentHeaderNames = new Set(
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
);
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
const validInheritedHeaders =
inheritedHeaders?.filter(
(pair) =>
pair.enabled &&
(pair.name || pair.value) &&
!currentHeaderNames.has(pair.name.toLowerCase()),
) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0;
return (
<div
className={
hasInheritedHeaders
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
: "@container w-full h-full"
}
>
{hasInheritedHeaders && (
<DetailsBanner
color="secondary"
className="text-sm"
summary={
<HStack>
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
</HStack>
}
>
<div className="pb-2">
{validInheritedHeaders?.map((pair, i) => (
<PairEditorRow
key={`${pair.id}.${i}`}
index={i}
disabled
disableDrag
className="py-1"
pair={ensurePairId(pair)}
stateKey={null}
nameAutocompleteFunctions
nameAutocompleteVariables
valueAutocompleteFunctions
valueAutocompleteVariables
/>
))}
</div>
</DetailsBanner>
)}
<PairOrBulkEditor
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="Header-Name"
nameValidate={validateHttpHeader}
onChange={onChange}
pairs={headers}
preferenceName="headers"
stateKey={stateKey}
valueType={valueType}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions
valueAutocompleteVariables
/>
</div>
);
}
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
"content-type": mimeTypes,
accept: ["*/*", ...mimeTypes],
"accept-encoding": encodings,
"content-encoding": encodings,
connection: connections,
"accept-charset": charsets,
"accept-language": acceptLanguages,
"cache-control": cacheControlDirectives,
"user-agent": userAgents,
pragma: ["no-cache"],
te: ["trailers", "compress", "deflate", "gzip"],
dnt: ["1", "0"],
"upgrade-insecure-requests": ["1"],
"x-requested-with": ["XMLHttpRequest"],
};
const valueType = (pair: Pair): InputProps["type"] => {
const name = pair.name.toLowerCase().trim();
if (
name.includes("authorization") ||
name.includes("api-key") ||
name.includes("access-token") ||
name.includes("auth") ||
name.includes("secret") ||
name.includes("token") ||
name === "cookie" ||
name === "set-cookie"
) {
return "password";
}
return "text";
};
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletion[] =
headerOptionsMap[name]?.map((o) =>
typeof o === "string"
? {
label: o,
type: "constant",
boost: 1, // Put above other completions
}
: {
// Show the short label but insert the full value (e.g. User-Agent)
label: o.label,
apply: o.value,
type: "constant",
boost: 1,
},
) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
minMatch: MIN_MATCH,
options: headerNames.map((t) =>
typeof t === "string"
? {
label: t,
type: "constant",
boost: 1, // Put above other completions
}
: {
...t,
boost: 1, // Put above other completions
},
),
};
const validateHttpHeader = (v: string) => {
if (v === "") {
return true;
}
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
};

View File

@@ -0,0 +1,260 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText";
interface Props {
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
}
export function HttpAuthenticationEditor({ model }: Props) {
const inheritedAuth = useInheritedAuthentication(model);
const authConfig = useHttpAuthenticationConfig(
model.authenticationType,
model.authentication,
model,
);
const handleChange = useCallback(
async (authentication: Record<string, unknown>) =>
await patchModel(model, { authentication }),
[model],
);
if (model.authenticationType === "none") {
return <EmptyStateText>No authentication</EmptyStateText>;
}
if (model.authenticationType != null && authConfig.data == null) {
return (
<EmptyStateText>
<p>
Auth plugin not found for{" "}
<InlineCode>{model.authenticationType}</InlineCode>
</p>
</EmptyStateText>
);
}
if (inheritedAuth == null) {
if (model.model === "workspace" || model.model === "folder") {
return (
<EmptyStateText className="flex-col gap-3">
<div className="not-italic flex flex-col items-center gap-3 text-center">
<p className="max-w-md text-sm text-text-subtle">
Choose an auth method to apply it to all requests in{" "}
<strong className="font-semibold text-text-subtle">
{resolvedModelName(model)}
</strong>
.
</p>
<AuthenticationTypeDropdown model={model} />
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
Documentation
</Link>
</div>
</EmptyStateText>
);
}
return <EmptyStateText>No authentication</EmptyStateText>;
}
if (inheritedAuth.authenticationType === "none") {
return <EmptyStateText>No authentication</EmptyStateText>;
}
const wasAuthInherited = inheritedAuth?.id !== model.id;
if (wasAuthInherited) {
const name = resolvedModelName(inheritedAuth);
const cta = inheritedAuth.model === "workspace" ? "Workspace" : name;
return (
<EmptyStateText>
<p>
Inherited from{" "}
<button
type="submit"
className="underline hover:text-text"
onClick={() => {
if (inheritedAuth.model === "folder")
openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings("auth");
}}
>
{cta}
</button>
</p>
</EmptyStateText>
);
}
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-3">
<div>
<HStack space={2} alignItems="start">
<SegmentedControl
label="Enabled"
hideLabel
name="enabled"
value={
model.authentication.disabled === false ||
model.authentication.disabled == null
? "__TRUE__"
: model.authentication.disabled === true
? "__FALSE__"
: "__DYNAMIC__"
}
options={[
{ label: "Enabled", value: "__TRUE__" },
{ label: "Disabled", value: "__FALSE__" },
{ label: "Enabled when...", value: "__DYNAMIC__" },
]}
onChange={async (enabled) => {
let disabled: boolean | string;
if (enabled === "__TRUE__") {
disabled = false;
} else if (enabled === "__FALSE__") {
disabled = true;
} else {
disabled = "";
}
await handleChange({ ...model.authentication, disabled });
}}
/>
{authConfig.data?.actions && authConfig.data.actions.length > 0 && (
<Dropdown
items={authConfig.data.actions.map(
(a): DropdownItem => ({
label: a.label,
leftSlot: a.icon ? <Icon icon={a.icon} /> : null,
onSelect: () => a.call(model),
}),
)}
>
<IconButton
title="Authentication Actions"
icon="settings"
size="xs"
className="text-secondary!"
/>
</Dropdown>
)}
</HStack>
{typeof model.authentication.disabled === "string" && (
<div className="mt-3">
<AuthenticationDisabledInput
className="w-full"
stateKey={`auth.${model.id}.dynamic`}
value={model.authentication.disabled}
onChange={(v) =>
handleChange({ ...model.authentication, disabled: v })
}
/>
</div>
)}
</div>
<DynamicForm
disabled={model.authentication.disabled === true}
autocompleteVariables
autocompleteFunctions
stateKey={`auth.${model.id}.${model.authenticationType}`}
inputs={authConfig.data?.args ?? []}
data={model.authentication}
onChange={handleChange}
/>
</div>
);
}
function AuthenticationTypeDropdown({ model }: Props) {
const options = useAuthDropdownOptions(model);
if (options == null) return null;
return (
<RadioDropdown
items={options.items}
itemsAfter={options.itemsAfter}
itemsBefore={options.itemsBefore}
value={options.value}
onChange={options.onChange}
>
<Button
color="secondary"
variant="border"
size="sm"
rightSlot={
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
}
>
Select Auth
</Button>
</RadioDropdown>
);
}
function AuthenticationDisabledInput({
value,
onChange,
stateKey,
className,
}: {
value: string;
onChange: InputProps["onChange"];
stateKey: string;
className?: string;
}) {
const rendered = useRenderTemplate({
template: value,
enabled: true,
purpose: "preview",
refreshKey: value,
});
return (
<Input
size="sm"
className={className}
label="Dynamic Disabled"
hideLabel
defaultValue={value}
placeholder="Enabled when this renders a non-empty value"
rightSlot={
<div className="px-1 flex items-center">
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
{rendered.isPending
? "loading"
: rendered.data
? "enabled"
: "disabled"}
</div>
</div>
}
autocompleteFunctions
autocompleteVariables
onChange={onChange}
stateKey={stateKey}
/>
);
}

View File

@@ -0,0 +1,69 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import type { SlotProps } from "@yaakapp-internal/ui";
import { SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { useCurrentGraphQLSchema } from "../hooks/useIntrospectGraphQL";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { workspaceLayoutAtom } from "../lib/atoms";
import { GraphQLDocsExplorer } from "./graphql/GraphQLDocsExplorer";
import { showGraphQLDocExplorerAtom } from "./graphql/graphqlAtoms";
import { HttpRequestPane } from "./HttpRequestPane";
import { HttpResponsePane } from "./HttpResponsePane";
interface Props {
activeRequest: HttpRequest;
style: CSSProperties;
}
export function HttpRequestLayout({ activeRequest, style }: Props) {
const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom);
const graphQLSchema = useCurrentGraphQLSchema(activeRequest);
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const wsId = activeWorkspace?.id ?? "n/a";
const requestResponseSplit = ({ style }: Pick<SlotProps, "style">) => (
<SplitLayout
storageKey={`http_layout::${wsId}`}
className="p-3 gap-1.5"
style={style}
layout={workspaceLayout}
firstSlot={({ orientation, style }) => (
<HttpRequestPane
style={style}
activeRequest={activeRequest}
fullHeight={orientation === "horizontal"}
/>
)}
secondSlot={({ style }) => (
<HttpResponsePane activeRequestId={activeRequest.id} style={style} />
)}
/>
);
if (
activeRequest.bodyType === "graphql" &&
showGraphQLDocExplorer[activeRequest.id] !== undefined &&
graphQLSchema != null
) {
return (
<SplitLayout
storageKey={`graphql_layout::${wsId}`}
defaultRatio={1 / 3}
firstSlot={requestResponseSplit}
secondSlot={({ style, orientation }) => (
<GraphQLDocsExplorer
requestId={activeRequest.id}
schema={graphQLSchema}
className={classNames(orientation === "horizontal" && "ml-0!")}
style={style}
/>
)}
/>
);
}
return requestResponseSplit({ style });
}

View File

@@ -0,0 +1,497 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML,
getContentTypeFromHeaders,
} from "../lib/model_util";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { FormMultipartEditor } from "./FormMultipartEditor";
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
const GraphQLEditor = lazy(() =>
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
);
interface Props {
style: CSSProperties;
fullHeight: boolean;
className?: string;
activeRequest: HttpRequest;
}
const TAB_BODY = "body";
const TAB_PARAMS = "params";
const TAB_HEADERS = "headers";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs";
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
const tabsRef = useRef<TabsRef>(null);
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const contentType = getContentTypeFromHeaders(activeRequest.headers);
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent(
"request_pane.focus_tab",
() => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
},
[],
);
const handleContentTypeChange = useCallback(
async (contentType: string | null, patch: Partial<Omit<HttpRequest, "headers">> = {}) => {
if (activeRequest == null) {
console.error("Failed to get active request to update", activeRequest);
return;
}
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== "content-type");
if (contentType != null) {
headers.push({
name: "Content-Type",
value: contentType,
enabled: true,
id: generateId(),
});
}
await patchModel(activeRequest, { ...patch, headers });
// Force update header editor so any changed headers are reflected
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
},
[activeRequest],
);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("http_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
let numParams = 0;
if (
activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ||
activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART
) {
numParams = Array.isArray(activeRequest.body?.form)
? activeRequest.body.form.filter((p) => p.name).length
: 0;
}
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: {
value: activeRequest.bodyType,
items: [
{ type: "separator", label: "Form Data" },
{ label: "Url Encoded", value: BODY_TYPE_FORM_URLENCODED },
{ label: "Multi-Part", value: BODY_TYPE_FORM_MULTIPART },
{ type: "separator", label: "Text Content" },
{ label: "GraphQL", value: BODY_TYPE_GRAPHQL },
{ label: "JSON", value: BODY_TYPE_JSON },
{ label: "XML", value: BODY_TYPE_XML },
{
label: "Other",
value: BODY_TYPE_OTHER,
shortLabel: nameOfContentTypeOr(contentType, "Other"),
},
{ type: "separator", label: "Other" },
{ label: "Binary File", value: BODY_TYPE_BINARY },
{ label: "No Body", shortLabel: "Body", value: BODY_TYPE_NONE },
],
onChange: async (bodyType) => {
if (bodyType === activeRequest.bodyType) return;
const showMethodToast = (newMethod: string) => {
if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;
showToast({
id: "switched-method",
message: (
<>
Request method switched to <InlineCode>POST</InlineCode>
</>
),
});
};
const patch: Partial<HttpRequest> = {
bodyType,
body: convertRequestBody({
body: activeRequest.body,
fromBodyType: activeRequest.bodyType,
toBodyType: bodyType,
}),
};
let newContentType: string | null | undefined;
if (bodyType === BODY_TYPE_NONE) {
newContentType = null;
} else if (
bodyType === BODY_TYPE_FORM_URLENCODED ||
bodyType === BODY_TYPE_FORM_MULTIPART ||
bodyType === BODY_TYPE_JSON ||
bodyType === BODY_TYPE_OTHER ||
bodyType === BODY_TYPE_XML
) {
const isDefaultishRequest =
activeRequest.bodyType === BODY_TYPE_NONE &&
activeRequest.method.toLowerCase() === "get";
const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;
if (isDefaultishRequest || requiresPost) {
patch.method = "POST";
showMethodToast(patch.method);
}
newContentType = bodyType === BODY_TYPE_OTHER ? "text/plain" : bodyType;
} else if (bodyType === BODY_TYPE_GRAPHQL) {
patch.method = "POST";
newContentType = "application/json";
showMethodToast(patch.method);
}
if (newContentType !== undefined) {
await handleContentTypeChange(newContentType, patch);
} else {
await patchModel(activeRequest, patch);
}
},
},
},
{
value: TAB_PARAMS,
rightSlot: <CountBadge count={urlParameterPairs.length} />,
label: "Params",
},
...headersTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[
activeRequest,
authTab,
contentType,
handleContentTypeChange,
hasDescription,
headersTab,
numParams,
numSettingsOverrides,
urlParameterPairs.length,
],
);
const { mutate: sendRequest } = useSendAnyHttpRequest();
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const updateKey = useRequestUpdateKey(activeRequestId);
const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
[activeRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest],
);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => getUrlCompletionConfig(autocompleteUrls),
[autocompleteUrls],
);
const handlePaste = useCallback(
async (e: ClipboardEvent, text: string) => {
if (looksLikeCurl(text)) {
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
} else {
const patch = prepareImportQuerystring(text);
if (patch != null) {
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
setTimeout(() => {
forceUrlRefresh();
forceParamsRefresh();
}, 100);
}
}
},
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
);
const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null),
[activeRequest.id, sendRequest],
);
const handleUrlChange = useCallback(
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
return (
<div
style={style}
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
>
{activeRequest && (
<>
<UrlBar
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
placeholder="https://example.com"
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={handleSend}
onCancel={cancelResponse}
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"}
/>
<Tabs
ref={tabsRef}
label="Request"
tabs={tabs}
tabListClassName="mt-1 -mb-1.5"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_HEADERS}>
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<JsonBodyEditor
forceUpdateKey={forceUpdateKey}
heightMode={fullHeight ? "full" : "auto"}
request={activeRequest}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ""}`}
language="xml"
onChange={handleBodyTextChange}
stateKey={`xml.${activeRequest.id}`}
/>
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<Suspense>
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
request={activeRequest}
onChange={handleBodyChange}
/>
</Suspense>
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
<FormUrlencodedEditor
forceUpdateKey={forceUpdateKey}
request={activeRequest}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
<FormMultipartEditor
forceUpdateKey={forceUpdateKey}
request={activeRequest}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
language={languageFromContentType(contentType)}
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ""}`}
onChange={handleBodyTextChange}
stateKey={`other.${activeRequest.id}`}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
)}
</ConfirmLargeRequestBody>
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={updateKey}
onChange={(description) => patchModel(activeRequest, { description })}
/>
</div>
</TabContent>
</Tabs>
</>
)}
</div>
);
}
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
const language = languageFromContentType(contentType);
if (language === "markdown") {
return "Markdown";
}
return fallback;
}

View File

@@ -0,0 +1,461 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { HotkeyList } from "./core/HotkeyList";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
style?: CSSProperties;
className?: string;
activeRequestId: string;
}
const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
interface RedirectDropWarning {
droppedBodyCount: number;
droppedHeaders: string[];
}
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
() => getRedirectDropWarning(responseEvents.data),
[responseEvents.data],
);
const shouldShowRedirectDropWarning =
activeResponse?.state === "closed" && redirectDropWarning != null;
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
label: "Response",
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: "Response", value: "pretty" },
...(mimeType?.startsWith("image")
? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
],
itemsAfter: [
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
},
},
{
value: TAB_REQUEST,
label: "Request",
rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0}
showZero
/>
),
},
{
value: TAB_COOKIES,
label: "Cookies",
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
{
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
],
[
activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
mimeType,
responseEvents.data?.length,
saveResponse.mutate,
setViewMode,
viewMode,
timelineViewMode,
setTimelineViewMode,
],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
return (
<div
style={style}
className={classNames(
className,
"x-theme-responsePane",
"max-h-full h-full",
"bg-surface rounded-md border border-border-subtle overflow-hidden",
"relative",
)}
>
{activeResponse == null ? (
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
className={classNames(
"text-text-subtle w-full shrink-0",
// Remove a bit of space because the tabs have lots too
"-mb-1.5",
)}
>
{activeResponse && (
<div
className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
"cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)}
>
<HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
</HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
Redirect changed this request
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1
? "redirect hop"
: "redirect hops"}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">
{getRedirectWarningLabel(redirectDropWarning)}
</span>
</PillButton>
</span>
</Tooltip>
) : (
<span />
)}
<div className="justify-self-end shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</div>
</div>
)}
</HStack>
<div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 shrink-0">
{activeResponse.error}
</Banner>
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === "initialized" ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
<LoadingIcon className="text-text-subtlest" />
Sending Request
</HStack>
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
Cancel
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === "closed" &&
(activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<HttpSvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === "pretty"}
/>
)}
</ConfirmLargeResponse>
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_REQUEST}>
<ConfirmLargeResponseRequest response={activeResponse}>
<RequestBodyViewer response={activeResponse} />
</ConfirmLargeResponseRequest>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_COOKIES}>
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
</TabContent>
</Tabs>
</div>
</div>
)}
</div>
);
}
function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null {
if (events == null || events.length === 0) return null;
let droppedBodyCount = 0;
const droppedHeaders = new Set<string>();
for (const e of events) {
const event = e.event;
if (event.type !== "redirect") {
continue;
}
if (event.dropped_body) {
droppedBodyCount += 1;
}
for (const headerName of event.dropped_headers ?? []) {
pushHeaderName(droppedHeaders, headerName);
}
}
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
return null;
}
return {
droppedBodyCount,
droppedHeaders: Array.from(droppedHeaders).sort(),
};
}
function pushHeaderName(headers: Set<string>, headerName: string): void {
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
if (existing == null) {
headers.add(headerName);
}
}
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
return "Dropped body and headers";
}
if (warning.droppedBodyCount > 0) {
return "Dropped body";
}
return "Dropped headers";
}
function EnsureCompleteResponse({
response,
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyUrl: string }>;
}) {
// Wait until the response has been fully-downloaded before asking for it
const complete = response.state === "closed";
const bodyUrl = useResponseBodyUrl(complete ? response : null);
if (!complete || bodyUrl.isPending) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <Component bodyUrl={bodyUrl.data} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyText({ response, filter: null });
if (!body.data) return null;
return <SvgViewer text={body.data} />;
}
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
const body = useResponseBodyText({ response, filter: null });
return <CsvViewer text={body.data ?? null} className={className} />;
}
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyBytes({ response });
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}

View File

@@ -0,0 +1,466 @@
import type {
AnyModel,
HttpResponse,
HttpResponseEvent,
HttpResponseEventData,
} from "@yaakapp-internal/models";
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { type ReactNode, useMemo, useState } from "react";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { useAllRequests } from "../hooks/useAllRequests";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import type { TimelineViewMode } from "./HttpResponsePane";
interface Props {
response: HttpResponse;
viewMode: TimelineViewMode;
}
export function HttpResponseTimeline({ response, viewMode }: Props) {
return <Inner key={response.id} response={response} viewMode={viewMode} />;
}
function Inner({ response, viewMode }: Props) {
const [showRaw, setShowRaw] = useState(false);
const { data: events, error, isLoading } = useHttpResponseEvents(response);
// Generate plain text representation of all events (with prefixes for timeline view)
const plainText = useMemo(() => {
if (!events || events.length === 0) return "";
return events.map((event) => formatEventText(event.event, true)).join("\n");
}, [events]);
// Plain text view - show all events as text in an editor
if (viewMode === "text") {
if (isLoading) {
return <div className="p-4 text-text-subtlest">Loading events...</div>;
} else if (error) {
return <div className="p-4 text-danger">{String(error)}</div>;
} else if (!events || events.length === 0) {
return <div className="p-4 text-text-subtlest">No events recorded</div>;
} else {
return (
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
);
}
}
return (
<EventViewer
events={events ?? []}
getEventKey={(event) => event.id}
error={error ? String(error) : null}
isLoading={isLoading}
loadingMessage="Loading events..."
emptyMessage="No events recorded"
splitLayoutStorageKey="http_response_events"
defaultRatio={0.25}
renderRow={({ event, isActive, onClick }) => {
const display = getEventDisplay(event.event);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
content={display.summary}
timestamp={event.createdAt}
/>
);
}}
renderDetail={({ event, onClose }) => (
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
)}
/>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function EventDetails({
event,
showRaw,
setShowRaw,
onClose,
}: {
event: HttpResponseEvent;
showRaw: boolean;
setShowRaw: (v: boolean) => void;
onClose: () => void;
}) {
const { label } = getEventDisplay(event.event);
const e = event.event;
const settingSourceModels = useSettingSourceModels();
const actions: EventDetailAction[] = [
{
key: "toggle-raw",
label: showRaw ? "Formatted" : "Text",
onClick: () => setShowRaw(!showRaw),
},
];
// Determine the title based on event type
const title = (() => {
switch (e.type) {
case "header_up":
return "Header Sent";
case "header_down":
return "Header Received";
case "send_url":
return "Request";
case "receive_url":
return "Response";
case "redirect":
return "Redirect";
case "setting":
return "Apply Setting";
case "chunk_sent":
return "Data Sent";
case "chunk_received":
return "Data Received";
case "dns_resolved":
return e.overridden ? "DNS Override" : "DNS Resolution";
default:
return label;
}
})();
// Render content based on view mode and event type
const renderContent = () => {
// Raw view - show plaintext representation (without prefix)
if (showRaw) {
const rawText = formatEventText(event.event, false);
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
}
// Headers - show name and value
if (e.type === "header_up" || e.type === "header_down") {
return (
<KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
);
}
// Request URL - show all URL parts separately
if (e.type === "send_url") {
const auth = e.username || e.password ? `${e.username}:${e.password}@` : "";
const isDefaultPort =
(e.scheme === "http" && e.port === 80) || (e.scheme === "https" && e.port === 443);
const portStr = isDefaultPort ? "" : `:${e.port}`;
const query = e.query ? `?${e.query}` : "";
const fragment = e.fragment ? `#${e.fragment}` : "";
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
return (
<KeyValueRows>
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
<KeyValueRow label="Method">{e.method}</KeyValueRow>
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
<KeyValueRow label="Host">{e.host}</KeyValueRow>
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
<KeyValueRow label="Path">{e.path}</KeyValueRow>
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
</KeyValueRows>
);
}
// Response status - show version and status separately
if (e.type === "receive_url") {
return (
<KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
</KeyValueRows>
);
}
// Redirect - show status, URL, and behavior
if (e.type === "redirect") {
const droppedHeaders = e.dropped_headers ?? [];
return (
<KeyValueRows>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
<KeyValueRow label="Location">{e.url}</KeyValueRow>
<KeyValueRow label="Behavior">
{e.behavior === "drop_body" ? "Drop body, change to GET" : "Preserve method and body"}
</KeyValueRow>
<KeyValueRow label="Body Dropped">{e.dropped_body ? "Yes" : "No"}</KeyValueRow>
<KeyValueRow label="Headers Dropped">
{droppedHeaders.length > 0 ? droppedHeaders.join(", ") : "--"}
</KeyValueRow>
</KeyValueRows>
);
}
// Settings - show as key/value
if (e.type === "setting") {
return (
<KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
{e.source_model != null ? (
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
) : null}
</KeyValueRows>
);
}
// Chunks - show formatted bytes
if (e.type === "chunk_sent" || e.type === "chunk_received") {
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
}
// DNS Resolution - show hostname, addresses, and timing
if (e.type === "dns_resolved") {
return (
<KeyValueRows>
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
<KeyValueRow label="Addresses">{e.addresses.join(", ")}</KeyValueRow>
<KeyValueRow label="Duration">
{e.overridden ? (
<span className="text-text-subtlest">--</span>
) : (
`${String(e.duration)}ms`
)}
</KeyValueRow>
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
</KeyValueRows>
);
}
// Default - use summary
const { summary } = getEventDisplay(event.event);
return <div className="font-mono text-editor">{summary}</div>;
};
return (
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
onClose={onClose}
/>
{renderContent()}
</div>
);
}
type EventTextParts = { prefix: ">" | "<" | "*"; text: string };
/** Get the prefix and text for an event */
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
switch (event.type) {
case "send_url":
return {
prefix: ">",
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
};
case "receive_url":
return { prefix: "<", text: `${event.version} ${event.status}` };
case "header_up":
return { prefix: ">", text: `${event.name}: ${event.value}` };
case "header_down":
return { prefix: "<", text: `${event.name}: ${event.value}` };
case "redirect": {
const behavior = event.behavior === "drop_body" ? "drop body" : "preserve";
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? "body dropped" : null,
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(", ")}` : null,
]
.filter(Boolean)
.join(", ");
return {
prefix: "*",
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ""})`,
};
}
case "setting":
return { prefix: "*", text: `Setting ${event.name}=${event.value}` };
case "info":
return { prefix: "*", text: event.message };
case "chunk_sent":
return { prefix: "*", text: `[${formatBytes(event.bytes)} sent]` };
case "chunk_received":
return { prefix: "*", text: `[${formatBytes(event.bytes)} received]` };
case "dns_resolved":
if (event.overridden) {
return {
prefix: "*",
text: `DNS override ${event.hostname} -> ${event.addresses.join(", ")}`,
};
}
return {
prefix: "*",
text: `DNS resolved ${event.hostname} to ${event.addresses.join(", ")} (${event.duration}ms)`,
};
default:
return { prefix: "*", text: "[unknown event]" };
}
}
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
const { prefix, text } = getEventTextParts(event);
return includePrefix ? `${prefix} ${text}` : text;
}
function useSettingSourceModels() {
const requests = useAllRequests();
const folders = useAtomValue(foldersAtom);
const workspaces = useAtomValue(workspacesAtom);
return useMemo<AnyModel[]>(
() => [...requests, ...folders, ...workspaces],
[requests, folders, workspaces],
);
}
function formatSettingSource(
event: Extract<HttpResponseEventData, { type: "setting" }>,
models: AnyModel[],
): string {
const sourceModel = event.source_model;
if (sourceModel == null || sourceModel === "default") {
return "Default";
}
const model =
event.source_id == null
? null
: (models.find((m) => m.model === sourceModel && m.id === event.source_id) ?? null);
const name = model == null ? event.source_name : resolvedModelName(model);
const label = sourceModel.replaceAll("_", " ");
return name == null || name.length === 0 ? label : `${name} (${label})`;
}
function formatSettingSourceModel(event: Extract<HttpResponseEventData, { type: "setting" }>) {
const sourceModel = event.source_model;
if (sourceModel == null || sourceModel === "default" || sourceModel === "workspace") {
return null;
}
return sourceModel;
}
type EventDisplay = {
icon: IconProps["icon"];
color: IconProps["color"];
label: string;
summary: ReactNode;
};
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) {
case "setting":
const sourceModel = formatSettingSourceModel(event);
return {
icon: "settings",
color: "secondary",
label: "Setting",
summary: `${event.name} = ${event.value}${sourceModel == null ? "" : ` (${sourceModel})`}`,
};
case "info":
return {
icon: "info",
color: "secondary",
label: "Info",
summary: event.message,
};
case "redirect": {
const droppedHeaders = event.dropped_headers ?? [];
const dropped = [
event.dropped_body ? "drop body" : null,
droppedHeaders.length > 0
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? "header" : "headers"}`
: null,
]
.filter(Boolean)
.join(", ");
return {
icon: "arrow_big_right_dash",
color: "success",
label: "Redirect",
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ""}`,
};
}
case "send_url":
return {
icon: "arrow_big_up_dash",
color: "primary",
label: "Request",
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`,
};
case "receive_url":
return {
icon: "arrow_big_down_dash",
color: "info",
label: "Response",
summary: `${event.version} ${event.status}`,
};
case "header_up":
return {
icon: "arrow_big_up_dash",
color: "primary",
label: "Header",
summary: `${event.name}: ${event.value}`,
};
case "header_down":
return {
icon: "arrow_big_down_dash",
color: "info",
label: "Header",
summary: `${event.name}: ${event.value}`,
};
case "chunk_sent":
return {
icon: "info",
color: "secondary",
label: "Chunk",
summary: `${formatBytes(event.bytes)} chunk sent`,
};
case "chunk_received":
return {
icon: "info",
color: "secondary",
label: "Chunk",
summary: `${formatBytes(event.bytes)} chunk received`,
};
case "dns_resolved":
return {
icon: "globe",
color: event.overridden ? "success" : "secondary",
label: event.overridden ? "DNS Override" : "DNS",
summary: event.overridden
? `${event.hostname} → ${event.addresses.join(", ")} (overridden)`
: `${event.hostname} → ${event.addresses.join(", ")} (${event.duration}ms)`,
};
default:
return {
icon: "info",
color: "secondary",
label: "Unknown",
summary: "Unknown event",
};
}
}

View File

@@ -0,0 +1,112 @@
import { platform, useCapability } from "@yaakapp-internal/platform";
import { Icon } from "@yaakapp-internal/ui";
import * as m from "motion/react-m";
import { useEffect, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { useWindowFocus } from "../hooks/useWindowFocus";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
/**
* Offers to create a request from a Curl command on the clipboard. A host that can read
* the clipboard on its own offers it whenever the window is focused; one that would have
* to prompt for permission first waits for the user to paste instead.
*/
export function ImportCurl() {
return useCapability("clipboardRead") ? <ImportCurlButton /> : <ImportCurlOnPaste />;
}
function ImportCurlButton() {
const focused = useWindowFocus();
const [clipboardText, setClipboardText] = useState("");
const [isLoading, setIsLoading] = useState(false);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useEffect(() => {
void platform.clipboard.readText().then(setClipboardText);
}, [focused]);
if (!looksLikeCurl(clipboardText)) {
return null;
}
return (
<m.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Button
size="2xs"
variant="border"
color="success"
className="rounded-full"
rightSlot={<Icon icon="import" size="sm" />}
isLoading={isLoading}
title="Import Curl command from clipboard"
onClick={async () => {
setIsLoading(true);
try {
await importCurl.mutateAsync({ command: clipboardText });
setClipboardText(""); // Hide the button until the clipboard changes
} catch (e) {
console.log("Failed to import curl", e);
} finally {
setIsLoading(false);
}
}}
>
Import Curl
</Button>
</m.div>
);
}
function ImportCurlOnPaste() {
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const command = e.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCurl(command)) return;
// Editable targets keep the text as text. The URL editor imports it itself.
if (isEditable(e.target)) return;
showToast({
id: "curl-paste",
color: "success",
message: (
<div>
<h2 className="font-semibold">Curl command detected</h2>
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
</div>
),
action: ({ hide }) => (
<Button
size="xs"
color="success"
className="mr-auto min-w-20"
rightSlot={<Icon icon="import" size="sm" />}
onClick={() => {
hide();
importCurl.mutate({ command });
}}
>
Create Request
</Button>
),
});
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, []);
return null;
}
function isEditable(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
if (target.closest(".cm-editor") != null) return true;
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}

View File

@@ -0,0 +1,795 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useMemo, useRef, useState } from "react";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
}
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
function isFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
* patching it in with effects after the first paint.
*/
export function ImportDataDialog(props: Props) {
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
const { currentWorkspace, listSources } = props;
useEffect(() => {
let cancelled = false;
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
load
.then((sources) => {
if (!cancelled) setInitialSources(sources);
})
.catch(() => {
if (!cancelled) setInitialSources([]);
});
return () => {
cancelled = true;
};
}, [currentWorkspace, listSources]);
if (initialSources == null) return null;
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
listSources,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
);
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
const trimmedSource = source?.trim() ?? "";
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
const selectSource = (value: string) => {
setSource(value);
// Remount the input so it shows the path of the newly-picked file
setForceUpdateKey((k) => k + 1);
};
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
useEffect(() => {
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) selectSource(p);
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
setOriginSources([]);
return;
}
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
})
.catch(() => setOriginSources([]));
}, 300);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [trimmedSource, filePath, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
return workspaces.find((w) => w.id === ids[0]) ?? null;
}, [originSources, workspaces]);
// A file linked to the current workspace defaults back into it, so re-importing doesn't
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
// silently targeting a workspace that is neither new nor current is too surprising. An
// explicit choice always wins.
const destinationKind =
destinationChoice ??
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
const destinationWorkspaceId =
destinationKind === "current"
? (currentWorkspace?.id ?? null)
: destinationKind === "other"
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
try {
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit({ ...plan, items });
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
const targets = new Set(togglableItems(node).map((i) => i.modelId));
if (checked && node.data.kind === "item") {
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const ancestor of ancestorsOf(node.data.item, byId)) {
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
}
}
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
setItems((prev) =>
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
);
};
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
if (item.action !== "delete") continue;
for (const parent of ancestorsOf(item, byId)) {
if (parent.action === "delete" && parent.selected) {
disabled.add(item.modelId);
}
}
}
return disabled;
}, [items]);
if (plan != null) {
const unchanged = items.filter((i) => i.action === "unchanged");
const footerNote =
unchanged.length > 0
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
: "";
const changeCount = items.filter((item) => {
if (disabledIds.has(item.modelId)) return false;
if (item.action === "conflict") return item.resolution === "take_source";
if (item.action === "unchanged") return false;
return item.selected;
}).length;
// The row's label carries what kind of destination it is, so the value can just be its name
const [destinationLabel, destinationValue] = ((): [string, string] => {
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [pluralize("New workspace", names.length), names.join(", ")];
}
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return [
"Destination",
folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
];
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
const planned = plan.resources.workspaces[0];
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
return {
key: existing?.id ?? planned?.id ?? "workspace",
data: {
kind: "destination",
label: existing?.name ?? planned?.name ?? "New workspace",
isNew: planDestination.type === "new_workspace",
},
children: itemTree,
};
})();
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings.map((warning) => (
<Banner
key={`${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
setPlan(null);
setItems([]);
}}
>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
);
}
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, curl, and Yaak exports
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2} className={classNames(workspaces.length === 0 && "hidden")}>
<Select
name="import-destination-kind"
label="Import location"
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
{lastImported != null && destinationWorkspaceId != null ? (
<div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
} else {
setDestinationChoice("other");
setOtherWorkspaceId(linkedWorkspace.id);
}
}}
>
{linkedWorkspace.name}
</button>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}”`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading}
onClick={handlePreview}
>
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</HStack>
</VStack>
);
}
function ImportTreeRow({
row,
onResolveConflict,
}: {
row: TreeRow;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
if (row.kind !== "item") {
return (
<>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" />
)}
</>
);
}
const { item } = row;
const label = actionLabel(item);
return (
<>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
{ value: "keep_mine", label: "Keep mine" },
{ value: "take_source", label: "Take source" },
]}
/>
</div>
) : (
label != null && (
<ActionChip
label={label}
help={actionHelp(item)}
className={classNames(
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)}
/>
)
)}
</>
);
}
function ActionChip({
label,
help,
className,
}: {
label: string;
help: string | null;
className?: string;
}) {
return (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode>
);
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "new";
case "update":
return "updated";
case "delete":
return "removed";
case "keep_local":
return "edited";
case "ignored":
return "ignored";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
const help = (text: string) =>
item.changedFields.length > 0
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
: text;
switch (item.action) {
case "create":
return "Not in this workspace yet";
case "update":
return item.reason === "moved_into_ignored_folder"
? "Moved into a folder that isn't imported. Importing that folder brings it along"
: help("Changed in the source since the last import");
case "delete":
return "Gone from the source since the last import. Checking it deletes it here";
case "keep_local":
return help("Changed here since the last import. Checking it reverts to the source");
case "conflict":
return help("Changed here and in the source since the last import");
case "ignored":
return "Not in this workspace. Imports leave it alone until you check it";
default:
return null;
}
}
function fieldLabel(field: string): string {
return field.replace(/([A-Z])/g, " $1").toLowerCase();
}
/** Every plan item above `item`, nearest first. */
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null) break;
ancestors.push(parent);
parentId = parent.parentId;
}
return ancestors;
}
/**
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
* workspace's environments sit in are headings: they aggregate their children and decide nothing
* themselves.
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string; icon: IconProps["icon"] }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = [];
for (const item of items) {
if (item.parentId != null && byId.has(item.parentId)) {
const siblings = childrenOf.get(item.parentId) ?? [];
siblings.push(item);
childrenOf.set(item.parentId, siblings);
} else {
roots.push(item);
}
}
const byKind = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "environment"),
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
key: item.modelId,
data: { kind: "item", item },
children: seen.has(item.modelId)
? []
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
// the base one, not its child — so a heading groups them into one thing to turn on and off.
const environments = roots.filter((i) => i.model === "environment");
const others = byKind(roots.filter((i) => i.model !== "environment"));
const nodes = others.map((r) => toNode(r, new Set()));
if (environments.length === 0) return nodes;
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables", icon: "variable" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
];
}
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
return [node.data, ...node.children.flatMap(collectRows)];
}
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
function isMissingFolder(item: ImportPlanItem): boolean {
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
}
/**
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
* along with a parent's.
*/
function togglableItem(row: TreeRow): ImportPlanItem | null {
if (row.kind !== "item") return null;
const { item } = row;
return item.action === "unchanged" || item.action === "conflict" ? null : item;
}
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
return collectRows(node)
.map(togglableItem)
.filter((i) => i != null);
}
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
const covered = togglableItems(node);
if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true;
if (selected === 0) return false;
return "indeterminate";
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { appInfo } from "../lib/appInfo";
interface Props {
children: ReactNode;
}
export function IsDev({ children }: Props) {
if (!appInfo.isDev) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,122 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { fireAndForget } from "../lib/fireAndForget";
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { jsonParseLinter } from "./core/Editor/json-lint";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
interface Props {
forceUpdateKey: string;
heightMode: EditorProps["heightMode"];
request: HttpRequest;
}
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const handleChange = useCallback(
(text: string) => patchModel(request, { body: { ...request.body, text } }),
[request],
);
const autoFix = request.body?.sendJsonComments !== true;
const lintExtension = useMemo(
() =>
linter(
jsonParseLinter(
autoFix
? { allowComments: true, allowTrailingCommas: true }
: { allowComments: false, allowTrailingCommas: false },
),
),
[autoFix],
);
const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
[request.body?.text],
);
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["json-fix-3", request.workspaceId],
fallback: false,
});
const handleToggleAutoFix = useCallback(() => {
const newBody = { ...request.body };
if (autoFix) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(request, { body: newBody }));
}, [request, autoFix]);
const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) {
fireAndForget(setBannerDismissed(true));
}
}, [bannerDismissed, setBannerDismissed]);
const showBanner = hasComments && autoFix && !bannerDismissed;
const stripMessage = "Automatically strip comments and trailing commas before sending";
const actions = useMemo<EditorProps["actions"]>(
() => [
showBanner && (
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p>
</Banner>
),
<div key="settings" className="opacity-100! shadow!">
<Dropdown
onOpen={handleDropdownOpen}
items={
[
{
label: "Automatically Fix JSON",
keepOpenOnSelect: true,
onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: (
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
),
},
] satisfies DropdownItem[]
}
>
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
</Dropdown>
</div>,
],
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
);
return (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={heightMode}
defaultValue={`${request.body?.text ?? ""}`}
language="json"
onChange={handleChange}
stateKey={`json.${request.id}`}
actions={actions}
lintExtension={lintExtension}
/>
);
}

View File

@@ -0,0 +1,10 @@
import { hotkeyActions } from "../hooks/useHotKey";
import { HotkeyList } from "./core/HotkeyList";
export function KeyboardShortcutsDialog() {
return (
<div className="grid h-full">
<HotkeyList hotkeys={hotkeyActions} className="pb-6" />
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { SniffedValue } from "./core/Editor/sniffValue";
import { showDialog } from "../lib/dialog";
import { decodeValue, largeValueActions } from "../lib/largeValue";
import { Dropdown } from "./core/Dropdown";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
/** The whole value, exactly as it reads in the document */
text: string;
sniffed: SniffedValue | null;
}
/**
* Shows a value the editor collapsed, in whatever viewer its type calls for.
*
* The editor only ever read the value's head, so this is where it is decoded in full — on an
* explicit click, behind a spinner, rather than during layout.
*/
export function LargeValueDialog({ text, sniffed }: Props) {
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
const decoded = useDecoded(text, viewer == null ? null : sniffed);
// Nothing recognised it, so there is nothing to decode it into
if (sniffed == null || viewer == null) {
return (
<PagedText text={text}>
{sniffed != null && (
<Banner color="info" className="mb-3">
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
response.
</Banner>
)}
</PagedText>
);
}
if (decoded == null) {
return (
<HStack className="h-full" alignItems="center" justifyContent="center">
<LoadingIcon />
</HStack>
);
}
if ("error" in decoded) {
return (
<PagedText text={text}>
<Banner color="danger" className="mb-3">
Failed to decode this {sniffed.label} value: {decoded.error}
</Banner>
</PagedText>
);
}
const { bytes } = decoded;
switch (viewer) {
case "svg":
return <DecodedSvg bytes={bytes} />;
case "image":
return (
<div className="h-full overflow-auto flex items-center justify-center">
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
</div>
);
case "audio":
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
case "video":
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
case "pdf":
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes} />
</Suspense>
);
case "text":
return <DecodedText bytes={bytes} />;
}
}
/**
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
*/
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <SvgViewer text={text} />;
}
function DecodedText({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <PagedText text={text} />;
}
/**
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
*
* It lives in the dialog's title rather than above the content, which would cost a band of
* whitespace the width of the dialog to hold one small button.
*/
function LargeValueActions({ text, sniffed }: Props) {
const items = useMemo(
() =>
largeValueActions({
value: () => text,
sniffed,
// The tag copies only what it hides; in here, what you see is the whole value
copyText: () => text,
}),
[text, sniffed],
);
return (
<Dropdown items={items}>
<Button size="xs" variant="border" forDropdown>
Actions
</Button>
</Dropdown>
);
}
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
/** Which viewer a media type calls for, or null if none of them can show it. */
function viewerFor(mime: string): Viewer | null {
if (mime.startsWith("image/svg")) return "svg";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "pdf";
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
return null;
}
type Decoded = { bytes: Uint8Array } | { error: string };
/**
* The value's bytes, once they exist.
*
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
* during render — the dialog paints with a spinner first, instead of opening late.
*/
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
const [decoded, setDecoded] = useState<Decoded | null>(null);
useEffect(() => {
if (sniffed == null) return;
setDecoded(null);
let cancelled = false;
const timer = setTimeout(() => {
let result: Decoded;
try {
result = { bytes: decodeValue(text, sniffed) };
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
if (!cancelled) setDecoded(result);
});
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [text, sniffed]);
return decoded;
}
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.slice().buffer;
}
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
const PAGE_CHARS = 50_000;
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
const WRAP_CHARS = 120;
/**
* The raw text, in pages of short lines.
*
* What is left when nothing can render the value. Handing it to an editor whole would walk
* straight back into the layout stall that collapsed it in the first place, so it is paged and
* hard-wrapped instead: no line is ever long, and no page is ever big.
*/
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
const from = page * PAGE_CHARS;
const to = Math.min(from + PAGE_CHARS, text.length);
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
<div>{children}</div>
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
<span>
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
</span>
{pages > 1 && (
<HStack space={2} alignItems="center" className="ml-auto">
<Button
size="xs"
variant="border"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span>
Page {page + 1} of {pages.toLocaleString()}
</span>
<Button
size="xs"
variant="border"
disabled={page >= pages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</HStack>
)}
</HStack>
</div>
);
}
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
function wrap(text: string): string {
const lines: string[] = [];
for (const line of text.split("\n")) {
if (line.length <= WRAP_CHARS) {
lines.push(line);
continue;
}
for (let i = 0; i < line.length; i += WRAP_CHARS) {
lines.push(line.slice(i, i + WRAP_CHARS));
}
}
return lines.join("\n");
}
export function showLargeValueDialog({ text, sniffed }: Props) {
showDialog({
id: "large-value",
size: "lg",
className: "h-[calc(100vh-10rem)]",
// Everything in one line: the same words the tag uses, and the same menu it carries. A
// separate description and a row of its own for the button cost three bands to say this.
title: (
<HStack space={3} alignItems="center">
<span>
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
</span>
<LargeValueActions text={text} sniffed={sniffed} />
</HStack>
),
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
});
}

View File

@@ -0,0 +1,139 @@
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useLicense } from "@yaakapp-internal/license";
import { settingsAtom } from "@yaakapp-internal/models";
import { differenceInCalendarDays } from "date-fns";
import { formatDate } from "date-fns/format";
import { useAtomValue } from "jotai";
import type { ReactNode } from "react";
import { openSettings } from "../commands/openSettings";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { jotaiStore } from "../lib/jotai";
import { CargoFeature } from "./CargoFeature";
import type { ButtonProps } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { PillButton } from "./core/PillButton";
import { platform } from "@yaakapp-internal/platform";
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
function getDetail(
data: LicenseCheckStatus,
dismissedExpired: string | null,
): { label: ReactNode; color: ButtonProps["color"]; options?: DropdownItem[] } | null | undefined {
const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null;
switch (data.status) {
case "active":
return null;
case "personal_use":
return { label: "Personal Use", color: "notice" };
case "trialing":
return { label: "Commercial Trial", color: "secondary" };
case "error":
return { label: "Error", color: "danger" };
case "inactive":
return { label: "Personal Use", color: "notice" };
case "past_due":
return { label: "Past Due", color: "danger" };
case "expired":
// Don't show the expired message if it's been less than 14 days since the last dismissal
if (dismissedAt && differenceInCalendarDays(new Date(), dismissedAt) < 14) {
return null;
}
return {
color: "notice",
label: data.data.changes > 0 ? "Updates Paused" : "License Expired",
options: [
{
label: `${data.data.changes} New Updates`,
color: "success",
leftSlot: <Icon icon="gift" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changes === 0 || data.data.changesUrl == null,
onSelect: () => platform.openUrl(data.data.changesUrl ?? ""),
},
{
type: "separator",
label: `License expired ${formatDate(data.data.periodEnd, "MMM dd, yyyy")}`,
},
{
label: <div className="min-w-48">Renew License</div>,
leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null,
onSelect: () => platform.openUrl(data.data.billingUrl),
},
{
label: "Enter License Key",
leftSlot: <Icon icon="key_round" />,
hidden: data.data.changesUrl == null,
onSelect: openLicenseDialog,
},
{ type: "separator" },
{
label: <span className="text-text-subtle">Remind me Later</span>,
leftSlot: <Icon icon="alarm_clock" className="text-text-subtle" />,
onSelect: () => jotaiStore.set(dismissedAtom, new Date().toISOString()),
},
],
};
}
}
export function LicenseBadge() {
return (
<CargoFeature feature="license">
<LicenseBadgeCmp />
</CargoFeature>
);
}
function LicenseBadgeCmp() {
const { check } = useLicense();
const settings = useAtomValue(settingsAtom);
const dismissed = useAtomValue(dismissedAtom);
// Dismissed license badge
if (settings.hideLicenseBadge) {
return null;
}
if (check.error) {
// Failed to check for license. Probably a network or server error, so just don't show anything.
return null;
}
// Hasn't loaded yet
if (check.data == null) {
return null;
}
const detail = getDetail(check.data, dismissed);
if (detail == null) {
return null;
}
if (detail.options && detail.options.length > 0) {
return (
<Dropdown items={detail.options}>
<PillButton color={detail.color}>
<div className="flex items-center gap-0.5">
{detail.label} <Icon icon="chevron_down" className="opacity-60" />
</div>
</PillButton>
</Dropdown>
);
}
return (
<PillButton color={detail.color} onClick={openLicenseDialog}>
{detail.label}
</PillButton>
);
}
function openLicenseDialog() {
openSettings.mutate("license");
}

View File

@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import classNames from "classnames";
import { platform } from "@yaakapp-internal/platform";
interface Props {
src: string;
className?: string;
}
export function LocalImage({ src: srcPath, className }: Props) {
const src = useQuery({
queryKey: ["local-image", srcPath],
queryFn: async () => {
const p = await platform.files.resolveResource(srcPath);
return platform.files.url(p);
},
});
return (
<img
src={src.data}
alt="Response preview"
className={classNames(
className,
"transition-opacity",
src.data == null ? "opacity-0" : "opacity-100",
)}
/>
);
}

View File

@@ -0,0 +1,113 @@
import type { CSSProperties } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
import remarkGfm from "remark-gfm";
import { ErrorBoundary } from "./ErrorBoundary";
import { Prose } from "./Prose";
interface Props {
children: string | null;
className?: string;
}
export function Markdown({ children, className }: Props) {
if (children == null) return null;
return (
<Prose className={className}>
<ErrorBoundary name="Markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
</ErrorBoundary>
</Prose>
);
}
const prismTheme = {
'pre[class*="language-"]': {
// Needs to be here, so the lib doesn't add its own
},
// Syntax tokens
comment: { color: "var(--textSubtle)" },
prolog: { color: "var(--textSubtle)" },
doctype: { color: "var(--textSubtle)" },
cdata: { color: "var(--textSubtle)" },
punctuation: { color: "var(--textSubtle)" },
property: { color: "var(--primary)" },
"attr-name": { color: "var(--primary)" },
string: { color: "var(--notice)" },
char: { color: "var(--notice)" },
number: { color: "var(--info)" },
constant: { color: "var(--info)" },
symbol: { color: "var(--info)" },
boolean: { color: "var(--warning)" },
"attr-value": { color: "var(--warning)" },
variable: { color: "var(--success)" },
tag: { color: "var(--info)" },
operator: { color: "var(--danger)" },
keyword: { color: "var(--danger)" },
function: { color: "var(--success)" },
"class-name": { color: "var(--primary)" },
builtin: { color: "var(--danger)" },
selector: { color: "var(--danger)" },
inserted: { color: "var(--success)" },
deleted: { color: "var(--danger)" },
regex: { color: "var(--warning)" },
important: { color: "var(--danger)", fontWeight: "bold" },
italic: { fontStyle: "italic" },
bold: { fontWeight: "bold" },
entity: { cursor: "help" },
};
const lineStyle: CSSProperties = {
paddingRight: "1.5em",
paddingLeft: "0",
opacity: 0.5,
};
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>
);
},
code(props) {
const { children, className, ref, ...extraProps } = props;
extraProps.node = undefined;
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
{...extraProps}
CodeTag="code"
showLineNumbers
PreTag="div"
lineNumberStyle={lineStyle}
language={match[1]}
style={prismTheme}
>
{String(children as string).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code {...extraProps} ref={ref} className={className}>
{children}
</code>
);
},
};

View File

@@ -0,0 +1,83 @@
import classNames from "classnames";
import { useRef, useState } from "react";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { SegmentedControl } from "./core/SegmentedControl";
import { Markdown } from "./Markdown";
type ViewMode = "edit" | "preview";
interface Props extends Pick<EditorProps, "heightMode" | "stateKey" | "forceUpdateKey"> {
placeholder: string;
className?: string;
editorClassName?: string;
defaultValue: string;
onChange: (value: string) => void;
name: string;
}
export function MarkdownEditor({
className,
editorClassName,
defaultValue,
onChange,
name,
forceUpdateKey,
...editorProps
}: Props) {
const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? "preview" : "edit");
const containerRef = useRef<HTMLDivElement>(null);
const editor = (
<Editor
hideGutter
wrapLines
className={classNames(editorClassName, "[&_.cm-line]:max-w-lg! max-h-full")}
language="markdown"
defaultValue={defaultValue}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
{...editorProps}
/>
);
const preview =
defaultValue.length === 0 ? (
<p className="text-text-subtlest">No description</p>
) : (
<div className="pr-1.5 overflow-y-auto max-h-full **:cursor-auto **:select-auto">
<Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown>
</div>
);
const contents = viewMode === "preview" ? preview : editor;
return (
<div
ref={containerRef}
className={classNames(
"group/markdown",
"relative w-full h-full pt-1.5 rounded-md gap-x-1.5",
"min-w-0", // Not sure why this is needed
className,
)}
>
<div className="h-full w-full">{contents}</div>
<div className="absolute top-0 right-0 pt-1.5 pr-1.5">
<SegmentedControl
name={name}
label="View mode"
hideLabel
onChange={setViewMode}
value={viewMode}
className="opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100"
options={[
{ icon: "eye", label: "Preview mode", value: "preview" },
{ icon: "pencil", label: "Edit mode", value: "edit" },
]}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,660 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
HttpVersion,
InheritedBoolSetting,
InheritedHttpVersionSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { useModelAncestors } from "../hooks/useModelAncestors";
import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_HTTP_VERSION,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import {
SettingOverrideRow,
SettingRow,
SettingRowBoolean,
SettingsList,
SettingsSection,
} from "./core/SettingRow";
const BYTES_PER_MB = 1024 * 1024;
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
interface Props {
showSectionTitles?: boolean;
model: ModelWithSettings;
}
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
};
type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model);
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
return (
<SettingsList className="space-y-8">
{supportsTlsSettings && (
<SettingsSection title={showSectionTitles ? "Requests" : null}>
{supportsHttpSettings && (
<IntegerSettingRow
settingDefinition={SETTING_REQUEST_TIMEOUT}
setting={model.settingRequestTimeout}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_TIMEOUT.modelKey,
model.settingRequestTimeout,
)}
onChange={(settingRequestTimeout) =>
patchHttpSettings(model, {
settingRequestTimeout,
})
}
/>
)}
{supportsMessageSizeSettings && (
<MessageSizeSettingRow
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
setting={model.settingRequestMessageSize}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
model.settingRequestMessageSize,
)}
onChange={(settingRequestMessageSize) =>
patchMessageSizeSettings(model, {
settingRequestMessageSize,
})
}
/>
)}
<BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_VALIDATE_CERTIFICATES.modelKey,
model.settingValidateCertificates,
)}
onChange={(settingValidateCertificates) =>
patchTlsSettings(model, {
settingValidateCertificates,
})
}
/>
{supportsHttpSettings && (
<BooleanSettingRow
settingDefinition={SETTING_FOLLOW_REDIRECTS}
setting={model.settingFollowRedirects}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_FOLLOW_REDIRECTS.modelKey,
model.settingFollowRedirects,
)}
onChange={(settingFollowRedirects) =>
patchHttpSettings(model, {
settingFollowRedirects,
})
}
/>
)}
{supportsHttpSettings && (
<HttpVersionSettingRow
settingDefinition={SETTING_HTTP_VERSION}
setting={model.settingHttpVersion}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_HTTP_VERSION.modelKey,
model.settingHttpVersion,
)}
onChange={(settingHttpVersion) =>
patchHttpSettings(model, {
settingHttpVersion,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_SEND_COOKIES.modelKey,
model.settingSendCookies,
)}
onChange={(settingSendCookies) =>
patchCookieSettings(model, {
settingSendCookies,
})
}
/>
<BooleanSettingRow
settingDefinition={SETTING_STORE_COOKIES}
setting={model.settingStoreCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_STORE_COOKIES.modelKey,
model.settingStoreCookies,
)}
onChange={(settingStoreCookies) =>
patchCookieSettings(model, {
settingStoreCookies,
})
}
/>
</SettingsSection>
)}
</SettingsList>
);
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
}
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(
model.settingFollowRedirects,
model.settingRequestTimeout,
model.settingHttpVersion,
);
}
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
.length;
}
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
}
}
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
}
}
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
}
function patchMessageSizeSettings(
model: ModelWithMessageSizeSettings,
patch: Partial<MessageSizeSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
}
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
function modelSupportsMessageSizeSettings(
model: ModelWithSettings,
): model is ModelWithMessageSizeSettings {
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
}
function BooleanSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: boolean;
setting: BooleanSetting;
settingDefinition: RequestSettingDefinition;
onChange: (setting: BooleanSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRowBoolean
checked={value}
title={settingDefinition.title}
description={settingDefinition.description}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Checkbox
hideLabel
size="md"
title={settingDefinition.title}
checked={value}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
{ label: "Automatic", value: "auto" },
{ label: "HTTP/1.1", value: "http1" },
{ label: "HTTP/2", value: "http2" },
];
function HttpVersionSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: HttpVersion;
setting: HttpVersionSetting;
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
onChange: (setting: HttpVersionSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange(value)}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) => onChange(parseInteger(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseInteger(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) => onChange(parseMegabytes(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseMegabytes(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeInput({
label,
name,
onChange,
placeholder,
value,
}: {
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<NumberUnitInput
name={name}
label={label}
unit="MB"
value={value}
inputMode="decimal"
step="any"
placeholder={placeholder}
validate={isValidMegabytes}
onChange={onChange}
/>
);
}
function NumberUnitInput({
inputMode,
label,
name,
onChange,
placeholder,
step,
unit,
validate,
value,
}: {
inputMode?: "decimal" | "numeric";
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
step?: number | "any";
unit: string;
validate: (value: string) => boolean;
value: string;
}) {
return (
<PlainInput
hideLabel
name={name}
label={label}
size="sm"
type="number"
inputMode={inputMode}
step={step}
placeholder={placeholder}
defaultValue={value}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
containerClassName="w-48!"
validate={validate}
rightSlot={
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
{unit}
</span>
}
onChange={onChange}
/>
);
}
function isInheritedSetting<T>(
setting: T | { enabled?: boolean; value: T },
): setting is { enabled?: boolean; value: T } {
return typeof setting === "object" && setting != null && "value" in setting;
}
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout" | "settingRequestMessageSize",
fallback: IntegerSetting,
): number;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: BooleanWorkspaceSettingKey,
fallback: BooleanSetting,
): boolean;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingHttpVersion",
fallback: HttpVersionSetting,
): HttpVersion;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: keyof WorkspaceSettings,
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
}
continue;
}
return setting;
}
return isInheritedSetting(fallback) ? fallback.value : fallback;
}
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
const megabytes = Number(value);
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
}
function parseInteger(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
}
function isValidInteger(value: string) {
const parsed = Number(value);
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
}
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
}

View File

@@ -0,0 +1,88 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
import { InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { pluralizeCount } from "../lib/pluralize";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Select } from "./core/Select";
interface Props {
activeWorkspaceId: string;
requests: (HttpRequest | GrpcRequest | WebsocketRequest)[];
onDone: () => void;
}
export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: Props) {
const workspaces = useAtomValue(workspacesAtom);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);
const targetWorkspace = workspaces.find((w) => w.id === selectedWorkspaceId);
const isSameWorkspace = selectedWorkspaceId === activeWorkspaceId;
return (
<VStack space={4} className="mb-4">
<Select
label="Target Workspace"
name="workspace"
value={selectedWorkspaceId}
onChange={setSelectedWorkspaceId}
options={workspaces.map((w) => ({
label: w.id === activeWorkspaceId ? `${w.name} (current)` : w.name,
value: w.id,
}))}
/>
<Button
color="primary"
disabled={isSameWorkspace}
onClick={async () => {
const patch = {
workspaceId: selectedWorkspaceId,
folderId: null,
};
await Promise.all(requests.map((r) => patchModel(r, patch)));
// Hide after a moment, to give time for requests to disappear
setTimeout(onDone, 100);
showToast({
id: "workspace-moved",
message:
requests.length === 1 && requests[0] != null ? (
<>
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{" "}
<InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
</>
) : (
<>
{pluralizeCount("request", requests.length)} moved to{" "}
<InlineCode>{targetWorkspace?.name ?? "unknown"}</InlineCode>
</>
),
action: ({ hide }) => (
<Button
size="xs"
color="secondary"
className="mr-auto min-w-20"
onClick={async () => {
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: selectedWorkspaceId },
});
hide();
}}
>
Switch to Workspace
</Button>
),
});
}}
>
{requests.length === 1 ? "Move" : `Move ${pluralizeCount("Request", requests.length)}`}
</Button>
</VStack>
);
}

View File

@@ -0,0 +1,212 @@
import type { Color } from "@yaakapp-internal/plugins";
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import {
createExampleWorkspace,
createFreshWorkspace,
type OnboardingChoice,
recordOnboardingChoice,
} from "../lib/onboarding";
import { showErrorToast } from "../lib/toast";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
import { Button } from "./core/Button";
/** Shown instead of a workspace when there are none. */
export function Onboarding() {
const settings = useAtomValue(settingsAtom);
const { capabilities } = platform;
const canImport = capabilities.plugins;
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
recordOnboardingChoice(choice);
setBusy(choice);
try {
await run();
} catch (err) {
showErrorToast({
id: "onboarding-failed",
title: "Something went wrong",
message: String(err),
});
} finally {
setBusy(null);
}
};
return (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
<HeaderSize
data-tauri-drag-region
size="lg"
className="x-theme-appHeader bg-surface"
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
/>
<div className="overflow-auto px-6 py-10 grid">
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
<div className="flex flex-col gap-1.5">
<Heading>How would you like to get started?</Heading>
<p className="text-text-subtle">
{canImport
? "Bring over existing work, try a real API, or start fresh."
: "Try a real API, or start fresh."}
</p>
</div>
<div className="flex flex-col gap-2">
{canImport && (
<StartOption
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
/>
)}
<StartOption
color="info"
icon="flask"
title="Try Yaak with a real API"
description="Ready-made requests you can send right away"
busy={busy === "example"}
disabled={busy != null}
onClick={choose("example", createExampleWorkspace)}
/>
<StartOption
color="success"
icon="plus"
title="Start fresh"
description="An empty workspace for your first request"
busy={busy === "fresh"}
disabled={busy != null}
onClick={choose("fresh", createFreshWorkspace)}
/>
</div>
{showAlreadyUsing && (
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
<span className="text-sm text-text-subtle">Already using Yaak?</span>
<div className="flex flex-wrap gap-2">
{capabilities.localFiles && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="folder_open" size="sm" />}
disabled={busy != null}
onClick={choose("open_folder", async () => {
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
});
if (dir == null) return;
await openWorkspaceFromSyncDir.mutateAsync(dir);
})}
>
Open folder
</Button>
)}
{capabilities.git && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="git_branch" size="sm" />}
disabled={busy != null}
onClick={choose("clone_git", () => {
showDialog({
id: "clone-git-repository",
size: "md",
title: "Clone Git Repository",
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
})}
>
Clone repository
</Button>
)}
{canImport && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="import" size="sm" />}
disabled={busy != null}
onClick={choose("import_yaak", () => importData.mutateAsync())}
>
Import
</Button>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
function StartOption({
color,
icon,
title,
description,
onClick,
busy,
disabled,
}: {
color: Color;
icon: IconProps["icon"];
title: string;
description: string;
onClick: () => void;
busy: boolean;
disabled: boolean;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={classNames(
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
"outline-border-focus focus-visible:outline-2",
disabled && !busy && "opacity-disabled",
)}
>
{/* The banner theme tints this tile from the theme's own color for each option */}
<div
className={classNames(
`x-theme-banner--${color}`,
"size-10 rounded-md bg-surface-highlight grid place-items-center",
)}
>
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
</div>
<div className="min-w-0">
<div className="font-semibold text-text">{title}</div>
<div className="text-sm text-text-subtle">{description}</div>
</div>
<Icon
icon="chevron_right"
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
/>
</button>
);
}

View File

@@ -0,0 +1,212 @@
@reference "../main.css";
.prose {
@apply text-text;
& > :first-child {
@apply mt-0;
}
& > :last-child {
@apply mb-0;
}
img,
video,
p,
ul,
ol,
table,
blockquote,
hr,
h1,
h2,
h3,
h4,
h5,
h6 {
@apply my-5;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply mt-10 leading-tight text-balance;
}
p {
@apply text-pretty;
}
h1 {
@apply text-4xl font-bold;
}
h2 {
@apply text-2xl font-bold;
}
h3 {
@apply text-xl font-bold;
}
em {
@apply italic;
}
strong {
@apply font-bold;
}
ul {
@apply list-disc;
ul,
ol {
@apply my-0;
}
}
ol {
@apply list-decimal;
ol,
ul {
@apply my-0;
}
}
ol,
ul {
@apply pl-6;
li p {
@apply inline-block my-0;
}
li {
@apply pl-2;
}
li::marker {
@apply text-success;
}
}
a {
@apply text-notice hover:underline;
* {
@apply text-notice!;
}
}
img,
video {
@apply max-h-[65vh];
@apply w-auto mx-auto rounded-md;
}
table code,
p code,
ol code,
ul code {
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
@apply px-1.5 py-0.5 rounded-sm not-italic;
@apply select-text;
}
pre {
@apply bg-surface-highlight! text-text!;
@apply px-4 py-3 rounded-md;
@apply overflow-auto whitespace-pre;
@apply text-editor font-mono;
code {
@apply font-normal;
}
}
.banner {
@apply border border-dashed;
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded-sm text-base;
&::before {
@apply block font-bold mb-1;
@apply text-text-subtlest;
content: "Note";
}
&.x-theme-banner--secondary::before {
content: "Info";
}
&.x-theme-banner--success::before {
content: "Tip";
}
&.x-theme-banner--notice::before {
content: "Important";
}
&.x-theme-banner--warning::before {
content: "Warning";
}
&.x-theme-banner--danger::before {
content: "Caution";
}
}
blockquote {
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded-sm shadow-lg;
p {
@apply m-0;
}
}
h2[id] > a .icon.icon-link {
@apply hidden w-4 h-4 bg-success ml-2;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24' stroke='currentColor' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'%3E%3C/path%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'%3E%3C/path%3E%3C/svg%3E");
mask-size: contain;
mask-repeat: no-repeat;
&:hover {
@apply bg-notice;
}
}
h2[id]:hover {
.icon.icon-link {
@apply inline-block;
}
}
hr {
@apply border-secondary border-dashed md:mx-[25%] my-10;
}
figure {
img {
@apply mb-0;
}
figcaption {
@apply relative pl-9 text-success text-sm pt-1;
p {
@apply m-0;
}
}
figcaption::before {
@apply border-info absolute left-2 top-0 h-3.5 w-6 rounded-bl border-l border-b border-dotted;
content: "";
}
}
}

View File

@@ -0,0 +1,12 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import "./Prose.css";
interface Props {
children: ReactNode;
className?: string;
}
export function Prose({ className, ...props }: Props) {
return <div className={classNames("prose", className)} {...props} />;
}

View File

@@ -0,0 +1,115 @@
import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
interface Props {
connections: GrpcConnection[];
activeConnection: GrpcConnection;
onPinnedConnectionId: (id: string) => void;
}
export function RecentGrpcConnectionsDropdown({
activeConnection,
connections,
onPinnedConnectionId,
}: Props) {
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return (
<Dropdown
items={[
{
label: "Clear Connection",
onSelect: () => deleteModel(activeConnection),
disabled: connections.length === 0,
},
{
label: `Clear ${pluralizeCount("Connection", connections.length)}`,
onSelect: deleteAllConnections.mutate,
hidden: connections.length <= 1,
disabled: connections.length === 0,
},
{ type: "separator", label: "History" },
...connectionHistoryItems,
]}
>
<IconButton
title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
className="m-0.5 text-text-subtle"
size="sm"
iconSize="md"
/>
</Dropdown>
);
}

View File

@@ -0,0 +1,159 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props {
responses: HttpResponse[];
activeResponse: HttpResponse;
onPinnedResponseId: (id: string) => void;
className?: string;
}
export const RecentHttpResponsesDropdown = function ResponsePane({
activeResponse,
responses,
onPinnedResponseId,
}: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a";
const responseHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentResponses = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const r of responses) {
const createdAt = `${r.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentResponses = true;
} else if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
responseHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
responseHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<HttpStatusTag short className="text-xs" response={r} />
<span className="text-text-subtlest">&bull;</span>
<span className="font-mono">{r.elapsed >= 0 ? formatMillis(r.elapsed) : "n/a"}</span>
<span className="text-text-subtlest">&bull;</span>
<SizeTag
className="text-xs"
contentLength={r.contentLength ?? 0}
contentLengthCompressed={r.contentLengthCompressed}
/>
</HStack>
),
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => {
onPinnedResponseId(r.id);
},
});
}
if (!hasRecentResponses && !hasShownRecentEmptyState) {
responseHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent requests</span>,
});
}
return (
<Dropdown
items={[
{
label: "Delete",
leftSlot: <Icon icon="trash" />,
onSelect: () => deleteModel(activeResponse),
},
{
label: "Delete all",
leftSlot: <Icon icon="trash" />,
onSelect: deleteAllResponses.mutate,
disabled: responses.length === 0,
},
{
label: "Unpin Response",
onSelect: () => onPinnedResponseId(activeResponse.id),
leftSlot: <Icon icon="unpin" />,
hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0,
},
{
type: "content",
hidden: dismissedMovedActions === true,
label: (
<DismissibleBanner
id={movedActionsBannerId}
color="info"
size="xs"
className="max-w-72"
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
),
},
{
type: "separator",
label: "Recent",
},
...responseHistoryItems,
]}
>
<IconButton
title="Show response history"
icon={activeResponse?.id === latestResponseId ? "history" : "pin"}
className="m-0.5 text-text-subtle"
size="sm"
iconSize="md"
/>
</Dropdown>
);
};

View File

@@ -0,0 +1,101 @@
import classNames from "classnames";
import { useMemo, useRef } from "react";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { useHotKey } from "../hooks/useHotKey";
import { useKeyboardEvent } from "../hooks/useKeyboardEvent";
import { useRecentRequests } from "../hooks/useRecentRequests";
import { jotaiStore } from "../lib/jotai";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { Button } from "./core/Button";
import type { DropdownItem, DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import { HttpMethodTag } from "./core/HttpMethodTag";
interface Props {
className?: string;
}
export function RecentRequestsDropdown({ className }: Props) {
const activeRequest = useActiveRequest();
const dropdownRef = useRef<DropdownRef>(null);
const [recentRequestIds] = useRecentRequests();
// Handle key-up
// TODO: Somehow make useHotKey have this functionality. Note: e.key does not work
// on Linux, for example, when Control is mapped to CAPS. This will never fire.
useKeyboardEvent("keyup", "Control", () => {
if (dropdownRef.current?.isOpen) {
dropdownRef.current?.select?.();
}
});
useHotKey("switcher.prev", () => {
if (!dropdownRef.current?.isOpen) {
// Select the second because the first is the current request
dropdownRef.current?.open(1);
} else {
dropdownRef.current?.next?.();
}
});
useHotKey("switcher.next", () => {
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
dropdownRef.current?.prev?.();
});
const items = useMemo(() => {
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (activeWorkspaceId === null) return [];
const requests = jotaiStore.get(allRequestsAtom);
const recentRequestItems: DropdownItem[] = [];
for (const id of recentRequestIds) {
const request = requests.find((r) => r.id === id);
if (request === undefined) continue;
recentRequestItems.push({
label: resolvedModelName(request),
leftSlot: <HttpMethodTag short className="text-xs" request={request} />,
onSelect: async () => {
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: activeWorkspaceId },
search: (prev) => ({ ...prev, request_id: request.id }),
});
},
});
}
// No recent requests to show
if (recentRequestItems.length === 0) {
return [
{
key: "no-recent-requests",
label: "No recent requests",
disabled: true,
},
];
}
return recentRequestItems.slice(0, 20);
}, [recentRequestIds]);
return (
<Dropdown ref={dropdownRef} items={items}>
<Button
size="sm"
hotkeyAction="switcher.toggle"
className={classNames(
className,
"truncate pointer-events-auto",
activeRequest == null && "text-text-subtlest italic",
)}
>
{resolvedModelName(activeRequest)}
</Button>
</Dropdown>
);
}

View File

@@ -0,0 +1,119 @@
import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
format,
isToday,
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
interface Props {
connections: WebsocketConnection[];
activeConnection: WebsocketConnection;
onPinnedConnectionId: (id: string) => void;
}
export function RecentWebsocketConnectionsDropdown({
activeConnection,
connections,
onPinnedConnectionId,
}: Props) {
const latestConnectionId = connections[0]?.id ?? "n/a";
const connectionHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
let hasRecentConnections = false;
let hasShownRecentEmptyState = false;
const now = new Date();
for (const c of connections) {
const createdAt = `${c.createdAt}Z`;
const createdAtDate = new Date(createdAt);
const minutesAgo = differenceInMinutes(now, createdAtDate);
const hoursAgo = differenceInHours(now, createdAtDate);
let historyGroup = format(createdAtDate, "MMM d, yyyy");
if (minutesAgo < 5) historyGroup = "Just now";
else if (minutesAgo < 15) historyGroup = "5 minutes ago";
else if (minutesAgo < 60) historyGroup = "15 minutes ago";
else if (hoursAgo < 3) historyGroup = "1 hour ago";
else if (hoursAgo < 6) historyGroup = "3 hours ago";
else if (isToday(createdAtDate)) historyGroup = "Today";
else if (isYesterday(createdAtDate)) historyGroup = "Yesterday";
else if (createdAtDate.getFullYear() === now.getFullYear()) historyGroup = format(createdAtDate, "MMM d");
const absoluteTime = format(createdAt, "MMM d, yyyy, h:mm:ss a O");
if (historyGroup === "Just now") {
hasRecentConnections = true;
} else if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
hasShownRecentEmptyState = true;
}
if (historyGroup !== "Just now" && historyGroup !== lastHistoryGroup) {
connectionHistoryItems.push({
type: "separator",
label: <span title={absoluteTime}>{historyGroup}</span>,
});
lastHistoryGroup = historyGroup;
}
connectionHistoryItems.push({
label: (
<HStack space={2} className="text-sm" title={absoluteTime}>
<span className="font-mono">{formatMillis(c.elapsed)}</span>
</HStack>
),
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
onSelect: () => onPinnedConnectionId(c.id),
});
}
if (!hasRecentConnections && !hasShownRecentEmptyState) {
connectionHistoryItems.push({
type: "content",
label: <span className="block px-4 py-1 text-sm text-text-subtle">No recent connections</span>,
});
}
return (
<Dropdown
items={[
{
label: "Clear Connection",
onSelect: () => deleteModel(activeConnection),
disabled: connections.length === 0,
},
{
label: `Clear ${pluralizeCount("Connection", connections.length)}`,
onSelect: () => {
const request = getModel("websocket_request", activeConnection.requestId);
if (request != null) {
deleteWebsocketConnections.mutate(request);
}
},
hidden: connections.length <= 1,
disabled: connections.length === 0,
},
{ type: "separator", label: "History" },
...connectionHistoryItems,
]}
>
<IconButton
title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? "history" : "pin"}
className="m-0.5 text-text-subtle"
size="sm"
iconSize="md"
/>
</Dropdown>
);
}

View File

@@ -0,0 +1,51 @@
import { workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
import { getRecentRequests } from "../hooks/useRecentRequests";
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { fireAndForget } from "../lib/fireAndForget";
import { router } from "../lib/router";
import { Onboarding } from "./Onboarding";
export function RedirectToLatestWorkspace() {
const workspaces = useAtomValue(workspacesAtom);
const recentWorkspaces = useRecentWorkspaces();
useEffect(() => {
if (workspaces.length === 0 || recentWorkspaces == null) {
return;
}
fireAndForget(
(async () => {
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? "n/a";
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null;
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null;
const requestId = (await getRecentRequests(workspaceId))[0] ?? null;
const params = { workspaceId };
const search = {
cookie_jar_id: cookieJarId,
environment_id: environmentId,
request_id: requestId,
};
// Whatever created the first workspace may have already navigated into it
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
if (workspaces.some((w) => w.id === currentId)) return;
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
})(),
);
}, [recentWorkspaces, workspaces, workspaces.length]);
// The global models are loaded before the router mounts, so an empty list is a real
// first launch (or the last workspace was just deleted), not a store still loading
if (workspaces.length === 0) {
return <Onboarding />;
}
return null;
}

View File

@@ -0,0 +1,102 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { lazy, Suspense } from "react";
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
import { LoadingIcon } from "@yaakapp-internal/ui";
import { EmptyStateText } from "./EmptyStateText";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { TextViewer } from "./responseViewers/TextViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
import { WebPageViewer } from "./responseViewers/WebPageViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
response: HttpResponse;
}
export function RequestBodyViewer({ response }: Props) {
return <RequestBodyViewerInner key={response.id} response={response} />;
}
function RequestBodyViewerInner({ response }: Props) {
const { data, isLoading, error } = useHttpRequestBody(response);
if (isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (error) {
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
}
if (data?.bodyText == null || data.bodyText.length === 0) {
return <EmptyStateText>No request body</EmptyStateText>;
}
const { bodyText, body } = data;
// Try to detect language from content-type header that was sent
const contentTypeHeader = response.requestHeaders.find(
(h) => h.name.toLowerCase() === "content-type",
);
const contentType = contentTypeHeader?.value ?? null;
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
const language = languageFromContentType(contentType, bodyText);
// Route to appropriate viewer based on content type
if (mimeType?.match(/^multipart/i)) {
const boundary = contentType?.split("boundary=")[1] ?? "unknown";
// Create a copy because parseMultipart may detach the buffer
const bodyCopy = new Uint8Array(body);
return (
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
);
}
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={bodyText} />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={body.buffer} />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={body} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={body} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={bodyText} />;
}
if (mimeType?.match(/^text\/html/i)) {
return <WebPageViewer html={bodyText} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={body} />
</Suspense>
);
}
return (
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
);
}

View File

@@ -0,0 +1,78 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import { memo, useCallback, useMemo } from "react";
import { showPrompt } from "../lib/prompt";
import { Button } from "./core/Button";
import type { DropdownItem } from "./core/Dropdown";
import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag";
import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownItem } from "./core/RadioDropdown";
import { RadioDropdown } from "./core/RadioDropdown";
type Props = {
request: HttpRequest;
className?: string;
};
const radioItems: RadioDropdownItem<string>[] = [
"GET",
"PUT",
"POST",
"PATCH",
"DELETE",
"OPTIONS",
"QUERY",
"HEAD",
].map((m) => ({
value: m,
label: <HttpMethodTagRaw method={m} />,
}));
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
request,
className,
}: Props) {
const handleChange = useCallback(
async (method: string) => {
await patchModel(request, { method });
},
[request],
);
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
key: "custom",
label: "CUSTOM",
leftSlot: <Icon icon="sparkles" />,
onSelect: async () => {
const newMethod = await showPrompt({
id: "custom-method",
label: "Http Method",
title: "Custom Method",
confirmText: "Save",
description: "Enter a custom method name",
placeholder: "CUSTOM",
});
if (newMethod == null) return;
await handleChange(newMethod);
},
},
],
[handleChange],
);
return (
<RadioDropdown
value={request.method}
items={radioItems}
itemsAfter={itemsAfter}
onChange={handleChange}
>
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
<HttpMethodTag request={request} noAlias />
</Button>
</RadioDropdown>
);
});

View File

@@ -0,0 +1,225 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useMemo } from "react";
import type { JSX } from "react/jsx-runtime";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
interface Props {
response: HttpResponse;
}
interface ParsedCookie {
name: string;
value: string;
domain?: string;
path?: string;
expires?: string;
maxAge?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: string;
isDeleted?: boolean;
}
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
// Parse "Cookie: name=value; name2=value2" format
return cookieHeader.split(";").map((pair) => {
const [name = "", ...valueParts] = pair.split("=");
return {
name: name.trim(),
value: valueParts.join("=").trim(),
};
});
}
function parseSetCookieHeader(setCookieHeader: string): ParsedCookie {
// Parse "Set-Cookie: name=value; Domain=...; Path=..." format
const parts = setCookieHeader.split(";").map((p) => p.trim());
const [nameValue = "", ...attributes] = parts;
const [name = "", ...valueParts] = nameValue.split("=");
const cookie: ParsedCookie = {
name: name.trim(),
value: valueParts.join("=").trim(),
};
for (const attr of attributes) {
const [key = "", val] = attr.split("=").map((s) => s.trim());
const lowerKey = key.toLowerCase();
if (lowerKey === "domain") cookie.domain = val;
else if (lowerKey === "path") cookie.path = val;
else if (lowerKey === "expires") cookie.expires = val;
else if (lowerKey === "max-age") cookie.maxAge = val;
else if (lowerKey === "secure") cookie.secure = true;
else if (lowerKey === "httponly") cookie.httpOnly = true;
else if (lowerKey === "samesite") cookie.sameSite = val;
}
// Detect if cookie is being deleted
if (cookie.maxAge !== undefined) {
const maxAgeNum = Number.parseInt(cookie.maxAge, 10);
if (!Number.isNaN(maxAgeNum) && maxAgeNum <= 0) {
cookie.isDeleted = true;
}
} else if (cookie.expires !== undefined) {
// Check if expires date is in the past
try {
const expiresDate = new Date(cookie.expires);
if (expiresDate.getTime() < Date.now()) {
cookie.isDeleted = true;
}
} catch {
// Invalid date, ignore
}
}
return cookie;
}
export function ResponseCookies({ response }: Props) {
const { data: events } = useHttpResponseEvents(response);
const { sentCookies, receivedCookies } = useMemo(() => {
if (!events) return { sentCookies: [], receivedCookies: [] };
// Use Maps to deduplicate by cookie name (latest value wins)
const sentMap = new Map<string, { name: string; value: string }>();
const receivedMap = new Map<string, ParsedCookie>();
for (const event of events) {
const e = event.event;
// Cookie headers sent (header_up with name=cookie)
if (e.type === "header_up" && e.name.toLowerCase() === "cookie") {
const cookies = parseCookieHeader(e.value);
for (const cookie of cookies) {
sentMap.set(cookie.name, cookie);
}
}
// Set-Cookie headers received (header_down with name=set-cookie)
if (e.type === "header_down" && e.name.toLowerCase() === "set-cookie") {
const cookie = parseSetCookieHeader(e.value);
receivedMap.set(cookie.name, cookie);
}
}
return {
sentCookies: Array.from(sentMap.values()),
receivedCookies: Array.from(receivedMap.values()),
};
}, [events]);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.sent_cookies`}
summary={
<h2 className="flex items-center">
Sent Cookies <CountBadge showZero count={sentCookies.length} />
</h2>
}
>
{sentCookies.length === 0 ? (
<NoCookies />
) : (
<KeyValueRows>
{sentCookies.map((cookie, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="primary" key={i} label={cookie.name}>
{cookie.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.received_cookies`}
summary={
<h2 className="flex items-center">
Received Cookies <CountBadge showZero count={receivedCookies.length} />
</h2>
}
>
{receivedCookies.length === 0 ? (
<NoCookies />
) : (
<div className="flex flex-col gap-4">
{receivedCookies.map((cookie, i) => (
// oxlint-disable-next-line react/no-array-index-key
<div key={i} className="flex flex-col gap-1">
<div className="flex items-center gap-2 my-1">
<span
className={classNames(
"font-mono text-editor select-auto cursor-auto",
cookie.isDeleted ? "line-through opacity-60 text-text-subtle" : "text-text",
)}
>
{cookie.name}
<span className="text-text-subtlest select-auto cursor-auto mx-0.5">=</span>
{cookie.value}
</span>
{cookie.isDeleted && (
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded-sm">
Deleted
</span>
)}
</div>
<KeyValueRows>
{[
cookie.domain && (
<KeyValueRow labelColor="info" label="Domain" key="domain">
{cookie.domain}
</KeyValueRow>
),
cookie.path && (
<KeyValueRow labelColor="info" label="Path" key="path">
{cookie.path}
</KeyValueRow>
),
cookie.expires && (
<KeyValueRow labelColor="info" label="Expires" key="expires">
{cookie.expires}
</KeyValueRow>
),
cookie.maxAge && (
<KeyValueRow labelColor="info" label="Max-Age" key="maxAge">
{cookie.maxAge}
</KeyValueRow>
),
cookie.secure && (
<KeyValueRow labelColor="info" label="Secure" key="secure">
true
</KeyValueRow>
),
cookie.httpOnly && (
<KeyValueRow labelColor="info" label="HttpOnly" key="httpOnly">
true
</KeyValueRow>
),
cookie.sameSite && (
<KeyValueRow labelColor="info" label="SameSite" key="sameSite">
{cookie.sameSite}
</KeyValueRow>
),
].filter((item): item is JSX.Element => Boolean(item))}
</KeyValueRows>
</div>
))}
</div>
)}
</DetailsBanner>
</div>
);
}
function NoCookies() {
return <span className="text-text-subtlest text-sm italic">No Cookies</span>;
}

View File

@@ -0,0 +1,110 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
}
export function ResponseHeaders({ response }: Props) {
const responseHeaders = useMemo(
() =>
[...response.headers].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.headers],
);
const requestHeaders = useMemo(
() =>
[...response.requestHeaders].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.requestHeaders],
);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span>
<IconButton
iconSize="sm"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Remote Address">
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Version">
{response.version ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<DetailsBanner
storageKey={`${response.requestId}.request_headers`}
summary={
<h2 className="flex items-center">
Request Headers <CountBadge showZero count={requestHeaders.length} />
</h2>
}
>
{requestHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{requestHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.response_headers`}
summary={
<h2 className="flex items-center">
Response Headers <CountBadge showZero count={responseHeaders.length} />
</h2>
}
>
{responseHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{responseHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
</div>
);
}
function NoHeaders() {
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
}

View File

@@ -0,0 +1,44 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
}
export function ResponseInfo({ response }: Props) {
return (
<div className="overflow-auto h-full pb-4">
<KeyValueRows>
<KeyValueRow labelColor="info" label="Version">
{response.version ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow labelColor="info" label="Remote Address">
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow
labelColor="info"
label={
<div className="flex items-center">
URL
<IconButton
iconSize="sm"
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
}
>
{
<div className="flex">
<span className="select-text cursor-text">{response.url}</span>
</div>
}
</KeyValueRow>
</KeyValueRows>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { Button, FormattedError, Heading, VStack } from "@yaakapp-internal/ui";
import { DetailsBanner } from "./core/DetailsBanner";
export default function RouteError({ error }: { error: unknown }) {
console.log("Error", error);
const stringified = JSON.stringify(error);
// oxlint-disable-next-line no-explicit-any -- none
const message = (error as any).message ?? stringified;
const stack =
typeof error === "object" && error != null && "stack" in error ? String(error.stack) : null;
return (
<div className="flex items-center justify-center h-full">
<VStack space={5} className="w-200 h-auto!">
<Heading>Route Error 🔥</Heading>
<FormattedError>
{message}
{stack && (
<DetailsBanner
color="secondary"
className="mt-3 select-auto text-xs max-h-[40vh]"
summary="Stack Trace"
>
<div className="mt-2 text-xs">{stack}</div>
</DetailsBanner>
)}
</FormattedError>
<VStack space={2}>
<Button
color="primary"
onClick={async () => {
window.location.assign("/");
}}
>
Go Home
</Button>
<Button color="info" onClick={() => window.location.reload()}>
Refresh
</Button>
</VStack>
</VStack>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { platform } from "@yaakapp-internal/platform";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import mime from "mime";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
type Props = Omit<ButtonProps, "type"> & {
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
filePath: string | null;
nameOverride?: string | null;
directory?: boolean;
inline?: boolean;
noun?: string;
help?: ReactNode;
hideLabel?: boolean;
label?: ReactNode;
};
// Special character to insert ltr text in rtl element
const rtlEscapeChar = <>&#x200E;</>;
export function SelectFile({
onChange,
filePath,
inline,
className,
directory,
noun,
nameOverride,
size = "sm",
label,
help,
hideLabel,
...props
}: Props) {
const handleClick = async () => {
const filePath = await platform.dialog.open({
title: directory ? "Select Folder" : "Select File",
multiple: false,
directory,
});
if (filePath == null) return;
const contentType = filePath ? mime.getType(filePath) : null;
onChange({ filePath, contentType });
};
const handleClear = async () => {
onChange({ filePath: null, contentType: null });
};
const itemLabel = noun ?? (directory ? "Folder" : "File");
const selectOrChange = (filePath ? "Change " : "Select ") + itemLabel;
const [isHovering, setIsHovering] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Listen for dropped files on the element
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
// as browser drag-n-drop.
useEffect(() => {
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
setIsHovering(isOver);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, onChange]);
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
return (
<div ref={ref} className="w-full">
{label && (
<Label htmlFor={null} help={help} visuallyHidden={hideLabel}>
{label}
</Label>
)}
<HStack className="relative justify-stretch overflow-hidden">
<Button
className={classNames(
className,
"rtl mr-1.5",
inline && "w-full",
filePath && inline && "font-mono text-xs",
isHovering && "border-notice!",
)}
color={isHovering ? "primary" : "secondary"}
onClick={handleClick}
size={size}
{...props}
>
{rtlEscapeChar}
{inline ? filePathWithNameOverride || selectOrChange : selectOrChange}
</Button>
{!inline && (
<>
{filePath && (
<IconButton
size={size === "auto" ? "md" : size}
variant="border"
icon="x"
title={`Unset ${itemLabel}`}
onClick={handleClear}
/>
)}
<div
className={classNames(
"truncate rtl pl-1.5 pr-3 text-text",
filePath && "font-mono",
size === "xs" && filePath && "text-xs",
size === "sm" && filePath && "text-sm",
)}
>
{rtlEscapeChar}
{filePath ?? `No ${itemLabel.toLowerCase()} selected`}
</div>
{filePath == null && help && !label && <IconTooltip content={help} />}
</>
)}
</HStack>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useKeyPressEvent } from "react-use";
import { appInfo } from "../../lib/appInfo";
import { capitalize } from "../../lib/capitalize";
import { CountBadge } from "../core/CountBadge";
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
import { SettingsCertificates } from "./SettingsCertificates";
import { SettingsGeneral } from "./SettingsGeneral";
import { SettingsHotkeys } from "./SettingsHotkeys";
import { SettingsInterface } from "./SettingsInterface";
import { SettingsLicense } from "./SettingsLicense";
import { SettingsPlugins } from "./SettingsPlugins";
import { SettingsProxy } from "./SettingsProxy";
import { SettingsTheme } from "./SettingsTheme";
interface Props {
tab?: SettingsTabWithSubtab | null;
/** Set when Settings is in a dialog rather than owning a window. */
hide?: () => void;
}
const TAB_GENERAL = "general";
const TAB_INTERFACE = "interface";
const TAB_THEME = "theme";
const TAB_SHORTCUTS = "shortcuts";
const TAB_PROXY = "proxy";
const TAB_CERTIFICATES = "certificates";
const TAB_PLUGINS = "plugins";
const TAB_LICENSE = "license";
const tabs = [
TAB_GENERAL,
TAB_THEME,
TAB_INTERFACE,
TAB_SHORTCUTS,
TAB_PLUGINS,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
export default function Settings({ tab, hide }: Props) {
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tab?.split(":") ?? [];
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
useKeyPressEvent("Escape", async () => {
if (hide == null) await platform.window.close();
});
return (
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
{hide ? (
<span />
) : (
<HeaderSize
data-tauri-drag-region
ignoreControlsSpacing
onlyXWindowControl
size="md"
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<HStack
space={2}
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
</HStack>
</HeaderSize>
)}
<Tabs
layout="horizontal"
defaultValue={mainTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
tabs={tabs.map(
(value): TabItem => ({
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
) : value === TAB_THEME ? (
<Icon icon="palette" className="text-secondary" />
) : value === TAB_INTERFACE ? (
<Icon icon="columns_2" className="text-secondary" />
) : value === TAB_SHORTCUTS ? (
<Icon icon="keyboard" className="text-secondary" />
) : value === TAB_CERTIFICATES ? (
<Icon icon="shield_check" className="text-secondary" />
) : value === TAB_PROXY ? (
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
rightSlot:
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
<CountBadge count color="notice" />
) : null,
}),
)}
>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
<SettingsGeneral />
</TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsInterface />
</TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
<SettingsCertificates />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsLicense />
</TabContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,254 @@
import type { ClientCertificate } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { DetailsBanner } from "../core/DetailsBanner";
import { IconButton } from "../core/IconButton";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { SelectFile } from "../SelectFile";
function createEmptyCertificate(): ClientCertificate {
return {
host: "",
port: null,
crtFile: null,
keyFile: null,
pfxFile: null,
passphrase: null,
enabled: true,
};
}
interface CertificateEditorProps {
certificate: ClientCertificate;
index: number;
onUpdate: (index: number, cert: ClientCertificate) => void;
onRemove: (index: number) => void;
}
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
const updateField = <K extends keyof ClientCertificate>(
field: K,
value: ClientCertificate[K],
) => {
onUpdate(index, { ...certificate, [field]: value });
};
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
const hasCrtKey = Boolean(
(certificate.crtFile && certificate.crtFile.length > 0) ||
(certificate.keyFile && certificate.keyFile.length > 0),
);
// Determine certificate type for display
const certType = hasPfx ? "PFX" : hasCrtKey ? "CERT" : null;
const defaultOpen = useRef<boolean>(!certificate.host);
return (
<DetailsBanner
defaultOpen={defaultOpen.current}
summary={
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
<HStack space={1.5}>
<Checkbox
className="ml-1"
checked={certificate.enabled ?? true}
title={certificate.enabled ? "Disable certificate" : "Enable certificate"}
hideLabel
onChange={(enabled) => updateField("enabled", enabled)}
/>
{certificate.host ? (
<InlineCode>
{certificate.host || <>&nbsp;</>}
{certificate.port != null && `:${certificate.port}`}
</InlineCode>
) : (
<span className="italic text-sm text-text-subtlest">Configure Certificate</span>
)}
{certType && <InlineCode>{certType}</InlineCode>}
</HStack>
<IconButton
icon="trash"
size="sm"
title="Remove certificate"
className="text-text-subtlest -mr-2"
onClick={() => onRemove(index)}
/>
</HStack>
}
>
<VStack space={3} className="mt-2">
<HStack space={2} alignItems="end">
<PlainInput
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
https://
</div>
}
validate={(value) => {
if (!value) return false;
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
return true;
}}
label="Host"
placeholder="example.com"
size="sm"
required
defaultValue={certificate.host}
onChange={(host) => updateField("host", host)}
/>
<PlainInput
label="Port"
hideLabel
validate={(value) => {
if (!value) return true;
if (Number.isNaN(parseInt(value, 10))) return false;
return true;
}}
placeholder="443"
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
:
</div>
}
size="sm"
className="w-24"
defaultValue={certificate.port?.toString() ?? ""}
onChange={(port) => updateField("port", port ? parseInt(port, 10) : null)}
/>
</HStack>
<Separator className="my-3" />
<VStack space={2}>
<SelectFile
label="CRT File"
noun="Cert"
filePath={certificate.crtFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField("crtFile", filePath)}
/>
<SelectFile
label="KEY File"
noun="Key"
filePath={certificate.keyFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField("keyFile", filePath)}
/>
</VStack>
<Separator className="my-3" />
<SelectFile
label="PFX File"
noun="Key"
filePath={certificate.pfxFile ?? null}
size="sm"
disabled={hasCrtKey}
onChange={({ filePath }) => updateField("pfxFile", filePath)}
/>
<PlainInput
label="Passphrase"
size="sm"
type="password"
defaultValue={certificate.passphrase ?? ""}
onChange={(passphrase) => updateField("passphrase", passphrase || null)}
/>
</VStack>
</DetailsBanner>
);
}
export function SettingsCertificates() {
const settings = useAtomValue(settingsAtom);
const certificates = settings.clientCertificates ?? [];
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
await patchModel(settings, { clientCertificates: newCertificates });
};
const handleAdd = async () => {
const newCert = createEmptyCertificate();
await updateCertificates([...certificates, newCert]);
};
const handleUpdate = async (index: number, cert: ClientCertificate) => {
const newCertificates = [...certificates];
newCertificates[index] = cert;
await updateCertificates(newCertificates);
};
const handleRemove = async (index: number) => {
const cert = certificates[index];
if (cert == null) return;
const host = cert.host || "this certificate";
const port = cert.port != null ? `:${cert.port}` : "";
const confirmed = await showConfirmDelete({
id: "confirm-remove-certificate",
title: "Delete Certificate",
description: (
<>
Permanently delete certificate for{" "}
<InlineCode>
{host}
{port}
</InlineCode>
?
</>
),
});
if (!confirmed) return;
const newCertificates = certificates.filter((_, i) => i !== index);
await updateCertificates(newCertificates);
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
// oxlint-disable-next-line react/no-array-index-key
key={index}
certificate={cert}
index={index}
onUpdate={handleUpdate}
onRemove={handleRemove}
/>
))}
</VStack>
)}
</VStack>
);
}

View File

@@ -0,0 +1,180 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo";
import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton";
import { platform } from "@yaakapp-internal/platform";
import {
ModelSettingRowBoolean,
ModelSettingSelectControl,
SettingValue,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() {
const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates();
if (settings == null) {
return null;
}
const showWorkspaceSettingsMovedBanner =
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return (
<VStack space={1.5} className="mb-4">
<div>
<Heading>General</Heading>
<p className="text-text-subtle">
Configure general settings for update behavior and more.
</p>
</div>
<div className="mt-3 mb-5">
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
</div>
<SettingsList className="space-y-8">
<CargoFeature feature="updater">
<SettingsSection title="Updates">
<SettingRow
title="Update Channel"
description="Choose whether Yaak should use stable releases or beta releases."
>
<div className="grid grid-cols-[12rem_auto] gap-1">
<ModelSettingSelectControl
model={settings}
modelKey="updateChannel"
label="Update Channel"
selectClassName="w-full!"
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
</SettingRow>
<SettingRowSelect
title="Update Behavior"
description="Choose whether updates are installed automatically or manually."
name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) =>
patchModel(settings, { autoupdate: v === "auto" })
}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
]}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="autoDownloadUpdates"
title="Automatically download updates"
description="Download Yaak updates in the background so they are ready to install."
disabled={!settings.autoupdate}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="checkNotifications"
title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications."
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
disabled
checked={false}
onChange={() => {}}
/>
</SettingsSection>
</CargoFeature>
<CargoFeature feature="license">
<SettingsSection title="Feedback">
<SettingRowBoolean
title="Prompt for feedback"
description="Show rare one-time prompts asking how new features are working."
checked={settings.promptFeedback}
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
/>
</SettingsSection>
</CargoFeature>
{showWorkspaceSettingsMovedBanner && (
<DismissibleBanner
id="workspace-settings-moved-2026-06-30"
color="info"
className="w-full p-4 max-w-xl mr-auto"
>
<p>
Workspace specific settings have moved to{" "}
<b>Workspace Settings</b>, accessible from the workspace switcher
menu.
</p>
</DismissibleBanner>
)}
<SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version.">
<SettingValue value={appInfo.version} />
</SettingRow>
<SettingRow
title="Data Directory"
description="Where Yaak stores application data."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appDataDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
);
}

View File

@@ -0,0 +1,365 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import {
Heading,
HStack,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
VStack,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { fuzzyMatch } from "fuzzbunny";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
defaultHotkeys,
formatHotkeyString,
getHotkeyScope,
type HotkeyAction,
hotkeyActions,
hotkeysAtom,
useHotkeyLabel,
} from "../../hooks/useHotKey";
import { capitalize } from "../../lib/capitalize";
import { showDialog } from "../../lib/dialog";
import { Button } from "../core/Button";
import { Dropdown, type DropdownItem } from "../core/Dropdown";
import { HotkeyRaw } from "../core/Hotkey";
import { IconButton } from "../core/IconButton";
import { PlainInput } from "../core/PlainInput";
const HOLD_KEYS = ["Shift", "Control", "Alt", "Meta"];
const LAYOUT_INSENSITIVE_KEYS = [
"Equal",
"Minus",
"BracketLeft",
"BracketRight",
"Backquote",
"Space",
];
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
function eventToHotkeyString(e: KeyboardEvent): string | null {
// Don't capture modifier-only key presses
if (HOLD_KEYS.includes(e.key)) {
return null;
}
const parts: string[] = [];
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
if (e.metaKey) {
parts.push("Meta");
}
if (e.ctrlKey) {
parts.push("Control");
}
if (e.altKey) {
parts.push("Alt");
}
if (e.shiftKey) {
parts.push("Shift");
}
// Get the main key - use the same logic as useHotKey.ts
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
parts.push(key);
return parts.join("+");
}
export function SettingsHotkeys() {
const settings = useAtomValue(settingsAtom);
const hotkeys = useAtomValue(hotkeysAtom);
const [filter, setFilter] = useState("");
const filteredActions = useMemo(() => {
if (!filter.trim()) {
return hotkeyActions;
}
return hotkeyActions.filter((action) => {
const scope = getHotkeyScope(action).replace(/_/g, " ");
const label = action.replace(/[_.]/g, " ");
const searchText = `${scope} ${label}`;
return fuzzyMatch(searchText, filter) != null;
});
}, [filter]);
if (settings == null) {
return null;
}
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Keyboard Shortcuts</Heading>
<p className="text-text-subtle">
Click the menu button to add, remove, or reset keyboard shortcuts.
</p>
</div>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
);
}
interface HotkeyRowProps {
action: HotkeyAction;
currentKeys: string[];
defaultKeys: string[];
onSave: (keys: string[]) => Promise<void>;
onReset: () => Promise<void>;
}
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
const label = useHotkeyLabel(action);
const scope = capitalize(getHotkeyScope(action).replace(/_/g, " "));
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
const isDisabled = currentKeys.length === 0;
const handleStartRecording = useCallback(() => {
showDialog({
id: `record-hotkey-${action}`,
title: label,
size: "sm",
render: ({ hide }) => (
<RecordHotkeyDialog
label={label}
onSave={async (key) => {
await onSave([...currentKeys, key]);
hide();
}}
onCancel={hide}
/>
),
});
}, [action, label, currentKeys, onSave]);
const handleRemove = useCallback(
async (keyToRemove: string) => {
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
await onSave(newKeys);
},
[currentKeys, onSave],
);
const handleClearAll = useCallback(async () => {
await onSave([]);
}, [onSave]);
// Build dropdown items dynamically
const dropdownItems: DropdownItem[] = [
{
label: "Add Keyboard Shortcut",
leftSlot: <Icon icon="plus" />,
onSelect: handleStartRecording,
},
];
// Add remove options for each existing shortcut
if (!isDisabled) {
currentKeys.forEach((key) => {
dropdownItems.push({
label: (
<HStack space={1.5}>
<span>Remove</span>
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
</HStack>
),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleRemove(key),
});
});
if (currentKeys.length > 1) {
dropdownItems.push(
{
type: "separator",
},
{
label: "Remove All Shortcuts",
leftSlot: <Icon icon="trash" />,
onSelect: handleClearAll,
},
);
}
}
if (isCustomized) {
dropdownItems.push({
type: "separator",
});
dropdownItems.push({
label: "Reset to Default",
leftSlot: <Icon icon="refresh" />,
onSelect: onReset,
});
}
return (
<TableRow>
<TableCell>
<span className="text-sm text-text-subtlest">{scope}</span>
</TableCell>
<TableCell>
<span className="text-sm">{label}</span>
</TableCell>
<TableCell>
<HStack space={1.5} className="py-1">
{isDisabled ? (
<span className="text-text-subtlest">Disabled</span>
) : (
currentKeys.map((k) => (
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
))
)}
</HStack>
</TableCell>
<TableCell align="right">
<Dropdown items={dropdownItems}>
<IconButton
icon="ellipsis_vertical"
size="sm"
title="Hotkey actions"
className="ml-auto text-text-subtlest"
/>
</Dropdown>
</TableCell>
</TableRow>
);
}
function arraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((v, i) => v === sortedB[i]);
}
interface RecordHotkeyDialogProps {
label: string;
onSave: (key: string) => void;
onCancel: () => void;
}
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
const [recordedKey, setRecordedKey] = useState<string | null>(null);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!isFocused) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
onCancel();
return;
}
const hotkeyString = eventToHotkeyString(e);
if (hotkeyString) {
setRecordedKey(hotkeyString);
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true });
};
}, [isFocused, onCancel]);
const handleSave = useCallback(() => {
if (recordedKey) {
onSave(recordedKey);
}
}, [recordedKey, onSave]);
return (
<VStack space={4}>
<div>
<p className="text-text-subtle mb-2">
Record a key combination for <span className="font-semibold">{label}</span>
</p>
<button
type="button"
data-disable-hotkey
aria-label="Keyboard shortcut input"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onClick={(e) => {
e.preventDefault();
e.currentTarget.focus();
}}
className={classNames(
"flex items-center justify-center",
"px-4 py-2 rounded-lg bg-surface-highlight border outline-hidden cursor-default w-full",
"border-border-subtle focus:border-border-focus",
)}
>
{recordedKey ? (
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
) : (
<span className="text-text-subtlest">Press keys...</span>
)}
</button>
</div>
<HStack space={2} justifyContent="end">
<Button color="secondary" onClick={onCancel}>
Cancel
</Button>
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
Save
</Button>
</HStack>
</VStack>
);
}

View File

@@ -0,0 +1,274 @@
import { platform } from "@yaakapp-internal/platform";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { rpc } from "../../lib/rpc";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link";
import {
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__";
const fontSizeOptions = [
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
].map((n) => ({ label: `${n}`, value: `${n}` }));
const keymaps: { value: EditorKeymap; label: string }[] = [
{ value: "default", label: "Default" },
{ value: "vim", label: "Vim" },
{ value: "vscode", label: "VSCode" },
{ value: "emacs", label: "Emacs" },
];
export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const fonts = useFonts();
if (settings == null || workspace == null) {
return null;
}
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Workspaces">
<SettingRowSelect
title="Open workspace behavior"
description="Choose what happens when opening another workspace."
name="switchWorkspaceBehavior"
value={
settings.openWorkspaceNewWindow === true
? "new"
: settings.openWorkspaceNewWindow === false
? "current"
: "ask"
}
onChange={async (v) => {
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
options={[
{ label: "Always ask", value: "ask" },
{ label: "Open in current window", value: "current" },
{ label: "Open in new window", value: "new" },
]}
/>
</SettingsSection>
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="w-72!"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { interfaceFont });
}}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="w-20!"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="w-72!"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="w-20!"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<ModelSettingRowSelect
model={settings}
modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="coloredMethods"
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{platform.osType() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
</SettingsList>
</VStack>
);
}
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return (
<SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox
hideLabel
size="md"
checked={nativeTitlebar}
title="Native title bar"
onChange={setNativeTitlebar}
/>
{settings.useNativeTitlebar !== nativeTitlebar && (
<Button
color="primary"
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await rpc("cmd_restart");
}}
>
Apply and Restart
</Button>
)}
</SettingRow>
);
}
function LicenseSettings({ settings }: { settings: Settings }) {
const license = useLicense();
if (license.check.data?.status !== "personal_use") {
return null;
}
return (
<SettingsSection title="License">
<SettingRowBoolean
checked={settings.hideLicenseBadge}
title="Hide personal use badge"
description="Hide the personal-use badge from the interface."
onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) {
const confirmed = await showConfirm({
id: "hide-license-badge",
title: "Confirm Personal Use",
confirmText: "Confirm",
description: (
<VStack space={3}>
<p>Hey there 👋🏼</p>
<p>
Yaak is free for personal projects and learning.{" "}
<strong>If you’re using Yaak at work, a license is required.</strong>
</p>
<p>
Licenses help keep Yaak independent and sustainable.{" "}
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
Purchase a License →
</Link>
</p>
</VStack>
),
requireTyping: "Personal Use",
color: "info",
});
if (!confirmed) {
return;
}
}
await patchModel(settings, { hideLicenseBadge });
}}
/>
</SettingsSection>
);
}

View File

@@ -0,0 +1,187 @@
import { useLicense } from "@yaakapp-internal/license";
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { differenceInDays } from "date-fns";
import { formatDate } from "date-fns/format";
import { useState } from "react";
import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { platform } from "@yaakapp-internal/platform";
export function SettingsLicense() {
return (
<CargoFeature feature="license">
<SettingsLicenseCmp />
</CargoFeature>
);
}
function SettingsLicenseCmp() {
const { check, activate, deactivate } = useLicense();
const [key, setKey] = useState<string>("");
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
if (check.isPending) {
return null;
}
const renderBanner = () => {
if (!check.data) return null;
switch (check.data.status) {
case "active":
return <Banner color="success">Your license is active 🥳</Banner>;
case "trialing":
return (
<Banner color="info" className="max-w-lg">
<p className="w-full">
<strong>
{pluralizeCount("day", differenceInDays(check.data.data.end, new Date()))}
</strong>{" "}
left to evaluate Yaak for commercial use.
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More
</Link>
</div>
</p>
</Banner>
);
case "personal_use":
return (
<Banner color="notice" className="max-w-lg">
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
You may continue using Yaak for personal use only.
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More
</Link>
</div>
</p>
</Banner>
);
case "inactive":
return (
<Banner color="danger">
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{" "}
for more details
</Banner>
);
case "expired":
return (
<Banner color="notice">
Your license expired{" "}
<strong>{formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")}</strong>. Please{" "}
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
updates.
{check.data.data.changesUrl && (
<>
<br />
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
</>
)}
</Banner>
);
case "past_due":
return (
<Banner color="danger">
<strong>Your payment method needs attention.</strong>
<br />
To re-activate your license, please{" "}
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
</Banner>
);
case "error":
return (
<Banner color="danger">
License check failed: {check.data.data.message} (Code: {check.data.data.code})
</Banner>
);
}
};
return (
<div className="flex flex-col gap-6 max-w-xl">
{renderBanner()}
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
{check.data?.status === "active" ? (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
Deactivate License
</Button>
<Button
color="secondary"
size="sm"
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
</Button>
</HStack>
) : (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
Activate License
</Button>
<Button
size="sm"
color="primary"
rightSlot={<Icon icon="external_link" />}
onClick={() =>
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
}
>
Purchase License
</Button>
</HStack>
)}
{activateFormVisible && (
<VStack
as="form"
space={3}
className="max-w-sm"
onSubmit={async (e) => {
e.preventDefault();
await activate.mutateAsync({ licenseKey: key });
toggleActivateFormVisible();
}}
>
<PlainInput
autoFocus
label="License Key"
name="key"
onChange={setKey}
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
/>
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
Submit
</Button>
</VStack>
)}
</div>
);
}

View File

@@ -0,0 +1,426 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { Plugin } from "@yaakapp-internal/models";
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
import type { PluginVersion } from "@yaakapp-internal/plugins";
import {
checkPluginUpdates,
installPlugin,
searchPlugins,
uninstallPlugin,
} from "@yaakapp-internal/plugins";
import {
HStack,
Icon,
InlineCode,
LoadingIcon,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
useDebouncedValue,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { useInstallPlugin } from "../../hooks/useInstallPlugin";
import { usePluginInfo } from "../../hooks/usePluginInfo";
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
import { showConfirmDelete } from "../../lib/confirm";
import { minPromiseMillis } from "../../lib/minPromiseMillis";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { CountBadge } from "../core/CountBadge";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { TabContent, Tabs } from "../core/Tabs/Tabs";
import { EmptyStateText } from "../EmptyStateText";
import { SelectFile } from "../SelectFile";
import { platform } from "@yaakapp-internal/platform";
interface SettingsPluginsProps {
defaultSubtab?: string;
}
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
const [directory, setDirectory] = useState<string | null>(null);
const plugins = useAtomValue(pluginsAtom);
const bundledPlugins = plugins.filter((p) => p.source === "bundled");
const installedPlugins = plugins.filter((p) => p.source !== "bundled");
const createPlugin = useInstallPlugin();
const refreshPlugins = useRefreshPlugins();
return (
<div className="h-full">
<Tabs
defaultValue={defaultSubtab}
label="Plugins"
addBorders
tabListClassName="px-6 pt-2"
tabs={[
{ label: "Discover", value: "search" },
{
label: "Installed",
value: "installed",
rightSlot: <CountBadge count={installedPlugins.length} />,
},
{
label: "Bundled",
value: "bundled",
rightSlot: <CountBadge count={bundledPlugins.length} />,
},
]}
>
<TabContent value="search" className="px-6">
<PluginSearch />
</TabContent>
<TabContent value="installed" className="pb-0">
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
<InstalledPlugins plugins={installedPlugins} className="px-6" />
<footer className="grid grid-cols-[minmax(0,1fr)_auto] py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
<SelectFile
size="xs"
noun="Plugin"
directory
onChange={({ filePath }) => setDirectory(filePath)}
filePath={directory}
/>
<HStack>
{directory && (
<Button
size="xs"
color="primary"
className="ml-auto"
onClick={() => {
if (directory == null) return;
createPlugin.mutate(directory);
setDirectory(null);
}}
>
Add Plugin
</Button>
)}
<IconButton
size="sm"
icon="refresh"
title="Reload plugins"
spin={refreshPlugins.isPending}
onClick={() => refreshPlugins.mutate()}
/>
<IconButton
size="sm"
icon="help"
title="View documentation"
onClick={() =>
platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
}
/>
</HStack>
</footer>
</div>
</TabContent>
<TabContent value="bundled" className="pb-0 px-6">
<BundledPlugins plugins={bundledPlugins} />
</TabContent>
</Tabs>
</div>
);
}
function PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {
const info = usePluginInfo(plugin.id).data;
if (info == null) {
return null;
}
return (
<PluginTableRow
plugin={plugin}
version={info.version}
name={info.name}
displayName={info.displayName}
url={plugin.url}
showCheckbox={true}
showUninstall={true}
/>
);
}
function PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {
const info = usePluginInfo(plugin.id).data;
if (info == null) {
return null;
}
return (
<PluginTableRow
plugin={plugin}
version={info.version}
name={info.name}
displayName={info.displayName}
url={plugin.url}
showCheckbox={true}
showUninstall={false}
/>
);
}
function PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion: PluginVersion }) {
const plugin = useAtomValue(pluginsAtom).find((p) => p.id === pluginVersion.id);
const pluginInfo = usePluginInfo(plugin?.id ?? null).data;
return (
<PluginTableRow
plugin={plugin ?? null}
version={pluginInfo?.version ?? pluginVersion.version}
name={pluginVersion.name}
displayName={pluginVersion.displayName}
url={pluginVersion.url}
showCheckbox={false}
/>
);
}
function PluginTableRow({
plugin,
name,
version,
displayName,
url,
showCheckbox = true,
showUninstall = true,
}: {
plugin: Plugin | null;
name: string;
version: string;
displayName: string;
url: string | null;
showCheckbox?: boolean;
showUninstall?: boolean;
}) {
const updates = usePluginUpdates();
const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;
const installPluginMutation = useMutation({
mutationKey: ["install_plugin", name],
mutationFn: (name: string) => installPlugin(name, null),
});
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
const refreshPlugins = useRefreshPlugins();
return (
<TableRow>
{showCheckbox && (
<TableCell className="py-0!">
<Checkbox
hideLabel
title={plugin?.enabled ? "Disable plugin" : "Enable plugin"}
checked={plugin?.enabled ?? false}
disabled={plugin == null}
onChange={async (enabled) => {
if (plugin) {
await patchModel(plugin, { enabled });
refreshPlugins.mutate();
}
}}
/>
</TableCell>
)}
<TableCell className="font-semibold">
{url ? (
<Link noUnderline href={url}>
{displayName}
</Link>
) : (
displayName
)}
</TableCell>
<TableCell>
<InlineCode>{name}</InlineCode>
</TableCell>
<TableCell>
<HStack space={1.5}>
<InlineCode>{version}</InlineCode>
{latestVersion != null && (
<InlineCode className="text-success flex items-center gap-1">
<Icon icon="arrow_up" size="sm" />
{latestVersion}
</InlineCode>
)}
</HStack>
</TableCell>
<TableCell className="py-0!">
<HStack justifyContent="end" space={1.5}>
{plugin != null && latestVersion != null ? (
<Button
variant="border"
color="success"
title={`Update to ${latestVersion}`}
size="xs"
isLoading={installPluginMutation.isPending}
onClick={() => installPluginMutation.mutate(name)}
>
Update
</Button>
) : plugin == null ? (
<Button
variant="border"
color="primary"
title={`Install ${version}`}
size="xs"
isLoading={installPluginMutation.isPending}
onClick={() => installPluginMutation.mutate(name)}
>
Install
</Button>
) : null}
{showUninstall && uninstall != null && (
<Button
size="xs"
title="Uninstall plugin"
variant="border"
isLoading={uninstall.isPending}
onClick={() => uninstall.mutate()}
>
Uninstall
</Button>
)}
</HStack>
</TableCell>
</TableRow>
);
}
function PluginSearch() {
const [query, setQuery] = useState<string>("");
const debouncedQuery = useDebouncedValue(query);
const results = useQuery({
queryKey: ["plugins", debouncedQuery],
queryFn: () => searchPlugins(query),
});
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-3">
<HStack space={1.5}>
<PlainInput
hideLabel
label="Search"
placeholder="Search plugins..."
onChange={setQuery}
defaultValue={query}
/>
</HStack>
<div className="w-full h-full">
{results.data == null ? (
<EmptyStateText>
<LoadingIcon size="xl" className="text-text-subtlest" />
</EmptyStateText>
) : (results.data.plugins ?? []).length === 0 ? (
<EmptyStateText>No plugins found</EmptyStateText>
) : (
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<TableBody>
{results.data.plugins.map((p) => (
<PluginTableRowForRemotePluginVersion key={p.id} pluginVersion={p} />
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}
function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
return plugins.length === 0 ? (
<div className={classNames(className, "pb-4")}>
<EmptyStateText className="text-center">
Plugins extend the functionality of Yaak.
<br />
Add your first plugin to get started.
</EmptyStateText>
</div>
) : (
<Table scrollable className={className}>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
))}
</tbody>
</Table>
);
}
function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
return plugins.length === 0 ? (
<div className="pb-4">
<EmptyStateText className="text-center">No bundled plugins found.</EmptyStateText>
</div>
) : (
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginTableRowForBundledPlugin key={p.id} plugin={p} />
))}
</tbody>
</Table>
);
}
function usePromptUninstall(pluginId: string | null, name: string) {
const mut = useMutation({
mutationKey: ["uninstall_plugin", pluginId],
mutationFn: async () => {
if (pluginId == null) return;
const confirmed = await showConfirmDelete({
id: `uninstall-plugin-${pluginId}`,
title: "Uninstall Plugin",
confirmText: "Uninstall",
description: (
<>
Permanently uninstall <InlineCode>{name}</InlineCode>?
</>
),
});
if (confirmed) {
await minPromiseMillis(uninstallPlugin(pluginId), 700);
}
},
});
return pluginId == null ? null : mut;
}
function usePluginUpdates() {
return useQuery({
queryKey: ["plugin_updates", usePluginsKey()],
queryFn: () => checkPluginUpdates(),
});
}

View File

@@ -0,0 +1,180 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import {
SettingRowBoolean,
SettingRowSelect,
SettingRowText,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
export function SettingsProxy() {
const settings = useAtomValue(settingsAtom);
const proxy = enabledProxyOrDefault(settings.proxy);
const patchProxy = async (patch: Partial<EnabledProxySetting>) => {
await patchModel(settings, {
proxy: {
...proxy,
...patch,
auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
},
});
};
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
title="Proxy"
description="Choose how Yaak should discover or use proxy settings."
name="proxy"
value={settings.proxy?.type ?? "automatic"}
onChange={async (v) => {
if (v === "automatic") {
await patchModel(settings, { proxy: undefined });
} else if (v === "enabled") {
await patchModel(settings, { proxy });
} else {
await patchModel(settings, { proxy: { type: "disabled" } });
}
}}
options={[
{ label: "Automatic proxy detection", value: "automatic" },
{ label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" },
]}
selectClassName="w-64!"
/>
</SettingsSection>
{settings.proxy?.type === "enabled" && (
<>
<SettingsSection title="Custom Proxy">
<SettingRowBoolean
checked={!settings.proxy.disabled}
title="Enable proxy"
description="Temporarily disable the proxy without losing the configuration."
onChange={(enabled) => patchProxy({ disabled: !enabled })}
/>
<SettingRowText
name="proxyHttp"
title={
<>
Proxy for <InlineCode>http://</InlineCode> traffic
</>
}
description="Proxy host used for unencrypted HTTP traffic."
value={settings.proxy.http}
placeholder="localhost:9090"
onChange={(http) => patchProxy({ http })}
/>
<SettingRowText
name="proxyHttps"
title={
<>
Proxy for <InlineCode>https://</InlineCode> traffic
</>
}
description="Proxy host used for HTTPS traffic."
value={settings.proxy.https}
placeholder="localhost:9090"
onChange={(https) => patchProxy({ https })}
/>
<SettingRowText
name="proxyBypass"
title="Proxy Bypass"
description="Comma-separated list of hosts that should bypass the proxy."
value={settings.proxy.bypass}
placeholder="127.0.0.1, *.example.com, localhost:3000"
inputWidthClassName="w-96!"
onChange={(bypass) => patchProxy({ bypass })}
/>
</SettingsSection>
<SettingsSection title="Authentication">
<SettingRowBoolean
checked={settings.proxy.auth != null}
title="Enable authentication"
description="Send proxy credentials with proxied requests."
onChange={(enabled) =>
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
}
/>
{settings.proxy.auth != null && (
<>
<SettingRowText
required
name="proxyUser"
title="User"
description="Username for proxy authentication."
value={settings.proxy.auth.user}
placeholder="myUser"
onChange={(user) =>
patchProxy({
auth: {
user,
password:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.password ?? "")
: "",
},
})
}
/>
<SettingRowText
name="proxyPassword"
title="Password"
description="Password for proxy authentication."
value={settings.proxy.auth.password}
placeholder="s3cretPassw0rd"
type="password"
onChange={(password) =>
patchProxy({
auth: {
user:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.user ?? "")
: "",
password,
},
})
}
/>
</>
)}
</SettingsSection>
</>
)}
</SettingsList>
</VStack>
);
}
type EnabledProxySetting = Extract<ProxySetting, { type: "enabled" }>;
function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
if (proxy?.type === "enabled") return proxy;
return {
disabled: false,
type: "enabled",
http: "",
https: "",
auth: { user: "", password: "" },
bypass: "",
};
}

View File

@@ -0,0 +1,173 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { lazy, Suspense } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
import type { ButtonProps } from "../core/Button";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import type { SelectProps } from "../core/Select";
import {
ModelSettingRowSelect,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
const buttonColors: ButtonProps["color"][] = [
"primary",
"info",
"success",
"notice",
"warning",
"danger",
"secondary",
"default",
];
const icons: IconProps["icon"][] = [
"info",
"box",
"update",
"alert_triangle",
"arrow_big_right_dash",
"download",
"copy",
"magic_wand",
"settings",
"trash",
"sparkles",
"pencil",
"paste",
"search",
"send_horizontal",
];
export function SettingsTheme() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const appearance = useResolvedAppearance();
const activeTheme = useResolvedTheme();
if (settings == null || workspace == null || activeTheme.data == null) {
return null;
}
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => !theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{" "}
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
Create Your Own
</Link>
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Theme">
<ModelSettingRowSelect
model={settings}
modelKey="appearance"
title="Appearance"
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
options={[
{ label: "Automatic", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
{(settings.appearance === "system" || settings.appearance === "light") && (
<SettingRowSelect
name="lightTheme"
title="Light theme"
description="Theme used when Yaak is in light mode."
value={activeTheme.data.light.id}
options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })}
/>
)}
{(settings.appearance === "system" || settings.appearance === "dark") && (
<SettingRowSelect
name="darkTheme"
title="Dark theme"
description="Theme used when Yaak is in dark mode."
value={activeTheme.data.dark.id}
options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })}
/>
)}
</SettingsSection>
<SettingsSection title="Preview">
<VStack
space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
<strong>{activeTheme.data.active.label}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
</HStack>
<Suspense>
<Editor
defaultValue={[
"let foo = { // Demo code editor",
' foo: ("bar" || "baz" ?? \'qux\'),',
" baz: [1, 10.2, null, false, true],",
"};",
].join("\n")}
heightMode="auto"
language="javascript"
stateKey={null}
/>
</Suspense>
</VStack>
</SettingsSection>
</SettingsList>
</VStack>
);
}

View File

@@ -0,0 +1,113 @@
import { useLicense } from "@yaakapp-internal/license";
import { useRef } from "react";
import { openSettings } from "../commands/openSettings";
import { useCheckForUpdates } from "../hooks/useCheckForUpdates";
import { useExportData } from "../hooks/useExportData";
import { appInfo } from "../lib/appInfo";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import { pricingUrl } from "../lib/pricingUrl";
import type { DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
import { platform } from "@yaakapp-internal/platform";
export function SettingsDropdown() {
const exportData = useExportData();
const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates();
const { check } = useLicense();
return (
<Dropdown
ref={dropdownRef}
items={[
{
label: "Settings",
hotKeyAction: "settings.show",
leftSlot: <Icon icon="settings" />,
onSelect: () => openSettings.mutate(null),
},
{
label: "Keyboard shortcuts",
hotKeyAction: "hotkeys.showHelp",
leftSlot: <Icon icon="keyboard" />,
onSelect: () => {
showDialog({
id: "hotkey",
title: "Keyboard Shortcuts",
size: "dynamic",
render: () => <KeyboardShortcutsDialog />,
});
},
},
{
label: "Plugins",
leftSlot: <Icon icon="puzzle" />,
onSelect: () => openSettings.mutate("plugins"),
},
{ type: "separator", label: "Share Workspace(s)" },
{
label: "Import Data",
leftSlot: <Icon icon="folder_input" />,
onSelect: () => importData.mutate(),
},
{
label: "Export Data",
leftSlot: <Icon icon="folder_output" />,
onSelect: () => exportData.mutate(),
},
{
label: "Create Run Button",
leftSlot: <Icon icon="rocket" />,
onSelect: () => platform.openUrl("https://yaak.app/button/new"),
},
{ type: "separator", label: `Yaak v${appInfo.version}` },
{
label: "Check for Updates",
leftSlot: <Icon icon="update" />,
hidden: !appInfo.featureUpdater,
onSelect: () => checkForUpdates.mutate(),
},
{
label: "Purchase License",
color: "success",
hidden: check.data == null || check.data.status === "active",
leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () =>
platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
},
{
label: "Install CLI",
hidden: appInfo.cliVersion != null,
leftSlot: <Icon icon="square_terminal" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
},
{
label: "Feedback",
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => platform.openUrl("https://yaak.app/feedback"),
},
{
label: "Changelog",
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
},
]}
>
<IconButton
size="sm"
title="Main Menu"
icon="settings"
iconColor="secondary"
className="pointer-events-auto"
/>
</Dropdown>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { HStack } from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
import { CreateDropdown } from "./CreateDropdown";
import { IconButton } from "./core/IconButton";
interface Props {
floating?: boolean;
}
export function SidebarActions({ floating = false }: Props) {
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
const hidden = floating ? floatingHidden : sidebarHidden;
const setHidden = useMemo(
() => (floating ? setFloatingHidden : setSidebarHidden),
[floating, setFloatingHidden, setSidebarHidden],
);
return (
<HStack className="h-full">
<IconButton
onClick={async () => {
await setHidden(!hidden);
}}
className="pointer-events-auto"
size="sm"
title="Toggle sidebar"
icon={hidden ? "left_panel_hidden" : "left_panel_visible"}
iconColor="secondary"
/>
<CreateDropdown hotKeyAction="model.create">
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
</CreateDropdown>
</HStack>
);
}

View File

@@ -0,0 +1,60 @@
import type { Workspace } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { switchWorkspace } from "../commands/switchWorkspace";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
interface Props {
hide: () => void;
workspace: Workspace;
}
export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const settings = useAtomValue(settingsAtom);
const [remember, setRemember] = useState<boolean>(false);
return (
<VStack space={3}>
<p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && (
<HStack justifyContent="end">
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
)}
</VStack>
);
}

View File

@@ -0,0 +1,121 @@
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile";
import { platform } from "@yaakapp-internal/platform";
export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
onCreateNewWorkspace: () => void;
value: { filePath: string | null; initGit?: boolean };
}
export function SyncToFilesystemSetting({
layout = "form",
onChange,
onCreateNewWorkspace,
value,
}: SyncToFilesystemSettingProps) {
const [syncDir, setSyncDir] = useState<string | null>(null);
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await platform.files.readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
};
if (layout === "settings") {
return (
<VStack className="w-full" space={0}>
{syncDir && (
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SettingRowDirectory
title="Local directory sync"
description="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={handleFilePathChange}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<SettingRowBoolean
checked={value.initGit}
title="Initialize Git Repo"
description="Create a Git repository in the selected sync directory."
onChange={(initGit) => onChange({ ...value, initGit })}
/>
)}
</VStack>
);
}
return (
<VStack className="w-full my-2" space={3}>
{syncDir && (
<Banner color="notice" className="flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SelectFile
directory
label="Local directory sync"
size="xs"
noun="Directory"
help="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<Checkbox
checked={value.initGit}
onChange={(initGit) => onChange({ ...value, initGit })}
title="Initialize Git Repo"
/>
)}
</VStack>
);
}

View File

@@ -0,0 +1,296 @@
import type { EditorView } from "@codemirror/view";
import type {
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
import {
templateTokensToString,
useTemplateTokensToString,
} from "../hooks/useTemplateTokensToString";
import { useToggle } from "../hooks/useToggle";
import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { Button } from "./core/Button";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
interface Props {
templateFunction: TemplateFunction;
initialTokens: Tokens;
hide: () => void;
onChange: (insert: string) => void;
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
}
export function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {
const [initialArgValues, setInitialArgValues] = useState<Record<string, string | boolean> | null>(
null,
);
useEffect(() => {
if (initialArgValues != null) {
return;
}
(async () => {
const initial = collectArgumentValues(initialTokens, templateFunction);
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
// we can display it in the editor input.
if (templateFunction.name === "secure") {
const template = await templateTokensToString(initialTokens);
initial.value = await convertTemplateToInsecure(template);
}
setInitialArgValues(initial);
})().catch(console.error);
}, [
initialArgValues,
initialTokens,
initialTokens.tokens,
templateFunction,
templateFunction.args,
templateFunction.name,
]);
if (initialArgValues == null) return null;
return (
<InitializedTemplateFunctionDialog
{...props}
templateFunction={templateFunction}
initialArgValues={initialArgValues}
/>
);
}
function InitializedTemplateFunctionDialog({
templateFunction: { name, previewType: ogPreviewType },
initialArgValues,
hide,
onChange,
model,
}: Omit<Props, "initialTokens"> & {
initialArgValues: Record<string, string | boolean>;
}) {
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
name,
value:
argValues[name] === DYNAMIC_FORM_NULL_ARG
? { type: "null" }
: typeof argValues[name] === "boolean"
? { type: "bool", value: argValues[name] === true }
: { type: "str", text: String(argValues[name] ?? "") },
}));
return {
tokens: [
{
type: "tag",
val: {
type: "fn",
name,
args: argTokens,
},
},
],
};
}, [argValues, name]);
const tagText = useTemplateTokensToString(tokens);
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
const handleDone = () => {
if (tagText.data) {
onChange(tagText.data);
}
hide();
};
const debouncedTagText = useDebouncedValue(tagText.data ?? "", 400);
const [renderKey, setRenderKey] = useState<string | null>(null);
const rendered = useRenderTemplate({
template: debouncedTagText,
enabled: previewType !== "none",
purpose: previewType === "click" ? "send" : "preview",
refreshKey: previewType === "live" ? renderKey + debouncedTagText : renderKey,
ignoreError: false,
});
const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only update this on rendered data change to keep secrets hidden on input change
const dataContainsSecrets = useMemo(() => {
for (const [name, value] of Object.entries(argValues)) {
const arg = templateFunction.data?.args.find((a) => "name" in a && a.name === name);
const isTextPassword = arg?.type === "text" && arg.password;
if (isTextPassword && typeof value === "string" && value && rendered.data?.includes(value)) {
return true;
}
}
return false;
}, [rendered.data]);
if (templateFunction.data == null || templateFunction.isPending) {
return (
<div className="h-full w-full flex items-center justify-center">
<LoadingIcon size="xl" className="text-text-subtlest" />
</div>
);
}
return (
<form
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
<div className="overflow-y-auto h-full px-6">
{name === "secure" ? (
<PlainInput
required
label="Value"
name="value"
type="password"
placeholder="••••••••••••"
defaultValue={String(argValues.value ?? "")}
onChange={(value) => setArgValues({ ...argValues, value })}
/>
) : (
<DynamicForm
autocompleteVariables
autocompleteFunctions
inputs={templateFunction.data.args}
data={argValues}
onChange={setArgValues}
stateKey={`template_function.${templateFunction.data.name}`}
/>
)}
</div>
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div>
</div>
) : (
<span />
)}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
</form>
);
}
TemplateFunctionDialog.show = (
fn: TemplateFunction,
tagValue: string,
startPos: number,
view: EditorView,
) => {
const initialTokens = parseTemplate(tagValue);
showDialog({
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}(…)</InlineCode>,
description: fn.description,
render: ({ hide }) => {
const model = jotaiStore.get(activeWorkspaceAtom);
if (model == null) return null;
return (
<TemplateFunctionDialog
templateFunction={fn}
model={model}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
);
},
});
};

View File

@@ -0,0 +1,43 @@
import { useAtomValue } from "jotai";
import { AnimatePresence } from "motion/react";
import type { ReactNode } from "react";
import { hideToast, toastsAtom } from "../lib/toast";
import { Toast, type ToastProps } from "./core/Toast";
import { ErrorBoundary } from "./ErrorBoundary";
import { Portal } from "@yaakapp-internal/ui";
export type ToastInstance = {
id: string;
uniqueKey: string;
message: ReactNode;
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
onClose?: ToastProps["onClose"];
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
export const Toasts = () => {
const toasts = useAtomValue(toastsAtom);
return (
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-50">
<AnimatePresence>
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
<Toast
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
</ErrorBoundary>
);
})}
</AnimatePresence>
</div>
</Portal>
);
};

View File

@@ -0,0 +1,113 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import type { IconProps } from "@yaakapp-internal/ui";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { FormEvent, ReactNode } from "react";
import { memo, useCallback, useRef, useState } from "react";
import { useHotKey } from "../hooks/useHotKey";
import { IconButton } from "./core/IconButton";
import type { InputHandle, InputProps } from "./core/Input";
import { Input } from "./core/Input";
type Props = Pick<HttpRequest, "url"> & {
className?: string;
placeholder: string;
onSend: () => void;
onUrlChange: (url: string) => void;
onPaste?: (v: string) => void;
onPasteOverwrite?: InputProps["onPasteOverwrite"];
onCancel: () => void;
submitIcon?: IconProps["icon"] | null;
isLoading: boolean;
forceUpdateKey: string;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
autocomplete?: InputProps["autocomplete"];
stateKey: InputProps["stateKey"];
};
export const UrlBar = memo(function UrlBar({
forceUpdateKey,
onUrlChange,
url,
placeholder,
className,
onSend,
onCancel,
onPaste,
onPasteOverwrite,
submitIcon = "send_horizontal",
autocomplete,
leftSlot,
rightSlot,
isLoading,
stateKey,
}: Props) {
const inputRef = useRef<InputHandle>(null);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleInitInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
useHotKey("url_bar.focus", () => {
inputRef.current?.selectAll();
});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading) onCancel();
else onSend();
};
return (
<form onSubmit={handleSubmit} className={classNames("x-theme-urlBar", className)}>
<Input
setRef={handleInitInputRef}
autocompleteFunctions
autocompleteVariables
stateKey={stateKey}
size="sm"
wrapLines={isFocused}
hideLabel
language="url"
className="px-1.5 py-0.5"
label="Enter URL"
name="url"
autocomplete={autocomplete}
forceUpdateKey={forceUpdateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
onChange={onUrlChange}
defaultValue={url}
placeholder={placeholder}
leftSlot={leftSlot}
rightSlot={
<HStack space={0.5}>
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
{submitIcon !== null && (
<div className="py-0.5 h-full">
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 mr-0.5 h-full!"
iconColor="secondary"
icon={isLoading ? "x" : submitIcon}
hotkeyAction="request.send"
onMouseDown={(e) => {
// Prevent the button from taking focus
e.preventDefault();
}}
/>
</div>
)}
</HStack>
}
/>
</form>
);
});

View File

@@ -0,0 +1,54 @@
import { VStack } from "@yaakapp-internal/ui";
import { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
pairs: EditablePair[];
stateKey: PairEditorProps["stateKey"];
onChange: PairEditorProps["onChange"];
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
const pairEditorRef = useRef<PairEditorHandle>(null);
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
pairEditorRef.current = ref;
}, []);
const [{ urlParametersKey }] = useRequestEditor();
useRequestEditorEvent(
"request_params.focus_value",
(name) => {
const pair = pairs.find((p) => p.name === name);
if (pair?.id != null) {
pairEditorRef.current?.focusValue(pair.id);
} else {
console.log(`Couldn't find pair to focus`, { name, pairs });
}
},
[pairs],
);
return (
<VStack className="h-full">
<PairOrBulkEditor
setRef={handleInitPairEditorRef}
allowMultilineValues
forceUpdateKey={forceUpdateKey + urlParametersKey}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="param_name"
onChange={onChange}
pairs={pairs}
preferenceName="url_parameters"
stateKey={stateKey}
valueAutocompleteFunctions
valueAutocompleteVariables
valuePlaceholder="Value"
/>
</VStack>
);
}

View File

@@ -0,0 +1,49 @@
import type { WebsocketRequest } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { SplitLayout } from "@yaakapp-internal/ui";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { workspaceLayoutAtom } from "../lib/atoms";
import { WebsocketRequestPane } from "./WebsocketRequestPane";
import { WebsocketResponsePane } from "./WebsocketResponsePane";
interface Props {
activeRequest: WebsocketRequest;
style: CSSProperties;
}
export function WebsocketRequestLayout({ activeRequest, style }: Props) {
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
const wsId = activeWorkspace?.id ?? "n/a";
return (
<SplitLayout
storageKey={`websocket_layout::${wsId}`}
className="p-3 gap-1.5"
layout={workspaceLayout}
style={style}
firstSlot={({ orientation, style }) => (
<WebsocketRequestPane
style={style}
activeRequest={activeRequest}
fullHeight={orientation === "horizontal"}
/>
)}
secondSlot={({ style }) => (
<div
style={style}
className={classNames(
"x-theme-responsePane",
"max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1",
"bg-surface rounded-md border border-border-subtle",
"shadow relative",
)}
>
<WebsocketResponsePane activeRequest={activeRequest} />
</div>
)}
/>
);
}

View File

@@ -0,0 +1,314 @@
import type { WebsocketRequest } from "@yaakapp-internal/models";
import {
flushAllModelWrites,
getModel,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { languageFromContentType } from "../lib/contentType";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
interface Props {
style: CSSProperties;
fullHeight: boolean;
className?: string;
activeRequest: WebsocketRequest;
}
const TAB_MESSAGE = "message";
const TAB_PARAMS = "params";
const TAB_HEADERS = "headers";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs";
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
const tabsRef = useRef<TabsRef>(null);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id);
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent(
"request_pane.focus_tab",
() => {
tabsRef.current?.setActiveTab(TAB_PARAMS);
},
[],
);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("websocket_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(() => {
return [
{
value: TAB_MESSAGE,
label: "Message",
} as TabItem,
{
value: TAB_PARAMS,
rightSlot: <CountBadge count={urlParameterPairs.length} />,
label: "Params",
},
...headersTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
];
}, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => getUrlCompletionConfig(autocompleteUrls),
[autocompleteUrls],
);
const handleConnect = useCallback(async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
await connectWebsocket({
requestId: activeRequest.id,
environmentId: getActiveEnvironment()?.id ?? null,
cookieJarId: getActiveCookieJar()?.id ?? null,
});
}, [activeRequest.id]);
const handleSend = useCallback(async () => {
if (connection == null) return;
await flushAllModelWrites(); // The backend reads the message from the DB
await sendWebsocket({
connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null,
});
}, [connection]);
const handleCancel = useCallback(async () => {
if (connection == null) return;
await closeWebsocket({ connectionId: connection?.id });
}, [connection]);
const handleUrlChange = useCallback(
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
const handlePaste = useCallback(
async (e: ClipboardEvent, text: string) => {
const patch = prepareImportQuerystring(text);
if (patch != null) {
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
setTimeout(() => {
forceUrlRefresh();
forceParamsRefresh();
}, 100);
}
},
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
);
const messageLanguage = languageFromContentType(null, activeRequest.message);
const isLoading = connection !== null && connection.state !== "closed";
return (
<div
style={style}
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
>
{activeRequest && (
<>
<div className="grid grid-cols-[minmax(0,1fr)_auto]">
<UrlBar
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
submitIcon={isLoading ? "send_horizontal" : "arrow_up_down"}
rightSlot={
isLoading && (
<IconButton
size="xs"
title="Close connection"
icon="x"
iconColor="secondary"
className="w-8 mr-0.5 h-full!"
onClick={handleCancel}
/>
)
}
placeholder="wss://example.com"
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={isLoading ? handleSend : handleConnect}
onCancel={cancelResponse}
onUrlChange={handleUrlChange}
forceUpdateKey={forceUpdateKey}
isLoading={activeResponse != null && activeResponse.state !== "closed"}
/>
</div>
<Tabs
ref={tabsRef}
label="Request"
tabs={tabs}
tabListClassName="mt-1 mb-1.5!"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_HEADERS}>
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_MESSAGE}>
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={activeRequest.message}
language={messageLanguage}
onChange={(message) => patchModelDebounced(activeRequest, { message })}
stateKey={`json.${activeRequest.id}`}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={forceUpdateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey}
onChange={(description) => patchModelDebounced(activeRequest, { description })}
/>
</div>
</TabContent>
</Tabs>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,241 @@
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import { hexy } from "hexy";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { useFormatText } from "../hooks/useFormatText";
import {
activeWebsocketConnectionAtom,
activeWebsocketConnectionsAtom,
setPinnedWebsocketConnectionId,
useWebsocketEvents,
} from "../hooks/usePinnedWebsocketConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { languageFromContentType } from "../lib/contentType";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
import { WebsocketStatusTag } from "./core/WebsocketStatusTag";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { RecentWebsocketConnectionsDropdown } from "./RecentWebsocketConnectionsDropdown";
interface Props {
activeRequest: WebsocketRequest;
}
export function WebsocketResponsePane({ activeRequest }: Props) {
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const [hexDumps, setHexDumps] = useState<Record<number, boolean>>({});
const activeConnection = useAtomValue(activeWebsocketConnectionAtom);
const connections = useAtomValue(activeWebsocketConnectionsAtom);
const events = useWebsocketEvents(activeConnection?.id ?? null);
if (activeConnection == null) {
return (
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
);
}
const header = (
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
<HStack space={2}>
{activeConnection.state !== "closed" && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
<WebsocketStatusTag connection={activeConnection} />
<span>&bull;</span>
<span>{events.length} Messages</span>
</HStack>
<HStack space={0.5} className="ml-auto">
<RecentWebsocketConnectionsDropdown
connections={connections}
activeConnection={activeConnection}
onPinnedConnectionId={setPinnedWebsocketConnectionId}
/>
</HStack>
</HStack>
);
return (
<ErrorBoundary name="Websocket Events">
<EventViewer
events={events}
getEventKey={(event) => event.id}
error={activeConnection.error}
header={header}
splitLayoutStorageKey="websocket_events"
defaultRatio={0.4}
renderRow={({ event, isActive, onClick }) => (
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, index, onClose }) => (
<WebsocketEventDetail
event={event}
hexDump={hexDumps[index] ?? event.messageType === "binary"}
setHexDump={(v) => setHexDumps({ ...hexDumps, [index]: v })}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
</ErrorBoundary>
);
}
function WebsocketEventRow({
event,
isActive,
onClick,
}: {
event: WebsocketEvent;
isActive: boolean;
onClick: () => void;
}) {
const { message: messageBytes, isServer, messageType } = event;
const message = messageBytes
? new TextDecoder("utf-8").decode(Uint8Array.from(messageBytes))
: "";
const iconColor =
messageType === "error"
? "warning"
: messageType === "close" || messageType === "open"
? "secondary"
: isServer
? "info"
: "primary";
const icon =
messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info"
: isServer
? "arrow_big_down_dash"
: "arrow_big_up_dash";
const content =
messageType === "close" ? (
"Disconnected from server"
) : messageType === "open" ? (
"Connected to server"
) : messageType === "error" ? (
<span className="text-warning">{message}</span>
) : message === "" ? (
<em className="italic text-text-subtlest">No content</em>
) : (
<span className="text-xs">{message.slice(0, 1000)}</span>
);
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color={iconColor} icon={icon} />}
content={content}
timestamp={event.createdAt}
/>
);
}
function WebsocketEventDetail({
event,
hexDump,
setHexDump,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: WebsocketEvent;
hexDump: boolean;
setHexDump: (v: boolean) => void;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const message = useMemo(() => {
if (hexDump) {
return event.message ? hexy(event.message) : "";
}
return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
}, [event.message, hexDump]);
const language = languageFromContentType(null, message);
const formattedMessage = useFormatText({ language, text: message, pretty: true });
const title =
event.messageType === "close"
? "Connection Closed"
: event.messageType === "open"
? "Connection Open"
: event.messageType === "error"
? "WebSocket Error"
: `Message ${event.isServer ? "Received" : "Sent"}`;
const actions: EventDetailAction[] =
message !== ""
? [
{
key: "toggle-hexdump",
label: hexDump ? "Show Message" : "Show Hexdump",
onClick: () => setHexDump(!hexDump),
},
]
: [];
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
copyText={formattedMessage || undefined}
onClose={onClose}
/>
{!showLarge && event.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : event.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage ?? ""}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import * as m from "motion/react-m";
import { useMemo, useState } from "react";
import {
getActiveCookieJar,
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
} from "../hooks/useActiveCookieJar";
import {
activeEnvironmentAtom,
useSubscribeActiveEnvironmentId,
} from "../hooks/useActiveEnvironment";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { useSubscribeActiveFolderId } from "../hooks/useActiveFolderId";
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveRequestId } from "../hooks/useActiveRequestId";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useFloatingSidebarHidden } from "../hooks/useFloatingSidebarHidden";
import { useHotKey } from "../hooks/useHotKey";
import { useSubscribeRecentCookieJars } from "../hooks/useRecentCookieJars";
import { useSubscribeRecentEnvironments } from "../hooks/useRecentEnvironments";
import { useSubscribeRecentRequests } from "../hooks/useRecentRequests";
import { useSubscribeRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
import { useSidebarWidth } from "../hooks/useSidebarWidth";
import { useSyncWorkspaceRequestTitle } from "../hooks/useSyncWorkspaceRequestTitle";
import { duplicateRequestOrFolderAndNavigate } from "../lib/duplicateRequestOrFolderAndNavigate";
import { importData } from "../lib/importData";
import { jotaiStore } from "../lib/jotai";
import { CreateDropdown } from "./CreateDropdown";
import { Button } from "./core/Button";
import { HotkeyList } from "./core/HotkeyList";
import { CookieDialog } from "./CookieDialog";
import { FeedbackLink } from "./core/Link";
import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout";
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
import { HttpRequestLayout } from "./HttpRequestLayout";
import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
import Sidebar from "./Sidebar";
import { SidebarActions } from "./SidebarActions";
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
import { WorkspaceHeader } from "./WorkspaceHeader";
const body = { gridArea: "body" };
export function Workspace() {
// First, subscribe to some things applicable to workspaces
useGlobalWorkspaceHooks();
const workspaces = useAtomValue(workspacesAtom);
const settings = useAtomValue(settingsAtom);
const osType = platform.osType();
const [width, setWidth] = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const [floating, setFloating] = useState(false);
const environmentBgStyle = useMemo(() => {
if (activeEnvironment?.color == null) return undefined;
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
return { background };
}, [activeEnvironment?.color]);
if (workspaces.length === 0) {
return <RedirectToLatestWorkspace />;
}
const header = (
<HeaderSize
data-tauri-drag-region
size="lg"
className="relative x-theme-appHeader bg-surface"
osType={osType}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<div className="absolute inset-0 pointer-events-none">
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div
style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
</HeaderSize>
);
const workspaceBody = (
<ErrorBoundary name="Workspace Body">
<WorkspaceBody />
</ErrorBoundary>
);
const sidebarContent = floating ? (
<div
className={classNames(
"x-theme-sidebar",
"h-full bg-surface border-r border-border-subtle",
"grid grid-rows-[auto_1fr]",
)}
>
<HeaderSize
hideControls
size="lg"
className="border-transparent flex items-center"
osType={osType}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<SidebarActions floating />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<Sidebar />
</ErrorBoundary>
</div>
) : (
<div className="x-theme-sidebar overflow-hidden bg-surface h-full">
<ErrorBoundary name="Sidebar">
<Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
);
return (
<div className="grid w-full h-full grid-rows-[auto_minmax(0,1fr)]">
{header}
<SidebarLayout
width={width ?? 250}
onWidthChange={setWidth}
hidden={sidebarHidden ?? false}
onHiddenChange={(hidden) => setSidebarHidden(hidden)}
floatingHidden={floatingSidebarHidden ?? true}
onFloatingHiddenChange={(hidden) => setFloatingSidebarHidden(hidden)}
onFloatingChange={setFloating}
sidebar={sidebarContent}
>
{workspaceBody}
</SidebarLayout>
</div>
);
}
function WorkspaceBody() {
const activeRequest = useAtomValue(activeRequestAtom);
const activeFolder = useAtomValue(activeFolderAtom);
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
if (activeWorkspace == null) {
return (
<m.div
className="m-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
// Delay the entering because the workspaces might load after a slight delay
transition={{ delay: 0.5 }}
>
<Banner color="warning" className="max-w-120">
The active workspace was not found. Select a workspace from the header menu or report this
bug to <FeedbackLink />
</Banner>
</m.div>
);
}
if (activeRequest?.model === "grpc_request") {
return <GrpcConnectionLayout style={body} />;
}
if (activeRequest?.model === "websocket_request") {
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
}
if (activeRequest?.model === "http_request") {
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
}
if (activeFolder != null) {
return <FolderLayout folder={activeFolder} style={body} />;
}
return (
<HotkeyList
hotkeys={["model.create", "sidebar.focus", "settings.show"]}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
Import
</Button>
<CreateDropdown hideFolder>
<Button variant="border" forDropdown size="sm">
New Request
</Button>
</CreateDropdown>
</HStack>
}
/>
);
}
function useGlobalWorkspaceHooks() {
useEnsureActiveCookieJar();
useSubscribeActiveRequestId();
useSubscribeActiveFolderId();
useSubscribeActiveEnvironmentId();
useSubscribeActiveCookieJarId();
useSubscribeRecentRequests();
useSubscribeRecentWorkspaces();
useSubscribeRecentEnvironments();
useSubscribeRecentCookieJars();
useSyncWorkspaceRequestTitle();
useHotKey("model.duplicate", () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
);
useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
enable: () => getActiveCookieJar() != null,
});
}

View File

@@ -0,0 +1,193 @@
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { memo, useCallback, useMemo } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { switchWorkspace } from "../commands/switchWorkspace";
import {
activeWorkspaceAtom,
activeWorkspaceIdAtom,
activeWorkspaceMetaAtom,
} from "../hooks/useActiveWorkspace";
import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import { jotaiStore } from "../lib/jotai";
import { revealInFinderText } from "../lib/reveal";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import type { DropdownItem } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownItem } from "./core/RadioDropdown";
import { RadioDropdown } from "./core/RadioDropdown";
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
import { platform } from "@yaakapp-internal/platform";
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
className,
...buttonProps
}: Props) {
const workspaces = useAtomValue(workspacesAtom);
const workspace = useAtomValue(activeWorkspaceAtom);
const createWorkspace = useCreateWorkspace();
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const { mutate: deleteSendHistory } = useDeleteSendHistory();
const workspaceActions = useWorkspaceActions();
const openCloneGitRepositoryDialog = useCallback(() => {
showDialog({
id: "clone-git-repository",
size: "md",
title: "Clone Git Repository",
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
}, []);
const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{
workspaceItems: RadioDropdownItem[];
itemsAfter: DropdownItem[];
itemsBefore: DropdownItem[];
}>(() => {
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
key: w.id,
label: w.name,
value: w.id,
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
}));
const itemsBefore: DropdownItem[] = [
{
label: "New Workspace",
leftSlot: <Icon icon="plus" />,
submenu: [
{
label: "Create Empty",
leftSlot: <Icon icon="plus_circle" />,
onSelect: createWorkspace,
},
{
label: "Open Folder",
leftSlot: <Icon icon="folder_open" />,
onSelect: async () => {
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
});
if (dir == null) return;
openWorkspaceFromSyncDir.mutate(dir);
},
},
{
label: "Clone Git Repository",
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: openCloneGitRepositoryDialog,
},
{
label: "Import Data",
leftSlot: <Icon icon="folder_input" />,
onSelect: () => importData.mutate(),
},
],
},
];
const itemsAfter: DropdownItem[] = [
...workspaceActions.map((a) => ({
label: a.label,
leftSlot: <Icon icon={a.icon ?? "empty"} />,
onSelect: async () => {
if (workspace != null) await a.call(workspace);
},
})),
...(workspaceActions.length > 0 ? [{ type: "separator" as const }] : []),
{
label: "Workspace Settings",
leftSlot: <Icon icon="settings" />,
hotKeyAction: "workspace_settings.show",
onSelect: openWorkspaceSettings,
},
{
label: revealInFinderText,
hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,
leftSlot: <Icon icon="folder_symlink" />,
onSelect: async () => {
if (workspaceMeta?.settingSyncDir == null) return;
await platform.revealItemInDir(workspaceMeta.settingSyncDir);
},
},
{
label: "Clear Send History",
color: "warning",
leftSlot: <Icon icon="history" />,
onSelect: deleteSendHistory,
},
];
return { workspaceItems, itemsAfter, itemsBefore };
}, [
workspaces,
workspaceMeta,
deleteSendHistory,
createWorkspace,
openCloneGitRepositoryDialog,
workspace?.id,
workspace,
workspaceActions.map,
workspaceActions.length,
]);
const handleSwitchWorkspace = useCallback(async (workspaceId: string | null) => {
if (workspaceId == null) return;
const settings = jotaiStore.get(settingsAtom);
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId === activeWorkspaceId) {
// Always open a new window if the selected one is already active
switchWorkspace.mutate({ workspaceId, inNewWindow: true });
return;
}
if (typeof settings.openWorkspaceNewWindow === "boolean") {
switchWorkspace.mutate({ workspaceId, inNewWindow: settings.openWorkspaceNewWindow });
return;
}
const workspace = getModel("workspace", workspaceId);
if (workspace == null) return;
showDialog({
id: "switch-workspace",
size: "sm",
title: "Switch Workspace",
render: ({ hide }) => <SwitchWorkspaceDialog workspace={workspace} hide={hide} />,
});
}, []);
return (
<RadioDropdown
items={workspaceItems}
itemsAfter={itemsAfter}
itemsBefore={itemsBefore}
onChange={handleSwitchWorkspace}
value={workspace?.id ?? null}
>
<Button
size="sm"
className={classNames(
className,
"text px-2! truncate",
workspace === null && "italic opacity-disabled",
)}
{...buttonProps}
>
{workspace?.name ?? "Workspace"}
</Button>
</RadioDropdown>
);
});

View File

@@ -0,0 +1,356 @@
import {
disableEncryption,
enableEncryption,
revealWorkspaceKey,
setWorkspaceKey,
} from "@yaakapp-internal/crypto";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { Banner, HStack, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { showConfirm } from "../lib/confirm";
import { CopyIconButton } from "./CopyIconButton";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { SettingRow } from "./core/SettingRow";
import { EncryptionHelp } from "./EncryptionHelp";
interface Props {
layout?: "form" | "settings";
size?: ButtonProps["size"];
expanded?: boolean;
onDone?: () => void;
onEnabledEncryption?: () => void;
}
export function WorkspaceEncryptionSetting({
layout = "form",
size,
expanded,
onDone,
onEnabledEncryption,
}: Props) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const [key, setKey] = useState<{ key: string | null; error: string | null } | null>(null);
useEffect(() => {
if (workspaceMeta == null) {
return;
}
if (workspaceMeta?.encryptionKey == null) {
setKey({ key: null, error: null });
return;
}
revealWorkspaceKey(workspaceMeta.workspaceId).then(
(key) => {
setKey({ key, error: null });
},
(err) => {
setKey({ key: null, error: `${err}` });
},
);
}, [workspaceMeta, workspaceMeta?.encryptionKey]);
if (key == null || workspace == null || workspaceMeta == null) {
return null;
}
// Prompt for key if it doesn't exist or could not be decrypted
if (
key.error != null ||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
) {
const enterKey = (
<EnterWorkspaceKey
workspaceMeta={workspaceMeta}
error={key.error}
onEnabled={() => {
onDone?.();
onEnabledEncryption?.();
}}
onDisabled={() => {
onDone?.();
}}
/>
);
return enterKey;
}
// Show the key if it exists
if (workspaceMeta.encryptionKey && key.key != null) {
const keyRevealer = (
<KeyRevealer
disableLabel={justEnabledEncryption}
defaultShow={justEnabledEncryption}
encryptionKey={key.key}
/>
);
const content = (
<VStack space={2} className="w-full">
{justEnabledEncryption && (
<Banner color="success" className="flex flex-col gap-2">
{helpAfterEncryption}
</Banner>
)}
{keyRevealer}
{onDone && (
<Button
color="secondary"
onClick={() => {
onDone();
onEnabledEncryption?.();
}}
>
Done
</Button>
)}
</VStack>
);
return content;
}
// Show button to enable encryption
if (layout === "settings") {
return (
<>
{error && (
<Banner color="danger" className="mb-3">
{error}
</Banner>
)}
<SettingRow
title="Workspace encryption"
description="Encrypt workspace secrets and sensitive values at rest."
>
<Button
color="secondary"
size={size}
onClick={async () => {
setError(null);
try {
await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true);
} catch (err) {
setError(`Failed to enable encryption: ${String(err)}`);
}
}}
>
Enable Encryption
</Button>
</SettingRow>
</>
);
}
return (
<div className="mb-auto flex flex-col-reverse">
<Button
className="mt-3"
color={expanded ? "info" : "secondary"}
size={size}
onClick={async () => {
setError(null);
try {
await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true);
} catch (err) {
setError(`Failed to enable encryption: ${String(err)}`);
}
}}
>
Enable Encryption
</Button>
{error && (
<Banner color="danger" className="mb-2">
{error}
</Banner>
)}
{expanded ? (
<Banner color="info" className="mb-6">
<EncryptionHelp />
</Banner>
) : (
<Label htmlFor={null} help={<EncryptionHelp />}>
Workspace encryption
</Label>
)}
</div>
);
}
const setWorkspaceKeyMut = createFastMutation({
mutationKey: ["set-workspace-key"],
mutationFn: setWorkspaceKey,
});
function EnterWorkspaceKey({
workspaceMeta,
onEnabled,
onDisabled,
error,
}: {
workspaceMeta: WorkspaceMeta;
onEnabled?: () => void;
onDisabled?: () => void;
error?: string | null;
}) {
const [key, setKey] = useState<string>("");
const handleForgotKey = async () => {
const confirmed = await showConfirm({
id: "disable-encryption",
title: "Disable Encryption",
color: "danger",
confirmText: "Disable Encryption",
description: (
<>
This will disable encryption for this workspace. Any previously encrypted values will fail
to decrypt and will need to be re-entered manually.
<br />
<br />
This action cannot be undone.
</>
),
});
if (confirmed) {
await disableEncryption(workspaceMeta.workspaceId);
onDisabled?.();
}
};
return (
<VStack space={4} className="w-full">
{error ? (
<Banner color="danger">{error}</Banner>
) : (
<Banner color="info">
This workspace contains encrypted values but no key is configured. Please enter the
workspace key to access the encrypted data.
</Banner>
)}
<HStack
as="form"
alignItems="end"
className="w-full"
space={1.5}
onSubmit={(e) => {
e.preventDefault();
setWorkspaceKeyMut.mutate(
{
workspaceId: workspaceMeta.workspaceId,
key: key.trim(),
},
{ onSuccess: onEnabled },
);
}}
>
<PlainInput
required
onChange={setKey}
label="Workspace encryption key"
placeholder="YK0000-111111-222222-333333-444444-AAAAAA-BBBBBB-CCCCCC-DDDDDD"
/>
<Button variant="border" type="submit" color="secondary">
Submit
</Button>
</HStack>
<button
type="button"
onClick={handleForgotKey}
className="text-text-subtlest text-sm hover:text-text-subtle"
>
Forgot your key?
</button>
</VStack>
);
}
function KeyRevealer({
defaultShow = false,
disableLabel = false,
encryptionKey,
}: {
defaultShow?: boolean;
disableLabel?: boolean;
encryptionKey: string;
}) {
const [show, setShow] = useStateWithDeps<boolean>(defaultShow, [defaultShow]);
return (
<div
className={classNames(
"w-full border border-border rounded-md pl-3 py-2 p-1",
"grid gap-1 grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<VStack space={0.5}>
{!disableLabel && (
<span className="text-sm text-primary flex items-center gap-1">
Workspace encryption key{" "}
<IconTooltip iconSize="sm" size="lg" content={helpAfterEncryption} />
</span>
)}
{encryptionKey && <HighlightedKey keyText={encryptionKey} show={show} />}
</VStack>
<HStack>
{encryptionKey && <CopyIconButton text={encryptionKey} title="Copy workspace key" />}
<IconButton
title={show ? "Hide" : "Reveal" + "workspace key"}
icon={show ? "eye_closed" : "eye"}
onClick={() => setShow((v) => !v)}
/>
</HStack>
</div>
);
}
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
return (
<span className="text-xs font-mono **:cursor-auto **:select-text">
{show ? (
keyText.split("").map((c, i) => {
return (
<span
// oxlint-disable-next-line no-array-index-key -- it's fine
key={i}
className={classNames(
c.match(/[0-9]/) && "text-info",
c === "-" && "text-text-subtle",
)}
>
{c}
</span>
);
})
) : (
<div className="text-text-subtle">•••••••••••••••••••••</div>
)}
</span>
);
}
const helpAfterEncryption = (
<p>
The following key is used for encryption operations within this workspace. It is stored securely
using your OS keychain, but it is recommended to back it up. If you share this workspace with
others, you&apos;ll need to send them this key to access any encrypted values.
</p>
);

View File

@@ -0,0 +1,94 @@
import { HStack, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue } from "jotai";
import { memo } from "react";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { useToggleCommandPalette } from "../hooks/useToggleCommandPalette";
import { workspaceLayoutAtom } from "../lib/atoms";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { CookieDropdown } from "./CookieDropdown";
import { IconButton } from "./core/IconButton";
import { PillButton } from "./core/PillButton";
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
import { ImportCurl } from "./ImportCurl";
import { LicenseBadge } from "./LicenseBadge";
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
import { SettingsDropdown } from "./SettingsDropdown";
import { SidebarActions } from "./SidebarActions";
import { WorkspaceActionsDropdown } from "./WorkspaceActionsDropdown";
interface Props {
className?: string;
floatingSidebar?: boolean;
}
export const WorkspaceHeader = memo(function WorkspaceHeader({
className,
floatingSidebar,
}: Props) {
const togglePalette = useToggleCommandPalette();
const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const showEncryptionSetup =
workspace != null &&
workspaceMeta != null &&
workspace.encryptionKeyChallenge != null &&
workspaceMeta.encryptionKey == null;
return (
<div
className={classNames(
className,
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full",
)}
>
<HStack space={0.5} className={classNames("flex-1 pointer-events-none")}>
<SidebarActions floating={floatingSidebar} />
<CookieDropdown />
<HStack className="min-w-0">
<WorkspaceActionsDropdown />
<Icon icon="chevron_right" color="secondary" />
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
</HStack>
</HStack>
<div className="pointer-events-none w-full max-w-[30vw] mx-auto flex justify-center">
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
<ImportCurl />
{showEncryptionSetup ? (
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
Enter Encryption Key
</PillButton>
) : (
<LicenseBadge />
)}
<IconButton
icon={
workspaceLayout === "responsive"
? "magic_wand"
: workspaceLayout === "horizontal"
? "columns_2"
: "rows_2"
}
title={`Change to ${workspaceLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
size="sm"
iconColor="secondary"
onClick={() =>
setWorkspaceLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
}
/>
<IconButton
icon="search"
title="Search or execute a command"
size="sm"
hotkeyAction="command_palette.toggle"
iconColor="secondary"
onClick={togglePalette}
/>
<SettingsDropdown />
</div>
</div>
);
});

Some files were not shown because too many files have changed in this diff Show More