mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-18 05:37:18 +02:00
Add cookie editing and inherited request settings
This commit is contained in:
@@ -1,19 +1,10 @@
|
||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
showDialog({
|
||||
id: "workspace-settings",
|
||||
size: "md",
|
||||
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
||||
),
|
||||
});
|
||||
WorkspaceSettingsDialog.show(workspaceId, tab);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
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";
|
||||
@@ -36,7 +37,6 @@ import { appInfo } from "../lib/appInfo";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { editEnvironment } from "../lib/editEnvironment";
|
||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||
import {
|
||||
@@ -99,6 +99,12 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
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",
|
||||
@@ -128,12 +134,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
key: "cookies.show",
|
||||
label: "Show Cookies",
|
||||
onSelect: async () => {
|
||||
showDialog({
|
||||
id: "cookies",
|
||||
title: "Manage Cookies",
|
||||
size: "full",
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
||||
});
|
||||
CookieDialog.show(activeCookieJar?.id ?? null);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
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, useMemo, useState } from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { cookieDomain } from "../lib/model_util";
|
||||
import { Banner, InlineCode } from "@yaakapp-internal/ui";
|
||||
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 { showAlert } from "../lib/alert";
|
||||
|
||||
interface Props {
|
||||
cookieJarId: string | null;
|
||||
@@ -12,56 +33,574 @@ interface Props {
|
||||
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 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());
|
||||
};
|
||||
|
||||
const handleEditCookie = () => {
|
||||
if (selectedCookie == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingCookieKey(cookieKey(selectedCookie));
|
||||
setDraftCookie(selectedCookie);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (isCreatingCookie) {
|
||||
setSelectedCookieKey(null);
|
||||
}
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
if (isEditingCookie) {
|
||||
handleCancelEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCookieKey(null);
|
||||
};
|
||||
|
||||
const handleSaveCookie = () => {
|
||||
if (cookieJar == null || draftCookie == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCookie = normalizeCookie(draftCookie);
|
||||
if (nextCookie.name.trim().length === 0) {
|
||||
showAlert({
|
||||
id: "invalid-cookie-name",
|
||||
title: "Invalid Cookie",
|
||||
body: "Cookie name is required.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCookieKey = cookieKey(nextCookie);
|
||||
const nextCookies = cookieJar.cookies.filter((cookie) => {
|
||||
const key = cookieKey(cookie);
|
||||
if (editingCookieKey != null && key === editingCookieKey) {
|
||||
return false;
|
||||
}
|
||||
return key !== nextCookieKey;
|
||||
});
|
||||
|
||||
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
||||
setSelectedCookieKey(nextCookieKey);
|
||||
setEditingCookieKey(null);
|
||||
setDraftCookie(null);
|
||||
};
|
||||
|
||||
if (cookieJar == null) {
|
||||
return <div>No cookie jar selected</div>;
|
||||
}
|
||||
|
||||
if (cookieJar.cookies.length === 0) {
|
||||
return (
|
||||
<Banner>
|
||||
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2 text-left">Domain</th>
|
||||
<th className="py-2 text-left pl-4">Cookie</th>
|
||||
<th className="py-2 pl-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{cookieJar?.cookies.map((c: Cookie) => (
|
||||
<tr key={JSON.stringify(c)}>
|
||||
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
|
||||
{cookieDomain(c)}
|
||||
</td>
|
||||
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
|
||||
{c.raw_cookie}
|
||||
</td>
|
||||
<td className="max-w-0 w-10">
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
title="Delete"
|
||||
className="ml-auto"
|
||||
onClick={() =>
|
||||
patchModel(cookieJar, {
|
||||
cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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.65}
|
||||
minHeightPx={10}
|
||||
firstSlot={({ style }) =>
|
||||
filteredCookies.length === 0 ? (
|
||||
<div style={style}>
|
||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable style={style}>
|
||||
<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);
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TruncatedWideTableCell className="min-w-[10rem]">
|
||||
{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>
|
||||
<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);
|
||||
}
|
||||
patchModel(cookieJar, {
|
||||
cookies: cookieJar.cookies.filter(
|
||||
(c2: Cookie) => cookieKey(c2) !== key,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
secondSlot={
|
||||
detailCookie == null
|
||||
? null
|
||||
: ({ style }) => (
|
||||
<div
|
||||
style={style}
|
||||
className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2"
|
||||
>
|
||||
<EventDetailHeader
|
||||
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
|
||||
copyText={isEditingCookie ? undefined : detailCookie.value}
|
||||
actions={
|
||||
isEditingCookie
|
||||
? [
|
||||
{
|
||||
key: "save",
|
||||
label: isCreatingCookie ? "Create" : "Save",
|
||||
onClick: handleSaveCookie,
|
||||
},
|
||||
{
|
||||
key: "cancel",
|
||||
label: "Cancel",
|
||||
onClick: handleCancelEdit,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
onClick: handleEditCookie,
|
||||
},
|
||||
]
|
||||
}
|
||||
onClose={handleCloseDetails}
|
||||
/>
|
||||
{isEditingCookie ? (
|
||||
<CookieEditor cookie={detailCookie} onChange={setDraftCookie} />
|
||||
) : (
|
||||
<CookieDetails cookie={detailCookie} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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,
|
||||
onChange,
|
||||
}: {
|
||||
cookie: Cookie;
|
||||
onChange: (cookie: Cookie) => void;
|
||||
}) {
|
||||
const sessionCookie = cookie.expires === "SessionEnd";
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<KeyValueRows>
|
||||
<CookieKeyValueRow label="Name">
|
||||
<CookieTextInput
|
||||
required
|
||||
autoFocus
|
||||
value={cookie.name}
|
||||
onChange={(name) => onChange({ ...cookie, name })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Value">
|
||||
<CookieTextarea
|
||||
value={cookie.value}
|
||||
onChange={(value) => onChange({ ...cookie, value })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Domain">
|
||||
<CookieTextInput
|
||||
value={cookieDomainInputValue(cookie)}
|
||||
placeholder="n/a"
|
||||
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow 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) =>
|
||||
onChange({
|
||||
...cookie,
|
||||
expires: checked
|
||||
? "SessionEnd"
|
||||
: cookieExpiresFromInput(defaultCookieExpiresInputValue()),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<CookieTextInput
|
||||
value={sessionCookie ? "" : cookieExpiresInputValue(cookie)}
|
||||
disabled={sessionCookie}
|
||||
onChange={(value) => onChange({ ...cookie, expires: cookieExpiresFromInput(value) })}
|
||||
/>
|
||||
</div>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="HTTP Only">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title="HTTP Only"
|
||||
checked={cookie.httpOnly}
|
||||
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Secure">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title="Secure"
|
||||
checked={cookie.secure}
|
||||
onChange={(secure) => onChange({ ...cookie, secure })}
|
||||
/>
|
||||
</CookieKeyValueRow>
|
||||
<CookieKeyValueRow label="Same Site">
|
||||
<select
|
||||
value={cookie.sameSite ?? ""}
|
||||
className={cookieInputClassName}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...cookie,
|
||||
sameSite:
|
||||
event.target.value === "" ? null : (event.target.value as Cookie["sameSite"]),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">n/a</option>
|
||||
<option value="Lax">Lax</option>
|
||||
<option value="Strict">Strict</option>
|
||||
<option value="None">None</option>
|
||||
</select>
|
||||
</CookieKeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
|
||||
return (
|
||||
<KeyValueRow labelClassName={classNames("w-[7rem] min-w-[7rem]", labelClassName)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CookieTextInput({
|
||||
autoFocus,
|
||||
disabled,
|
||||
onChange,
|
||||
placeholder,
|
||||
required,
|
||||
value,
|
||||
}: {
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
className={cookieInputClassName}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
||||
return (
|
||||
<textarea
|
||||
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NEW_COOKIE_KEY = "__new-cookie__";
|
||||
const cookieInputClassName = classNames(
|
||||
"w-full min-w-0 rounded bg-transparent px-1 py-0.5",
|
||||
"border border-transparent outline-none",
|
||||
"hover:border-border-subtle focus:border-border-focus",
|
||||
"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,
|
||||
name: cookie.name.trim(),
|
||||
path: cookie.path.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"] {
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) {
|
||||
return "SessionEnd";
|
||||
}
|
||||
|
||||
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 JSON.stringify([cookie.name, cookieDomain(cookie), cookie.path]);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { memo, useMemo } from "react";
|
||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
||||
import { CookieDialog } from "./CookieDialog";
|
||||
@@ -36,12 +35,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
|
||||
leftSlot: <Icon icon="cookie" />,
|
||||
onSelect: () => {
|
||||
if (activeCookieJar == null) return;
|
||||
showDialog({
|
||||
id: "cookies",
|
||||
title: "Manage Cookies",
|
||||
size: "full",
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
|
||||
});
|
||||
CookieDialog.show(activeCookieJar.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ 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;
|
||||
@@ -29,6 +30,7 @@ interface Props {
|
||||
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_VARIABLES = "variables";
|
||||
const TAB_GENERAL = "general";
|
||||
|
||||
@@ -36,6 +38,7 @@ export type FolderSettingsTab =
|
||||
| typeof TAB_AUTH
|
||||
| typeof TAB_HEADERS
|
||||
| typeof TAB_GENERAL
|
||||
| typeof TAB_SETTINGS
|
||||
| typeof TAB_VARIABLES;
|
||||
|
||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
@@ -51,6 +54,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
(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 [];
|
||||
@@ -60,6 +64,11 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
value: TAB_GENERAL,
|
||||
label: "General",
|
||||
},
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
@@ -68,7 +77,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
||||
},
|
||||
];
|
||||
}, [authTab, folder, headersTab, numVars]);
|
||||
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
|
||||
|
||||
if (folder == null) return null;
|
||||
|
||||
@@ -159,6 +168,9 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
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>
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 {
|
||||
@@ -47,6 +48,7 @@ interface Props {
|
||||
const TAB_MESSAGE = "message";
|
||||
const TAB_METADATA = "metadata";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
|
||||
export function GrpcRequestPane({
|
||||
@@ -66,6 +68,7 @@ export function GrpcRequestPane({
|
||||
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);
|
||||
@@ -128,13 +131,18 @@ export function GrpcRequestPane({
|
||||
{ value: TAB_MESSAGE, label: "Message" },
|
||||
...metadataTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[activeRequest.description, authTab, metadataTab],
|
||||
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
@@ -278,6 +286,9 @@ export function GrpcRequestPane({
|
||||
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
|
||||
|
||||
@@ -51,6 +51,7 @@ 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";
|
||||
|
||||
@@ -69,6 +70,7 @@ 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";
|
||||
|
||||
@@ -92,6 +94,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
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(
|
||||
@@ -234,6 +237,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
@@ -246,6 +254,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
handleContentTypeChange,
|
||||
headersTab,
|
||||
numParams,
|
||||
numSettingsOverrides,
|
||||
urlParameterPairs.length,
|
||||
],
|
||||
);
|
||||
@@ -372,6 +381,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
<ModelSettingsEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
InheritedBoolSetting,
|
||||
InheritedIntSetting,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import {
|
||||
SettingOverrideRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowNumber,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "./core/SettingRow";
|
||||
|
||||
interface Props {
|
||||
showSectionTitles?: boolean;
|
||||
model: ModelWithSettings;
|
||||
}
|
||||
|
||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||
type IntegerSetting = number | InheritedIntSetting;
|
||||
type CookieSettingsPatch = {
|
||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||
};
|
||||
type HttpSettingsPatch = {
|
||||
settingValidateCertificates?: ModelWithHttpSettings["settingValidateCertificates"];
|
||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||
};
|
||||
|
||||
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
||||
const ancestors = useModelAncestors(model);
|
||||
const supportsHttpSettings =
|
||||
model.model === "workspace" || model.model === "folder" || model.model === "http_request";
|
||||
|
||||
return (
|
||||
<SettingsList className="space-y-8">
|
||||
{supportsHttpSettings && (
|
||||
<SettingsSection title={showSectionTitles ? "Requests" : null}>
|
||||
<IntegerSettingRow
|
||||
title="Request Timeout"
|
||||
description="Maximum request duration in milliseconds. Set to 0 to disable the timeout."
|
||||
name="settingRequestTimeout"
|
||||
setting={model.settingRequestTimeout}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
"settingRequestTimeout",
|
||||
model.settingRequestTimeout,
|
||||
)}
|
||||
onChange={(settingRequestTimeout) =>
|
||||
patchHttpSettings(model, {
|
||||
settingRequestTimeout,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<BooleanSettingRow
|
||||
title="Validate TLS certificates"
|
||||
description="When disabled, skip validation of server certificates."
|
||||
setting={model.settingValidateCertificates}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
"settingValidateCertificates",
|
||||
model.settingValidateCertificates,
|
||||
)}
|
||||
onChange={(settingValidateCertificates) =>
|
||||
patchHttpSettings(model, {
|
||||
settingValidateCertificates,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<BooleanSettingRow
|
||||
title="Follow redirects"
|
||||
description="Follow HTTP redirects automatically."
|
||||
setting={model.settingFollowRedirects}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
"settingFollowRedirects",
|
||||
model.settingFollowRedirects,
|
||||
)}
|
||||
onChange={(settingFollowRedirects) =>
|
||||
patchHttpSettings(model, {
|
||||
settingFollowRedirects,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
<SettingsSection title={supportsHttpSettings || showSectionTitles ? "Cookies" : null}>
|
||||
<BooleanSettingRow
|
||||
title="Automatically send cookies"
|
||||
description="Attach matching cookies from the active cookie jar to outgoing requests."
|
||||
setting={model.settingSendCookies}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
"settingSendCookies",
|
||||
model.settingSendCookies,
|
||||
)}
|
||||
onChange={(settingSendCookies) =>
|
||||
patchCookieSettings(model, {
|
||||
settingSendCookies,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<BooleanSettingRow
|
||||
title="Automatically store cookies"
|
||||
description="Save cookies from Set-Cookie response headers to the active cookie jar."
|
||||
setting={model.settingStoreCookies}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
"settingStoreCookies",
|
||||
model.settingStoreCookies,
|
||||
)}
|
||||
onChange={(settingStoreCookies) =>
|
||||
patchCookieSettings(model, {
|
||||
settingStoreCookies,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
);
|
||||
}
|
||||
|
||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
const settings: (BooleanSetting | IntegerSetting)[] = [
|
||||
model.settingSendCookies,
|
||||
model.settingStoreCookies,
|
||||
];
|
||||
|
||||
if (model.model === "workspace" || model.model === "folder" || model.model === "http_request") {
|
||||
settings.push(
|
||||
model.settingValidateCertificates,
|
||||
model.settingFollowRedirects,
|
||||
model.settingRequestTimeout,
|
||||
);
|
||||
}
|
||||
|
||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||
.length;
|
||||
}
|
||||
|
||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
||||
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
||||
if (model.model === "websocket_request")
|
||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||
}
|
||||
|
||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
||||
return patchModel(model, patch as Partial<HttpRequest>);
|
||||
}
|
||||
|
||||
function BooleanSettingRow({
|
||||
description,
|
||||
inheritedValue,
|
||||
setting,
|
||||
title,
|
||||
onChange,
|
||||
}: {
|
||||
description: string;
|
||||
inheritedValue: boolean;
|
||||
setting: BooleanSetting;
|
||||
title: string;
|
||||
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={title}
|
||||
description={description}
|
||||
onChange={(value) => onChange(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={title}
|
||||
description={description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="md"
|
||||
title={title}
|
||||
checked={value}
|
||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegerSettingRow({
|
||||
description,
|
||||
inheritedValue,
|
||||
name,
|
||||
setting,
|
||||
title,
|
||||
onChange,
|
||||
}: {
|
||||
description: string;
|
||||
inheritedValue: number;
|
||||
name: string;
|
||||
setting: IntegerSetting;
|
||||
title: string;
|
||||
onChange: (setting: IntegerSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
const showReset = overridden && value !== inheritedValue;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRowNumber
|
||||
name={name}
|
||||
title={title}
|
||||
description={description}
|
||||
value={value}
|
||||
placeholder="0"
|
||||
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
||||
onChange={(value) => onChange(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={title}
|
||||
description={description}
|
||||
overridden={showReset}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<PlainInput
|
||||
hideLabel
|
||||
name={name}
|
||||
label={title}
|
||||
size="sm"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
defaultValue={`${value}`}
|
||||
containerClassName="!w-48"
|
||||
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...setting,
|
||||
enabled: true,
|
||||
value: Number.parseInt(value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
fallback: IntegerSetting,
|
||||
): number;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: BooleanWorkspaceSettingKey,
|
||||
fallback: BooleanSetting,
|
||||
): boolean;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: keyof WorkspaceSettings,
|
||||
fallback: BooleanSetting | IntegerSetting,
|
||||
) {
|
||||
for (const ancestor of ancestors) {
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
||||
if (isInheritedSetting(setting)) {
|
||||
if (setting.enabled === true) {
|
||||
return setting.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return setting;
|
||||
}
|
||||
|
||||
return isInheritedSetting(fallback) ? fallback.value : fallback;
|
||||
}
|
||||
|
||||
type WorkspaceSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
| "settingStoreCookies"
|
||||
| "settingValidateCertificates"
|
||||
>;
|
||||
|
||||
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
|
||||
@@ -19,6 +19,7 @@ type Props = Omit<ButtonProps, "type"> & {
|
||||
inline?: boolean;
|
||||
noun?: string;
|
||||
help?: ReactNode;
|
||||
hideLabel?: boolean;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
@@ -36,6 +37,7 @@ export function SelectFile({
|
||||
size = "sm",
|
||||
label,
|
||||
help,
|
||||
hideLabel,
|
||||
...props
|
||||
}: Props) {
|
||||
const handleClick = async () => {
|
||||
@@ -95,7 +97,7 @@ export function SelectFile({
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{label && (
|
||||
<Label htmlFor={null} help={help}>
|
||||
<Label htmlFor={null} help={help} visuallyHidden={hideLabel}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
@@ -7,12 +7,18 @@ import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { revealInFinderText } from "../../lib/reveal";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Select } from "../core/Select";
|
||||
import { Separator } from "../core/Separator";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingRowNumber,
|
||||
ModelSettingSelectControl,
|
||||
SettingValue,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
@@ -29,147 +35,159 @@ export function SettingsGeneral() {
|
||||
<Heading>General</Heading>
|
||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
||||
</div>
|
||||
<CargoFeature feature="updater">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
||||
<Select
|
||||
name="updateChannel"
|
||||
label="Update Channel"
|
||||
labelPosition="left"
|
||||
labelClassName="w-[14rem]"
|
||||
size="sm"
|
||||
value={settings.updateChannel}
|
||||
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
|
||||
options={[
|
||||
{ label: "Stable", value: "stable" },
|
||||
{ label: "Beta (more frequent)", value: "beta" },
|
||||
]}
|
||||
/>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Check for updates"
|
||||
icon="refresh"
|
||||
spin={checkForUpdates.isPending}
|
||||
onClick={() => checkForUpdates.mutateAsync()}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<Select
|
||||
name="autoupdate"
|
||||
value={settings.autoupdate ? "auto" : "manual"}
|
||||
label="Update Behavior"
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
labelClassName="w-[14rem]"
|
||||
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
||||
options={[
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ label: "Manual", value: "manual" },
|
||||
]}
|
||||
/>
|
||||
<Checkbox
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={settings.autoDownloadUpdates}
|
||||
disabled={!settings.autoupdate}
|
||||
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
|
||||
title="Automatically download updates"
|
||||
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={settings.checkNotifications}
|
||||
title="Check for notifications"
|
||||
help="Periodically ping Yaak servers to check for relevant notifications."
|
||||
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||
/>
|
||||
<Checkbox
|
||||
disabled
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={false}
|
||||
title="Send anonymous usage statistics"
|
||||
help="Yaak is local-first and does not collect analytics or usage data 🔐"
|
||||
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||
/>
|
||||
</CargoFeature>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Heading level={2}>
|
||||
Workspace{" "}
|
||||
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
|
||||
{workspace.name}
|
||||
</div>
|
||||
</Heading>
|
||||
<VStack className="mt-1 w-full" space={3}>
|
||||
<PlainInput
|
||||
required
|
||||
size="sm"
|
||||
name="requestTimeout"
|
||||
label="Request Timeout (ms)"
|
||||
labelClassName="w-[14rem]"
|
||||
placeholder="0"
|
||||
labelPosition="left"
|
||||
defaultValue={`${workspace.settingRequestTimeout}`}
|
||||
validate={(value) => Number.parseInt(value, 10) >= 0}
|
||||
onChange={(v) =>
|
||||
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
|
||||
}
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={workspace.settingValidateCertificates}
|
||||
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
|
||||
title="Validate TLS certificates"
|
||||
onChange={(settingValidateCertificates) =>
|
||||
patchModel(workspace, { settingValidateCertificates })
|
||||
}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={workspace.settingFollowRedirects}
|
||||
title="Follow redirects"
|
||||
onChange={(settingFollowRedirects) =>
|
||||
patchModel(workspace, {
|
||||
settingFollowRedirects,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Heading level={2}>App Info</Heading>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
|
||||
<KeyValueRow
|
||||
label="Data Directory"
|
||||
rightSlot={
|
||||
<IconButton
|
||||
title={revealInFinderText}
|
||||
icon="folder_open"
|
||||
size="2xs"
|
||||
onClick={() => revealItemInDir(appInfo.appDataDir)}
|
||||
<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>
|
||||
|
||||
<SettingsSection
|
||||
title={
|
||||
<>
|
||||
Workspace{" "}
|
||||
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
|
||||
{workspace.name}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{appInfo.appDataDir}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow
|
||||
label="Logs Directory"
|
||||
rightSlot={
|
||||
<IconButton
|
||||
title={revealInFinderText}
|
||||
icon="folder_open"
|
||||
size="2xs"
|
||||
onClick={() => revealItemInDir(appInfo.appLogDir)}
|
||||
<ModelSettingRowNumber
|
||||
model={workspace}
|
||||
modelKey="settingRequestTimeout"
|
||||
title="Request Timeout"
|
||||
description="Maximum request duration in milliseconds. Set to 0 to disable the timeout."
|
||||
placeholder="0"
|
||||
required
|
||||
validate={(value) => Number.parseInt(value, 10) >= 0}
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={workspace}
|
||||
modelKey="settingValidateCertificates"
|
||||
title="Validate TLS certificates"
|
||||
description="When disabled, skip validation of server certificates."
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={workspace}
|
||||
modelKey="settingFollowRedirects"
|
||||
title="Follow redirects"
|
||||
description="Follow HTTP redirects automatically."
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={workspace}
|
||||
modelKey="settingSendCookies"
|
||||
title="Automatically send cookies"
|
||||
description="Attach matching cookies from the active cookie jar to outgoing requests."
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={workspace}
|
||||
modelKey="settingStoreCookies"
|
||||
title="Automatically store cookies"
|
||||
description="Save cookies from Set-Cookie response headers to the active cookie jar."
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<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: () => revealItemInDir(appInfo.appDataDir),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{appInfo.appLogDir}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</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: () => revealItemInDir(appInfo.appLogDir),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
@@ -13,7 +13,16 @@ import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { Link } from "../core/Link";
|
||||
import { Select } from "../core/Select";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingRowSelect,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingSelectControl,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
||||
|
||||
@@ -38,154 +47,172 @@ export function SettingsInterface() {
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<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>
|
||||
<Select
|
||||
name="switchWorkspaceBehavior"
|
||||
label="Open workspace behavior"
|
||||
size="sm"
|
||||
help="When opening a workspace, should it open in the current window or a new window?"
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
<HStack space={2} alignItems="end">
|
||||
{fonts.data && (
|
||||
<Select
|
||||
size="sm"
|
||||
name="uiFont"
|
||||
label="Interface font"
|
||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: "System default", value: NULL_FONT_VALUE },
|
||||
...(fonts.data.uiFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
// Some people like monospace fonts for the UI
|
||||
...(fonts.data.editorFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
]}
|
||||
<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) => {
|
||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { interfaceFont });
|
||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
hideLabel
|
||||
size="sm"
|
||||
name="interfaceFontSize"
|
||||
label="Interface Font Size"
|
||||
defaultValue="14"
|
||||
value={`${settings.interfaceFontSize}`}
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack space={2} alignItems="end">
|
||||
{fonts.data && (
|
||||
<Select
|
||||
size="sm"
|
||||
name="editorFont"
|
||||
label="Editor font"
|
||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: "System default", value: NULL_FONT_VALUE },
|
||||
...(fonts.data.editorFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
{ label: "Always ask", value: "ask" },
|
||||
{ label: "Open in current window", value: "current" },
|
||||
{ label: "Open in new window", value: "new" },
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { editorFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
hideLabel
|
||||
size="sm"
|
||||
name="editorFontSize"
|
||||
label="Editor Font Size"
|
||||
defaultValue="12"
|
||||
value={`${settings.editorFontSize}`}
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) =>
|
||||
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
<Select
|
||||
leftSlot={<Icon icon="keyboard" color="secondary" />}
|
||||
size="sm"
|
||||
name="editorKeymap"
|
||||
label="Editor keymap"
|
||||
value={`${settings.editorKeymap}`}
|
||||
options={keymaps}
|
||||
onChange={(v) => patchModel(settings, { editorKeymap: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={settings.editorSoftWrap}
|
||||
title="Wrap editor lines"
|
||||
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={settings.coloredMethods}
|
||||
title="Colorize request methods"
|
||||
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
|
||||
/>
|
||||
<CargoFeature feature="license">
|
||||
<LicenseSettings settings={settings} />
|
||||
</CargoFeature>
|
||||
</SettingsSection>
|
||||
|
||||
<NativeTitlebarSetting settings={settings} />
|
||||
<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>
|
||||
|
||||
{type() !== "macos" && (
|
||||
<Checkbox
|
||||
checked={settings.hideWindowControls}
|
||||
title="Hide window controls"
|
||||
help="Hide the close/maximize/minimize controls on Windows or Linux"
|
||||
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
|
||||
/>
|
||||
)}
|
||||
<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} />
|
||||
{type() !== "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 (
|
||||
<div className="flex gap-1 overflow-hidden h-2xs">
|
||||
<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"
|
||||
help="Use the operating system's standard title bar and window controls"
|
||||
onChange={setNativeTitlebar}
|
||||
/>
|
||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="2xs"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||
await invokeCmd("cmd_restart");
|
||||
@@ -194,7 +221,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
||||
Apply and Restart
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,37 +232,40 @@ function LicenseSettings({ settings }: { settings: Settings }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={settings.hideLicenseBadge}
|
||||
title="Hide personal use badge"
|
||||
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="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
requireTyping: "Personal Use",
|
||||
color: "info",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return; // Cancel
|
||||
<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="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
requireTyping: "Personal Use",
|
||||
color: "info",
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await patchModel(settings, { hideLicenseBadge });
|
||||
}}
|
||||
/>
|
||||
await patchModel(settings, { hideLicenseBadge });
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Select } from "../core/Select";
|
||||
import { Separator } from "../core/Separator";
|
||||
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">
|
||||
@@ -18,188 +33,146 @@ export function SettingsProxy() {
|
||||
traffic, or routing through specific infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
name="proxy"
|
||||
label="Proxy"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={settings.proxy?.type ?? "automatic"}
|
||||
onChange={async (v) => {
|
||||
if (v === "automatic") {
|
||||
await patchModel(settings, { proxy: undefined });
|
||||
} else if (v === "enabled") {
|
||||
await patchModel(settings, {
|
||||
proxy: {
|
||||
disabled: false,
|
||||
type: "enabled",
|
||||
http: "",
|
||||
https: "",
|
||||
auth: { user: "", password: "" },
|
||||
bypass: "",
|
||||
},
|
||||
});
|
||||
} 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" },
|
||||
]}
|
||||
/>
|
||||
{settings.proxy?.type === "enabled" && (
|
||||
<VStack space={1.5}>
|
||||
<Checkbox
|
||||
className="my-3"
|
||||
checked={!settings.proxy.disabled}
|
||||
title="Enable proxy"
|
||||
help="Use this to temporarily disable the proxy without losing the configuration"
|
||||
onChange={async (enabled) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||
const auth = proxy?.type === "enabled" ? proxy.auth : null;
|
||||
const disabled = !enabled;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label={
|
||||
<>
|
||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
||||
</>
|
||||
<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" } });
|
||||
}
|
||||
placeholder="localhost:9090"
|
||||
defaultValue={settings.proxy?.http}
|
||||
onChange={async (http) => {
|
||||
const { proxy } = settings;
|
||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||
const auth = proxy?.type === "enabled" ? proxy.auth : null;
|
||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||
await patchModel(settings, {
|
||||
proxy: {
|
||||
type: "enabled",
|
||||
http,
|
||||
https,
|
||||
auth,
|
||||
disabled,
|
||||
bypass,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label={
|
||||
<>
|
||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
placeholder="localhost:9090"
|
||||
defaultValue={settings.proxy?.https}
|
||||
onChange={async (https) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||
const auth = proxy?.type === "enabled" ? proxy.auth : null;
|
||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Separator className="my-6" />
|
||||
<Checkbox
|
||||
checked={settings.proxy.auth != null}
|
||||
title="Enable authentication"
|
||||
onChange={async (enabled) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||
const auth = enabled ? { user: "", password: "" } : null;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{ label: "Automatic proxy detection", value: "automatic" },
|
||||
{ label: "Custom proxy configuration", value: "enabled" },
|
||||
{ label: "No proxy", value: "disabled" },
|
||||
]}
|
||||
selectClassName="!w-64"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{settings.proxy.auth != null && (
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
required
|
||||
size="sm"
|
||||
label="User"
|
||||
placeholder="myUser"
|
||||
defaultValue={settings.proxy.auth.user}
|
||||
onChange={async (user) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
{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 })}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="s3cretPassw0rd"
|
||||
defaultValue={settings.proxy.auth.password}
|
||||
onChange={async (password) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
<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 })}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
{settings.proxy.type === "enabled" && (
|
||||
<>
|
||||
<Separator className="my-6" />
|
||||
<PlainInput
|
||||
label="Proxy Bypass"
|
||||
help="Comma-separated list to bypass the proxy."
|
||||
defaultValue={settings.proxy.bypass}
|
||||
<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"
|
||||
onChange={async (bypass) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
|
||||
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
inputWidthClassName="!w-96"
|
||||
onChange={(bypass) => patchProxy({ bypass })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</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: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ import type { ButtonProps } from "../core/Button";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Link } from "../core/Link";
|
||||
import type { SelectProps } from "../core/Select";
|
||||
import { Select } from "../core/Select";
|
||||
import {
|
||||
ModelSettingRowSelect,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
||||
|
||||
@@ -67,7 +72,7 @@ export function SettingsTheme() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Theme</Heading>
|
||||
<p className="text-text-subtle">
|
||||
@@ -77,96 +82,92 @@ export function SettingsTheme() {
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
name="appearance"
|
||||
label="Appearance"
|
||||
labelPosition="top"
|
||||
size="sm"
|
||||
value={settings.appearance}
|
||||
onChange={(appearance) => patchModel(settings, { appearance })}
|
||||
options={[
|
||||
{ label: "Automatic", value: "system" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
]}
|
||||
/>
|
||||
<HStack space={2}>
|
||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
||||
<Select
|
||||
hideLabel
|
||||
leftSlot={<Icon icon="sun" color="secondary" />}
|
||||
name="lightTheme"
|
||||
label="Light Theme"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
value={activeTheme.data.light.id}
|
||||
options={lightThemes}
|
||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
||||
<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 === "dark") && (
|
||||
<Select
|
||||
hideLabel
|
||||
name="darkTheme"
|
||||
className="flex-1"
|
||||
label="Dark Theme"
|
||||
leftSlot={<Icon icon="moon" color="secondary" />}
|
||||
size="sm"
|
||||
value={activeTheme.data.dark.id}
|
||||
options={darkThemes}
|
||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
{(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>
|
||||
|
||||
<VStack
|
||||
space={3}
|
||||
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded 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 title="Preview">
|
||||
<VStack
|
||||
space={3}
|
||||
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,20 +4,79 @@ 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";
|
||||
|
||||
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 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 && (
|
||||
@@ -47,18 +106,7 @@ export function SyncToFilesystemSetting({
|
||||
noun="Directory"
|
||||
help="Sync data to a folder for backup and Git integration."
|
||||
filePath={value.filePath}
|
||||
onChange={async ({ filePath }) => {
|
||||
if (filePath != null) {
|
||||
const files = await readDir(filePath);
|
||||
if (files.length > 0) {
|
||||
setSyncDir(filePath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSyncDir(null);
|
||||
onChange({ ...value, filePath });
|
||||
}}
|
||||
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
|
||||
/>
|
||||
|
||||
{value.filePath && typeof value.initGit === "boolean" && (
|
||||
|
||||
@@ -34,6 +34,7 @@ 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";
|
||||
|
||||
@@ -48,6 +49,7 @@ 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";
|
||||
|
||||
@@ -69,6 +71,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
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(
|
||||
@@ -109,12 +112,17 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
},
|
||||
];
|
||||
}, [authTab, headersTab, urlParameterPairs.length]);
|
||||
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
@@ -266,6 +274,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
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
|
||||
|
||||
@@ -20,16 +20,24 @@ 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({ size, expanded, onDone, onEnabledEncryption }: Props) {
|
||||
export function WorkspaceEncryptionSetting({
|
||||
layout = "form",
|
||||
size,
|
||||
expanded,
|
||||
onDone,
|
||||
onEnabledEncryption,
|
||||
}: Props) {
|
||||
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -66,7 +74,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
key.error != null ||
|
||||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
|
||||
) {
|
||||
return (
|
||||
const enterKey = (
|
||||
<EnterWorkspaceKey
|
||||
workspaceMeta={workspaceMeta}
|
||||
error={key.error}
|
||||
@@ -79,6 +87,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return enterKey;
|
||||
}
|
||||
|
||||
// Show the key if it exists
|
||||
@@ -90,7 +100,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
encryptionKey={key.key}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
|
||||
const content = (
|
||||
<VStack space={2} className="w-full">
|
||||
{justEnabledEncryption && (
|
||||
<Banner color="success" className="flex flex-col gap-2">
|
||||
@@ -111,9 +122,43 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
||||
)}
|
||||
</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
|
||||
|
||||
@@ -5,16 +5,19 @@ import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { router } from "../lib/router";
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { SettingsList, SettingsSection } from "./core/SettingRow";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { DnsOverridesEditor } from "./DnsOverridesEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
|
||||
import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
|
||||
|
||||
@@ -25,17 +28,17 @@ interface Props {
|
||||
}
|
||||
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_DATA = "data";
|
||||
const TAB_DNS = "dns";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_GENERAL = "general";
|
||||
const TAB_SETTINGS = "settings";
|
||||
|
||||
export type WorkspaceSettingsTab =
|
||||
| typeof TAB_AUTH
|
||||
| typeof TAB_DNS
|
||||
| typeof TAB_HEADERS
|
||||
| typeof TAB_GENERAL
|
||||
| typeof TAB_DATA;
|
||||
| typeof TAB_SETTINGS;
|
||||
|
||||
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
||||
|
||||
@@ -71,8 +74,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
tabs={[
|
||||
{ value: TAB_GENERAL, label: "Workspace" },
|
||||
{
|
||||
value: TAB_DATA,
|
||||
label: "Storage",
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
@@ -100,6 +103,20 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
stateKey={`headers.${workspace.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
|
||||
<SettingsList className="space-y-8 pb-3">
|
||||
<SettingsSection title={null}>
|
||||
<SyncToFilesystemSetting
|
||||
layout="settings"
|
||||
value={{ filePath: workspaceMeta.settingSyncDir }}
|
||||
onCreateNewWorkspace={hide}
|
||||
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
|
||||
/>
|
||||
<WorkspaceEncryptionSetting layout="settings" size="xs" />
|
||||
</SettingsSection>
|
||||
<ModelSettingsEditor model={workspace} showSectionTitles />
|
||||
</SettingsList>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full">
|
||||
<PlainInput
|
||||
@@ -152,19 +169,21 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
</HStack>
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DATA} className="overflow-y-auto h-full px-4">
|
||||
<VStack space={4} alignItems="start" className="pb-3 h-full">
|
||||
<SyncToFilesystemSetting
|
||||
value={{ filePath: workspaceMeta.settingSyncDir }}
|
||||
onCreateNewWorkspace={hide}
|
||||
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
|
||||
/>
|
||||
<WorkspaceEncryptionSetting size="xs" />
|
||||
</VStack>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4">
|
||||
<DnsOverridesEditor workspace={workspace} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
|
||||
showDialog({
|
||||
id: "workspace-settings",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-5rem)] !max-h-[50rem]",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CheckboxProps {
|
||||
hideLabel?: boolean;
|
||||
fullWidth?: boolean;
|
||||
help?: ReactNode;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
@@ -25,6 +26,7 @@ export function Checkbox({
|
||||
hideLabel,
|
||||
fullWidth,
|
||||
help,
|
||||
size = "sm",
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<HStack
|
||||
@@ -37,7 +39,9 @@ export function Checkbox({
|
||||
<input
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"appearance-none w-4 h-4 flex-shrink-0 border border-border",
|
||||
"appearance-none flex-shrink-0 border border-border",
|
||||
size === "sm" && "w-4 h-4",
|
||||
size === "md" && "w-5 h-5",
|
||||
"rounded outline-none ring-0",
|
||||
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
||||
disabled && "border-dotted",
|
||||
@@ -50,7 +54,7 @@ export function Checkbox({
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Icon
|
||||
size="sm"
|
||||
size={size}
|
||||
className={classNames(disabled && "opacity-disabled")}
|
||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
|
||||
interface Props {
|
||||
children:
|
||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
||||
selectable?: boolean;
|
||||
}
|
||||
|
||||
export function KeyValueRows({ children }: Props) {
|
||||
export function KeyValueRows({ children, selectable }: Props) {
|
||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
||||
return (
|
||||
<table className="text-editor font-mono min-w-0 w-full mb-auto">
|
||||
<table
|
||||
className={classNames(
|
||||
"text-editor font-mono min-w-0 w-full mb-auto",
|
||||
selectable &&
|
||||
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
|
||||
)}
|
||||
>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{childArray.map((child, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
@@ -28,6 +36,8 @@ interface KeyValueRowProps {
|
||||
leftSlot?: ReactNode;
|
||||
labelClassName?: string;
|
||||
labelColor?: "secondary" | "primary" | "info";
|
||||
enableCopy?: boolean;
|
||||
copyText?: string;
|
||||
}
|
||||
|
||||
export function KeyValueRow({
|
||||
@@ -37,7 +47,24 @@ export function KeyValueRow({
|
||||
leftSlot,
|
||||
labelColor = "secondary",
|
||||
labelClassName,
|
||||
enableCopy,
|
||||
copyText,
|
||||
}: KeyValueRowProps) {
|
||||
const textToCopy =
|
||||
copyText ??
|
||||
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
|
||||
const resolvedRightSlot =
|
||||
rightSlot ??
|
||||
(enableCopy && textToCopy != null ? (
|
||||
<CopyIconButton
|
||||
text={textToCopy}
|
||||
className="text-text-subtle"
|
||||
size="2xs"
|
||||
title={`Copy ${label}`}
|
||||
iconSize="sm"
|
||||
/>
|
||||
) : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
@@ -55,7 +82,11 @@ export function KeyValueRow({
|
||||
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
|
||||
{leftSlot ?? <span aria-hidden />}
|
||||
{children}
|
||||
{rightSlot ? <div className="ml-1.5">{rightSlot}</div> : <span aria-hidden />}
|
||||
{resolvedRightSlot ? (
|
||||
<div className="ml-1.5">{resolvedRightSlot}</div>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
|
||||
@@ -43,6 +43,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
disabled,
|
||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||
help,
|
||||
hideLabel,
|
||||
@@ -163,7 +164,8 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
"relative w-full rounded-md text",
|
||||
"border",
|
||||
"overflow-hidden",
|
||||
focused ? "border-border-focus" : "border-border-subtle",
|
||||
focused && !disabled ? "border-border-focus" : "border-border-subtle",
|
||||
disabled && "border-dotted",
|
||||
hasChanged && "has-[:invalid]:border-danger", // For built-in HTML validation
|
||||
size === "md" && "min-h-md",
|
||||
size === "sm" && "min-h-sm",
|
||||
@@ -198,12 +200,13 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
||||
className={classNames(commonClassName, "h-full")}
|
||||
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
|
||||
@@ -109,7 +109,15 @@ export function Select<T extends string>({
|
||||
) : (
|
||||
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
|
||||
// light mode
|
||||
<RadioDropdown value={value} onChange={handleChange} items={options}>
|
||||
<RadioDropdown
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
items={options.map((o) =>
|
||||
o.type === "separator" || o.value !== defaultValue
|
||||
? o
|
||||
: { ...o, label: <>{o.label} (default)</> },
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="w-full text-sm font-mono"
|
||||
justify="start"
|
||||
|
||||
@@ -24,7 +24,7 @@ export function Separator({
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"h-0 border-t opacity-60",
|
||||
"opacity-60",
|
||||
color == null && "border-border",
|
||||
color === "primary" && "border-primary",
|
||||
color === "secondary" && "border-secondary",
|
||||
@@ -34,8 +34,8 @@ export function Separator({
|
||||
color === "danger" && "border-danger",
|
||||
color === "info" && "border-info",
|
||||
dashed && "border-dashed",
|
||||
orientation === "horizontal" && "w-full h-[1px]",
|
||||
orientation === "vertical" && "h-full w-[1px]",
|
||||
orientation === "horizontal" && "w-full h-0 border-t",
|
||||
orientation === "vertical" && "h-full w-0 border-l",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
import { PlainInput } from "./PlainInput";
|
||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
||||
import { Select } from "./Select";
|
||||
import { SelectFile } from "../SelectFile";
|
||||
|
||||
type ModelKeyOfValue<T, V> = {
|
||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
type SettingRowBaseProps = {
|
||||
className?: string;
|
||||
controlClassName?: string;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={classNames("w-full", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
children,
|
||||
className,
|
||||
description,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
title: ReactNode | null;
|
||||
}) {
|
||||
const showHeader = title != null || description != null;
|
||||
|
||||
return (
|
||||
<section className={classNames(className, "w-full")}>
|
||||
{showHeader && (
|
||||
<div className="border-b border-border-subtle pb-2">
|
||||
{title != null && <div className="text-text-subtle">{title}</div>}
|
||||
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="[&>*:last-child]:border-b-0">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingRow({
|
||||
children,
|
||||
className,
|
||||
controlClassName,
|
||||
description,
|
||||
disabled,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
} & SettingRowBaseProps) {
|
||||
return (
|
||||
<div
|
||||
aria-disabled={disabled || undefined}
|
||||
className={classNames(
|
||||
className,
|
||||
"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-6 border-b border-border-subtle py-4",
|
||||
disabled && "opacity-disabled",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text text-text">{title}</div>
|
||||
{description != null && (
|
||||
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames("flex items-center justify-end", controlClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingValue({
|
||||
actions,
|
||||
className,
|
||||
copyText,
|
||||
enableCopy = true,
|
||||
value,
|
||||
}: {
|
||||
actions?: SettingValueAction[];
|
||||
className?: string;
|
||||
copyText?: string;
|
||||
enableCopy?: boolean;
|
||||
value: ReactNode;
|
||||
}) {
|
||||
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
|
||||
const textToCopy = copyText ?? textValue;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{actions?.map((action) => (
|
||||
<IconButton
|
||||
key={action.title}
|
||||
icon={action.icon}
|
||||
title={action.title}
|
||||
size="2xs"
|
||||
iconSize="sm"
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
))}
|
||||
{enableCopy && textToCopy != null && (
|
||||
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SettingValueAction = {
|
||||
icon: IconButtonProps["icon"];
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function SettingRowBoolean({
|
||||
checked,
|
||||
checkboxSize = "md",
|
||||
onChange,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
checked: boolean;
|
||||
checkboxSize?: "sm" | "md";
|
||||
onChange: (checked: boolean) => void;
|
||||
} & SettingRowBaseProps) {
|
||||
return (
|
||||
<SettingRow title={title} {...props}>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size={checkboxSize}
|
||||
checked={checked}
|
||||
disabled={props.disabled}
|
||||
title={title}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
|
||||
model,
|
||||
modelKey,
|
||||
...props
|
||||
}: {
|
||||
model: M;
|
||||
modelKey: K;
|
||||
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
|
||||
return (
|
||||
<SettingRowBoolean
|
||||
checked={model[modelKey] as boolean}
|
||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingRowNumber({
|
||||
inputClassName,
|
||||
inputWidthClassName = "!w-48",
|
||||
name,
|
||||
onChange,
|
||||
placeholder,
|
||||
required,
|
||||
title,
|
||||
type = "number",
|
||||
validate,
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
inputClassName?: string;
|
||||
inputWidthClassName?: string;
|
||||
name: string;
|
||||
onChange: (value: number) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
type?: "number";
|
||||
validate?: (value: string) => boolean;
|
||||
value: number;
|
||||
} & SettingRowBaseProps) {
|
||||
return (
|
||||
<SettingRow title={title} {...props}>
|
||||
<PlainInput
|
||||
required={required}
|
||||
hideLabel
|
||||
size="sm"
|
||||
name={name}
|
||||
label={typeof title === "string" ? title : name}
|
||||
placeholder={placeholder}
|
||||
defaultValue={`${value}`}
|
||||
validate={validate}
|
||||
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
|
||||
type={type}
|
||||
className={inputClassName}
|
||||
containerClassName={inputWidthClassName}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
|
||||
model,
|
||||
modelKey,
|
||||
name = String(modelKey),
|
||||
...props
|
||||
}: {
|
||||
model: M;
|
||||
modelKey: K;
|
||||
name?: string;
|
||||
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
|
||||
return (
|
||||
<SettingRowNumber
|
||||
name={name}
|
||||
value={model[modelKey] as number}
|
||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingRowText({
|
||||
inputClassName,
|
||||
inputWidthClassName = "!w-80",
|
||||
name,
|
||||
onChange,
|
||||
placeholder,
|
||||
required,
|
||||
title,
|
||||
type = "text",
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
inputClassName?: string;
|
||||
inputWidthClassName?: string;
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
type?: "text" | "password";
|
||||
value: string;
|
||||
} & SettingRowBaseProps) {
|
||||
return (
|
||||
<SettingRow title={title} {...props}>
|
||||
<PlainInput
|
||||
required={required}
|
||||
hideLabel
|
||||
size="sm"
|
||||
name={name}
|
||||
label={typeof title === "string" ? title : name}
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
onChange={onChange}
|
||||
type={type}
|
||||
className={inputClassName}
|
||||
containerClassName={inputWidthClassName}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
|
||||
model,
|
||||
modelKey,
|
||||
name = String(modelKey),
|
||||
...props
|
||||
}: {
|
||||
model: M;
|
||||
modelKey: K;
|
||||
name?: string;
|
||||
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
|
||||
return (
|
||||
<SettingRowText
|
||||
name={name}
|
||||
value={model[modelKey] as string}
|
||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingRowFile({
|
||||
buttonClassName,
|
||||
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
|
||||
directory,
|
||||
filePath,
|
||||
nameOverride,
|
||||
noun,
|
||||
onChange,
|
||||
size = "xs",
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
buttonClassName?: string;
|
||||
directory?: boolean;
|
||||
filePath: string | null;
|
||||
nameOverride?: string | null;
|
||||
noun?: string;
|
||||
onChange: (filePath: string | null) => void | Promise<void>;
|
||||
size?: Parameters<typeof SelectFile>[0]["size"];
|
||||
} & SettingRowBaseProps) {
|
||||
return (
|
||||
<SettingRow title={title} controlClassName={controlClassName} {...props}>
|
||||
<SelectFile
|
||||
directory={directory}
|
||||
inline
|
||||
hideLabel
|
||||
label={typeof title === "string" ? title : noun}
|
||||
size={size}
|
||||
noun={noun}
|
||||
nameOverride={nameOverride}
|
||||
filePath={filePath}
|
||||
className={buttonClassName}
|
||||
onChange={({ filePath }) => onChange(filePath)}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingRowDirectory({
|
||||
noun = "Directory",
|
||||
...props
|
||||
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
|
||||
return <SettingRowFile directory noun={noun} {...props} />;
|
||||
}
|
||||
|
||||
export function SettingRowSelect<T extends string>({
|
||||
defaultValue,
|
||||
name,
|
||||
onChange,
|
||||
options,
|
||||
selectClassName = "!w-48",
|
||||
title,
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
defaultValue?: T;
|
||||
name: string;
|
||||
onChange: (value: T) => void;
|
||||
options: RadioDropdownItem<T>[];
|
||||
selectClassName?: string;
|
||||
value: T;
|
||||
} & SettingRowBaseProps) {
|
||||
return (
|
||||
<SettingRow title={title} {...props}>
|
||||
<SettingSelectControl
|
||||
name={name}
|
||||
label={typeof title === "string" ? title : name}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
selectClassName={selectClassName}
|
||||
disabled={props.disabled}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingSelectControl<T extends string>({
|
||||
defaultValue,
|
||||
disabled,
|
||||
label,
|
||||
name,
|
||||
onChange,
|
||||
options,
|
||||
selectClassName = "!w-48",
|
||||
value,
|
||||
}: {
|
||||
defaultValue?: T;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
name: string;
|
||||
onChange: (value: T) => void;
|
||||
options: RadioDropdownItem<T>[];
|
||||
selectClassName?: string;
|
||||
value: T;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
hideLabel
|
||||
name={name}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
label={label}
|
||||
size="sm"
|
||||
className={selectClassName}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSettingSelectControl<
|
||||
M extends AnyModel,
|
||||
K extends ModelKeyOfValue<M, string>,
|
||||
V extends M[K] & string,
|
||||
>({
|
||||
model,
|
||||
modelKey,
|
||||
name = String(modelKey),
|
||||
...props
|
||||
}: {
|
||||
model: M;
|
||||
modelKey: K;
|
||||
name?: string;
|
||||
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
|
||||
return (
|
||||
<SettingSelectControl
|
||||
name={name}
|
||||
value={model[modelKey] as V}
|
||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSettingRowSelect<
|
||||
M extends AnyModel,
|
||||
K extends ModelKeyOfValue<M, string>,
|
||||
V extends M[K] & string,
|
||||
>({
|
||||
model,
|
||||
modelKey,
|
||||
name = String(modelKey),
|
||||
...props
|
||||
}: {
|
||||
model: M;
|
||||
modelKey: K;
|
||||
name?: string;
|
||||
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
|
||||
return (
|
||||
<SettingRowSelect
|
||||
name={name}
|
||||
value={model[modelKey] as V}
|
||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingOverrideRow({
|
||||
children,
|
||||
className,
|
||||
controlClassName,
|
||||
description,
|
||||
disabled,
|
||||
onResetOverride,
|
||||
overridden,
|
||||
resetTitle = "Reset override",
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
controlClassName?: string;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onResetOverride: () => void;
|
||||
overridden: boolean;
|
||||
resetTitle?: string;
|
||||
title: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SettingRow
|
||||
className={className}
|
||||
controlClassName={controlClassName}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{title}
|
||||
{overridden && (
|
||||
<IconButton
|
||||
icon="undo_2"
|
||||
size="2xs"
|
||||
iconSize="sm"
|
||||
title={resetTitle}
|
||||
className="text-text-subtle"
|
||||
onClick={onResetOverride}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
@@ -596,7 +596,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
|
||||
color: "success",
|
||||
label: "Open Workspace Settings",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => openWorkspaceSettings("data"),
|
||||
onSelect: () => openWorkspaceSettings("settings"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
|
||||
{authentication.find((a) => a.name === inheritedAuth.authenticationType)
|
||||
?.shortLabel ?? "UNKNOWN"}
|
||||
<IconTooltip
|
||||
icon="magic_wand"
|
||||
icon="zap_off"
|
||||
iconSize="xs"
|
||||
content="Authentication was inherited from an ancestor"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user