mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-06-30 18:11:39 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a34ba45c2 | |||
| 961ad36c73 | |||
| d1e6f8fb33 | |||
| 930a816f42 | |||
| ec0143aa93 | |||
| 3cc54dea22 | |||
| a8fb144c09 | |||
| 6813fa8bf2 | |||
| cf7de26a2e | |||
| 8676272657 | |||
| c3aecfdc0c | |||
| 09adcda2d9 | |||
| 18b983bfe5 | |||
| 9ffd8d4810 | |||
| 55d0066efd | |||
| 1de0a5942c | |||
| fd0ca6d455 | |||
| 84b89e2708 | |||
| 7db3e9b879 | |||
| 8109a28967 | |||
| 3de9a1edd4 | |||
| 1b28dfd9d1 | |||
| 9f51c61447 | |||
| b17ccbeebe | |||
| 463cc6f5a3 | |||
| 1307ea4e67 | |||
| 710b8e34ac | |||
| f251772a4a | |||
| fa40ceaa31 |
Generated
+11
-9
@@ -215,7 +215,7 @@ dependencies = [
|
|||||||
"objc2-foundation 0.3.1",
|
"objc2-foundation 0.3.1",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
"wl-clipboard-rs",
|
"wl-clipboard-rs",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazy_static 1.5.0",
|
"lazy_static 1.5.0",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
|
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6534,7 +6534,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6547,7 +6547,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.9.4",
|
"linux-raw-sys 0.9.4",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.45"
|
version = "0.4.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -7988,7 +7988,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.0.7",
|
"rustix 1.0.7",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9317,7 +9317,7 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10052,6 +10052,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"yaak-core",
|
||||||
"yaak-crypto",
|
"yaak-crypto",
|
||||||
"yaak-http",
|
"yaak-http",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
@@ -10182,6 +10183,7 @@ dependencies = [
|
|||||||
"webbrowser",
|
"webbrowser",
|
||||||
"yaak",
|
"yaak",
|
||||||
"yaak-api",
|
"yaak-api",
|
||||||
|
"yaak-core",
|
||||||
"yaak-crypto",
|
"yaak-crypto",
|
||||||
"yaak-http",
|
"yaak-http",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
||||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
|
|
||||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
if (workspaceId == null) return;
|
if (workspaceId == null) return;
|
||||||
showDialog({
|
WorkspaceSettingsDialog.show(workspaceId, tab);
|
||||||
id: "workspace-settings",
|
|
||||||
size: "md",
|
|
||||||
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
|
|
||||||
noPadding: true,
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { createFolder } from "../commands/commands";
|
import { createFolder } from "../commands/commands";
|
||||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||||
import { openSettings } from "../commands/openSettings";
|
import { openSettings } from "../commands/openSettings";
|
||||||
|
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
import { switchWorkspace } from "../commands/switchWorkspace";
|
||||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||||
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
|
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||||
@@ -36,7 +37,6 @@ import { appInfo } from "../lib/appInfo";
|
|||||||
import { copyToClipboard } from "../lib/copy";
|
import { copyToClipboard } from "../lib/copy";
|
||||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { editEnvironment } from "../lib/editEnvironment";
|
import { editEnvironment } from "../lib/editEnvironment";
|
||||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||||
import {
|
import {
|
||||||
@@ -99,6 +99,12 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
action: "settings.show",
|
action: "settings.show",
|
||||||
onSelect: () => openSettings.mutate(null),
|
onSelect: () => openSettings.mutate(null),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "workspace_settings.open",
|
||||||
|
label: "Open Workspace Settings",
|
||||||
|
action: "workspace_settings.show",
|
||||||
|
onSelect: () => openWorkspaceSettings(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "app.create",
|
key: "app.create",
|
||||||
label: "Create Workspace",
|
label: "Create Workspace",
|
||||||
@@ -127,13 +133,9 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
{
|
{
|
||||||
key: "cookies.show",
|
key: "cookies.show",
|
||||||
label: "Show Cookies",
|
label: "Show Cookies",
|
||||||
|
action: "cookies_editor.show",
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
showDialog({
|
CookieDialog.show(activeCookieJar?.id ?? null);
|
||||||
id: "cookies",
|
|
||||||
title: "Manage Cookies",
|
|
||||||
size: "full",
|
|
||||||
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,40 @@
|
|||||||
import type { Cookie } from "@yaakapp-internal/models";
|
import type { Cookie } from "@yaakapp-internal/models";
|
||||||
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
|
||||||
|
import { formatDate } from "date-fns/format";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
import {
|
||||||
|
type ComponentProps,
|
||||||
|
type CSSProperties,
|
||||||
|
type FormEvent,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { showDialog } from "../lib/dialog";
|
||||||
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { cookieDomain } from "../lib/model_util";
|
import { 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 { IconButton } from "./core/IconButton";
|
||||||
|
import { Checkbox } from "./core/Checkbox";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { EventDetailHeader } from "./core/EventViewer";
|
||||||
|
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||||
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
import { Select } from "./core/Select";
|
||||||
|
import { showAlert } from "../lib/alert";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cookieJarId: string | null;
|
cookieJarId: string | null;
|
||||||
@@ -12,56 +43,689 @@ interface Props {
|
|||||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
const cookieJars = useAtomValue(cookieJarsAtom);
|
||||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
|
||||||
|
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
|
||||||
|
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
|
||||||
|
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
|
||||||
|
const [draftExpiresInput, setDraftExpiresInput] = useState("");
|
||||||
|
const editorFormRef = useRef<HTMLFormElement>(null);
|
||||||
|
const filteredCookies = useMemo(() => {
|
||||||
|
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
|
||||||
|
}, [cookieJar?.cookies, filter]);
|
||||||
|
const selectedCookie = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedCookieKey == null
|
||||||
|
? null
|
||||||
|
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
|
||||||
|
[filteredCookies, selectedCookieKey],
|
||||||
|
);
|
||||||
|
const detailCookie = draftCookie ?? selectedCookie;
|
||||||
|
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
|
||||||
|
const isEditingCookie = draftCookie != null;
|
||||||
|
|
||||||
|
const handleAddCookie = () => {
|
||||||
|
setSelectedCookieKey(null);
|
||||||
|
setEditingCookieKey(NEW_COOKIE_KEY);
|
||||||
|
setDraftCookie(newCookieDraft());
|
||||||
|
setDraftExpiresInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCookie = () => {
|
||||||
|
if (selectedCookie == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingCookieKey(cookieKey(selectedCookie));
|
||||||
|
setDraftCookie(selectedCookie);
|
||||||
|
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
if (isCreatingCookie) {
|
||||||
|
setSelectedCookieKey(null);
|
||||||
|
}
|
||||||
|
setEditingCookieKey(null);
|
||||||
|
setDraftCookie(null);
|
||||||
|
setDraftExpiresInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDetails = () => {
|
||||||
|
if (isEditingCookie) {
|
||||||
|
handleCancelEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCookieKey(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCookie = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (cookieJar == null || draftCookie == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextCookie = normalizeCookie(draftCookie);
|
||||||
|
if (nextCookie.expires !== "SessionEnd") {
|
||||||
|
const expires = cookieExpiresFromInput(draftExpiresInput);
|
||||||
|
if (expires == null) {
|
||||||
|
showAlert({
|
||||||
|
id: "invalid-cookie-expires",
|
||||||
|
title: "Invalid Cookie",
|
||||||
|
body: "Cookie expiration must be a valid date.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCookie = { ...nextCookie, expires };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCookieKey = cookieKey(nextCookie);
|
||||||
|
const nextCookies = cookieJar.cookies.filter((cookie) => {
|
||||||
|
const key = cookieKey(cookie);
|
||||||
|
if (editingCookieKey != null && key === editingCookieKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return key !== nextCookieKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
||||||
|
setSelectedCookieKey(nextCookieKey);
|
||||||
|
setEditingCookieKey(null);
|
||||||
|
setDraftCookie(null);
|
||||||
|
setDraftExpiresInput("");
|
||||||
|
};
|
||||||
|
|
||||||
if (cookieJar == null) {
|
if (cookieJar == null) {
|
||||||
return <div>No cookie jar selected</div>;
|
return <div>No cookie jar selected</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookieJar.cookies.length === 0) {
|
return (
|
||||||
|
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
|
<PlainInput
|
||||||
|
name="cookie-filter"
|
||||||
|
label="Filter cookies"
|
||||||
|
hideLabel
|
||||||
|
placeholder="Filter cookies"
|
||||||
|
defaultValue={filter}
|
||||||
|
forceUpdateKey={filterUpdateKey}
|
||||||
|
onChange={setFilter}
|
||||||
|
rightSlot={
|
||||||
|
filter.length > 0 && (
|
||||||
|
<IconButton
|
||||||
|
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
|
||||||
|
icon="x"
|
||||||
|
title="Clear filter"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter("");
|
||||||
|
setFilterUpdateKey((key) => key + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
|
||||||
|
</div>
|
||||||
|
{cookieJar.cookies.length === 0 && detailCookie == null ? (
|
||||||
|
<EmptyStateText>
|
||||||
|
Cookies will appear when a response includes a Set-Cookie header.
|
||||||
|
</EmptyStateText>
|
||||||
|
) : filteredCookies.length === 0 && detailCookie == null ? (
|
||||||
|
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
||||||
|
) : (
|
||||||
|
<SplitLayout
|
||||||
|
layout="vertical"
|
||||||
|
storageKey="cookie-dialog-details"
|
||||||
|
defaultRatio={0.5}
|
||||||
|
className="-mx-2"
|
||||||
|
minHeightPx={10}
|
||||||
|
firstSlot={({ style }) =>
|
||||||
|
filteredCookies.length === 0 ? (
|
||||||
|
<div style={style}>
|
||||||
|
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table scrollable style={style} className="pr-0.5">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell>Name</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Value</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Domain</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Path</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Expires</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Size</TableHeaderCell>
|
||||||
|
<TableHeaderCell>HTTP Only</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Secure</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Same Site</TableHeaderCell>
|
||||||
|
<TableHeaderCell>
|
||||||
|
<IconButton
|
||||||
|
icon="list_x"
|
||||||
|
size="sm"
|
||||||
|
className="text-text-subtle"
|
||||||
|
title="Clear all cookies"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCookieKey(null);
|
||||||
|
setEditingCookieKey(null);
|
||||||
|
setDraftCookie(null);
|
||||||
|
setDraftExpiresInput("");
|
||||||
|
void patchModel(cookieJar, { cookies: [] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
|
||||||
|
{filteredCookies.map((c: Cookie) => {
|
||||||
|
const key = cookieKey(c);
|
||||||
|
const isSelected = key === selectedCookieKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={key}
|
||||||
|
className={classNames(
|
||||||
|
"group/tr cursor-default",
|
||||||
|
isSelected && "[&_td]:bg-surface-highlight",
|
||||||
|
!isSelected && "hover:[&_td]:bg-surface-hover",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCookieKey(key);
|
||||||
|
setEditingCookieKey(null);
|
||||||
|
setDraftCookie(null);
|
||||||
|
setDraftExpiresInput("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
|
||||||
|
{c.name}
|
||||||
|
</TableCell>
|
||||||
|
<TruncatedWideTableCell className="min-w-[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 className="rounded-r pr-2">
|
||||||
|
<IconButton
|
||||||
|
icon="trash"
|
||||||
|
size="xs"
|
||||||
|
iconSize="sm"
|
||||||
|
title="Delete"
|
||||||
|
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isSelected) {
|
||||||
|
setSelectedCookieKey(null);
|
||||||
|
}
|
||||||
|
if (editingCookieKey === key) {
|
||||||
|
setEditingCookieKey(null);
|
||||||
|
setDraftCookie(null);
|
||||||
|
setDraftExpiresInput("");
|
||||||
|
}
|
||||||
|
void patchModel(cookieJar, {
|
||||||
|
cookies: cookieJar.cookies.filter(
|
||||||
|
(c2: Cookie) => cookieKey(c2) !== key,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
secondSlot={
|
||||||
|
detailCookie == null
|
||||||
|
? null
|
||||||
|
: ({ style }) => (
|
||||||
|
<CookieDetailsPane
|
||||||
|
formRef={editorFormRef}
|
||||||
|
isEditing={isEditingCookie}
|
||||||
|
onSubmit={handleSaveCookie}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<EventDetailHeader
|
||||||
|
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
|
||||||
|
copyText={isEditingCookie ? undefined : detailCookie.value}
|
||||||
|
actions={
|
||||||
|
isEditingCookie
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "save",
|
||||||
|
label: isCreatingCookie ? "Create" : "Save",
|
||||||
|
onClick: () => editorFormRef.current?.requestSubmit(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cancel",
|
||||||
|
label: "Cancel",
|
||||||
|
onClick: handleCancelEdit,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
onClick: handleEditCookie,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
onClose={handleCloseDetails}
|
||||||
|
/>
|
||||||
|
{isEditingCookie ? (
|
||||||
|
<CookieEditor
|
||||||
|
cookie={detailCookie}
|
||||||
|
expiresInputValue={draftExpiresInput}
|
||||||
|
onChange={setDraftCookie}
|
||||||
|
onExpiresInputChange={setDraftExpiresInput}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CookieDetails cookie={detailCookie} />
|
||||||
|
)}
|
||||||
|
</CookieDetailsPane>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function CookieDetailsPane({
|
||||||
|
children,
|
||||||
|
formRef,
|
||||||
|
isEditing,
|
||||||
|
onSubmit,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
formRef: RefObject<HTMLFormElement | null>;
|
||||||
|
isEditing: boolean;
|
||||||
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
style: CSSProperties;
|
||||||
|
}) {
|
||||||
|
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
<Banner>
|
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
|
||||||
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
|
{children}
|
||||||
</Banner>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-2">
|
<div style={style} className={className}>
|
||||||
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
{children}
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CookieDialog.show = (cookieJarId: string | null) => {
|
||||||
|
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
|
||||||
|
if (cookieJar == null) {
|
||||||
|
showAlert({
|
||||||
|
id: "invalid-jar",
|
||||||
|
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
|
||||||
|
title: "Invalid Cookie Jar",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDialog({
|
||||||
|
id: "cookies",
|
||||||
|
title: `${cookieJar.name} Cookies`,
|
||||||
|
size: "full",
|
||||||
|
render: () => <CookieDialog cookieJarId={cookieJarId} />,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function CookieDetails({ cookie }: { cookie: Cookie }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<KeyValueRows selectable>
|
||||||
|
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
|
||||||
|
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
|
||||||
|
{cookie.sameSite && (
|
||||||
|
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
|
||||||
|
)}
|
||||||
|
</KeyValueRows>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CookieEditor({
|
||||||
|
cookie,
|
||||||
|
expiresInputValue,
|
||||||
|
onChange,
|
||||||
|
onExpiresInputChange,
|
||||||
|
}: {
|
||||||
|
cookie: Cookie;
|
||||||
|
expiresInputValue: string;
|
||||||
|
onChange: (cookie: Cookie) => void;
|
||||||
|
onExpiresInputChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const sessionCookie = cookie.expires === "SessionEnd";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<KeyValueRows>
|
||||||
|
<CookieKeyValueRow align="middle" label="Name">
|
||||||
|
<CookieTextInput
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
pattern={NON_EMPTY_INPUT_PATTERN}
|
||||||
|
value={cookie.name}
|
||||||
|
onChange={(name) => onChange({ ...cookie, name })}
|
||||||
|
/>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Value">
|
||||||
|
<CookieTextarea
|
||||||
|
value={cookie.value}
|
||||||
|
onChange={(value) => onChange({ ...cookie, value })}
|
||||||
|
/>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow align="middle" label="Domain">
|
||||||
|
<CookieTextInput
|
||||||
|
required
|
||||||
|
pattern={NON_EMPTY_INPUT_PATTERN}
|
||||||
|
value={cookieDomainInputValue(cookie)}
|
||||||
|
placeholder="example.com"
|
||||||
|
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
|
||||||
|
/>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow align="middle" label="Path">
|
||||||
|
<CookieTextInput
|
||||||
|
value={cookie.path}
|
||||||
|
placeholder="/"
|
||||||
|
onChange={(path) => onChange({ ...cookie, path })}
|
||||||
|
/>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Expires">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={sessionCookie}
|
||||||
|
title="Session cookie"
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
onChange({ ...cookie, expires: "SessionEnd" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInput =
|
||||||
|
cookieExpiresFromInput(expiresInputValue) == null
|
||||||
|
? defaultCookieExpiresInputValue()
|
||||||
|
: expiresInputValue;
|
||||||
|
|
||||||
|
onExpiresInputChange(expiresInput);
|
||||||
|
onChange({
|
||||||
|
...cookie,
|
||||||
|
expires: cookieExpiresFromInput(expiresInput)!,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CookieTextInput
|
||||||
|
value={sessionCookie ? "" : expiresInputValue}
|
||||||
|
disabled={sessionCookie}
|
||||||
|
onChange={(value) => {
|
||||||
|
onExpiresInputChange(value);
|
||||||
|
|
||||||
|
const expires = cookieExpiresFromInput(value);
|
||||||
|
if (expires != null) {
|
||||||
|
onChange({ ...cookie, expires });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow align="middle" label="HTTP Only">
|
||||||
|
<Checkbox
|
||||||
|
hideLabel
|
||||||
|
title="HTTP Only"
|
||||||
|
checked={cookie.httpOnly}
|
||||||
|
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
|
||||||
|
/>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow align="middle" label="Secure">
|
||||||
|
<Checkbox
|
||||||
|
hideLabel
|
||||||
|
title="Secure"
|
||||||
|
checked={cookie.secure}
|
||||||
|
onChange={(secure) => onChange({ ...cookie, secure })}
|
||||||
|
/>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
<CookieKeyValueRow align="middle" label="Same Site">
|
||||||
|
<Select
|
||||||
|
hideLabel
|
||||||
|
name="cookie-same-site"
|
||||||
|
label="Same Site"
|
||||||
|
value={cookie.sameSite ?? ""}
|
||||||
|
size="xs"
|
||||||
|
className="w-full"
|
||||||
|
options={[
|
||||||
|
{ label: "n/a", value: "" },
|
||||||
|
{ label: "Lax", value: "Lax" },
|
||||||
|
{ label: "Strict", value: "Strict" },
|
||||||
|
{ label: "None", value: "None" },
|
||||||
|
]}
|
||||||
|
onChange={(sameSite) =>
|
||||||
|
onChange({
|
||||||
|
...cookie,
|
||||||
|
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CookieKeyValueRow>
|
||||||
|
</KeyValueRows>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
|
||||||
|
return <KeyValueRow labelClassName={classNames("w-[7rem]", labelClassName)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CookieTextInput({
|
||||||
|
autoFocus,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
pattern,
|
||||||
|
placeholder,
|
||||||
|
required,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
autoFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
pattern?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
className={cookieInputClassName}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
pattern={pattern}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEW_COOKIE_KEY = "__new-cookie__";
|
||||||
|
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
|
||||||
|
const cookieInputClassName = classNames(
|
||||||
|
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
|
||||||
|
"border border-border-subtle outline-none",
|
||||||
|
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
|
||||||
|
"focus:border-border-focus invalid:border-danger",
|
||||||
|
"disabled:opacity-disabled disabled:border-dotted",
|
||||||
|
);
|
||||||
|
|
||||||
|
function cookieSize(cookie: Cookie) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newCookieDraft(): Cookie {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
value: "",
|
||||||
|
domain: "NotPresent",
|
||||||
|
expires: "SessionEnd",
|
||||||
|
path: "/",
|
||||||
|
secure: false,
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCookie(cookie: Cookie): Cookie {
|
||||||
|
return {
|
||||||
|
...cookie,
|
||||||
|
domain: normalizeCookieDomain(cookie.domain),
|
||||||
|
name: cookie.name.trim(),
|
||||||
|
path: cookie.path.trim() || "/",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
|
||||||
|
if (domain === "NotPresent" || domain === "Empty") {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("Suffix" in domain) {
|
||||||
|
return { Suffix: domain.Suffix.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { HostOnly: domain.HostOnly.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieDomainInputValue(cookie: Cookie) {
|
||||||
|
const domain = cookieDomain(cookie);
|
||||||
|
return domain === "n/a" ? "" : domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
|
||||||
|
const trimmedDomain = domain.trim();
|
||||||
|
if (trimmedDomain.length === 0) {
|
||||||
|
return { ...cookie, domain: "NotPresent" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
|
||||||
|
return { ...cookie, domain: { Suffix: trimmedDomain } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...cookie, domain: { HostOnly: trimmedDomain } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieExpires(cookie: Cookie) {
|
||||||
|
if (cookie.expires === "SessionEnd") {
|
||||||
|
return "Session";
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresSeconds = Number(cookie.expires.AtUtc);
|
||||||
|
if (!Number.isFinite(expiresSeconds)) {
|
||||||
|
return cookie.expires.AtUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(expiresSeconds * 1000);
|
||||||
|
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieExpiresInputValue(cookie: Cookie) {
|
||||||
|
if (cookie.expires === "SessionEnd") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresSeconds = Number(cookie.expires.AtUtc);
|
||||||
|
if (!Number.isFinite(expiresSeconds)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(expiresSeconds * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultCookieExpiresInputValue() {
|
||||||
|
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
if (!Number.isFinite(time)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { AtUtc: `${Math.floor(time / 1000)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieMatchesFilter(cookie: Cookie, filter: string) {
|
||||||
|
const query = filter.trim().toLowerCase();
|
||||||
|
if (query.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
|
||||||
|
value.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieKey(cookie: Cookie) {
|
||||||
|
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieDomainKey(domain: Cookie["domain"]) {
|
||||||
|
if (typeof domain !== "string" && "HostOnly" in domain) {
|
||||||
|
return `HostOnly:${domain.HostOnly}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof domain !== "string" && "Suffix" in domain) {
|
||||||
|
return `Suffix:${domain.Suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { memo, useMemo } from "react";
|
|||||||
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||||
import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
|
import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import { showPrompt } from "../lib/prompt";
|
import { showPrompt } from "../lib/prompt";
|
||||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
||||||
import { CookieDialog } from "./CookieDialog";
|
import { CookieDialog } from "./CookieDialog";
|
||||||
@@ -36,12 +35,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
|
|||||||
leftSlot: <Icon icon="cookie" />,
|
leftSlot: <Icon icon="cookie" />,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
if (activeCookieJar == null) return;
|
if (activeCookieJar == null) return;
|
||||||
showDialog({
|
CookieDialog.show(activeCookieJar.id);
|
||||||
id: "cookies",
|
|
||||||
title: "Manage Cookies",
|
|
||||||
size: "full",
|
|
||||||
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import type { ReactNode } from "react";
|
|||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyStateText({ children, className }: Props) {
|
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full pb-2">
|
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { EnvironmentEditor } from "./EnvironmentEditor";
|
|||||||
import { HeadersEditor } from "./HeadersEditor";
|
import { HeadersEditor } from "./HeadersEditor";
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
folderId: string | null;
|
folderId: string | null;
|
||||||
@@ -29,6 +30,7 @@ interface Props {
|
|||||||
|
|
||||||
const TAB_AUTH = "auth";
|
const TAB_AUTH = "auth";
|
||||||
const TAB_HEADERS = "headers";
|
const TAB_HEADERS = "headers";
|
||||||
|
const TAB_SETTINGS = "settings";
|
||||||
const TAB_VARIABLES = "variables";
|
const TAB_VARIABLES = "variables";
|
||||||
const TAB_GENERAL = "general";
|
const TAB_GENERAL = "general";
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ export type FolderSettingsTab =
|
|||||||
| typeof TAB_AUTH
|
| typeof TAB_AUTH
|
||||||
| typeof TAB_HEADERS
|
| typeof TAB_HEADERS
|
||||||
| typeof TAB_GENERAL
|
| typeof TAB_GENERAL
|
||||||
|
| typeof TAB_SETTINGS
|
||||||
| typeof TAB_VARIABLES;
|
| typeof TAB_VARIABLES;
|
||||||
|
|
||||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||||
@@ -51,6 +54,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
(e) => e.parentModel === "folder" && e.parentId === folderId,
|
||||||
);
|
);
|
||||||
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
||||||
|
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
const tabs = useMemo<TabItem[]>(() => {
|
||||||
if (folder == null) return [];
|
if (folder == null) return [];
|
||||||
@@ -60,6 +64,11 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
value: TAB_GENERAL,
|
value: TAB_GENERAL,
|
||||||
label: "General",
|
label: "General",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: TAB_SETTINGS,
|
||||||
|
label: "Settings",
|
||||||
|
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||||
|
},
|
||||||
...headersTab,
|
...headersTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
{
|
{
|
||||||
@@ -68,7 +77,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [authTab, folder, headersTab, numVars]);
|
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
|
||||||
|
|
||||||
if (folder == null) return null;
|
if (folder == null) return null;
|
||||||
|
|
||||||
@@ -159,6 +168,9 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
stateKey={`headers.${folder.id}`}
|
stateKey={`headers.${folder.id}`}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</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">
|
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
||||||
{folderEnvironment == null ? (
|
{folderEnvironment == null ? (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { GrpcEditor } from "./GrpcEditor";
|
|||||||
import { HeadersEditor } from "./HeadersEditor";
|
import { HeadersEditor } from "./HeadersEditor";
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||||
import { UrlBar } from "./UrlBar";
|
import { UrlBar } from "./UrlBar";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -47,6 +48,7 @@ interface Props {
|
|||||||
const TAB_MESSAGE = "message";
|
const TAB_MESSAGE = "message";
|
||||||
const TAB_METADATA = "metadata";
|
const TAB_METADATA = "metadata";
|
||||||
const TAB_AUTH = "auth";
|
const TAB_AUTH = "auth";
|
||||||
|
const TAB_SETTINGS = "settings";
|
||||||
const TAB_DESCRIPTION = "description";
|
const TAB_DESCRIPTION = "description";
|
||||||
|
|
||||||
export function GrpcRequestPane({
|
export function GrpcRequestPane({
|
||||||
@@ -66,6 +68,7 @@ export function GrpcRequestPane({
|
|||||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||||
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
|
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
|
||||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||||
|
const numSettingsOverrides = countOverriddenSettings(activeRequest);
|
||||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||||
|
|
||||||
const urlContainerEl = useRef<HTMLDivElement>(null);
|
const urlContainerEl = useRef<HTMLDivElement>(null);
|
||||||
@@ -128,13 +131,18 @@ export function GrpcRequestPane({
|
|||||||
{ value: TAB_MESSAGE, label: "Message" },
|
{ value: TAB_MESSAGE, label: "Message" },
|
||||||
...metadataTab,
|
...metadataTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
|
{
|
||||||
|
value: TAB_SETTINGS,
|
||||||
|
label: "Settings",
|
||||||
|
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: TAB_DESCRIPTION,
|
value: TAB_DESCRIPTION,
|
||||||
label: "Info",
|
label: "Info",
|
||||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[activeRequest.description, authTab, metadataTab],
|
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMetadataChange = useCallback(
|
const handleMetadataChange = useCallback(
|
||||||
@@ -278,6 +286,9 @@ export function GrpcRequestPane({
|
|||||||
onChange={handleMetadataChange}
|
onChange={handleMetadataChange}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
<TabContent value={TAB_SETTINGS}>
|
||||||
|
<ModelSettingsEditor model={activeRequest} />
|
||||||
|
</TabContent>
|
||||||
<TabContent value={TAB_DESCRIPTION}>
|
<TabContent value={TAB_DESCRIPTION}>
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||||
<PlainInput
|
<PlainInput
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||||
|
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
|
||||||
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
|
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
|
||||||
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
|
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
|
||||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
|
import { Button } from "./core/Button";
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { Input, type InputProps } from "./core/Input";
|
import { Input, type InputProps } from "./core/Input";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
|
import { RadioDropdown } from "./core/RadioDropdown";
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
import { SegmentedControl } from "./core/SegmentedControl";
|
||||||
import { DynamicForm } from "./DynamicForm";
|
import { DynamicForm } from "./DynamicForm";
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
@@ -35,7 +38,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
|
async (authentication: Record<string, unknown>) =>
|
||||||
|
await patchModel(model, { authentication }),
|
||||||
[model],
|
[model],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -47,7 +51,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
return (
|
return (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
<p>
|
<p>
|
||||||
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
|
Auth plugin not found for{" "}
|
||||||
|
<InlineCode>{model.authenticationType}</InlineCode>
|
||||||
</p>
|
</p>
|
||||||
</EmptyStateText>
|
</EmptyStateText>
|
||||||
);
|
);
|
||||||
@@ -56,11 +61,20 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
if (inheritedAuth == null) {
|
if (inheritedAuth == null) {
|
||||||
if (model.model === "workspace" || model.model === "folder") {
|
if (model.model === "workspace" || model.model === "folder") {
|
||||||
return (
|
return (
|
||||||
<EmptyStateText className="flex-col gap-1">
|
<EmptyStateText className="flex-col gap-3">
|
||||||
<p>
|
<div className="not-italic flex flex-col items-center gap-3 text-center">
|
||||||
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
|
<p className="max-w-md text-sm text-text-subtle">
|
||||||
</p>
|
Choose an auth method to apply it to all requests in{" "}
|
||||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
<strong className="font-semibold text-text-subtle">
|
||||||
|
{resolvedModelName(model)}
|
||||||
|
</strong>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<AuthenticationTypeDropdown model={model} />
|
||||||
|
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
|
||||||
|
Documentation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</EmptyStateText>
|
</EmptyStateText>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -83,7 +97,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="underline hover:text-text"
|
className="underline hover:text-text"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
|
if (inheritedAuth.model === "folder")
|
||||||
|
openFolderSettings(inheritedAuth.id, "auth");
|
||||||
else openWorkspaceSettings("auth");
|
else openWorkspaceSettings("auth");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -103,7 +118,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
hideLabel
|
hideLabel
|
||||||
name="enabled"
|
name="enabled"
|
||||||
value={
|
value={
|
||||||
model.authentication.disabled === false || model.authentication.disabled == null
|
model.authentication.disabled === false ||
|
||||||
|
model.authentication.disabled == null
|
||||||
? "__TRUE__"
|
? "__TRUE__"
|
||||||
: model.authentication.disabled === true
|
: model.authentication.disabled === true
|
||||||
? "__FALSE__"
|
? "__FALSE__"
|
||||||
@@ -151,7 +167,9 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
stateKey={`auth.${model.id}.dynamic`}
|
stateKey={`auth.${model.id}.dynamic`}
|
||||||
value={model.authentication.disabled}
|
value={model.authentication.disabled}
|
||||||
onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
|
onChange={(v) =>
|
||||||
|
handleChange({ ...model.authentication, disabled: v })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -169,6 +187,33 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AuthenticationTypeDropdown({ model }: Props) {
|
||||||
|
const options = useAuthDropdownOptions(model);
|
||||||
|
|
||||||
|
if (options == null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioDropdown
|
||||||
|
items={options.items}
|
||||||
|
itemsAfter={options.itemsAfter}
|
||||||
|
itemsBefore={options.itemsBefore}
|
||||||
|
value={options.value}
|
||||||
|
onChange={options.onChange}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
variant="border"
|
||||||
|
size="sm"
|
||||||
|
rightSlot={
|
||||||
|
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Select Auth
|
||||||
|
</Button>
|
||||||
|
</RadioDropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AuthenticationDisabledInput({
|
function AuthenticationDisabledInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -198,7 +243,11 @@ function AuthenticationDisabledInput({
|
|||||||
rightSlot={
|
rightSlot={
|
||||||
<div className="px-1 flex items-center">
|
<div className="px-1 flex items-center">
|
||||||
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
|
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
|
||||||
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
|
{rendered.isPending
|
||||||
|
? "loading"
|
||||||
|
: rendered.data
|
||||||
|
? "enabled"
|
||||||
|
: "disabled"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
|||||||
import { JsonBodyEditor } from "./JsonBodyEditor";
|
import { JsonBodyEditor } from "./JsonBodyEditor";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||||
|
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||||
import { UrlBar } from "./UrlBar";
|
import { UrlBar } from "./UrlBar";
|
||||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ const TAB_BODY = "body";
|
|||||||
const TAB_PARAMS = "params";
|
const TAB_PARAMS = "params";
|
||||||
const TAB_HEADERS = "headers";
|
const TAB_HEADERS = "headers";
|
||||||
const TAB_AUTH = "auth";
|
const TAB_AUTH = "auth";
|
||||||
|
const TAB_SETTINGS = "settings";
|
||||||
const TAB_DESCRIPTION = "description";
|
const TAB_DESCRIPTION = "description";
|
||||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
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 authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
||||||
const inheritedHeaders = useInheritedHeaders(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)
|
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
||||||
useRequestEditorEvent(
|
useRequestEditorEvent(
|
||||||
@@ -234,6 +237,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
},
|
},
|
||||||
...headersTab,
|
...headersTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
|
{
|
||||||
|
value: TAB_SETTINGS,
|
||||||
|
label: "Settings",
|
||||||
|
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: TAB_DESCRIPTION,
|
value: TAB_DESCRIPTION,
|
||||||
label: "Info",
|
label: "Info",
|
||||||
@@ -246,6 +254,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
handleContentTypeChange,
|
handleContentTypeChange,
|
||||||
headersTab,
|
headersTab,
|
||||||
numParams,
|
numParams,
|
||||||
|
numSettingsOverrides,
|
||||||
urlParameterPairs.length,
|
urlParameterPairs.length,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -372,6 +381,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
<TabContent value={TAB_SETTINGS}>
|
||||||
|
<ModelSettingsEditor model={activeRequest} />
|
||||||
|
</TabContent>
|
||||||
<TabContent value={TAB_BODY}>
|
<TabContent value={TAB_BODY}>
|
||||||
<ConfirmLargeRequestBody request={activeRequest}>
|
<ConfirmLargeRequestBody request={activeRequest}>
|
||||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyModel,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
HttpResponseEvent,
|
HttpResponseEvent,
|
||||||
HttpResponseEventData,
|
HttpResponseEventData,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
|
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import { type ReactNode, useMemo, useState } from "react";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||||
|
import { useAllRequests } from "../hooks/useAllRequests";
|
||||||
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||||
import { EventViewerRow } from "./core/EventViewerRow";
|
import { EventViewerRow } from "./core/EventViewerRow";
|
||||||
@@ -95,6 +100,7 @@ function EventDetails({
|
|||||||
}) {
|
}) {
|
||||||
const { label } = getEventDisplay(event.event);
|
const { label } = getEventDisplay(event.event);
|
||||||
const e = event.event;
|
const e = event.event;
|
||||||
|
const settingSourceModels = useSettingSourceModels();
|
||||||
|
|
||||||
const actions: EventDetailAction[] = [
|
const actions: EventDetailAction[] = [
|
||||||
{
|
{
|
||||||
@@ -211,6 +217,9 @@ function EventDetails({
|
|||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||||
|
{e.source_model != null ? (
|
||||||
|
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
|
||||||
|
) : null}
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -315,6 +324,44 @@ function formatEventText(event: HttpResponseEventData, includePrefix: boolean):
|
|||||||
return includePrefix ? `${prefix} ${text}` : text;
|
return includePrefix ? `${prefix} ${text}` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useSettingSourceModels() {
|
||||||
|
const requests = useAllRequests();
|
||||||
|
const folders = useAtomValue(foldersAtom);
|
||||||
|
const workspaces = useAtomValue(workspacesAtom);
|
||||||
|
|
||||||
|
return useMemo<AnyModel[]>(
|
||||||
|
() => [...requests, ...folders, ...workspaces],
|
||||||
|
[requests, folders, workspaces],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSettingSource(
|
||||||
|
event: Extract<HttpResponseEventData, { type: "setting" }>,
|
||||||
|
models: AnyModel[],
|
||||||
|
): string {
|
||||||
|
const sourceModel = event.source_model;
|
||||||
|
if (sourceModel == null || sourceModel === "default") {
|
||||||
|
return "Default";
|
||||||
|
}
|
||||||
|
|
||||||
|
const model =
|
||||||
|
event.source_id == null
|
||||||
|
? null
|
||||||
|
: (models.find((m) => m.model === sourceModel && m.id === event.source_id) ?? null);
|
||||||
|
const name = model == null ? event.source_name : resolvedModelName(model);
|
||||||
|
const label = sourceModel.replaceAll("_", " ");
|
||||||
|
return name == null || name.length === 0 ? label : `${name} (${label})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSettingSourceModel(event: Extract<HttpResponseEventData, { type: "setting" }>) {
|
||||||
|
const sourceModel = event.source_model;
|
||||||
|
if (sourceModel == null || sourceModel === "default" || sourceModel === "workspace") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceModel;
|
||||||
|
}
|
||||||
|
|
||||||
type EventDisplay = {
|
type EventDisplay = {
|
||||||
icon: IconProps["icon"];
|
icon: IconProps["icon"];
|
||||||
color: IconProps["color"];
|
color: IconProps["color"];
|
||||||
@@ -325,11 +372,12 @@ type EventDisplay = {
|
|||||||
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "setting":
|
case "setting":
|
||||||
|
const sourceModel = formatSettingSourceModel(event);
|
||||||
return {
|
return {
|
||||||
icon: "settings",
|
icon: "settings",
|
||||||
color: "secondary",
|
color: "secondary",
|
||||||
label: "Setting",
|
label: "Setting",
|
||||||
summary: `${event.name} = ${event.value}`,
|
summary: `${event.name} = ${event.value}${sourceModel == null ? "" : ` (${sourceModel})`}`,
|
||||||
};
|
};
|
||||||
case "info":
|
case "info":
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,634 @@
|
|||||||
|
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 {
|
||||||
|
modelSupportsSetting,
|
||||||
|
type RequestSettingDefinition,
|
||||||
|
SETTING_FOLLOW_REDIRECTS,
|
||||||
|
SETTING_REQUEST_MESSAGE_SIZE,
|
||||||
|
SETTING_REQUEST_TIMEOUT,
|
||||||
|
SETTING_SEND_COOKIES,
|
||||||
|
SETTING_STORE_COOKIES,
|
||||||
|
SETTING_VALIDATE_CERTIFICATES,
|
||||||
|
} from "../lib/requestSettings";
|
||||||
|
import { Checkbox } from "./core/Checkbox";
|
||||||
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
import {
|
||||||
|
SettingOverrideRow,
|
||||||
|
SettingRow,
|
||||||
|
SettingRowBoolean,
|
||||||
|
SettingsList,
|
||||||
|
SettingsSection,
|
||||||
|
} from "./core/SettingRow";
|
||||||
|
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
|
||||||
|
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showSectionTitles?: boolean;
|
||||||
|
model: ModelWithSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelWithSettings =
|
||||||
|
| Workspace
|
||||||
|
| Folder
|
||||||
|
| HttpRequest
|
||||||
|
| WebsocketRequest
|
||||||
|
| GrpcRequest;
|
||||||
|
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||||
|
type ModelWithTlsSettings =
|
||||||
|
| Workspace
|
||||||
|
| Folder
|
||||||
|
| HttpRequest
|
||||||
|
| WebsocketRequest
|
||||||
|
| GrpcRequest;
|
||||||
|
type ModelWithCookieSettings =
|
||||||
|
| Workspace
|
||||||
|
| Folder
|
||||||
|
| HttpRequest
|
||||||
|
| WebsocketRequest;
|
||||||
|
type ModelWithMessageSizeSettings =
|
||||||
|
| Workspace
|
||||||
|
| Folder
|
||||||
|
| WebsocketRequest
|
||||||
|
| GrpcRequest;
|
||||||
|
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||||
|
type IntegerSetting = number | InheritedIntSetting;
|
||||||
|
type CookieSettingsPatch = {
|
||||||
|
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||||
|
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||||
|
};
|
||||||
|
type HttpSettingsPatch = {
|
||||||
|
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||||
|
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||||
|
};
|
||||||
|
type TlsSettingsPatch = {
|
||||||
|
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
|
||||||
|
};
|
||||||
|
type MessageSizeSettingsPatch = {
|
||||||
|
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ModelSettingsEditor({
|
||||||
|
model,
|
||||||
|
showSectionTitles = false,
|
||||||
|
}: Props) {
|
||||||
|
const ancestors = useModelAncestors(model);
|
||||||
|
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||||
|
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||||
|
const supportsTlsSettings = modelSupportsTlsSettings(model);
|
||||||
|
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsList className="space-y-8">
|
||||||
|
{supportsTlsSettings && (
|
||||||
|
<SettingsSection title={showSectionTitles ? "Requests" : null}>
|
||||||
|
{supportsHttpSettings && (
|
||||||
|
<IntegerSettingRow
|
||||||
|
settingDefinition={SETTING_REQUEST_TIMEOUT}
|
||||||
|
setting={model.settingRequestTimeout}
|
||||||
|
inheritedValue={resolveInheritedValue(
|
||||||
|
ancestors,
|
||||||
|
SETTING_REQUEST_TIMEOUT.modelKey,
|
||||||
|
model.settingRequestTimeout,
|
||||||
|
)}
|
||||||
|
onChange={(settingRequestTimeout) =>
|
||||||
|
patchHttpSettings(model, {
|
||||||
|
settingRequestTimeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{supportsMessageSizeSettings && (
|
||||||
|
<MessageSizeSettingRow
|
||||||
|
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
|
||||||
|
setting={model.settingRequestMessageSize}
|
||||||
|
inheritedValue={resolveInheritedValue(
|
||||||
|
ancestors,
|
||||||
|
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
|
||||||
|
model.settingRequestMessageSize,
|
||||||
|
)}
|
||||||
|
onChange={(settingRequestMessageSize) =>
|
||||||
|
patchMessageSizeSettings(model, {
|
||||||
|
settingRequestMessageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<BooleanSettingRow
|
||||||
|
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
|
||||||
|
setting={model.settingValidateCertificates}
|
||||||
|
inheritedValue={resolveInheritedValue(
|
||||||
|
ancestors,
|
||||||
|
SETTING_VALIDATE_CERTIFICATES.modelKey,
|
||||||
|
model.settingValidateCertificates,
|
||||||
|
)}
|
||||||
|
onChange={(settingValidateCertificates) =>
|
||||||
|
patchTlsSettings(model, {
|
||||||
|
settingValidateCertificates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{supportsHttpSettings && (
|
||||||
|
<BooleanSettingRow
|
||||||
|
settingDefinition={SETTING_FOLLOW_REDIRECTS}
|
||||||
|
setting={model.settingFollowRedirects}
|
||||||
|
inheritedValue={resolveInheritedValue(
|
||||||
|
ancestors,
|
||||||
|
SETTING_FOLLOW_REDIRECTS.modelKey,
|
||||||
|
model.settingFollowRedirects,
|
||||||
|
)}
|
||||||
|
onChange={(settingFollowRedirects) =>
|
||||||
|
patchHttpSettings(model, {
|
||||||
|
settingFollowRedirects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
{supportsCookieSettings && (
|
||||||
|
<SettingsSection
|
||||||
|
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
||||||
|
>
|
||||||
|
<BooleanSettingRow
|
||||||
|
settingDefinition={SETTING_SEND_COOKIES}
|
||||||
|
setting={model.settingSendCookies}
|
||||||
|
inheritedValue={resolveInheritedValue(
|
||||||
|
ancestors,
|
||||||
|
SETTING_SEND_COOKIES.modelKey,
|
||||||
|
model.settingSendCookies,
|
||||||
|
)}
|
||||||
|
onChange={(settingSendCookies) =>
|
||||||
|
patchCookieSettings(model, {
|
||||||
|
settingSendCookies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BooleanSettingRow
|
||||||
|
settingDefinition={SETTING_STORE_COOKIES}
|
||||||
|
setting={model.settingStoreCookies}
|
||||||
|
inheritedValue={resolveInheritedValue(
|
||||||
|
ancestors,
|
||||||
|
SETTING_STORE_COOKIES.modelKey,
|
||||||
|
model.settingStoreCookies,
|
||||||
|
)}
|
||||||
|
onChange={(settingStoreCookies) =>
|
||||||
|
patchCookieSettings(model, {
|
||||||
|
settingStoreCookies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</SettingsList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||||
|
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
||||||
|
|
||||||
|
if (modelSupportsCookieSettings(model)) {
|
||||||
|
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.push(model.settingValidateCertificates);
|
||||||
|
|
||||||
|
if (modelSupportsHttpSettings(model)) {
|
||||||
|
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelSupportsMessageSizeSettings(model)) {
|
||||||
|
settings.push(model.settingRequestMessageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings.filter(
|
||||||
|
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchCookieSettings(
|
||||||
|
model: ModelWithCookieSettings,
|
||||||
|
patch: Partial<CookieSettingsPatch>,
|
||||||
|
) {
|
||||||
|
switch (model.model) {
|
||||||
|
case "workspace":
|
||||||
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
|
case "folder":
|
||||||
|
return patchModel(model, patch as Partial<Folder>);
|
||||||
|
case "http_request":
|
||||||
|
return patchModel(model, patch as Partial<HttpRequest>);
|
||||||
|
case "websocket_request":
|
||||||
|
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchHttpSettings(
|
||||||
|
model: ModelWithHttpSettings,
|
||||||
|
patch: Partial<HttpSettingsPatch>,
|
||||||
|
) {
|
||||||
|
switch (model.model) {
|
||||||
|
case "workspace":
|
||||||
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
|
case "folder":
|
||||||
|
return patchModel(model, patch as Partial<Folder>);
|
||||||
|
case "http_request":
|
||||||
|
return patchModel(model, patch as Partial<HttpRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchTlsSettings(
|
||||||
|
model: ModelWithTlsSettings,
|
||||||
|
patch: Partial<TlsSettingsPatch>,
|
||||||
|
) {
|
||||||
|
switch (model.model) {
|
||||||
|
case "workspace":
|
||||||
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
|
case "folder":
|
||||||
|
return patchModel(model, patch as Partial<Folder>);
|
||||||
|
case "http_request":
|
||||||
|
return patchModel(model, patch as Partial<HttpRequest>);
|
||||||
|
case "websocket_request":
|
||||||
|
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||||
|
case "grpc_request":
|
||||||
|
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchMessageSizeSettings(
|
||||||
|
model: ModelWithMessageSizeSettings,
|
||||||
|
patch: Partial<MessageSizeSettingsPatch>,
|
||||||
|
) {
|
||||||
|
switch (model.model) {
|
||||||
|
case "workspace":
|
||||||
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
|
case "folder":
|
||||||
|
return patchModel(model, patch as Partial<Folder>);
|
||||||
|
case "websocket_request":
|
||||||
|
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||||
|
case "grpc_request":
|
||||||
|
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSupportsHttpSettings(
|
||||||
|
model: ModelWithSettings,
|
||||||
|
): model is ModelWithHttpSettings {
|
||||||
|
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSupportsCookieSettings(
|
||||||
|
model: ModelWithSettings,
|
||||||
|
): model is ModelWithCookieSettings {
|
||||||
|
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSupportsTlsSettings(
|
||||||
|
model: ModelWithSettings,
|
||||||
|
): model is ModelWithTlsSettings {
|
||||||
|
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelSupportsMessageSizeSettings(
|
||||||
|
model: ModelWithSettings,
|
||||||
|
): model is ModelWithMessageSizeSettings {
|
||||||
|
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BooleanSettingRow({
|
||||||
|
inheritedValue,
|
||||||
|
setting,
|
||||||
|
settingDefinition,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
inheritedValue: boolean;
|
||||||
|
setting: BooleanSetting;
|
||||||
|
settingDefinition: RequestSettingDefinition;
|
||||||
|
onChange: (setting: BooleanSetting) => void;
|
||||||
|
}) {
|
||||||
|
const inherited = isInheritedSetting(setting);
|
||||||
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
|
const value = inherited
|
||||||
|
? overridden
|
||||||
|
? setting.value
|
||||||
|
: inheritedValue
|
||||||
|
: setting;
|
||||||
|
|
||||||
|
if (!inherited) {
|
||||||
|
return (
|
||||||
|
<SettingRowBoolean
|
||||||
|
checked={value}
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
onChange={(value) => onChange(value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingOverrideRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
overridden={overridden}
|
||||||
|
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
hideLabel
|
||||||
|
size="md"
|
||||||
|
title={settingDefinition.title}
|
||||||
|
checked={value}
|
||||||
|
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||||
|
/>
|
||||||
|
</SettingOverrideRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegerSettingRow({
|
||||||
|
inheritedValue,
|
||||||
|
setting,
|
||||||
|
settingDefinition,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
inheritedValue: number;
|
||||||
|
setting: IntegerSetting;
|
||||||
|
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
|
||||||
|
onChange: (setting: IntegerSetting) => void;
|
||||||
|
}) {
|
||||||
|
const inherited = isInheritedSetting(setting);
|
||||||
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
|
const value = inherited
|
||||||
|
? overridden
|
||||||
|
? setting.value
|
||||||
|
: inheritedValue
|
||||||
|
: setting;
|
||||||
|
|
||||||
|
if (!inherited) {
|
||||||
|
return (
|
||||||
|
<SettingRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
>
|
||||||
|
<NumberUnitInput
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
|
label={settingDefinition.title}
|
||||||
|
unit="ms"
|
||||||
|
value={`${value}`}
|
||||||
|
placeholder={`${settingDefinition.defaultValue}`}
|
||||||
|
validate={isValidInteger}
|
||||||
|
onChange={(value) => onChange(parseInteger(value))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingOverrideRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
overridden={overridden}
|
||||||
|
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||||
|
>
|
||||||
|
<NumberUnitInput
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
|
label={settingDefinition.title}
|
||||||
|
unit="ms"
|
||||||
|
value={`${value}`}
|
||||||
|
placeholder={`${settingDefinition.defaultValue}`}
|
||||||
|
validate={isValidInteger}
|
||||||
|
onChange={(value) =>
|
||||||
|
onChange({
|
||||||
|
...setting,
|
||||||
|
enabled: true,
|
||||||
|
value: parseInteger(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingOverrideRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageSizeSettingRow({
|
||||||
|
inheritedValue,
|
||||||
|
setting,
|
||||||
|
settingDefinition,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
inheritedValue: number;
|
||||||
|
setting: IntegerSetting;
|
||||||
|
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
|
||||||
|
onChange: (setting: IntegerSetting) => void;
|
||||||
|
}) {
|
||||||
|
const inherited = isInheritedSetting(setting);
|
||||||
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
|
const value = inherited
|
||||||
|
? overridden
|
||||||
|
? setting.value
|
||||||
|
: inheritedValue
|
||||||
|
: setting;
|
||||||
|
const displayValue = formatMegabytes(value);
|
||||||
|
const placeholder = formatMegabytes(settingDefinition.defaultValue);
|
||||||
|
|
||||||
|
if (!inherited) {
|
||||||
|
return (
|
||||||
|
<SettingRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
>
|
||||||
|
<MessageSizeInput
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
|
label={settingDefinition.title}
|
||||||
|
value={displayValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(value) => onChange(parseMegabytes(value))}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingOverrideRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
overridden={overridden}
|
||||||
|
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||||
|
>
|
||||||
|
<MessageSizeInput
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
|
label={settingDefinition.title}
|
||||||
|
value={displayValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(value) =>
|
||||||
|
onChange({
|
||||||
|
...setting,
|
||||||
|
enabled: true,
|
||||||
|
value: parseMegabytes(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingOverrideRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageSizeInput({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberUnitInput
|
||||||
|
name={name}
|
||||||
|
label={label}
|
||||||
|
unit="MB"
|
||||||
|
value={value}
|
||||||
|
inputMode="decimal"
|
||||||
|
step="any"
|
||||||
|
placeholder={placeholder}
|
||||||
|
validate={isValidMegabytes}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberUnitInput({
|
||||||
|
inputMode,
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
step,
|
||||||
|
unit,
|
||||||
|
validate,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
inputMode?: "decimal" | "numeric";
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
step?: number | "any";
|
||||||
|
unit: string;
|
||||||
|
validate: (value: string) => boolean;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PlainInput
|
||||||
|
hideLabel
|
||||||
|
name={name}
|
||||||
|
label={label}
|
||||||
|
size="sm"
|
||||||
|
type="number"
|
||||||
|
inputMode={inputMode}
|
||||||
|
step={step}
|
||||||
|
placeholder={placeholder}
|
||||||
|
defaultValue={value}
|
||||||
|
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
containerClassName="!w-48"
|
||||||
|
validate={validate}
|
||||||
|
rightSlot={
|
||||||
|
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInheritedSetting<T>(
|
||||||
|
setting: T | { enabled?: boolean; value: T },
|
||||||
|
): setting is { enabled?: boolean; value: T } {
|
||||||
|
return typeof setting === "object" && setting != null && "value" in setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInheritedValue(
|
||||||
|
ancestors: (Folder | Workspace)[],
|
||||||
|
key: "settingRequestTimeout" | "settingRequestMessageSize",
|
||||||
|
fallback: IntegerSetting,
|
||||||
|
): number;
|
||||||
|
function resolveInheritedValue(
|
||||||
|
ancestors: (Folder | Workspace)[],
|
||||||
|
key: BooleanWorkspaceSettingKey,
|
||||||
|
fallback: BooleanSetting,
|
||||||
|
): boolean;
|
||||||
|
function resolveInheritedValue(
|
||||||
|
ancestors: (Folder | Workspace)[],
|
||||||
|
key: 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"
|
||||||
|
| "settingRequestMessageSize"
|
||||||
|
| "settingRequestTimeout"
|
||||||
|
| "settingSendCookies"
|
||||||
|
| "settingStoreCookies"
|
||||||
|
| "settingValidateCertificates"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type BooleanWorkspaceSettingKey = Exclude<
|
||||||
|
keyof WorkspaceSettings,
|
||||||
|
"settingRequestTimeout" | "settingRequestMessageSize"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function formatMegabytes(bytes: number) {
|
||||||
|
const megabytes = bytes / BYTES_PER_MB;
|
||||||
|
return Number.isInteger(megabytes)
|
||||||
|
? `${megabytes}`
|
||||||
|
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMegabytes(value: string) {
|
||||||
|
const megabytes = Number(value);
|
||||||
|
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInteger(value: string) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidInteger(value: string) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidMegabytes(value: string) {
|
||||||
|
if (value === "") return true;
|
||||||
|
const megabytes = Number(value);
|
||||||
|
return (
|
||||||
|
Number.isFinite(megabytes) &&
|
||||||
|
megabytes >= 0 &&
|
||||||
|
megabytes <= MAX_MESSAGE_SIZE_MB
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type Props = Omit<ButtonProps, "type"> & {
|
|||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
noun?: string;
|
noun?: string;
|
||||||
help?: ReactNode;
|
help?: ReactNode;
|
||||||
|
hideLabel?: boolean;
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function SelectFile({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
label,
|
label,
|
||||||
help,
|
help,
|
||||||
|
hideLabel,
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
@@ -95,7 +97,7 @@ export function SelectFile({
|
|||||||
return (
|
return (
|
||||||
<div ref={ref} className="w-full">
|
<div ref={ref} className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<Label htmlFor={null} help={help}>
|
<Label htmlFor={null} help={help} visuallyHidden={hideLabel}>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,174 +2,164 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
|
||||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
||||||
import { appInfo } from "../../lib/appInfo";
|
import { appInfo } from "../../lib/appInfo";
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
import { revealInFinderText } from "../../lib/reveal";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow";
|
import {
|
||||||
import { PlainInput } from "../core/PlainInput";
|
ModelSettingRowBoolean,
|
||||||
import { Select } from "../core/Select";
|
ModelSettingSelectControl,
|
||||||
import { Separator } from "../core/Separator";
|
SettingValue,
|
||||||
|
SettingRow,
|
||||||
|
SettingRowBoolean,
|
||||||
|
SettingRowSelect,
|
||||||
|
SettingsList,
|
||||||
|
SettingsSection,
|
||||||
|
} from "../core/SettingRow";
|
||||||
|
|
||||||
|
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
export function SettingsGeneral() {
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const checkForUpdates = useCheckForUpdates();
|
const checkForUpdates = useCheckForUpdates();
|
||||||
|
|
||||||
if (settings == null || workspace == null) {
|
if (settings == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showWorkspaceSettingsMovedBanner =
|
||||||
|
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<VStack space={1.5} className="mb-4">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Heading>General</Heading>
|
<Heading>General</Heading>
|
||||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
<p className="text-text-subtle">
|
||||||
|
Configure general settings for update behavior and more.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CargoFeature feature="updater">
|
<SettingsList className="space-y-8">
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
<CargoFeature feature="updater">
|
||||||
<Select
|
<SettingsSection title="Updates">
|
||||||
name="updateChannel"
|
<SettingRow
|
||||||
label="Update Channel"
|
title="Update Channel"
|
||||||
labelPosition="left"
|
description="Choose whether Yaak should use stable releases or beta releases."
|
||||||
labelClassName="w-[14rem]"
|
>
|
||||||
size="sm"
|
<div className="grid grid-cols-[12rem_auto] gap-1">
|
||||||
value={settings.updateChannel}
|
<ModelSettingSelectControl
|
||||||
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
|
model={settings}
|
||||||
options={[
|
modelKey="updateChannel"
|
||||||
{ label: "Stable", value: "stable" },
|
label="Update Channel"
|
||||||
{ label: "Beta (more frequent)", value: "beta" },
|
selectClassName="!w-full"
|
||||||
]}
|
options={[
|
||||||
/>
|
{ label: "Stable", value: "stable" },
|
||||||
<IconButton
|
{ label: "Beta", value: "beta" },
|
||||||
variant="border"
|
]}
|
||||||
size="sm"
|
/>
|
||||||
title="Check for updates"
|
<IconButton
|
||||||
icon="refresh"
|
variant="border"
|
||||||
spin={checkForUpdates.isPending}
|
size="sm"
|
||||||
onClick={() => checkForUpdates.mutateAsync()}
|
title="Check for updates"
|
||||||
/>
|
icon="refresh"
|
||||||
</div>
|
spin={checkForUpdates.isPending}
|
||||||
|
onClick={() => checkForUpdates.mutateAsync()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
<Select
|
<SettingRowSelect
|
||||||
name="autoupdate"
|
title="Update Behavior"
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
description="Choose whether updates are installed automatically or manually."
|
||||||
label="Update Behavior"
|
name="autoupdate"
|
||||||
labelPosition="left"
|
value={settings.autoupdate ? "auto" : "manual"}
|
||||||
size="sm"
|
onChange={(v) =>
|
||||||
labelClassName="w-[14rem]"
|
patchModel(settings, { autoupdate: v === "auto" })
|
||||||
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
}
|
||||||
options={[
|
options={[
|
||||||
{ label: "Automatic", value: "auto" },
|
{ label: "Automatic", value: "auto" },
|
||||||
{ label: "Manual", value: "manual" },
|
{ 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)}
|
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
<ModelSettingRowBoolean
|
||||||
{appInfo.appDataDir}
|
model={settings}
|
||||||
</KeyValueRow>
|
modelKey="autoDownloadUpdates"
|
||||||
<KeyValueRow
|
title="Automatically download updates"
|
||||||
label="Logs Directory"
|
description="Download Yaak updates in the background so they are ready to install."
|
||||||
rightSlot={
|
disabled={!settings.autoupdate}
|
||||||
<IconButton
|
|
||||||
title={revealInFinderText}
|
|
||||||
icon="folder_open"
|
|
||||||
size="2xs"
|
|
||||||
onClick={() => revealItemInDir(appInfo.appLogDir)}
|
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
<ModelSettingRowBoolean
|
||||||
{appInfo.appLogDir}
|
model={settings}
|
||||||
</KeyValueRow>
|
modelKey="checkNotifications"
|
||||||
</KeyValueRows>
|
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>
|
||||||
|
|
||||||
|
{showWorkspaceSettingsMovedBanner && (
|
||||||
|
<DismissibleBanner
|
||||||
|
id="workspace-settings-moved-2026-06-30"
|
||||||
|
color="info"
|
||||||
|
className="p-4 max-w-xl mx-auto"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Workspace specific settings have moved to{" "}
|
||||||
|
<b>Workspace Settings</b>, accessible from the workspace switcher
|
||||||
|
menu.
|
||||||
|
</p>
|
||||||
|
</DismissibleBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsSection title="App Info">
|
||||||
|
<SettingRow title="Version" description="Current Yaak version.">
|
||||||
|
<SettingValue value={appInfo.version} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Data Directory"
|
||||||
|
description="Where Yaak stores application data."
|
||||||
|
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
||||||
|
>
|
||||||
|
<SettingValue
|
||||||
|
value={appInfo.appDataDir}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
title: revealInFinderText,
|
||||||
|
icon: "folder_open",
|
||||||
|
onClick: () => revealItemInDir(appInfo.appDataDir),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Logs Directory"
|
||||||
|
description="Where Yaak writes application logs."
|
||||||
|
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
||||||
|
>
|
||||||
|
<SettingValue
|
||||||
|
value={appInfo.appLogDir}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
title: revealInFinderText,
|
||||||
|
icon: "folder_open",
|
||||||
|
onClick: () => revealItemInDir(appInfo.appLogDir),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsList>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useFonts } from "@yaakapp-internal/fonts";
|
|||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
||||||
import { patchModel, settingsAtom } 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 { useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
@@ -13,7 +13,16 @@ import { CargoFeature } from "../CargoFeature";
|
|||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
import { Link } from "../core/Link";
|
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__";
|
const NULL_FONT_VALUE = "__NULL_FONT__";
|
||||||
|
|
||||||
@@ -38,154 +47,172 @@ export function SettingsInterface() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={3} className="mb-4">
|
<VStack space={1.5} className="mb-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Heading>Interface</Heading>
|
<Heading>Interface</Heading>
|
||||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<SettingsList className="space-y-8">
|
||||||
name="switchWorkspaceBehavior"
|
<SettingsSection title="Workspaces">
|
||||||
label="Open workspace behavior"
|
<SettingRowSelect
|
||||||
size="sm"
|
title="Open workspace behavior"
|
||||||
help="When opening a workspace, should it open in the current window or a new window?"
|
description="Choose what happens when opening another workspace."
|
||||||
value={
|
name="switchWorkspaceBehavior"
|
||||||
settings.openWorkspaceNewWindow === true
|
value={
|
||||||
? "new"
|
settings.openWorkspaceNewWindow === true
|
||||||
: settings.openWorkspaceNewWindow === false
|
? "new"
|
||||||
? "current"
|
: settings.openWorkspaceNewWindow === false
|
||||||
: "ask"
|
? "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,
|
|
||||||
})) ?? []),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
onChange={async (v) => {
|
||||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||||
await patchModel(settings, { interfaceFont });
|
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={[
|
options={[
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
{ label: "Always ask", value: "ask" },
|
||||||
...(fonts.data.editorFonts.map((f) => ({
|
{ label: "Open in current window", value: "current" },
|
||||||
label: f,
|
{ label: "Open in new window", value: "new" },
|
||||||
value: f,
|
|
||||||
})) ?? []),
|
|
||||||
]}
|
]}
|
||||||
onChange={async (v) => {
|
|
||||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { editorFont });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</SettingsSection>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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" && (
|
<SettingRow
|
||||||
<Checkbox
|
title="Editor font"
|
||||||
checked={settings.hideWindowControls}
|
description="Font used in request and response editors."
|
||||||
title="Hide window controls"
|
controlClassName="gap-1"
|
||||||
help="Hide the close/maximize/minimize controls on Windows or Linux"
|
>
|
||||||
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
|
{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>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
||||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
||||||
|
|
||||||
return (
|
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
|
<Checkbox
|
||||||
|
hideLabel
|
||||||
|
size="md"
|
||||||
checked={nativeTitlebar}
|
checked={nativeTitlebar}
|
||||||
title="Native title bar"
|
title="Native title bar"
|
||||||
help="Use the operating system's standard title bar and window controls"
|
|
||||||
onChange={setNativeTitlebar}
|
onChange={setNativeTitlebar}
|
||||||
/>
|
/>
|
||||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
{settings.useNativeTitlebar !== nativeTitlebar && (
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
size="2xs"
|
size="xs"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||||
await invokeCmd("cmd_restart");
|
await invokeCmd("cmd_restart");
|
||||||
@@ -194,7 +221,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|||||||
Apply and Restart
|
Apply and Restart
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SettingRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,37 +232,40 @@ function LicenseSettings({ settings }: { settings: Settings }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Checkbox
|
<SettingsSection title="License">
|
||||||
checked={settings.hideLicenseBadge}
|
<SettingRowBoolean
|
||||||
title="Hide personal use badge"
|
checked={settings.hideLicenseBadge}
|
||||||
onChange={async (hideLicenseBadge) => {
|
title="Hide personal use badge"
|
||||||
if (hideLicenseBadge) {
|
description="Hide the personal-use badge from the interface."
|
||||||
const confirmed = await showConfirm({
|
onChange={async (hideLicenseBadge) => {
|
||||||
id: "hide-license-badge",
|
if (hideLicenseBadge) {
|
||||||
title: "Confirm Personal Use",
|
const confirmed = await showConfirm({
|
||||||
confirmText: "Confirm",
|
id: "hide-license-badge",
|
||||||
description: (
|
title: "Confirm Personal Use",
|
||||||
<VStack space={3}>
|
confirmText: "Confirm",
|
||||||
<p>Hey there 👋🏼</p>
|
description: (
|
||||||
<p>
|
<VStack space={3}>
|
||||||
Yaak is free for personal projects and learning.{" "}
|
<p>Hey there 👋🏼</p>
|
||||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
<p>
|
||||||
</p>
|
Yaak is free for personal projects and learning.{" "}
|
||||||
<p>
|
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
</p>
|
||||||
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
<p>
|
||||||
</p>
|
Licenses help keep Yaak independent and sustainable.{" "}
|
||||||
</VStack>
|
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||||
),
|
</p>
|
||||||
requireTyping: "Personal Use",
|
</VStack>
|
||||||
color: "info",
|
),
|
||||||
});
|
requireTyping: "Personal Use",
|
||||||
if (!confirmed) {
|
color: "info",
|
||||||
return; // Cancel
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
await patchModel(settings, { hideLicenseBadge });
|
||||||
await patchModel(settings, { hideLicenseBadge });
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
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 { useAtomValue } from "jotai";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import {
|
||||||
import { PlainInput } from "../core/PlainInput";
|
SettingRowBoolean,
|
||||||
import { Select } from "../core/Select";
|
SettingRowSelect,
|
||||||
import { Separator } from "../core/Separator";
|
SettingRowText,
|
||||||
|
SettingsList,
|
||||||
|
SettingsSection,
|
||||||
|
} from "../core/SettingRow";
|
||||||
|
|
||||||
export function SettingsProxy() {
|
export function SettingsProxy() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
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 (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<VStack space={1.5} className="mb-4">
|
||||||
@@ -18,188 +33,146 @@ export function SettingsProxy() {
|
|||||||
traffic, or routing through specific infrastructure.
|
traffic, or routing through specific infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<SettingsList className="space-y-8">
|
||||||
name="proxy"
|
<SettingsSection title="Proxy">
|
||||||
label="Proxy"
|
<SettingRowSelect
|
||||||
hideLabel
|
title="Proxy"
|
||||||
size="sm"
|
description="Choose how Yaak should discover or use proxy settings."
|
||||||
value={settings.proxy?.type ?? "automatic"}
|
name="proxy"
|
||||||
onChange={async (v) => {
|
value={settings.proxy?.type ?? "automatic"}
|
||||||
if (v === "automatic") {
|
onChange={async (v) => {
|
||||||
await patchModel(settings, { proxy: undefined });
|
if (v === "automatic") {
|
||||||
} else if (v === "enabled") {
|
await patchModel(settings, { proxy: undefined });
|
||||||
await patchModel(settings, {
|
} else if (v === "enabled") {
|
||||||
proxy: {
|
await patchModel(settings, { proxy });
|
||||||
disabled: false,
|
} else {
|
||||||
type: "enabled",
|
await patchModel(settings, { proxy: { type: "disabled" } });
|
||||||
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
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
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 && (
|
{settings.proxy?.type === "enabled" && (
|
||||||
<HStack space={1.5}>
|
<>
|
||||||
<PlainInput
|
<SettingsSection title="Custom Proxy">
|
||||||
required
|
<SettingRowBoolean
|
||||||
size="sm"
|
checked={!settings.proxy.disabled}
|
||||||
label="User"
|
title="Enable proxy"
|
||||||
placeholder="myUser"
|
description="Temporarily disable the proxy without losing the configuration."
|
||||||
defaultValue={settings.proxy.auth.user}
|
onChange={(enabled) => patchProxy({ disabled: !enabled })}
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<PlainInput
|
<SettingRowText
|
||||||
size="sm"
|
name="proxyHttp"
|
||||||
label="Password"
|
title={
|
||||||
type="password"
|
<>
|
||||||
placeholder="s3cretPassw0rd"
|
Proxy for <InlineCode>http://</InlineCode> traffic
|
||||||
defaultValue={settings.proxy.auth.password}
|
</>
|
||||||
onChange={async (password) => {
|
}
|
||||||
const { proxy } = settings;
|
description="Proxy host used for unencrypted HTTP traffic."
|
||||||
const http = proxy?.type === "enabled" ? proxy.http : "";
|
value={settings.proxy.http}
|
||||||
const https = proxy?.type === "enabled" ? proxy.https : "";
|
placeholder="localhost:9090"
|
||||||
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
onChange={(http) => patchProxy({ http })}
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</HStack>
|
<SettingRowText
|
||||||
)}
|
name="proxyHttps"
|
||||||
{settings.proxy.type === "enabled" && (
|
title={
|
||||||
<>
|
<>
|
||||||
<Separator className="my-6" />
|
Proxy for <InlineCode>https://</InlineCode> traffic
|
||||||
<PlainInput
|
</>
|
||||||
label="Proxy Bypass"
|
}
|
||||||
help="Comma-separated list to bypass the proxy."
|
description="Proxy host used for HTTPS traffic."
|
||||||
defaultValue={settings.proxy.bypass}
|
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"
|
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
||||||
onChange={async (bypass) => {
|
inputWidthClassName="!w-96"
|
||||||
const { proxy } = settings;
|
onChange={(bypass) => patchProxy({ bypass })}
|
||||||
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 },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</SettingsSection>
|
||||||
)}
|
|
||||||
</VStack>
|
<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>
|
</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 { IconButton } from "../core/IconButton";
|
||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
import type { SelectProps } from "../core/Select";
|
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 })));
|
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
||||||
|
|
||||||
@@ -67,7 +72,7 @@ export function SettingsTheme() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={3} className="mb-4">
|
<VStack space={1.5} className="mb-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Heading>Theme</Heading>
|
<Heading>Theme</Heading>
|
||||||
<p className="text-text-subtle">
|
<p className="text-text-subtle">
|
||||||
@@ -77,96 +82,92 @@ export function SettingsTheme() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<SettingsList className="space-y-8">
|
||||||
name="appearance"
|
<SettingsSection title="Theme">
|
||||||
label="Appearance"
|
<ModelSettingRowSelect
|
||||||
labelPosition="top"
|
model={settings}
|
||||||
size="sm"
|
modelKey="appearance"
|
||||||
value={settings.appearance}
|
title="Appearance"
|
||||||
onChange={(appearance) => patchModel(settings, { appearance })}
|
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
||||||
options={[
|
options={[
|
||||||
{ label: "Automatic", value: "system" },
|
{ label: "Automatic", value: "system" },
|
||||||
{ label: "Light", value: "light" },
|
{ label: "Light", value: "light" },
|
||||||
{ label: "Dark", value: "dark" },
|
{ 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 })}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{(settings.appearance === "system" || settings.appearance === "light") && (
|
||||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
<SettingRowSelect
|
||||||
<Select
|
name="lightTheme"
|
||||||
hideLabel
|
title="Light theme"
|
||||||
name="darkTheme"
|
description="Theme used when Yaak is in light mode."
|
||||||
className="flex-1"
|
value={activeTheme.data.light.id}
|
||||||
label="Dark Theme"
|
options={lightThemes}
|
||||||
leftSlot={<Icon icon="moon" color="secondary" />}
|
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
||||||
size="sm"
|
/>
|
||||||
value={activeTheme.data.dark.id}
|
)}
|
||||||
options={darkThemes}
|
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
||||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
<SettingRowSelect
|
||||||
/>
|
name="darkTheme"
|
||||||
)}
|
title="Dark theme"
|
||||||
</HStack>
|
description="Theme used when Yaak is in dark mode."
|
||||||
|
value={activeTheme.data.dark.id}
|
||||||
|
options={darkThemes}
|
||||||
|
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<VStack
|
<SettingsSection title="Preview">
|
||||||
space={3}
|
<VStack
|
||||||
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
|
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"} />
|
<HStack className="text" space={1.5}>
|
||||||
<strong>{activeTheme.data.active.label}</strong>
|
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
||||||
<em>(preview)</em>
|
<strong>{activeTheme.data.active.label}</strong>
|
||||||
</HStack>
|
<em>(preview)</em>
|
||||||
<HStack space={1.5} className="w-full">
|
</HStack>
|
||||||
{buttonColors.map((c, i) => (
|
<HStack space={1.5} className="w-full">
|
||||||
<IconButton
|
{buttonColors.map((c, i) => (
|
||||||
key={c}
|
<IconButton
|
||||||
color={c}
|
key={c}
|
||||||
size="2xs"
|
color={c}
|
||||||
iconSize="xs"
|
size="2xs"
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
iconSize="xs"
|
||||||
iconClassName="text"
|
icon={icons[i % icons.length] ?? "info"}
|
||||||
title={`${c}`}
|
iconClassName="text"
|
||||||
/>
|
title={`${c}`}
|
||||||
))}
|
/>
|
||||||
{buttonColors.map((c, i) => (
|
))}
|
||||||
<IconButton
|
{buttonColors.map((c, i) => (
|
||||||
key={c}
|
<IconButton
|
||||||
color={c}
|
key={c}
|
||||||
variant="border"
|
color={c}
|
||||||
size="2xs"
|
variant="border"
|
||||||
iconSize="xs"
|
size="2xs"
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
iconSize="xs"
|
||||||
iconClassName="text"
|
icon={icons[i % icons.length] ?? "info"}
|
||||||
title={`${c}`}
|
iconClassName="text"
|
||||||
/>
|
title={`${c}`}
|
||||||
))}
|
/>
|
||||||
</HStack>
|
))}
|
||||||
<Suspense>
|
</HStack>
|
||||||
<Editor
|
<Suspense>
|
||||||
defaultValue={[
|
<Editor
|
||||||
"let foo = { // Demo code editor",
|
defaultValue={[
|
||||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
"let foo = { // Demo code editor",
|
||||||
" baz: [1, 10.2, null, false, true],",
|
' foo: ("bar" || "baz" ?? \'qux\'),',
|
||||||
"};",
|
" baz: [1, 10.2, null, false, true],",
|
||||||
].join("\n")}
|
"};",
|
||||||
heightMode="auto"
|
].join("\n")}
|
||||||
language="javascript"
|
heightMode="auto"
|
||||||
stateKey={null}
|
language="javascript"
|
||||||
/>
|
stateKey={null}
|
||||||
</Suspense>
|
/>
|
||||||
</VStack>
|
</Suspense>
|
||||||
|
</VStack>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsList>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
|||||||
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
||||||
import type { FieldDef } from "./core/Editor/filter/extension";
|
import type { FieldDef } from "./core/Editor/filter/extension";
|
||||||
import { filter } from "./core/Editor/filter/extension";
|
import { filter } from "./core/Editor/filter/extension";
|
||||||
|
import type { Ast } from "./core/Editor/filter/query";
|
||||||
import { evaluate, parseQuery } from "./core/Editor/filter/query";
|
import { evaluate, parseQuery } from "./core/Editor/filter/query";
|
||||||
|
import { formatFieldFilter } from "./core/Editor/filter/format";
|
||||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +81,7 @@ import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-in
|
|||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import type { InputHandle } from "./core/Input";
|
import type { InputHandle } from "./core/Input";
|
||||||
import { Input } from "./core/Input";
|
import { Input } from "./core/Input";
|
||||||
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
||||||
import { GitDropdown } from "./git/GitDropdown";
|
import { GitDropdown } from "./git/GitDropdown";
|
||||||
import { gitCallbacks } from "./git/callbacks";
|
import { gitCallbacks } from "./git/callbacks";
|
||||||
@@ -108,7 +111,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||||
const filterText = useAtomValue(sidebarFilterAtom);
|
const filterText = useAtomValue(sidebarFilterAtom);
|
||||||
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
|
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||||
const wrapperRef = useRef<HTMLElement>(null);
|
const wrapperRef = useRef<HTMLElement>(null);
|
||||||
const treeRef = useRef<TreeHandle>(null);
|
const treeRef = useRef<TreeHandle>(null);
|
||||||
const filterRef = useRef<InputHandle>(null);
|
const filterRef = useRef<InputHandle>(null);
|
||||||
@@ -227,7 +230,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clearFilterText = useCallback(() => {
|
const clearFilterText = useCallback(() => {
|
||||||
jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` });
|
setSidebarFilterText("");
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
filterRef.current?.focus();
|
filterRef.current?.focus();
|
||||||
});
|
});
|
||||||
@@ -252,6 +255,13 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const applyFilterExample = useCallback((text: string) => {
|
||||||
|
setSidebarFilterText(text);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
filterRef.current?.focus();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
||||||
|
|
||||||
const getSelectedTreeModels = useCallback(
|
const getSelectedTreeModels = useCallback(
|
||||||
@@ -654,8 +664,43 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{allHidden ? (
|
{allHidden ? (
|
||||||
<div className="italic text-text-subtle p-3 text-sm text-center">
|
<div className="p-3 text-sm text-center">
|
||||||
No results for <InlineCode>{filterText.text}</InlineCode>
|
{(emptyFilterSuggestions?.length ?? 0) > 0 ? (
|
||||||
|
<EmptyStateText
|
||||||
|
wrapperClassName="!h-auto mb-auto"
|
||||||
|
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
No results, but found matches for{" "}
|
||||||
|
{emptyFilterSuggestions?.map((suggestion, i) => (
|
||||||
|
<span key={suggestion.field}>
|
||||||
|
{i > 0 && " or "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-full rounded align-middle focus-visible:outline focus-visible:outline-2 focus-visible:outline-info"
|
||||||
|
onClick={() => applyFilterExample(suggestion.filterText)}
|
||||||
|
>
|
||||||
|
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
|
||||||
|
{suggestion.filterText}
|
||||||
|
</InlineCode>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</EmptyStateText>
|
||||||
|
) : (
|
||||||
|
<EmptyStateText
|
||||||
|
wrapperClassName="!h-auto mb-auto"
|
||||||
|
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
No results for{" "}
|
||||||
|
<InlineCode className="inline-block max-w-36 truncate align-middle">
|
||||||
|
{filterText.text}
|
||||||
|
</InlineCode>
|
||||||
|
</div>
|
||||||
|
</EmptyStateText>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tree
|
<Tree
|
||||||
@@ -786,7 +831,48 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
|
|||||||
key: "",
|
key: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
|
type SidebarFilterSuggestion = {
|
||||||
|
field: string;
|
||||||
|
filterText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function setSidebarFilterText(text: string) {
|
||||||
|
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSidebarSuggestionValue(ast: Ast | null) {
|
||||||
|
if (ast == null) return null;
|
||||||
|
|
||||||
|
if (ast.type === "Term" || ast.type === "Phrase") {
|
||||||
|
const value = ast.value.trim();
|
||||||
|
return value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ast.type === "Field") {
|
||||||
|
const value = ast.value.trim();
|
||||||
|
return value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
|
||||||
|
return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarSuggestionFieldOrder = [
|
||||||
|
"url",
|
||||||
|
"folder",
|
||||||
|
"method",
|
||||||
|
"type",
|
||||||
|
"grpc_service",
|
||||||
|
"grpc_method",
|
||||||
|
"name",
|
||||||
|
];
|
||||||
|
|
||||||
|
const sidebarTreeAtom = atom<
|
||||||
|
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
|
||||||
|
>((get) => {
|
||||||
const allModels = get(memoAllPotentialChildrenAtom);
|
const allModels = get(memoAllPotentialChildrenAtom);
|
||||||
const activeWorkspace = get(activeWorkspaceAtom);
|
const activeWorkspace = get(activeWorkspaceAtom);
|
||||||
const filter = get(sidebarFilterAtom);
|
const filter = get(sidebarFilterAtom);
|
||||||
@@ -807,9 +893,11 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryAst = parseQuery(filter.text);
|
const queryAst = parseQuery(filter.text);
|
||||||
|
const suggestionValue = getSidebarSuggestionValue(queryAst);
|
||||||
|
|
||||||
// returns true if this node OR any child matches the filter
|
// returns true if this node OR any child matches the filter
|
||||||
const allFields: Record<string, Set<string>> = {};
|
const allFields: Record<string, Set<string>> = {};
|
||||||
|
const suggestionFields = new Set<string>();
|
||||||
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
||||||
const childItems = childrenMap[node.item.id] ?? [];
|
const childItems = childrenMap[node.item.id] ?? [];
|
||||||
let matchesSelf = true;
|
let matchesSelf = true;
|
||||||
@@ -821,6 +909,13 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
|||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
allFields[field] = allFields[field] ?? new Set();
|
allFields[field] = allFields[field] ?? new Set();
|
||||||
allFields[field].add(value);
|
allFields[field].add(value);
|
||||||
|
if (
|
||||||
|
isLeafNode &&
|
||||||
|
suggestionValue != null &&
|
||||||
|
sidebarFieldMatchesValue(value, suggestionValue)
|
||||||
|
) {
|
||||||
|
suggestionFields.add(field);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queryAst != null) {
|
if (queryAst != null) {
|
||||||
@@ -874,7 +969,18 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
|||||||
values: Array.from(values).filter((v) => v.length < 20),
|
values: Array.from(values).filter((v) => v.length < 20),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return [root, fields] as const;
|
const suggestions = Array.from(suggestionFields)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
|
||||||
|
const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
|
||||||
|
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
|
||||||
|
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
|
||||||
|
})
|
||||||
|
.map((field) => ({
|
||||||
|
field,
|
||||||
|
filterText: formatFieldFilter(field, suggestionValue ?? ""),
|
||||||
|
}));
|
||||||
|
return [root, fields, suggestions] as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
||||||
|
|||||||
@@ -4,20 +4,79 @@ import { useState } from "react";
|
|||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
|
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { SelectFile } from "./SelectFile";
|
||||||
|
|
||||||
export interface SyncToFilesystemSettingProps {
|
export interface SyncToFilesystemSettingProps {
|
||||||
|
layout?: "form" | "settings";
|
||||||
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
|
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
|
||||||
onCreateNewWorkspace: () => void;
|
onCreateNewWorkspace: () => void;
|
||||||
value: { filePath: string | null; initGit?: boolean };
|
value: { filePath: string | null; initGit?: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncToFilesystemSetting({
|
export function SyncToFilesystemSetting({
|
||||||
|
layout = "form",
|
||||||
onChange,
|
onChange,
|
||||||
onCreateNewWorkspace,
|
onCreateNewWorkspace,
|
||||||
value,
|
value,
|
||||||
}: SyncToFilesystemSettingProps) {
|
}: SyncToFilesystemSettingProps) {
|
||||||
const [syncDir, setSyncDir] = useState<string | null>(null);
|
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 (
|
return (
|
||||||
<VStack className="w-full my-2" space={3}>
|
<VStack className="w-full my-2" space={3}>
|
||||||
{syncDir && (
|
{syncDir && (
|
||||||
@@ -47,18 +106,7 @@ export function SyncToFilesystemSetting({
|
|||||||
noun="Directory"
|
noun="Directory"
|
||||||
help="Sync data to a folder for backup and Git integration."
|
help="Sync data to a folder for backup and Git integration."
|
||||||
filePath={value.filePath}
|
filePath={value.filePath}
|
||||||
onChange={async ({ filePath }) => {
|
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
|
||||||
if (filePath != null) {
|
|
||||||
const files = await readDir(filePath);
|
|
||||||
if (files.length > 0) {
|
|
||||||
setSyncDir(filePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSyncDir(null);
|
|
||||||
onChange({ ...value, filePath });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{value.filePath && typeof value.initGit === "boolean" && (
|
{value.filePath && typeof value.initGit === "boolean" && (
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
|||||||
import { HeadersEditor } from "./HeadersEditor";
|
import { HeadersEditor } from "./HeadersEditor";
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||||
import { UrlBar } from "./UrlBar";
|
import { UrlBar } from "./UrlBar";
|
||||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ const TAB_MESSAGE = "message";
|
|||||||
const TAB_PARAMS = "params";
|
const TAB_PARAMS = "params";
|
||||||
const TAB_HEADERS = "headers";
|
const TAB_HEADERS = "headers";
|
||||||
const TAB_AUTH = "auth";
|
const TAB_AUTH = "auth";
|
||||||
|
const TAB_SETTINGS = "settings";
|
||||||
const TAB_DESCRIPTION = "description";
|
const TAB_DESCRIPTION = "description";
|
||||||
const TABS_STORAGE_KEY = "websocket_request_tabs";
|
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 authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
||||||
const inheritedHeaders = useInheritedHeaders(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)
|
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
||||||
useRequestEditorEvent(
|
useRequestEditorEvent(
|
||||||
@@ -109,12 +112,17 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
},
|
},
|
||||||
...headersTab,
|
...headersTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
|
{
|
||||||
|
value: TAB_SETTINGS,
|
||||||
|
label: "Settings",
|
||||||
|
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: TAB_DESCRIPTION,
|
value: TAB_DESCRIPTION,
|
||||||
label: "Info",
|
label: "Info",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [authTab, headersTab, urlParameterPairs.length]);
|
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||||
|
|
||||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||||
@@ -266,6 +274,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
stateKey={`json.${activeRequest.id}`}
|
stateKey={`json.${activeRequest.id}`}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
<TabContent value={TAB_SETTINGS}>
|
||||||
|
<ModelSettingsEditor model={activeRequest} />
|
||||||
|
</TabContent>
|
||||||
<TabContent value={TAB_DESCRIPTION}>
|
<TabContent value={TAB_DESCRIPTION}>
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||||
<PlainInput
|
<PlainInput
|
||||||
|
|||||||
@@ -105,10 +105,18 @@ function WebsocketEventRow({
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
const iconColor =
|
const iconColor =
|
||||||
messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary";
|
messageType === "error"
|
||||||
|
? "warning"
|
||||||
|
: messageType === "close" || messageType === "open"
|
||||||
|
? "secondary"
|
||||||
|
: isServer
|
||||||
|
? "info"
|
||||||
|
: "primary";
|
||||||
|
|
||||||
const icon =
|
const icon =
|
||||||
messageType === "close" || messageType === "open"
|
messageType === "error"
|
||||||
|
? "alert_triangle"
|
||||||
|
: messageType === "close" || messageType === "open"
|
||||||
? "info"
|
? "info"
|
||||||
: isServer
|
: isServer
|
||||||
? "arrow_big_down_dash"
|
? "arrow_big_down_dash"
|
||||||
@@ -119,6 +127,8 @@ function WebsocketEventRow({
|
|||||||
"Disconnected from server"
|
"Disconnected from server"
|
||||||
) : messageType === "open" ? (
|
) : messageType === "open" ? (
|
||||||
"Connected to server"
|
"Connected to server"
|
||||||
|
) : messageType === "error" ? (
|
||||||
|
<span className="text-warning">{message}</span>
|
||||||
) : message === "" ? (
|
) : message === "" ? (
|
||||||
<em className="italic text-text-subtlest">No content</em>
|
<em className="italic text-text-subtlest">No content</em>
|
||||||
) : (
|
) : (
|
||||||
@@ -170,7 +180,9 @@ function WebsocketEventDetail({
|
|||||||
? "Connection Closed"
|
? "Connection Closed"
|
||||||
: event.messageType === "open"
|
: event.messageType === "open"
|
||||||
? "Connection Open"
|
? "Connection Open"
|
||||||
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
: event.messageType === "error"
|
||||||
|
? "WebSocket Error"
|
||||||
|
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
||||||
|
|
||||||
const actions: EventDetailAction[] =
|
const actions: EventDetailAction[] =
|
||||||
message !== ""
|
message !== ""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAtomValue } from "jotai";
|
|||||||
import * as m from "motion/react-m";
|
import * as m from "motion/react-m";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
getActiveCookieJar,
|
||||||
useEnsureActiveCookieJar,
|
useEnsureActiveCookieJar,
|
||||||
useSubscribeActiveCookieJarId,
|
useSubscribeActiveCookieJarId,
|
||||||
} from "../hooks/useActiveCookieJar";
|
} from "../hooks/useActiveCookieJar";
|
||||||
@@ -33,6 +34,7 @@ import { jotaiStore } from "../lib/jotai";
|
|||||||
import { CreateDropdown } from "./CreateDropdown";
|
import { CreateDropdown } from "./CreateDropdown";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { HotkeyList } from "./core/HotkeyList";
|
import { HotkeyList } from "./core/HotkeyList";
|
||||||
|
import { CookieDialog } from "./CookieDialog";
|
||||||
import { FeedbackLink } from "./core/Link";
|
import { FeedbackLink } from "./core/Link";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
import { FolderLayout } from "./FolderLayout";
|
import { FolderLayout } from "./FolderLayout";
|
||||||
@@ -218,4 +220,8 @@ function useGlobalWorkspaceHooks() {
|
|||||||
useHotKey("model.duplicate", () =>
|
useHotKey("model.duplicate", () =>
|
||||||
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
|
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
|
||||||
|
enable: () => getActiveCookieJar() != null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,24 @@ import { IconButton } from "./core/IconButton";
|
|||||||
import { IconTooltip } from "./core/IconTooltip";
|
import { IconTooltip } from "./core/IconTooltip";
|
||||||
import { Label } from "./core/Label";
|
import { Label } from "./core/Label";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
import { SettingRow } from "./core/SettingRow";
|
||||||
import { EncryptionHelp } from "./EncryptionHelp";
|
import { EncryptionHelp } from "./EncryptionHelp";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
layout?: "form" | "settings";
|
||||||
size?: ButtonProps["size"];
|
size?: ButtonProps["size"];
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
onDone?: () => void;
|
onDone?: () => void;
|
||||||
onEnabledEncryption?: () => 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 [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -66,7 +74,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
|||||||
key.error != null ||
|
key.error != null ||
|
||||||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
|
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
|
||||||
) {
|
) {
|
||||||
return (
|
const enterKey = (
|
||||||
<EnterWorkspaceKey
|
<EnterWorkspaceKey
|
||||||
workspaceMeta={workspaceMeta}
|
workspaceMeta={workspaceMeta}
|
||||||
error={key.error}
|
error={key.error}
|
||||||
@@ -79,6 +87,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return enterKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the key if it exists
|
// Show the key if it exists
|
||||||
@@ -90,7 +100,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
|||||||
encryptionKey={key.key}
|
encryptionKey={key.key}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return (
|
|
||||||
|
const content = (
|
||||||
<VStack space={2} className="w-full">
|
<VStack space={2} className="w-full">
|
||||||
{justEnabledEncryption && (
|
{justEnabledEncryption && (
|
||||||
<Banner color="success" className="flex flex-col gap-2">
|
<Banner color="success" className="flex flex-col gap-2">
|
||||||
@@ -111,9 +122,43 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
|
|||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show button to enable encryption
|
// 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 (
|
return (
|
||||||
<div className="mb-auto flex flex-col-reverse">
|
<div className="mb-auto flex flex-col-reverse">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
|
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||||
|
import { showDialog } from "../lib/dialog";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { CopyIconButton } from "./CopyIconButton";
|
import { CopyIconButton } from "./CopyIconButton";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
import { SettingsList, SettingsSection } from "./core/SettingRow";
|
||||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||||
import { DnsOverridesEditor } from "./DnsOverridesEditor";
|
import { DnsOverridesEditor } from "./DnsOverridesEditor";
|
||||||
import { HeadersEditor } from "./HeadersEditor";
|
import { HeadersEditor } from "./HeadersEditor";
|
||||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
import { ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||||
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
|
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
|
||||||
import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
|
import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
|
||||||
|
|
||||||
@@ -25,17 +28,17 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TAB_AUTH = "auth";
|
const TAB_AUTH = "auth";
|
||||||
const TAB_DATA = "data";
|
|
||||||
const TAB_DNS = "dns";
|
const TAB_DNS = "dns";
|
||||||
const TAB_HEADERS = "headers";
|
const TAB_HEADERS = "headers";
|
||||||
const TAB_GENERAL = "general";
|
const TAB_GENERAL = "general";
|
||||||
|
const TAB_SETTINGS = "settings";
|
||||||
|
|
||||||
export type WorkspaceSettingsTab =
|
export type WorkspaceSettingsTab =
|
||||||
| typeof TAB_AUTH
|
| typeof TAB_AUTH
|
||||||
| typeof TAB_DNS
|
| typeof TAB_DNS
|
||||||
| typeof TAB_HEADERS
|
| typeof TAB_HEADERS
|
||||||
| typeof TAB_GENERAL
|
| typeof TAB_GENERAL
|
||||||
| typeof TAB_DATA;
|
| typeof TAB_SETTINGS;
|
||||||
|
|
||||||
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
||||||
|
|
||||||
@@ -71,8 +74,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
tabs={[
|
tabs={[
|
||||||
{ value: TAB_GENERAL, label: "Workspace" },
|
{ value: TAB_GENERAL, label: "Workspace" },
|
||||||
{
|
{
|
||||||
value: TAB_DATA,
|
value: TAB_SETTINGS,
|
||||||
label: "Storage",
|
label: "Settings",
|
||||||
},
|
},
|
||||||
...headersTab,
|
...headersTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
@@ -100,6 +103,22 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
stateKey={`headers.${workspace.id}`}
|
stateKey={`headers.${workspace.id}`}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</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 })}
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<WorkspaceEncryptionSetting layout="settings" size="xs" />
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
<ModelSettingsEditor model={workspace} showSectionTitles />
|
||||||
|
</SettingsList>
|
||||||
|
</TabContent>
|
||||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
<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">
|
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full">
|
||||||
<PlainInput
|
<PlainInput
|
||||||
@@ -152,19 +171,21 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
</TabContent>
|
</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">
|
<TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4">
|
||||||
<DnsOverridesEditor workspace={workspace} />
|
<DnsOverridesEditor workspace={workspace} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</Tabs>
|
</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} />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { parseBulkPairLine } from "./BulkPairEditor";
|
||||||
|
|
||||||
|
describe("parseBulkPairLine", () => {
|
||||||
|
test("parses colon-space pairs as name and value", () => {
|
||||||
|
expect(parseBulkPairLine("foo: bar")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
name: "foo",
|
||||||
|
value: "bar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves colon-without-space lines as a name with an empty value", () => {
|
||||||
|
expect(parseBulkPairLine("foo:bar")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
name: "foo:bar",
|
||||||
|
value: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves malformed lines instead of dropping their contents", () => {
|
||||||
|
expect(parseBulkPairLine("not a pair")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
name: "not a pair",
|
||||||
|
value: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unescapes newlines in parsed values", () => {
|
||||||
|
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
name: "foo",
|
||||||
|
value: "bar\nbaz",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ export function BulkPairEditor({
|
|||||||
const pairsText = useMemo(() => {
|
const pairsText = useMemo(() => {
|
||||||
return pairs
|
return pairs
|
||||||
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
|
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
|
||||||
.map(pairToLine)
|
.map(formatBulkPairLine)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}, [pairs]);
|
}, [pairs]);
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function BulkPairEditor({
|
|||||||
const pairs = text
|
const pairs = text
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l: string) => l.trim())
|
.filter((l: string) => l.trim())
|
||||||
.map(lineToPair);
|
.map(parseBulkPairLine);
|
||||||
onChange(pairs);
|
onChange(pairs);
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange],
|
||||||
@@ -47,16 +47,16 @@ export function BulkPairEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pairToLine(pair: Pair) {
|
export function formatBulkPairLine(pair: Pair) {
|
||||||
const value = pair.value.replaceAll("\n", "\\n");
|
const value = pair.value.replaceAll("\n", "\\n");
|
||||||
return `${pair.name}: ${value}`;
|
return `${pair.name}: ${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineToPair(line: string): PairWithId {
|
export function parseBulkPairLine(line: string): PairWithId {
|
||||||
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
|
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: (name ?? "").trim(),
|
name: (name ?? line).trim(),
|
||||||
value: (value ?? "").replaceAll("\\n", "\n").trim(),
|
value: (value ?? "").replaceAll("\\n", "\n").trim(),
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface CheckboxProps {
|
|||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
help?: ReactNode;
|
help?: ReactNode;
|
||||||
|
size?: "sm" | "md";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Checkbox({
|
export function Checkbox({
|
||||||
@@ -25,6 +26,7 @@ export function Checkbox({
|
|||||||
hideLabel,
|
hideLabel,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
help,
|
help,
|
||||||
|
size = "sm",
|
||||||
}: CheckboxProps) {
|
}: CheckboxProps) {
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
@@ -37,7 +39,9 @@ export function Checkbox({
|
|||||||
<input
|
<input
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={classNames(
|
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",
|
"rounded outline-none ring-0",
|
||||||
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
||||||
disabled && "border-dotted",
|
disabled && "border-dotted",
|
||||||
@@ -50,7 +54,7 @@ export function Checkbox({
|
|||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<Icon
|
<Icon
|
||||||
size="sm"
|
size={size}
|
||||||
className={classNames(disabled && "opacity-disabled")}
|
className={classNames(disabled && "opacity-disabled")}
|
||||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -580,6 +580,10 @@ function getExtensions({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
...baseExtensions, // Must be first
|
...baseExtensions, // Must be first
|
||||||
|
EditorView.contentAttributes.of({
|
||||||
|
autocapitalize: "off",
|
||||||
|
autocorrect: "off",
|
||||||
|
}),
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
onFocus.current?.();
|
onFocus.current?.();
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export interface FilterOptions {
|
|||||||
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
||||||
}
|
}
|
||||||
|
|
||||||
const IDENT = /[A-Za-z0-9_/]+$/;
|
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
|
||||||
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
|
const VALUE_IDENT = /\S+$/;
|
||||||
|
const VALUE_IDENT_ONLY = /^\S+$/;
|
||||||
|
|
||||||
function normalizeFields(fields: FieldDef[]): {
|
function normalizeFields(fields: FieldDef[]): {
|
||||||
fieldNames: string[];
|
fieldNames: string[];
|
||||||
@@ -31,14 +32,37 @@ function normalizeFields(fields: FieldDef[]): {
|
|||||||
return { fieldNames, fieldMap };
|
return { fieldNames, fieldMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
|
function wordBefore(
|
||||||
|
doc: string,
|
||||||
|
pos: number,
|
||||||
|
pattern: RegExp,
|
||||||
|
): { from: number; to: number; text: string } | null {
|
||||||
const upto = doc.slice(0, pos);
|
const upto = doc.slice(0, pos);
|
||||||
const m = upto.match(IDENT);
|
const m = upto.match(pattern);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const from = pos - m[0].length;
|
const from = pos - m[0].length;
|
||||||
return { from, to: pos, text: m[0] };
|
return { from, to: pos, text: m[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
|
||||||
|
const w = wordBefore(doc, pos, FIELD_IDENT);
|
||||||
|
const from = w?.from ?? pos;
|
||||||
|
const beforeToken = doc[from - 1];
|
||||||
|
|
||||||
|
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
|
||||||
|
return { from, includeAt: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beforeToken === "@") {
|
||||||
|
const beforeAt = doc[from - 2];
|
||||||
|
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
|
||||||
|
return { from, includeAt: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function inPhrase(ctx: CompletionContext): boolean {
|
function inPhrase(ctx: CompletionContext): boolean {
|
||||||
// Lezer node names from your grammar: Phrase is the quoted token
|
// Lezer node names from your grammar: Phrase is the quoted token
|
||||||
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
||||||
@@ -81,7 +105,7 @@ function contextInfo(stateDoc: string, pos: number) {
|
|||||||
if (inValue) {
|
if (inValue) {
|
||||||
// word before the colon = field name
|
// word before the colon = field name
|
||||||
const beforeColon = stateDoc.slice(0, lastColon);
|
const beforeColon = stateDoc.slice(0, lastColon);
|
||||||
const m = beforeColon.match(IDENT);
|
const m = beforeColon.match(FIELD_IDENT);
|
||||||
fieldName = m ? m[0] : null;
|
fieldName = m ? m[0] : null;
|
||||||
|
|
||||||
// nothing (or only spaces) typed after the colon?
|
// nothing (or only spaces) typed after the colon?
|
||||||
@@ -93,15 +117,16 @@ function contextInfo(stateDoc: string, pos: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a completion list for field names */
|
/** Build a completion list for field names */
|
||||||
function fieldNameCompletions(fieldNames: string[]): Completion[] {
|
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
|
||||||
return fieldNames.map((name) => ({
|
return fieldNames.map((name) => ({
|
||||||
label: name,
|
label: name,
|
||||||
type: "property",
|
type: "property",
|
||||||
apply: (view, _completion, from, to) => {
|
apply: (view, _completion, from, to) => {
|
||||||
// Insert "name:" (leave cursor right after colon)
|
// Leave cursor right after the field filter colon.
|
||||||
|
const insert = `${includeAt ? "@" : ""}${name}:`;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from, to, insert: `${name}:` },
|
changes: { from, to, insert },
|
||||||
selection: { anchor: from + name.length + 1 },
|
selection: { anchor: from + insert.length },
|
||||||
});
|
});
|
||||||
startCompletion(view);
|
startCompletion(view);
|
||||||
},
|
},
|
||||||
@@ -115,7 +140,7 @@ function fieldValueCompletions(
|
|||||||
if (!def || !def.values) return null;
|
if (!def || !def.values) return null;
|
||||||
const vals = Array.isArray(def.values) ? def.values : def.values();
|
const vals = Array.isArray(def.values) ? def.values : def.values();
|
||||||
return vals.map((v) => ({
|
return vals.map((v) => ({
|
||||||
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
|
label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
|
||||||
displayLabel: v,
|
displayLabel: v,
|
||||||
type: "constant",
|
type: "constant",
|
||||||
}));
|
}));
|
||||||
@@ -132,14 +157,13 @@ function makeCompletionSource(opts: FilterOptions) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = wordBefore(doc, pos);
|
|
||||||
const from = w?.from ?? pos;
|
|
||||||
const to = pos;
|
|
||||||
|
|
||||||
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
||||||
|
|
||||||
// In field value position
|
// In field value position
|
||||||
if (inValue && fieldName) {
|
if (inValue && fieldName) {
|
||||||
|
const w = wordBefore(doc, pos, VALUE_IDENT);
|
||||||
|
const from = w?.from ?? pos;
|
||||||
|
const to = pos;
|
||||||
const valDefs = fieldMap[fieldName];
|
const valDefs = fieldMap[fieldName];
|
||||||
const vals = fieldValueCompletions(valDefs);
|
const vals = fieldValueCompletions(valDefs);
|
||||||
|
|
||||||
@@ -162,7 +186,11 @@ function makeCompletionSource(opts: FilterOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not in a value: suggest field names (and maybe boolean ops)
|
// Not in a value: suggest field names (and maybe boolean ops)
|
||||||
const options: Completion[] = fieldNameCompletions(fieldNames);
|
const completion = fieldCompletionFrom(doc, pos);
|
||||||
|
if (completion == null) return null;
|
||||||
|
const { from, includeAt } = completion;
|
||||||
|
const to = pos;
|
||||||
|
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
|
||||||
|
|
||||||
return { from, to, options, filter: true };
|
return { from, to, options, filter: true };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
@skip { space+ }
|
@skip { space+ }
|
||||||
@tokens {
|
@tokens {
|
||||||
space { std.whitespace+ }
|
space { $[ \t\r\n]+ }
|
||||||
|
|
||||||
LParen { "(" }
|
LParen { "(" }
|
||||||
RParen { ")" }
|
RParen { ")" }
|
||||||
|
At { "@" }
|
||||||
Colon { ":" }
|
Colon { ":" }
|
||||||
Not { "-" | "NOT" }
|
Not { "-" | "NOT" }
|
||||||
|
|
||||||
@@ -16,8 +17,10 @@
|
|||||||
// "quoted phrase" with simple escapes: \" and \\
|
// "quoted phrase" with simple escapes: \" and \\
|
||||||
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
||||||
|
|
||||||
// field/word characters (keep generous for URLs/paths)
|
// Bare words run until filter syntax or whitespace. Leading '-' remains unary
|
||||||
Word { $[A-Za-z0-9_]+ }
|
// negation, but '-' may appear after the first character.
|
||||||
|
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
|
||||||
|
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
|
||||||
|
|
||||||
@precedence { Not, And, Or, Word }
|
@precedence { Not, And, Or, Word }
|
||||||
}
|
}
|
||||||
@@ -60,12 +63,12 @@ Field {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FieldName {
|
FieldName {
|
||||||
Word
|
At? Word
|
||||||
}
|
}
|
||||||
|
|
||||||
FieldValue {
|
FieldValue {
|
||||||
Phrase
|
Phrase
|
||||||
| Term
|
| FieldValueWord
|
||||||
}
|
}
|
||||||
|
|
||||||
Term {
|
Term {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { parser } from "./filter";
|
||||||
|
|
||||||
|
function getNodeNames(input: string): string[] {
|
||||||
|
const tree = parser.parse(input);
|
||||||
|
const nodes: string[] = [];
|
||||||
|
const cursor = tree.cursor();
|
||||||
|
do {
|
||||||
|
if (cursor.name !== "Query") {
|
||||||
|
nodes.push(cursor.name);
|
||||||
|
}
|
||||||
|
} while (cursor.next());
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("filter grammar", () => {
|
||||||
|
test("parses URL-like field values as one value", () => {
|
||||||
|
const nodes = getNodeNames("@url:yaak.app/foo-bar");
|
||||||
|
|
||||||
|
expect(nodes).not.toContain("⚠");
|
||||||
|
expect(nodes).toContain("FieldValue");
|
||||||
|
expect(nodes).toContain("FieldValueWord");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses punctuation-heavy field values as one value", () => {
|
||||||
|
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
|
||||||
|
|
||||||
|
expect(nodes).not.toContain("⚠");
|
||||||
|
expect(nodes).toContain("FieldValue");
|
||||||
|
expect(nodes).toContain("FieldValueWord");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses operator-looking field values as one value", () => {
|
||||||
|
const negativeValueNodes = getNodeNames("@url:-foo");
|
||||||
|
const operatorWordNodes = getNodeNames("@url:AND");
|
||||||
|
|
||||||
|
expect(negativeValueNodes).not.toContain("⚠");
|
||||||
|
expect(negativeValueNodes).toContain("FieldValueWord");
|
||||||
|
expect(operatorWordNodes).not.toContain("⚠");
|
||||||
|
expect(operatorWordNodes).toContain("FieldValueWord");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
/* oxlint-disable */
|
|
||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||||
import { LRParser } from "@lezer/lr";
|
import {LRParser} from "@lezer/lr"
|
||||||
import { highlight } from "./highlight";
|
import {highlight} from "./highlight"
|
||||||
export const parser = LRParser.deserialize({
|
export const parser = LRParser.deserialize({
|
||||||
version: 14,
|
version: 14,
|
||||||
states:
|
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
|
||||||
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
|
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
|
||||||
stateData:
|
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
|
||||||
"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
|
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
|
||||||
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
|
maxTerm: 27,
|
||||||
nodeNames:
|
|
||||||
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
|
|
||||||
maxTerm: 25,
|
|
||||||
nodeProps: [
|
nodeProps: [
|
||||||
["openedBy", 8, "LParen"],
|
["openedBy", 8,"LParen"],
|
||||||
["closedBy", 9, "RParen"],
|
["closedBy", 9,"RParen"]
|
||||||
],
|
],
|
||||||
propSources: [highlight],
|
propSources: [highlight],
|
||||||
skippedNodes: [0, 20],
|
skippedNodes: [0,22],
|
||||||
repeatNodeCount: 3,
|
repeatNodeCount: 3,
|
||||||
tokenData:
|
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!OY!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
|
||||||
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
|
tokenizers: [0, 1],
|
||||||
tokenizers: [0],
|
topRules: {"Query":[0,1]},
|
||||||
topRules: { Query: [0, 1] },
|
tokenPrec: 145
|
||||||
tokenPrec: 145,
|
})
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { formatFieldFilter } from "./format";
|
||||||
|
import { evaluate, parseQuery } from "./query";
|
||||||
|
|
||||||
|
function matchesFormattedUrl(value: string) {
|
||||||
|
return evaluate(parseQuery(formatFieldFilter("url", value)), {
|
||||||
|
fields: { url: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatFieldFilter", () => {
|
||||||
|
test("keeps URL-like values bare", () => {
|
||||||
|
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
|
||||||
|
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps non-syntax punctuation bare", () => {
|
||||||
|
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
|
||||||
|
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps values that start with an operator token bare", () => {
|
||||||
|
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
|
||||||
|
expect(matchesFormattedUrl("-foo")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps boolean operator words bare", () => {
|
||||||
|
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
|
||||||
|
expect(formatFieldFilter("url", "or")).toBe("@url:or");
|
||||||
|
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
|
||||||
|
expect(matchesFormattedUrl("AND")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapes quoted values", () => {
|
||||||
|
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
|
||||||
|
expect(matchesFormattedUrl('say "hi"')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotes values that start with a quote", () => {
|
||||||
|
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
|
||||||
|
expect(matchesFormattedUrl('"hi"')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const bareFieldValue = /^[^\s"]\S*$/;
|
||||||
|
|
||||||
|
export function formatFieldFilter(field: string, value: string) {
|
||||||
|
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
|
||||||
|
return `@${field}:${filterValue}`;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const highlight = styleTags({
|
|||||||
Phrase: t.string, // "quoted string"
|
Phrase: t.string, // "quoted string"
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
|
"FieldName/At": t.attributeName,
|
||||||
"FieldName/Word": t.attributeName,
|
"FieldName/Word": t.attributeName,
|
||||||
"FieldValue/Term/Word": t.attributeValue,
|
"FieldValue/FieldValueWord": t.attributeValue,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ type Tok =
|
|||||||
| { kind: "EOF" };
|
| { kind: "EOF" };
|
||||||
|
|
||||||
const isSpace = (c: string) => /\s/.test(c);
|
const isSpace = (c: string) => /\s/.test(c);
|
||||||
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
|
const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
|
||||||
|
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
|
||||||
|
|
||||||
export function tokenize(input: string): Tok[] {
|
export function tokenize(input: string): Tok[] {
|
||||||
const toks: Tok[] = [];
|
const toks: Tok[] = [];
|
||||||
@@ -42,7 +43,13 @@ export function tokenize(input: string): Tok[] {
|
|||||||
|
|
||||||
const readWord = () => {
|
const readWord = () => {
|
||||||
let s = "";
|
let s = "";
|
||||||
while (i < n && isIdent(peek())) s += advance();
|
while (i < n && isWordChar(peek())) s += advance();
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFieldValue = () => {
|
||||||
|
let s = "";
|
||||||
|
while (i < n && !isSpace(peek())) s += advance();
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +92,9 @@ export function tokenize(input: string): Tok[] {
|
|||||||
if (c === ":") {
|
if (c === ":") {
|
||||||
toks.push({ kind: "COLON" });
|
toks.push({ kind: "COLON" });
|
||||||
i++;
|
i++;
|
||||||
|
if (peek() && !isSpace(peek()) && peek() !== `"`) {
|
||||||
|
toks.push({ kind: "WORD", text: readFieldValue() });
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (c === `"`) {
|
if (c === `"`) {
|
||||||
@@ -99,7 +109,7 @@ export function tokenize(input: string): Tok[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WORD / AND / OR / NOT
|
// WORD / AND / OR / NOT
|
||||||
if (isIdent(c)) {
|
if (isWordStart(c)) {
|
||||||
const w = readWord();
|
const w = readWord();
|
||||||
const upper = w.toUpperCase();
|
const upper = w.toUpperCase();
|
||||||
if (upper === "AND") toks.push({ kind: "AND" });
|
if (upper === "AND") toks.push({ kind: "AND" });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@top pairs { (Key Sep Value "\n")* }
|
@top pairs { (Key Sep Value "\n")* }
|
||||||
|
|
||||||
@tokens {
|
@tokens {
|
||||||
Sep { ":" }
|
Sep { ":" $[ \t]+ }
|
||||||
Key { ":"? ![:]+ }
|
Key { ":"? ![:]+ }
|
||||||
Value { ![\n]+ }
|
Value { ![\n]+ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { parser } from "./pairs";
|
||||||
|
|
||||||
|
function getNodeNames(input: string): string[] {
|
||||||
|
const tree = parser.parse(input);
|
||||||
|
const nodes: string[] = [];
|
||||||
|
const cursor = tree.cursor();
|
||||||
|
do {
|
||||||
|
if (cursor.name !== "pairs") {
|
||||||
|
nodes.push(cursor.name);
|
||||||
|
}
|
||||||
|
} while (cursor.next());
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pairs grammar", () => {
|
||||||
|
test("parses colon-space pairs with a value", () => {
|
||||||
|
expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not parse colon-without-space as a value", () => {
|
||||||
|
const nodes = getNodeNames("foo:bar\n");
|
||||||
|
|
||||||
|
expect(nodes).not.toContain("Value");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ export const parser = LRParser.deserialize({
|
|||||||
skippedNodes: [0],
|
skippedNodes: [0],
|
||||||
repeatNodeCount: 1,
|
repeatNodeCount: 1,
|
||||||
tokenData:
|
tokenData:
|
||||||
"$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh",
|
"%]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOh",
|
||||||
tokenizers: [0, 1, 2],
|
tokenizers: [0, 1, 2],
|
||||||
topRules: { pairs: [0, 1] },
|
topRules: { pairs: [0, 1] },
|
||||||
tokenPrec: 0,
|
tokenPrec: 0,
|
||||||
|
|||||||
@@ -240,7 +240,13 @@ export function EventDetailHeader({
|
|||||||
</HStack>
|
</HStack>
|
||||||
<HStack space={2} className="items-center">
|
<HStack space={2} className="items-center">
|
||||||
{actions?.map((action) => (
|
{actions?.map((action) => (
|
||||||
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
|
<Button
|
||||||
|
key={action.key}
|
||||||
|
type="button"
|
||||||
|
variant="border"
|
||||||
|
size="xs"
|
||||||
|
onClick={action.onClick}
|
||||||
|
>
|
||||||
{action.icon}
|
{action.icon}
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -290,10 +290,10 @@ function BaseInput({
|
|||||||
<HStack
|
<HStack
|
||||||
className={classNames(
|
className={classNames(
|
||||||
inputWrapperClassName,
|
inputWrapperClassName,
|
||||||
"w-full min-w-0 px-2",
|
"flex-1 min-w-0 px-2",
|
||||||
fullHeight && "h-full",
|
fullHeight && "h-full",
|
||||||
leftSlot ? "pl-0.5 -ml-2" : null,
|
leftSlot ? "pl-0" : null,
|
||||||
rightSlot ? "pr-0.5 -mr-2" : null,
|
rightSlot ? "pr-0" : null,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Editor
|
<Editor
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||||
|
import { CopyIconButton } from "../CopyIconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children:
|
children:
|
||||||
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
| ReactElement<HTMLAttributes<HTMLTableColElement>>
|
||||||
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
|
| (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];
|
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
||||||
return (
|
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">
|
<tbody className="divide-y divide-surface-highlight">
|
||||||
{childArray.map((child, i) => (
|
{childArray.map((child, i) => (
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
@@ -26,8 +34,11 @@ interface KeyValueRowProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
leftSlot?: ReactNode;
|
leftSlot?: ReactNode;
|
||||||
|
align?: "top" | "middle";
|
||||||
labelClassName?: string;
|
labelClassName?: string;
|
||||||
labelColor?: "secondary" | "primary" | "info";
|
labelColor?: "secondary" | "primary" | "info";
|
||||||
|
enableCopy?: boolean;
|
||||||
|
copyText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyValueRow({
|
export function KeyValueRow({
|
||||||
@@ -35,14 +46,36 @@ export function KeyValueRow({
|
|||||||
children,
|
children,
|
||||||
rightSlot,
|
rightSlot,
|
||||||
leftSlot,
|
leftSlot,
|
||||||
|
align = "top",
|
||||||
labelColor = "secondary",
|
labelColor = "secondary",
|
||||||
labelClassName,
|
labelClassName,
|
||||||
|
enableCopy,
|
||||||
|
copyText,
|
||||||
}: KeyValueRowProps) {
|
}: KeyValueRowProps) {
|
||||||
|
const textToCopy =
|
||||||
|
copyText ??
|
||||||
|
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
|
||||||
|
const copyTitle =
|
||||||
|
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
|
||||||
|
const resolvedRightSlot =
|
||||||
|
rightSlot ??
|
||||||
|
(enableCopy && textToCopy != null ? (
|
||||||
|
<CopyIconButton
|
||||||
|
text={textToCopy}
|
||||||
|
className="text-text-subtle"
|
||||||
|
size="2xs"
|
||||||
|
title={copyTitle}
|
||||||
|
iconSize="sm"
|
||||||
|
/>
|
||||||
|
) : null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"select-none py-0.5 pr-2 h-full align-top max-w-[10rem]",
|
"select-none py-0.5 pr-2 h-full max-w-[10rem]",
|
||||||
|
align === "top" && "align-top",
|
||||||
|
align === "middle" && "align-middle",
|
||||||
labelClassName,
|
labelClassName,
|
||||||
labelColor === "primary" && "text-primary",
|
labelColor === "primary" && "text-primary",
|
||||||
labelColor === "secondary" && "text-text-subtle",
|
labelColor === "secondary" && "text-text-subtle",
|
||||||
@@ -51,11 +84,21 @@ export function KeyValueRow({
|
|||||||
>
|
>
|
||||||
<span className="select-text cursor-text">{label}</span>
|
<span className="select-text cursor-text">{label}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="select-none py-0.5 break-all align-top max-w-[15rem]">
|
<td
|
||||||
|
className={classNames(
|
||||||
|
"select-none py-0.5 break-all max-w-[15rem]",
|
||||||
|
align === "top" && "align-top",
|
||||||
|
align === "middle" && "align-middle",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
|
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
|
||||||
{leftSlot ?? <span aria-hidden />}
|
{leftSlot ?? <span aria-hidden />}
|
||||||
{children}
|
{children}
|
||||||
{rightSlot ? <div className="ml-1.5">{rightSlot}</div> : <span aria-hidden />}
|
{resolvedRightSlot ? (
|
||||||
|
<div className="ml-1.5">{resolvedRightSlot}</div>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { FocusEvent, HTMLAttributes, ReactNode } from "react";
|
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -28,10 +28,9 @@ export type PlainInputProps = Omit<
|
|||||||
| "extraExtensions"
|
| "extraExtensions"
|
||||||
| "forcedEnvironmentId"
|
| "forcedEnvironmentId"
|
||||||
> &
|
> &
|
||||||
Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & {
|
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
|
||||||
onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"];
|
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
|
||||||
type?: "text" | "password" | "number";
|
type?: "text" | "password" | "number";
|
||||||
step?: number;
|
|
||||||
hideObscureToggle?: boolean;
|
hideObscureToggle?: boolean;
|
||||||
labelRightSlot?: ReactNode;
|
labelRightSlot?: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -43,6 +42,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
disabled,
|
||||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||||
help,
|
help,
|
||||||
hideLabel,
|
hideLabel,
|
||||||
@@ -51,6 +51,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
labelClassName,
|
labelClassName,
|
||||||
labelPosition = "top",
|
labelPosition = "top",
|
||||||
labelRightSlot,
|
labelRightSlot,
|
||||||
|
inputMode,
|
||||||
leftSlot,
|
leftSlot,
|
||||||
name,
|
name,
|
||||||
onBlur,
|
onBlur,
|
||||||
@@ -63,6 +64,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
required,
|
required,
|
||||||
rightSlot,
|
rightSlot,
|
||||||
size = "md",
|
size = "md",
|
||||||
|
step,
|
||||||
tint,
|
tint,
|
||||||
type = "text",
|
type = "text",
|
||||||
validate,
|
validate,
|
||||||
@@ -163,7 +165,8 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
"relative w-full rounded-md text",
|
"relative w-full rounded-md text",
|
||||||
"border",
|
"border",
|
||||||
"overflow-hidden",
|
"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
|
hasChanged && "has-[:invalid]:border-danger", // For built-in HTML validation
|
||||||
size === "md" && "min-h-md",
|
size === "md" && "min-h-md",
|
||||||
size === "sm" && "min-h-sm",
|
size === "sm" && "min-h-sm",
|
||||||
@@ -198,15 +201,18 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
defaultValue={defaultValue ?? undefined}
|
defaultValue={defaultValue ?? undefined}
|
||||||
|
disabled={disabled}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
|
inputMode={inputMode}
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
||||||
className={classNames(commonClassName, "h-full")}
|
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required={required}
|
required={required}
|
||||||
|
step={step}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onKeyDownCapture={onKeyDownCapture}
|
onKeyDownCapture={onKeyDownCapture}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
|
||||||
// light mode
|
// 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
|
<Button
|
||||||
className="w-full text-sm font-mono"
|
className="w-full text-sm font-mono"
|
||||||
justify="start"
|
justify="start"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function Separator({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"h-0 border-t opacity-60",
|
"opacity-60",
|
||||||
color == null && "border-border",
|
color == null && "border-border",
|
||||||
color === "primary" && "border-primary",
|
color === "primary" && "border-primary",
|
||||||
color === "secondary" && "border-secondary",
|
color === "secondary" && "border-secondary",
|
||||||
@@ -34,8 +34,8 @@ export function Separator({
|
|||||||
color === "danger" && "border-danger",
|
color === "danger" && "border-danger",
|
||||||
color === "info" && "border-info",
|
color === "info" && "border-info",
|
||||||
dashed && "border-dashed",
|
dashed && "border-dashed",
|
||||||
orientation === "horizontal" && "w-full h-[1px]",
|
orientation === "horizontal" && "w-full h-0 border-t",
|
||||||
orientation === "vertical" && "h-full w-[1px]",
|
orientation === "vertical" && "h-full w-0 border-l",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
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,
|
||||||
|
"@container border-b border-border-subtle py-4",
|
||||||
|
disabled && "opacity-disabled",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"grid grid-cols-1 gap-2",
|
||||||
|
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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 min-w-0 items-center justify-start @[40rem]:justify-end",
|
||||||
|
controlClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</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,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
model: M;
|
||||||
|
modelKey: K;
|
||||||
|
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
|
||||||
|
return (
|
||||||
|
<SettingRowNumber
|
||||||
|
name={String(modelKey)}
|
||||||
|
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,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
model: M;
|
||||||
|
modelKey: K;
|
||||||
|
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
|
||||||
|
return (
|
||||||
|
<SettingRowText
|
||||||
|
name={String(modelKey)}
|
||||||
|
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,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
model: M;
|
||||||
|
modelKey: K;
|
||||||
|
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
|
||||||
|
return (
|
||||||
|
<SettingSelectControl
|
||||||
|
name={String(modelKey)}
|
||||||
|
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,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
model: M;
|
||||||
|
modelKey: K;
|
||||||
|
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
|
||||||
|
return (
|
||||||
|
<SettingRowSelect
|
||||||
|
name={String(modelKey)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
||||||
import type { GitCommit } from "@yaakapp-internal/git";
|
import type { GitCommit } from "@yaakapp-internal/git";
|
||||||
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
import { SplitLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { formatDistanceToNowStrict } from "date-fns";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
WebsocketRequest,
|
WebsocketRequest,
|
||||||
Workspace,
|
Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { modelToYaml } from "../../lib/diffYaml";
|
import { modelToYaml } from "../../lib/diffYaml";
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
|
|||||||
color: "success",
|
color: "success",
|
||||||
label: "Open Workspace Settings",
|
label: "Open Workspace Settings",
|
||||||
leftSlot: <Icon icon="settings" />,
|
leftSlot: <Icon icon="settings" />,
|
||||||
onSelect: () => openWorkspaceSettings("data"),
|
onSelect: () => openWorkspaceSettings("settings"),
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
|
|||||||
text={text}
|
text={text}
|
||||||
language={language}
|
language={language}
|
||||||
stateKey={`response.body.${response.id}`}
|
stateKey={`response.body.${response.id}`}
|
||||||
|
filterStateKey={`response.body.${response.requestId}`}
|
||||||
pretty={pretty}
|
pretty={pretty}
|
||||||
className={className}
|
className={className}
|
||||||
onFilter={filterCallback}
|
onFilter={filterCallback}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface Props {
|
|||||||
text: string;
|
text: string;
|
||||||
language: EditorProps["language"];
|
language: EditorProps["language"];
|
||||||
stateKey: string | null;
|
stateKey: string | null;
|
||||||
|
filterStateKey?: string | null;
|
||||||
pretty?: boolean;
|
pretty?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
onFilter?: (filter: string) => {
|
onFilter?: (filter: string) => {
|
||||||
@@ -27,16 +28,25 @@ interface Props {
|
|||||||
|
|
||||||
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
||||||
|
|
||||||
export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) {
|
export function TextViewer({
|
||||||
|
language,
|
||||||
|
text,
|
||||||
|
stateKey,
|
||||||
|
filterStateKey,
|
||||||
|
pretty,
|
||||||
|
className,
|
||||||
|
onFilter,
|
||||||
|
}: Props) {
|
||||||
|
const filterKey = filterStateKey ?? stateKey;
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
const [filterTextMap, setFilterTextMap] = useFilterText();
|
||||||
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
|
||||||
const debouncedFilterText = useDebouncedValue(filterText);
|
const debouncedFilterText = useDebouncedValue(filterText);
|
||||||
const setFilterText = useCallback(
|
const setFilterText = useCallback(
|
||||||
(v: string | null) => {
|
(v: string | null) => {
|
||||||
if (!stateKey) return;
|
if (!filterKey) return;
|
||||||
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
|
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
|
||||||
},
|
},
|
||||||
[setFilterTextMap, stateKey],
|
[filterKey, setFilterTextMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSearching = filterText != null;
|
const isSearching = filterText != null;
|
||||||
@@ -64,7 +74,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
|
|||||||
nodes.push(
|
nodes.push(
|
||||||
<div key="input" className="w-full !opacity-100">
|
<div key="input" className="w-full !opacity-100">
|
||||||
<Input
|
<Input
|
||||||
key={stateKey ?? "filter"}
|
key={filterKey ?? "filter"}
|
||||||
validate={!filteredResponse.error}
|
validate={!filteredResponse.error}
|
||||||
hideLabel
|
hideLabel
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -76,7 +86,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
|
|||||||
defaultValue={filterText}
|
defaultValue={filterText}
|
||||||
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
||||||
onChange={setFilterText}
|
onChange={setFilterText}
|
||||||
stateKey={stateKey ? `filter.${stateKey}` : null}
|
stateKey={filterKey ? `filter.${filterKey}` : null}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
@@ -97,12 +107,12 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
|
|||||||
return nodes;
|
return nodes;
|
||||||
}, [
|
}, [
|
||||||
canFilter,
|
canFilter,
|
||||||
|
filterKey,
|
||||||
filterText,
|
filterText,
|
||||||
filteredResponse.error,
|
filteredResponse.error,
|
||||||
filteredResponse.isPending,
|
filteredResponse.isPending,
|
||||||
isSearching,
|
isSearching,
|
||||||
language,
|
language,
|
||||||
stateKey,
|
|
||||||
setFilterText,
|
setFilterText,
|
||||||
toggleSearch,
|
toggleSearch,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMemo } from "react";
|
|||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||||
import { IconTooltip } from "../components/core/IconTooltip";
|
import { IconTooltip } from "../components/core/IconTooltip";
|
||||||
|
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
|
||||||
import type { TabItem } from "../components/core/Tabs/Tabs";
|
import type { TabItem } from "../components/core/Tabs/Tabs";
|
||||||
import { capitalize } from "../lib/capitalize";
|
import { capitalize } from "../lib/capitalize";
|
||||||
import { showConfirm } from "../lib/confirm";
|
import { showConfirm } from "../lib/confirm";
|
||||||
@@ -14,156 +15,192 @@ import type { AuthenticatedModel } from "./useInheritedAuthentication";
|
|||||||
import { useInheritedAuthentication } from "./useInheritedAuthentication";
|
import { useInheritedAuthentication } from "./useInheritedAuthentication";
|
||||||
import { useModelAncestors } from "./useModelAncestors";
|
import { useModelAncestors } from "./useModelAncestors";
|
||||||
|
|
||||||
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {
|
export function useAuthTab<T extends string>(
|
||||||
|
tabValue: T,
|
||||||
|
model: AuthenticatedModel | null,
|
||||||
|
) {
|
||||||
|
const options = useAuthDropdownOptions(model);
|
||||||
|
|
||||||
|
return useMemo<TabItem[]>(() => {
|
||||||
|
if (model == null || options == null) return [];
|
||||||
|
|
||||||
|
const tab: TabItem = {
|
||||||
|
value: tabValue,
|
||||||
|
label: "Auth",
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [tab];
|
||||||
|
}, [model, options, tabValue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthDropdownOptions(
|
||||||
|
model: AuthenticatedModel | null,
|
||||||
|
): Omit<RadioDropdownProps, "children"> | null {
|
||||||
const authentication = useHttpAuthenticationSummaries();
|
const authentication = useHttpAuthenticationSummaries();
|
||||||
const inheritedAuth = useInheritedAuthentication(model);
|
const inheritedAuth = useInheritedAuthentication(model);
|
||||||
const ancestors = useModelAncestors(model);
|
const ancestors = useModelAncestors(model);
|
||||||
const parentModel = ancestors[0] ?? null;
|
const parentModel = ancestors[0] ?? null;
|
||||||
|
|
||||||
return useMemo<TabItem[]>(() => {
|
return useMemo(() => {
|
||||||
if (model == null) return [];
|
if (model == null) return null;
|
||||||
|
|
||||||
const tab: TabItem = {
|
return {
|
||||||
value: tabValue,
|
value: model.authenticationType,
|
||||||
label: "Auth",
|
items: [
|
||||||
options: {
|
...authentication.map((a) => ({
|
||||||
value: model.authenticationType,
|
label: a.label || "UNKNOWN",
|
||||||
items: [
|
shortLabel: a.shortLabel,
|
||||||
...authentication.map((a) => ({
|
value: a.name,
|
||||||
label: a.label || "UNKNOWN",
|
})),
|
||||||
shortLabel: a.shortLabel,
|
{ type: "separator" },
|
||||||
value: a.name,
|
{
|
||||||
})),
|
label: "Inherit from Parent",
|
||||||
{ type: "separator" },
|
shortLabel:
|
||||||
{
|
inheritedAuth != null &&
|
||||||
label: "Inherit from Parent",
|
inheritedAuth.authenticationType !== "none" ? (
|
||||||
shortLabel:
|
<HStack space={1.5}>
|
||||||
inheritedAuth != null && inheritedAuth.authenticationType !== "none" ? (
|
{authentication.find(
|
||||||
<HStack space={1.5}>
|
(a) => a.name === inheritedAuth.authenticationType,
|
||||||
{authentication.find((a) => a.name === inheritedAuth.authenticationType)
|
)?.shortLabel ?? "UNKNOWN"}
|
||||||
?.shortLabel ?? "UNKNOWN"}
|
<IconTooltip
|
||||||
<IconTooltip
|
icon="zap_off"
|
||||||
icon="magic_wand"
|
iconSize="xs"
|
||||||
iconSize="xs"
|
content="Authentication was inherited from an ancestor"
|
||||||
content="Authentication was inherited from an ancestor"
|
/>
|
||||||
/>
|
</HStack>
|
||||||
</HStack>
|
) : (
|
||||||
) : (
|
"Auth"
|
||||||
"Auth"
|
),
|
||||||
),
|
value: null,
|
||||||
value: null,
|
},
|
||||||
},
|
{ label: "No Auth", shortLabel: "No Auth", value: "none" },
|
||||||
{ label: "No Auth", shortLabel: "No Auth", value: "none" },
|
],
|
||||||
],
|
itemsAfter: (() => {
|
||||||
itemsAfter: (() => {
|
const actions: (
|
||||||
const actions: (
|
| { type: "separator"; label: string }
|
||||||
| { type: "separator"; label: string }
|
| {
|
||||||
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }
|
label: string;
|
||||||
)[] = [];
|
leftSlot: React.ReactNode;
|
||||||
|
onSelect: () => Promise<void>;
|
||||||
// Promote: move auth from current model up to parent
|
|
||||||
if (
|
|
||||||
parentModel &&
|
|
||||||
model.authenticationType &&
|
|
||||||
model.authenticationType !== "none" &&
|
|
||||||
(parentModel.authenticationType == null || parentModel.authenticationType === "none")
|
|
||||||
) {
|
|
||||||
actions.push(
|
|
||||||
{ type: "separator", label: "Actions" },
|
|
||||||
{
|
|
||||||
label: `Promote to ${capitalize(parentModel.model)}`,
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={parentModel.model === "workspace" ? "corner_right_up" : "folder_up"}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
onSelect: async () => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "promote-auth-confirm",
|
|
||||||
title: "Promote Authentication",
|
|
||||||
confirmText: "Promote",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Move authentication config to{" "}
|
|
||||||
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await patchModel(model, { authentication: {}, authenticationType: null });
|
|
||||||
await patchModel(parentModel, {
|
|
||||||
authentication: model.authentication,
|
|
||||||
authenticationType: model.authenticationType,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parentModel.model === "folder") {
|
|
||||||
openFolderSettings(parentModel.id, "auth");
|
|
||||||
} else {
|
|
||||||
openWorkspaceSettings("auth");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy from ancestor: copy auth config down to current model
|
|
||||||
const ancestorWithAuth = ancestors.find(
|
|
||||||
(a) => a.authenticationType != null && a.authenticationType !== "none",
|
|
||||||
);
|
|
||||||
if (ancestorWithAuth) {
|
|
||||||
if (actions.length === 0) {
|
|
||||||
actions.push({ type: "separator", label: "Actions" });
|
|
||||||
}
|
}
|
||||||
actions.push({
|
)[] = [];
|
||||||
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
|
|
||||||
|
// Promote: move auth from current model up to parent
|
||||||
|
if (
|
||||||
|
parentModel &&
|
||||||
|
model.authenticationType &&
|
||||||
|
model.authenticationType !== "none" &&
|
||||||
|
(parentModel.authenticationType == null ||
|
||||||
|
parentModel.authenticationType === "none")
|
||||||
|
) {
|
||||||
|
actions.push(
|
||||||
|
{ type: "separator", label: "Actions" },
|
||||||
|
{
|
||||||
|
label: `Promote to ${capitalize(parentModel.model)}`,
|
||||||
leftSlot: (
|
leftSlot: (
|
||||||
<Icon
|
<Icon
|
||||||
icon={
|
icon={
|
||||||
ancestorWithAuth.model === "workspace" ? "corner_right_down" : "folder_down"
|
parentModel.model === "workspace"
|
||||||
|
? "corner_right_up"
|
||||||
|
: "folder_up"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
const confirmed = await showConfirm({
|
const confirmed = await showConfirm({
|
||||||
id: "copy-auth-confirm",
|
id: "promote-auth-confirm",
|
||||||
title: "Copy Authentication",
|
title: "Promote Authentication",
|
||||||
confirmText: "Copy",
|
confirmText: "Promote",
|
||||||
description: (
|
description: (
|
||||||
<>
|
<>
|
||||||
Copy{" "}
|
Move authentication config to{" "}
|
||||||
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType)
|
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
|
||||||
?.label ?? "authentication"}{" "}
|
|
||||||
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
|
|
||||||
This will override the current authentication but will not affect the{" "}
|
|
||||||
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await patchModel(model, {
|
await patchModel(model, {
|
||||||
authentication: { ...ancestorWithAuth.authentication },
|
authentication: {},
|
||||||
authenticationType: ancestorWithAuth.authenticationType,
|
authenticationType: null,
|
||||||
});
|
});
|
||||||
|
await patchModel(parentModel, {
|
||||||
|
authentication: model.authentication,
|
||||||
|
authenticationType: model.authenticationType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentModel.model === "folder") {
|
||||||
|
openFolderSettings(parentModel.id, "auth");
|
||||||
|
} else {
|
||||||
|
openWorkspaceSettings("auth");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return actions.length > 0 ? actions : undefined;
|
// Copy from ancestor: copy auth config down to current model
|
||||||
})(),
|
const ancestorWithAuth = ancestors.find(
|
||||||
onChange: async (authenticationType) => {
|
(a) =>
|
||||||
let authentication: Folder["authentication"] = model.authentication;
|
a.authenticationType != null && a.authenticationType !== "none",
|
||||||
if (model.authenticationType !== authenticationType) {
|
);
|
||||||
authentication = {
|
if (ancestorWithAuth) {
|
||||||
// Reset auth if changing types
|
if (actions.length === 0) {
|
||||||
};
|
actions.push({ type: "separator", label: "Actions" });
|
||||||
}
|
}
|
||||||
await patchModel(model, { authentication, authenticationType });
|
actions.push({
|
||||||
},
|
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
|
||||||
|
leftSlot: (
|
||||||
|
<Icon
|
||||||
|
icon={
|
||||||
|
ancestorWithAuth.model === "workspace"
|
||||||
|
? "corner_right_down"
|
||||||
|
: "folder_down"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
onSelect: async () => {
|
||||||
|
const confirmed = await showConfirm({
|
||||||
|
id: "copy-auth-confirm",
|
||||||
|
title: "Copy Authentication",
|
||||||
|
confirmText: "Copy",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Copy{" "}
|
||||||
|
{authentication.find(
|
||||||
|
(a) => a.name === ancestorWithAuth.authenticationType,
|
||||||
|
)?.label ?? "authentication"}{" "}
|
||||||
|
config from{" "}
|
||||||
|
<InlineCode>
|
||||||
|
{resolvedModelName(ancestorWithAuth)}
|
||||||
|
</InlineCode>
|
||||||
|
? This will override the current authentication but will not
|
||||||
|
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
await patchModel(model, {
|
||||||
|
authentication: { ...ancestorWithAuth.authentication },
|
||||||
|
authenticationType: ancestorWithAuth.authenticationType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.length > 0 ? actions : undefined;
|
||||||
|
})(),
|
||||||
|
onChange: async (authenticationType) => {
|
||||||
|
let authentication: Folder["authentication"] = model.authentication;
|
||||||
|
if (model.authenticationType !== authenticationType) {
|
||||||
|
authentication = {
|
||||||
|
// Reset auth if changing types
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await patchModel(model, { authentication, authenticationType });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
|
||||||
return [tab];
|
|
||||||
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type HotkeyAction =
|
|||||||
| "app.zoom_out"
|
| "app.zoom_out"
|
||||||
| "app.zoom_reset"
|
| "app.zoom_reset"
|
||||||
| "command_palette.toggle"
|
| "command_palette.toggle"
|
||||||
|
| "cookies_editor.show"
|
||||||
| "editor.autocomplete"
|
| "editor.autocomplete"
|
||||||
| "environment_editor.toggle"
|
| "environment_editor.toggle"
|
||||||
| "hotkeys.showHelp"
|
| "hotkeys.showHelp"
|
||||||
@@ -43,6 +44,7 @@ const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
|
|||||||
"app.zoom_out": ["Meta+Minus"],
|
"app.zoom_out": ["Meta+Minus"],
|
||||||
"app.zoom_reset": ["Meta+0"],
|
"app.zoom_reset": ["Meta+0"],
|
||||||
"command_palette.toggle": ["Meta+k"],
|
"command_palette.toggle": ["Meta+k"],
|
||||||
|
"cookies_editor.show": ["Meta+Shift+k"],
|
||||||
"editor.autocomplete": ["Control+Space"],
|
"editor.autocomplete": ["Control+Space"],
|
||||||
"environment_editor.toggle": ["Meta+Shift+e"],
|
"environment_editor.toggle": ["Meta+Shift+e"],
|
||||||
"request.rename": ["Control+Shift+r"],
|
"request.rename": ["Control+Shift+r"],
|
||||||
@@ -73,6 +75,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
|
|||||||
"app.zoom_out": ["Control+Minus"],
|
"app.zoom_out": ["Control+Minus"],
|
||||||
"app.zoom_reset": ["Control+0"],
|
"app.zoom_reset": ["Control+0"],
|
||||||
"command_palette.toggle": ["Control+k"],
|
"command_palette.toggle": ["Control+k"],
|
||||||
|
"cookies_editor.show": ["Control+Shift+k"],
|
||||||
"editor.autocomplete": ["Control+Space"],
|
"editor.autocomplete": ["Control+Space"],
|
||||||
"environment_editor.toggle": ["Control+Shift+e"],
|
"environment_editor.toggle": ["Control+Shift+e"],
|
||||||
"request.rename": ["F2"],
|
"request.rename": ["F2"],
|
||||||
@@ -128,6 +131,7 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
|
|||||||
"app.zoom_out": "Zoom Out",
|
"app.zoom_out": "Zoom Out",
|
||||||
"app.zoom_reset": "Zoom to Actual Size",
|
"app.zoom_reset": "Zoom to Actual Size",
|
||||||
"command_palette.toggle": "Toggle Command Palette",
|
"command_palette.toggle": "Toggle Command Palette",
|
||||||
|
"cookies_editor.show": "Show Cookies",
|
||||||
"editor.autocomplete": "Trigger Autocomplete",
|
"editor.autocomplete": "Trigger Autocomplete",
|
||||||
"environment_editor.toggle": "Edit Environments",
|
"environment_editor.toggle": "Edit Environments",
|
||||||
"hotkeys.showHelp": "Show Keyboard Shortcuts",
|
"hotkeys.showHelp": "Show Keyboard Shortcuts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Appearance } from "../lib/theme/appearance";
|
import type { Appearance } from "@yaakapp-internal/theme";
|
||||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance";
|
import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
|
||||||
|
|
||||||
export function usePreferredAppearance() {
|
export function usePreferredAppearance() {
|
||||||
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
|
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
|
import { resolveAppearance } from "@yaakapp-internal/theme";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { resolveAppearance } from "../lib/theme/appearance";
|
|
||||||
import { usePreferredAppearance } from "./usePreferredAppearance";
|
import { usePreferredAppearance } from "./usePreferredAppearance";
|
||||||
|
|
||||||
export function useResolvedAppearance() {
|
export function useResolvedAppearance() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { getResolvedTheme, getThemes } from "../lib/theme/themes";
|
import { getResolvedTheme, getThemes } from "../lib/themes";
|
||||||
import { usePluginsKey } from "./usePlugins";
|
import { usePluginsKey } from "./usePlugins";
|
||||||
import { usePreferredAppearance } from "./usePreferredAppearance";
|
import { usePreferredAppearance } from "./usePreferredAppearance";
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,32 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { getModel } from "@yaakapp-internal/models";
|
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { invokeCmd } from "../lib/tauri";
|
||||||
import { getActiveCookieJar } from "./useActiveCookieJar";
|
import { getActiveCookieJar } from "./useActiveCookieJar";
|
||||||
import { getActiveEnvironment } from "./useActiveEnvironment";
|
import { getActiveEnvironment } from "./useActiveEnvironment";
|
||||||
import { createFastMutation, useFastMutation } from "./useFastMutation";
|
import { createFastMutation, useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
|
async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await flushAllModelWrites();
|
||||||
|
|
||||||
|
return invokeCmd("cmd_send_http_request", {
|
||||||
|
requestId: id,
|
||||||
|
environmentId: getActiveEnvironment()?.id,
|
||||||
|
cookieJarId: getActiveCookieJar()?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSendAnyHttpRequest() {
|
export function useSendAnyHttpRequest() {
|
||||||
return useFastMutation<HttpResponse | null, string, string | null>({
|
return useFastMutation<HttpResponse | null, string, string | null>({
|
||||||
mutationKey: ["send_any_request"],
|
mutationKey: ["send_any_request"],
|
||||||
mutationFn: async (id) => {
|
mutationFn: sendAnyHttpRequestById,
|
||||||
const request = getModel("http_request", id ?? "n/a");
|
|
||||||
if (request == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return invokeCmd("cmd_send_http_request", {
|
|
||||||
request,
|
|
||||||
environmentId: getActiveEnvironment()?.id,
|
|
||||||
cookieJarId: getActiveCookieJar()?.id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
||||||
mutationKey: ["send_any_request"],
|
mutationKey: ["send_any_request"],
|
||||||
mutationFn: async (id) => {
|
mutationFn: sendAnyHttpRequestById,
|
||||||
const request = getModel("http_request", id ?? "n/a");
|
|
||||||
if (request == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return invokeCmd("cmd_send_http_request", {
|
|
||||||
request,
|
|
||||||
environmentId: getActiveEnvironment()?.id,
|
|
||||||
cookieJarId: getActiveCookieJar()?.id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,10 +35,15 @@ export async function deleteModelWithConfirm(
|
|||||||
<>
|
<>
|
||||||
the following?
|
the following?
|
||||||
<Prose className="mt-2">
|
<Prose className="mt-2">
|
||||||
<ul>
|
<ul className="space-y-1">
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<InlineCode>{resolvedModelName(m)}</InlineCode>
|
<InlineCode
|
||||||
|
className="inline-block truncate align-bottom max-w-full"
|
||||||
|
title={resolvedModelName(m)}
|
||||||
|
>
|
||||||
|
{resolvedModelName(m)}
|
||||||
|
</InlineCode>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ export function initGlobalListeners() {
|
|||||||
color: "danger",
|
color: "danger",
|
||||||
timeout: null,
|
timeout: null,
|
||||||
message: `Failed to load plugin "${name}": ${err}`,
|
message: `Failed to load plugin "${name}": ${err}`,
|
||||||
|
action: ({ hide }) => (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="danger"
|
||||||
|
variant="border"
|
||||||
|
onClick={() => {
|
||||||
|
hide();
|
||||||
|
openSettings.mutate("plugins:installed");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Plugins
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { AnyModel, Workspace } from "@yaakapp-internal/models";
|
||||||
|
|
||||||
|
type ModelType = AnyModel["model"];
|
||||||
|
|
||||||
|
type WorkspaceRequestSettings = Pick<
|
||||||
|
Workspace,
|
||||||
|
| "settingFollowRedirects"
|
||||||
|
| "settingRequestMessageSize"
|
||||||
|
| "settingRequestTimeout"
|
||||||
|
| "settingSendCookies"
|
||||||
|
| "settingStoreCookies"
|
||||||
|
| "settingValidateCertificates"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ModelForType<T extends ModelType> = Extract<AnyModel, { model: T }>;
|
||||||
|
|
||||||
|
type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
||||||
|
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
||||||
|
}[ModelType];
|
||||||
|
|
||||||
|
export type RequestSettingDefinition<
|
||||||
|
K extends RequestSettingKey = RequestSettingKey,
|
||||||
|
> = {
|
||||||
|
defaultValue: WorkspaceRequestSettings[K];
|
||||||
|
description: string;
|
||||||
|
modelKey: K;
|
||||||
|
models: readonly ModelTypeWithSetting<K>[];
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RequestSettingKey = keyof WorkspaceRequestSettings;
|
||||||
|
|
||||||
|
function defineRequestSetting<const K extends RequestSettingKey>(
|
||||||
|
setting: RequestSettingDefinition<K>,
|
||||||
|
) {
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
||||||
|
defaultValue: 0,
|
||||||
|
description: "Maximum request duration in milliseconds. Set to 0 to disable.",
|
||||||
|
modelKey: "settingRequestTimeout",
|
||||||
|
models: ["workspace", "folder", "http_request"],
|
||||||
|
title: "Request Timeout",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
||||||
|
defaultValue: 64 * 1024 * 1024,
|
||||||
|
description:
|
||||||
|
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||||
|
modelKey: "settingRequestMessageSize",
|
||||||
|
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
||||||
|
title: "Message Size Limit",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
||||||
|
defaultValue: true,
|
||||||
|
description: "When disabled, skip validation of server certificates.",
|
||||||
|
modelKey: "settingValidateCertificates",
|
||||||
|
models: [
|
||||||
|
"workspace",
|
||||||
|
"folder",
|
||||||
|
"http_request",
|
||||||
|
"websocket_request",
|
||||||
|
"grpc_request",
|
||||||
|
],
|
||||||
|
title: "Validate TLS certificates",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
||||||
|
defaultValue: true,
|
||||||
|
description: "Follow HTTP redirects automatically.",
|
||||||
|
modelKey: "settingFollowRedirects",
|
||||||
|
models: ["workspace", "folder", "http_request"],
|
||||||
|
title: "Follow redirects",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||||
|
defaultValue: true,
|
||||||
|
description:
|
||||||
|
"Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||||
|
modelKey: "settingSendCookies",
|
||||||
|
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||||
|
title: "Automatically send cookies",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||||
|
defaultValue: true,
|
||||||
|
description:
|
||||||
|
"Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||||
|
modelKey: "settingStoreCookies",
|
||||||
|
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||||
|
title: "Automatically store cookies",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function modelSupportsSetting<K extends RequestSettingKey>(
|
||||||
|
model: Pick<AnyModel, "model">,
|
||||||
|
setting: RequestSettingDefinition<K>,
|
||||||
|
) {
|
||||||
|
return setting.models.some((modelType) => modelType === model.model);
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export type { Appearance } from "@yaakapp-internal/theme";
|
|
||||||
export {
|
|
||||||
getCSSAppearance,
|
|
||||||
getWindowAppearance,
|
|
||||||
resolveAppearance,
|
|
||||||
subscribeToPreferredAppearance,
|
|
||||||
subscribeToWindowAppearanceChange,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
|
|
||||||
export {
|
|
||||||
addThemeStylesToDocument,
|
|
||||||
applyThemeToDocument,
|
|
||||||
completeTheme,
|
|
||||||
getThemeCSS,
|
|
||||||
indent,
|
|
||||||
setThemeOnDocument,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { YaakColor } from "@yaakapp-internal/theme";
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
||||||
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
import {
|
||||||
import { invokeCmd } from "../tauri";
|
defaultDarkTheme,
|
||||||
import type { Appearance } from "./appearance";
|
defaultLightTheme,
|
||||||
import { resolveAppearance } from "./appearance";
|
resolveAppearance,
|
||||||
|
type Appearance,
|
||||||
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
} from "@yaakapp-internal/theme";
|
||||||
|
import { invokeCmd } from "./tauri";
|
||||||
|
|
||||||
export async function getThemes() {
|
export async function getThemes() {
|
||||||
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-pdf": "^10.0.1",
|
"react-pdf": "^10.0.1",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"react-use": "^17.6.0",
|
"react-use": "^17.6.1",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
"remark-frontmatter": "^5.0.0",
|
"remark-frontmatter": "^5.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
@@ -98,15 +98,15 @@
|
|||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"decompress": "^4.2.1",
|
"decompress": "^4.2.1",
|
||||||
"internal-ip": "^8.0.0",
|
"internal-ip": "^8.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.14",
|
||||||
"postcss-nesting": "^13.0.2",
|
"postcss-nesting": "^13.0.2",
|
||||||
"rollup": "^4.60.3",
|
"rollup": "^4.60.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||||
"vite-plugin-static-copy": "^3.3.0",
|
"vite-plugin-static-copy": "^3.3.0",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"vite-plugin-top-level-await": "^1.5.0",
|
"vite-plugin-top-level-await": "^1.5.0",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vite-plus": "^0.1.20"
|
"vite-plus": "^0.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
||||||
import type { ModelPayload } from "@yaakapp-internal/models";
|
import type { ModelPayload } from "@yaakapp-internal/models";
|
||||||
|
import type { Appearance } from "@yaakapp-internal/theme";
|
||||||
|
import {
|
||||||
|
applyThemeToDocument,
|
||||||
|
getCSSAppearance,
|
||||||
|
subscribeToPreferredAppearance,
|
||||||
|
} from "@yaakapp-internal/theme";
|
||||||
import { getSettings } from "./lib/settings";
|
import { getSettings } from "./lib/settings";
|
||||||
import type { Appearance } from "./lib/theme/appearance";
|
import { getResolvedTheme } from "./lib/themes";
|
||||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
|
|
||||||
import { getResolvedTheme } from "./lib/theme/themes";
|
|
||||||
import { applyThemeToDocument } from "@yaakapp-internal/theme";
|
|
||||||
|
|
||||||
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
|
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
|
||||||
// a good appearance guess so we're not waiting too long
|
// a good appearance guess so we're not waiting too long
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default defineConfig(async () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
|
target: "esnext",
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
outDir: "../../dist/apps/yaak-client",
|
outDir: "../../dist/apps/yaak-client",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||||
"vite-plus": "^0.1.20"
|
"vite-plus": "^0.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ webbrowser = "1"
|
|||||||
zip = "4"
|
zip = "4"
|
||||||
yaak = { workspace = true }
|
yaak = { workspace = true }
|
||||||
yaak-api = { workspace = true }
|
yaak-api = { workspace = true }
|
||||||
|
yaak-core = { workspace = true }
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ pub enum Commands {
|
|||||||
/// Authentication commands
|
/// Authentication commands
|
||||||
Auth(AuthArgs),
|
Auth(AuthArgs),
|
||||||
|
|
||||||
|
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
|
||||||
|
Import(ImportArgs),
|
||||||
|
|
||||||
|
/// Export Yaak workspace data
|
||||||
|
Export(ExportArgs),
|
||||||
|
|
||||||
/// Plugin development and publishing commands
|
/// Plugin development and publishing commands
|
||||||
Plugin(PluginArgs),
|
Plugin(PluginArgs),
|
||||||
|
|
||||||
@@ -92,6 +98,34 @@ pub struct SendArgs {
|
|||||||
pub fail_fast: bool,
|
pub fail_fast: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ImportArgs {
|
||||||
|
/// Path to the file to import
|
||||||
|
pub file: PathBuf,
|
||||||
|
|
||||||
|
/// Existing workspace ID to import into when supported by the importer
|
||||||
|
#[arg(long = "workspace-id", value_name = "WORKSPACE_ID")]
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ExportArgs {
|
||||||
|
/// Path to write the Yaak export JSON file
|
||||||
|
pub file: PathBuf,
|
||||||
|
|
||||||
|
/// Workspace IDs to export (defaults to the only workspace when exactly one exists)
|
||||||
|
#[arg(value_name = "WORKSPACE_ID")]
|
||||||
|
pub workspace_ids: Vec<String>,
|
||||||
|
|
||||||
|
/// Export all workspaces
|
||||||
|
#[arg(long, conflicts_with = "workspace_ids")]
|
||||||
|
pub all: bool,
|
||||||
|
|
||||||
|
/// Include private environments in the export
|
||||||
|
#[arg(long)]
|
||||||
|
pub include_private_environments: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
#[command(disable_help_subcommand = true)]
|
#[command(disable_help_subcommand = true)]
|
||||||
pub struct CookieJarArgs {
|
pub struct CookieJarArgs {
|
||||||
@@ -447,6 +481,10 @@ pub enum PluginCommands {
|
|||||||
/// Install a plugin from a local directory or from the registry
|
/// Install a plugin from a local directory or from the registry
|
||||||
Install(InstallPluginArgs),
|
Install(InstallPluginArgs),
|
||||||
|
|
||||||
|
/// Generate plugin metadata for the registry
|
||||||
|
#[command(hide = true)]
|
||||||
|
Metadata(PluginPathArg),
|
||||||
|
|
||||||
/// Publish a Yaak plugin version to the plugin registry
|
/// Publish a Yaak plugin version to the plugin registry
|
||||||
Publish(PluginPathArg),
|
Publish(PluginPathArg),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
use crate::cli::{ExportArgs, ImportArgs};
|
||||||
|
use crate::context::CliContext;
|
||||||
|
use crate::utils::workspace::resolve_workspace_id;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use yaak::export::{self, ExportDataParams};
|
||||||
|
use yaak::import;
|
||||||
|
use yaak_core::WorkspaceContext;
|
||||||
|
use yaak_models::util::BatchUpsertResult;
|
||||||
|
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||||
|
|
||||||
|
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||||
|
|
||||||
|
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
||||||
|
match import(ctx, args).await {
|
||||||
|
Ok(result) => {
|
||||||
|
println!("Imported {}", format_counts(&result));
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Error: {error}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
|
||||||
|
match export(ctx, args) {
|
||||||
|
Ok(count) => {
|
||||||
|
println!("Exported {count} workspace(s)");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Error: {error}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
|
||||||
|
if let Some(workspace_id) = args.workspace_id.as_deref() {
|
||||||
|
ctx.db()
|
||||||
|
.get_workspace(workspace_id)
|
||||||
|
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_contents = read_import_file(&args.file)?;
|
||||||
|
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
|
||||||
|
let plugin_manager = ctx.plugin_manager();
|
||||||
|
let import_result = plugin_manager
|
||||||
|
.import_data(&plugin_context, &file_contents)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||||
|
let resources = import_result.resources;
|
||||||
|
let workspace_id = args.workspace_id;
|
||||||
|
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||||
|
return Err(
|
||||||
|
"This import requires a workspace context. Provide --workspace-id <WORKSPACE_ID>."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let workspace_context = WorkspaceContext {
|
||||||
|
workspace_id,
|
||||||
|
environment_id: None,
|
||||||
|
cookie_jar_id: None,
|
||||||
|
request_id: None,
|
||||||
|
};
|
||||||
|
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||||
|
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
||||||
|
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
|
||||||
|
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
|
||||||
|
export::export_data(ExportDataParams {
|
||||||
|
query_manager: ctx.query_manager(),
|
||||||
|
yaak_version: env!("CARGO_PKG_VERSION"),
|
||||||
|
export_path: &args.file,
|
||||||
|
workspace_ids: workspace_id_refs,
|
||||||
|
include_private_environments: args.include_private_environments,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to export data: {e}"))?;
|
||||||
|
|
||||||
|
Ok(workspace_ids.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_export_workspace_ids(
|
||||||
|
ctx: &CliContext,
|
||||||
|
workspace_ids: Vec<String>,
|
||||||
|
all: bool,
|
||||||
|
) -> CommandResult<Vec<String>> {
|
||||||
|
if all {
|
||||||
|
let workspaces =
|
||||||
|
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
|
||||||
|
if workspaces.is_empty() {
|
||||||
|
return Err("No workspaces found to export".to_string());
|
||||||
|
}
|
||||||
|
return Ok(workspaces.into_iter().map(|w| w.id).collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
if workspace_ids.is_empty() {
|
||||||
|
return resolve_workspace_id(ctx, None, "export").map(|id| vec![id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for workspace_id in &workspace_ids {
|
||||||
|
ctx.db()
|
||||||
|
.get_workspace(workspace_id)
|
||||||
|
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(workspace_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
|
||||||
|
fs::read_to_string(path).map_err(|err| {
|
||||||
|
if err.kind() == ErrorKind::InvalidData {
|
||||||
|
format!(
|
||||||
|
"Import file must be UTF-8 text; binary files are not supported: {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Unable to read import file {}: {err}", path.display())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
|
||||||
|
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|
||||||
|
|| resources.environments.iter().any(|e| {
|
||||||
|
e.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| e.parent_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
|| resources.folders.iter().any(|f| {
|
||||||
|
f.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| f.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
|| resources.http_requests.iter().any(|r| {
|
||||||
|
r.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
|| resources.grpc_requests.iter().any(|r| {
|
||||||
|
r.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
|| resources.websocket_requests.iter().any(|r| {
|
||||||
|
r.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_counts(result: &BatchUpsertResult) -> String {
|
||||||
|
let names = [
|
||||||
|
"workspace",
|
||||||
|
"environment",
|
||||||
|
"folder",
|
||||||
|
"HTTP request",
|
||||||
|
"gRPC request",
|
||||||
|
"WebSocket request",
|
||||||
|
];
|
||||||
|
let counts = [
|
||||||
|
(result.workspaces.len(), names[0]),
|
||||||
|
(result.environments.len(), names[1]),
|
||||||
|
(result.folders.len(), names[2]),
|
||||||
|
(result.http_requests.len(), names[3]),
|
||||||
|
(result.grpc_requests.len(), names[4]),
|
||||||
|
(result.websocket_requests.len(), names[5]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let non_zero: Vec<String> = counts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(count, _)| *count > 0)
|
||||||
|
.map(|(count, name)| format!("{count} {name}{}", if count == 1 { "" } else { "s" }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if non_zero.is_empty() { "nothing".to_string() } else { non_zero.join(", ") }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod auth;
|
|||||||
pub mod cookie_jar;
|
pub mod cookie_jar;
|
||||||
pub mod environment;
|
pub mod environment;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
|
pub mod import_export;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use std::collections::HashSet;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, IsTerminal, Read, Write};
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
@@ -27,6 +28,11 @@ use zip::write::SimpleFileOptions;
|
|||||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||||
|
|
||||||
const KEYRING_USER: &str = "yaak";
|
const KEYRING_USER: &str = "yaak";
|
||||||
|
const METADATA_NODE_BIN: &str = "node";
|
||||||
|
const PLUGIN_RUNTIME_NODE_VERSION: &str = include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../../packages/plugin-runtime/.node-version"
|
||||||
|
));
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum Environment {
|
enum Environment {
|
||||||
@@ -103,6 +109,16 @@ pub async fn run_publish(args: PluginPathArg) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn run_metadata(args: PluginPathArg) -> i32 {
|
||||||
|
match metadata(args) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
ui::error(&error);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn build(args: PluginPathArg) -> CommandResult {
|
async fn build(args: PluginPathArg) -> CommandResult {
|
||||||
let plugin_dir = resolve_plugin_dir(args.path)?;
|
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||||
ensure_plugin_build_inputs(&plugin_dir)?;
|
ensure_plugin_build_inputs(&plugin_dir)?;
|
||||||
@@ -112,10 +128,21 @@ async fn build(args: PluginPathArg) -> CommandResult {
|
|||||||
for warning in warnings {
|
for warning in warnings {
|
||||||
ui::warning(&warning);
|
ui::warning(&warning);
|
||||||
}
|
}
|
||||||
|
generate_plugin_metadata(&plugin_dir)?;
|
||||||
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
|
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn metadata(args: PluginPathArg) -> CommandResult {
|
||||||
|
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||||
|
generate_plugin_metadata(&plugin_dir)?;
|
||||||
|
ui::success(&format!(
|
||||||
|
"Generated plugin metadata at {}",
|
||||||
|
plugin_dir.join("build/metadata.json").display()
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn dev(args: PluginPathArg) -> CommandResult {
|
async fn dev(args: PluginPathArg) -> CommandResult {
|
||||||
let plugin_dir = resolve_plugin_dir(args.path)?;
|
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||||
ensure_plugin_build_inputs(&plugin_dir)?;
|
ensure_plugin_build_inputs(&plugin_dir)?;
|
||||||
@@ -153,7 +180,15 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
|||||||
});
|
});
|
||||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||||
}
|
}
|
||||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {}
|
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
||||||
|
match generate_plugin_metadata(&watch_root) {
|
||||||
|
Ok(()) => ui::success(&format!(
|
||||||
|
"Generated plugin metadata at {}",
|
||||||
|
watch_root.join("build/metadata.json").display()
|
||||||
|
)),
|
||||||
|
Err(error) => ui::error(&error),
|
||||||
|
}
|
||||||
|
}
|
||||||
WatcherEvent::Event(BundleEvent::Error(event)) => {
|
WatcherEvent::Event(BundleEvent::Error(event)) => {
|
||||||
if event.error.diagnostics.is_empty() {
|
if event.error.diagnostics.is_empty() {
|
||||||
ui::error("Plugin build failed");
|
ui::error("Plugin build failed");
|
||||||
@@ -228,6 +263,7 @@ async fn publish(args: PluginPathArg) -> CommandResult {
|
|||||||
for warning in warnings {
|
for warning in warnings {
|
||||||
ui::warning(&warning);
|
ui::warning(&warning);
|
||||||
}
|
}
|
||||||
|
generate_plugin_metadata(&plugin_dir)?;
|
||||||
|
|
||||||
ui::info("Archiving plugin");
|
ui::info("Archiving plugin");
|
||||||
let archive = create_publish_archive(&plugin_dir)?;
|
let archive = create_publish_archive(&plugin_dir)?;
|
||||||
@@ -379,6 +415,79 @@ async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
|||||||
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
|
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generate_plugin_metadata(plugin_dir: &Path) -> CommandResult {
|
||||||
|
let entry_path = plugin_dir.join("build/index.js");
|
||||||
|
if !entry_path.is_file() {
|
||||||
|
return Err("build/index.js does not exist. Run `yaak plugin build` first.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_metadata_node_version()?;
|
||||||
|
|
||||||
|
let metadata_path = plugin_dir.join("build/metadata.json");
|
||||||
|
let output = Command::new(METADATA_NODE_BIN)
|
||||||
|
.arg("-e")
|
||||||
|
.arg(METADATA_SCRIPT)
|
||||||
|
.arg(entry_path.canonicalize().map_err(|e| {
|
||||||
|
format!("Failed to resolve plugin entrypoint {}: {e}", entry_path.display())
|
||||||
|
})?)
|
||||||
|
.arg(&metadata_path)
|
||||||
|
.current_dir(plugin_dir)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run Node.js to generate plugin metadata: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let message = if stderr.is_empty() {
|
||||||
|
format!("Node.js exited with status {}", output.status)
|
||||||
|
} else {
|
||||||
|
stderr
|
||||||
|
};
|
||||||
|
return Err(format!("Failed to generate plugin metadata: {message}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_metadata_node_version() -> CommandResult {
|
||||||
|
let minimum_major = PLUGIN_RUNTIME_NODE_VERSION
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('v')
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.and_then(|part| part.parse::<u32>().ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Invalid plugin runtime Node.js version {:?} in packages/plugin-runtime/.node-version",
|
||||||
|
PLUGIN_RUNTIME_NODE_VERSION.trim()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let output = Command::new(METADATA_NODE_BIN)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Node.js {minimum_major} or newer is required: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"`{METADATA_NODE_BIN} --version` failed with status {}",
|
||||||
|
output.status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let major = version
|
||||||
|
.trim_start_matches('v')
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.and_then(|part| part.parse::<u32>().ok())
|
||||||
|
.ok_or_else(|| format!("Could not parse Node.js version {version:?}"))?;
|
||||||
|
|
||||||
|
if major >= minimum_major {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Node.js {minimum_major} or newer is required. Found {version}."))
|
||||||
|
}
|
||||||
|
|
||||||
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
||||||
let build_dir = plugin_dir.join("build");
|
let build_dir = plugin_dir.join("build");
|
||||||
if build_dir.exists() {
|
if build_dir.exists() {
|
||||||
@@ -578,6 +687,11 @@ const TEMPLATE_PACKAGE_JSON: &str = r#"{
|
|||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const METADATA_SCRIPT: &str = include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../../packages/plugin-runtime/src/metadata.ts"
|
||||||
|
));
|
||||||
|
|
||||||
const TEMPLATE_TSCONFIG: &str = r#"{
|
const TEMPLATE_TSCONFIG: &str = r#"{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2021",
|
"target": "es2021",
|
||||||
@@ -636,7 +750,8 @@ describe("Example Plugin", () => {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::create_publish_archive;
|
use super::{create_publish_archive, generate_plugin_metadata};
|
||||||
|
use serde_json::Value;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
@@ -659,6 +774,7 @@ mod tests {
|
|||||||
.expect("write src/index.ts");
|
.expect("write src/index.ts");
|
||||||
fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
|
fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
|
||||||
.expect("write build/index.js");
|
.expect("write build/index.js");
|
||||||
|
fs::write(root.join("build/metadata.json"), "{}\n").expect("write build/metadata.json");
|
||||||
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
|
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
|
||||||
|
|
||||||
let archive = create_publish_archive(root).expect("create archive");
|
let archive = create_publish_archive(root).expect("create archive");
|
||||||
@@ -673,8 +789,74 @@ mod tests {
|
|||||||
assert!(names.contains("README.md"));
|
assert!(names.contains("README.md"));
|
||||||
assert!(names.contains("package.json"));
|
assert!(names.contains("package.json"));
|
||||||
assert!(names.contains("package-lock.json"));
|
assert!(names.contains("package-lock.json"));
|
||||||
|
assert!(names.contains("build/metadata.json"));
|
||||||
assert!(names.contains("src/index.ts"));
|
assert!(names.contains("src/index.ts"));
|
||||||
assert!(names.contains("build/index.js"));
|
assert!(names.contains("build/index.js"));
|
||||||
assert!(!names.contains("ignored/secret.txt"));
|
assert!(!names.contains("ignored/secret.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_plugin_metadata_detects_api_types() {
|
||||||
|
let dir = TempDir::new().expect("temp dir");
|
||||||
|
let root = dir.path();
|
||||||
|
fs::create_dir_all(root.join("build")).expect("create build");
|
||||||
|
fs::write(
|
||||||
|
root.join("build/index.js"),
|
||||||
|
r##"
|
||||||
|
exports.plugin = {
|
||||||
|
themes: [{
|
||||||
|
id: "midnight",
|
||||||
|
label: "Midnight",
|
||||||
|
dark: true,
|
||||||
|
base: { surface: "#000000", text: "#ffffff" },
|
||||||
|
}],
|
||||||
|
templateFunctions: [{
|
||||||
|
name: "signature",
|
||||||
|
description: "Create a signature",
|
||||||
|
args: [{ type: "text", name: "secret", dynamic() {} }],
|
||||||
|
onRender() {},
|
||||||
|
}],
|
||||||
|
workspaceActions: [{
|
||||||
|
label: "Sync workspace",
|
||||||
|
icon: "info",
|
||||||
|
onSelect() {},
|
||||||
|
}],
|
||||||
|
folderActions: [{
|
||||||
|
label: "Export folder",
|
||||||
|
icon: "copy",
|
||||||
|
onSelect() {},
|
||||||
|
}],
|
||||||
|
async init() {},
|
||||||
|
};
|
||||||
|
"##,
|
||||||
|
)
|
||||||
|
.expect("write build/index.js");
|
||||||
|
|
||||||
|
generate_plugin_metadata(root).expect("generate metadata");
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(root.join("build/metadata.json")).expect("read metadata");
|
||||||
|
let metadata: Value = serde_json::from_str(&contents).expect("metadata json");
|
||||||
|
let api_types = metadata["apiTypes"].as_array().expect("apiTypes array");
|
||||||
|
|
||||||
|
for expected in [
|
||||||
|
"folderActions",
|
||||||
|
"templateFunctions",
|
||||||
|
"themes",
|
||||||
|
"workspaceActions",
|
||||||
|
"lifecycle",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
api_types.iter().any(|value| value.as_str() == Some(expected)),
|
||||||
|
"missing api type {expected}: {api_types:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(metadata["apis"]["themes"]["items"][0]["id"], "midnight");
|
||||||
|
assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace");
|
||||||
|
assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init");
|
||||||
|
assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null());
|
||||||
|
assert!(
|
||||||
|
metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,29 @@ async fn main() {
|
|||||||
|
|
||||||
let exit_code = match command {
|
let exit_code = match command {
|
||||||
Commands::Auth(args) => commands::auth::run(args).await,
|
Commands::Auth(args) => commands::auth::run(args).await,
|
||||||
|
Commands::Import(args) => {
|
||||||
|
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||||
|
let execution_context = CliExecutionContext {
|
||||||
|
workspace_id: args.workspace_id.clone(),
|
||||||
|
..CliExecutionContext::default()
|
||||||
|
};
|
||||||
|
context.init_plugins(execution_context).await;
|
||||||
|
let exit_code = commands::import_export::run_import(&context, args).await;
|
||||||
|
context.shutdown().await;
|
||||||
|
exit_code
|
||||||
|
}
|
||||||
|
Commands::Export(args) => {
|
||||||
|
let context = CliContext::new(data_dir.clone(), app_id);
|
||||||
|
let exit_code = commands::import_export::run_export(&context, args);
|
||||||
|
context.shutdown().await;
|
||||||
|
exit_code
|
||||||
|
}
|
||||||
Commands::Plugin(args) => match args.command {
|
Commands::Plugin(args) => match args.command {
|
||||||
PluginCommands::Build(args) => commands::plugin::run_build(args).await,
|
PluginCommands::Build(args) => commands::plugin::run_build(args).await,
|
||||||
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
|
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
|
||||||
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
|
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
|
||||||
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
|
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
|
||||||
|
PluginCommands::Metadata(args) => commands::plugin::run_metadata(args).await,
|
||||||
PluginCommands::Install(install_args) => {
|
PluginCommands::Install(install_args) => {
|
||||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||||
context.init_plugins(CliExecutionContext::default()).await;
|
context.init_plugins(CliExecutionContext::default()).await;
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ async fn build_plugin_reply(
|
|||||||
let names = cookie_jar
|
let names = cookie_jar
|
||||||
.cookies
|
.cookies
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|c| parse_cookie_name_value(&c.raw_cookie).map(|(name, _)| name))
|
.map(|c| c.name)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
||||||
@@ -531,12 +531,6 @@ async fn render_json_value_for_cli<T: TemplateCallback>(
|
|||||||
render_json_value_raw(value, vars, cb, opt).await
|
render_json_value_raw(value, vars, cb, opt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
|
|
||||||
let first_part = raw_cookie.split(';').next()?.trim();
|
|
||||||
let (name, value) = first_part.split_once('=')?;
|
|
||||||
Some((name.trim().to_string(), value.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
|
fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
|
||||||
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
|
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
|
||||||
clipboard.set_text(text.to_string()).map_err(|e| e.to_string())
|
clipboard.set_text(text.to_string()).map_err(|e| e.to_string())
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::{cli_cmd, parse_created_id, query_manager, seed_request};
|
||||||
|
use predicates::str::contains;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_writes_yaak_workspace_file() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
let export_path = temp_dir.path().join("export.json");
|
||||||
|
|
||||||
|
let create_assert =
|
||||||
|
cli_cmd(data_dir).args(["workspace", "create", "--name", "Export Me"]).assert().success();
|
||||||
|
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
|
||||||
|
seed_request(data_dir, &workspace_id, "req_export");
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args([
|
||||||
|
"export",
|
||||||
|
export_path.to_str().expect("export path is utf-8"),
|
||||||
|
&workspace_id,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("Exported 1 workspace(s)"));
|
||||||
|
|
||||||
|
let exported: Value = serde_json::from_str(
|
||||||
|
&std::fs::read_to_string(export_path).expect("export file should exist"),
|
||||||
|
)
|
||||||
|
.expect("export should be JSON");
|
||||||
|
|
||||||
|
assert_eq!(exported["yaakSchema"], 4);
|
||||||
|
assert_eq!(exported["resources"]["workspaces"][0]["id"], workspace_id);
|
||||||
|
assert_eq!(exported["resources"]["httpRequests"][0]["id"], "req_export");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_reads_yaak_workspace_file() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
let import_path = temp_dir.path().join("import.json");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&import_path,
|
||||||
|
r#"{
|
||||||
|
"yaakVersion": "test",
|
||||||
|
"yaakSchema": 4,
|
||||||
|
"resources": {
|
||||||
|
"workspaces": [
|
||||||
|
{
|
||||||
|
"model": "workspace",
|
||||||
|
"id": "wrk_import",
|
||||||
|
"name": "Imported Workspace"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"httpRequests": [
|
||||||
|
{
|
||||||
|
"model": "http_request",
|
||||||
|
"id": "req_import",
|
||||||
|
"workspaceId": "wrk_import",
|
||||||
|
"name": "Imported Request",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://example.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write import fixture");
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args([
|
||||||
|
"import",
|
||||||
|
import_path.to_str().expect("import path is utf-8"),
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("Imported 1 workspace, 1 HTTP request"));
|
||||||
|
|
||||||
|
let query_manager = query_manager(data_dir);
|
||||||
|
let db = query_manager.connect();
|
||||||
|
assert_eq!(
|
||||||
|
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||||
|
"Imported Workspace"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.get_http_request("req_import").expect("request imported").url,
|
||||||
|
"https://example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||||
|
std::fs::write(
|
||||||
|
path,
|
||||||
|
r#"{
|
||||||
|
"name": "Local",
|
||||||
|
"_postman_variable_scope": "environment",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "abc123",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write postman environment fixture");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_postman_environment_requires_workspace_id() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
let import_path = temp_dir.path().join("postman-env.json");
|
||||||
|
|
||||||
|
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
|
||||||
|
write_postman_environment_fixture(&import_path);
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args([
|
||||||
|
"import",
|
||||||
|
import_path.to_str().expect("import path is utf-8"),
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(contains("requires a workspace context"))
|
||||||
|
.stderr(contains("--workspace-id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_postman_environment_uses_workspace_id() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
let import_path = temp_dir.path().join("postman-env.json");
|
||||||
|
|
||||||
|
let create_assert =
|
||||||
|
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
|
||||||
|
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
|
||||||
|
write_postman_environment_fixture(&import_path);
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args([
|
||||||
|
"import",
|
||||||
|
import_path.to_str().expect("import path is utf-8"),
|
||||||
|
"--workspace-id",
|
||||||
|
&workspace_id,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("Imported 1 environment"));
|
||||||
|
|
||||||
|
let query_manager = query_manager(data_dir);
|
||||||
|
let db = query_manager.connect();
|
||||||
|
let environments =
|
||||||
|
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
|
||||||
|
|
||||||
|
let imported_environment =
|
||||||
|
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
||||||
|
assert_eq!(imported_environment.workspace_id, workspace_id);
|
||||||
|
}
|
||||||
@@ -7,34 +7,6 @@ use tempfile::TempDir;
|
|||||||
use yaak_models::models::HttpRequest;
|
use yaak_models::models::HttpRequest;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn top_level_send_workspace_sends_http_requests_and_prints_summary() {
|
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
||||||
let data_dir = temp_dir.path();
|
|
||||||
seed_workspace(data_dir, "wk_test");
|
|
||||||
|
|
||||||
let server = TestHttpServer::spawn_ok("workspace bulk send");
|
|
||||||
let request = HttpRequest {
|
|
||||||
id: "rq_workspace_send".to_string(),
|
|
||||||
workspace_id: "wk_test".to_string(),
|
|
||||||
name: "Workspace Send".to_string(),
|
|
||||||
method: "GET".to_string(),
|
|
||||||
url: server.url.clone(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
query_manager(data_dir)
|
|
||||||
.connect()
|
|
||||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
|
||||||
.expect("Failed to seed workspace request");
|
|
||||||
|
|
||||||
cli_cmd(data_dir)
|
|
||||||
.args(["send", "wk_test"])
|
|
||||||
.assert()
|
|
||||||
.success()
|
|
||||||
.stdout(contains("workspace bulk send"))
|
|
||||||
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_level_send_folder_sends_http_requests_and_prints_summary() {
|
fn top_level_send_folder_sends_http_requests_and_prints_summary() {
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ pub enum Error {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
ApiError(#[from] yaak_api::Error),
|
ApiError(#[from] yaak_api::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
YaakError(#[from] yaak::Error),
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
use crate::PluginContextExt;
|
use crate::PluginContextExt;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
use log::info;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fs::read_to_string;
|
use std::fs::read_to_string;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use tauri::{Manager, Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
|
use yaak::import::{self, ImportDataParams};
|
||||||
use yaak_core::WorkspaceContext;
|
use yaak_core::WorkspaceContext;
|
||||||
use yaak_models::models::{
|
use yaak_models::util::BatchUpsertResult;
|
||||||
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
|
||||||
};
|
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
|
|
||||||
@@ -19,113 +15,24 @@ pub(crate) async fn import_data<R: Runtime>(
|
|||||||
file_path: &str,
|
file_path: &str,
|
||||||
) -> Result<BatchUpsertResult> {
|
) -> Result<BatchUpsertResult> {
|
||||||
let plugin_manager = window.state::<PluginManager>();
|
let plugin_manager = window.state::<PluginManager>();
|
||||||
|
let query_manager = window.db_manager();
|
||||||
let file = read_import_file(file_path)?;
|
let file = read_import_file(file_path)?;
|
||||||
let file_contents = file.as_str();
|
let plugin_context = window.plugin_context();
|
||||||
let import_result = plugin_manager.import_data(&window.plugin_context(), file_contents).await?;
|
let workspace_context = WorkspaceContext {
|
||||||
|
|
||||||
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
|
|
||||||
|
|
||||||
// Create WorkspaceContext from window
|
|
||||||
let ctx = WorkspaceContext {
|
|
||||||
workspace_id: window.workspace_id(),
|
workspace_id: window.workspace_id(),
|
||||||
environment_id: window.environment_id(),
|
environment_id: window.environment_id(),
|
||||||
cookie_jar_id: window.cookie_jar_id(),
|
cookie_jar_id: window.cookie_jar_id(),
|
||||||
request_id: None,
|
request_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let resources = import_result.resources;
|
Ok(import::import_data(ImportDataParams {
|
||||||
|
query_manager: &query_manager,
|
||||||
let workspaces: Vec<Workspace> = resources
|
plugin_manager: &plugin_manager,
|
||||||
.workspaces
|
plugin_context: &plugin_context,
|
||||||
.into_iter()
|
workspace_context,
|
||||||
.map(|mut v| {
|
contents: &file,
|
||||||
v.id = maybe_gen_id::<Workspace>(&ctx, v.id.as_str(), &mut id_map);
|
})
|
||||||
v
|
.await?)
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let environments: Vec<Environment> = resources
|
|
||||||
.environments
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<Environment>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
|
|
||||||
("folder", Some(parent_id)) => {
|
|
||||||
v.parent_id = Some(maybe_gen_id::<Folder>(&ctx, &parent_id, &mut id_map));
|
|
||||||
}
|
|
||||||
("", _) => {
|
|
||||||
// Fix any empty ones
|
|
||||||
v.parent_model = "workspace".to_string();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Parent ID only required for the folder case
|
|
||||||
v.parent_id = None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let folders: Vec<Folder> = resources
|
|
||||||
.folders
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<Folder>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let http_requests: Vec<HttpRequest> = resources
|
|
||||||
.http_requests
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<HttpRequest>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let grpc_requests: Vec<GrpcRequest> = resources
|
|
||||||
.grpc_requests
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<GrpcRequest>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let websocket_requests: Vec<WebsocketRequest> = resources
|
|
||||||
.websocket_requests
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<WebsocketRequest>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
info!("Importing data");
|
|
||||||
|
|
||||||
let upserted = window.with_tx(|tx| {
|
|
||||||
tx.batch_upsert(
|
|
||||||
workspaces,
|
|
||||||
environments,
|
|
||||||
folders,
|
|
||||||
http_requests,
|
|
||||||
grpc_requests,
|
|
||||||
websocket_requests,
|
|
||||||
&UpdateSource::Import,
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(upserted)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_import_file(file_path: &str) -> Result<String> {
|
fn read_import_file(file_path: &str) -> Result<String> {
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ use error::Result as YaakResult;
|
|||||||
use eventsource_client::{EventParser, SSE};
|
use eventsource_client::{EventParser, SSE};
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::path::{Path, PathBuf};
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -31,6 +30,7 @@ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::task::block_in_place;
|
use tokio::task::block_in_place;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
use yaak::export::{self, ExportDataParams};
|
||||||
use yaak_common::command::new_checked_command;
|
use yaak_common::command::new_checked_command;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||||
@@ -41,7 +41,7 @@ use yaak_models::models::{
|
|||||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
||||||
WorkspaceMeta,
|
WorkspaceMeta,
|
||||||
};
|
};
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||||
@@ -54,7 +54,7 @@ use yaak_plugins::events::{
|
|||||||
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
||||||
};
|
};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
use yaak_sse::sse::ServerSentEvent;
|
use yaak_sse::sse::ServerSentEvent;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
@@ -295,7 +295,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
|||||||
unrendered_request.folder_id.as_deref(),
|
unrendered_request.folder_id.as_deref(),
|
||||||
environment_id,
|
environment_id,
|
||||||
)?;
|
)?;
|
||||||
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
|
let resolved_settings =
|
||||||
|
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
||||||
|
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
@@ -330,8 +331,9 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
|||||||
&uri,
|
&uri,
|
||||||
&proto_files,
|
&proto_files,
|
||||||
&metadata,
|
&metadata,
|
||||||
workspace.setting_validate_certificates,
|
resolved_settings.validate_certificates.value,
|
||||||
client_certificate,
|
client_certificate,
|
||||||
|
resolved_settings.request_message_size.value,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| GenericError(e.to_string()))?)
|
.map_err(|e| GenericError(e.to_string()))?)
|
||||||
@@ -353,7 +355,8 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
unrendered_request.folder_id.as_deref(),
|
unrendered_request.folder_id.as_deref(),
|
||||||
environment_id,
|
environment_id,
|
||||||
)?;
|
)?;
|
||||||
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
|
let resolved_settings =
|
||||||
|
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
||||||
|
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
@@ -423,8 +426,9 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
uri.as_str(),
|
uri.as_str(),
|
||||||
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
|
||||||
&metadata,
|
&metadata,
|
||||||
workspace.setting_validate_certificates,
|
resolved_settings.validate_certificates.value,
|
||||||
client_cert.clone(),
|
client_cert.clone(),
|
||||||
|
resolved_settings.request_message_size.value,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -714,7 +718,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
Some(s) => GrpcEvent {
|
Some(s) => GrpcEvent {
|
||||||
error: Some(s.message().to_string()),
|
error: Some(s.message().to_string()),
|
||||||
status: Some(s.code() as i32),
|
status: Some(s.code() as i32),
|
||||||
content: "Failed to connect".to_string(),
|
content: "Request failed".to_string(),
|
||||||
metadata: metadata_to_map(s.metadata().clone()),
|
metadata: metadata_to_map(s.metadata().clone()),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
@@ -722,7 +726,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
None => GrpcEvent {
|
None => GrpcEvent {
|
||||||
error: Some(e.message),
|
error: Some(e.message),
|
||||||
status: Some(Code::Unknown as i32),
|
status: Some(Code::Unknown as i32),
|
||||||
content: "Failed to connect".to_string(),
|
content: "Request failed".to_string(),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
},
|
},
|
||||||
@@ -738,7 +742,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&GrpcEvent {
|
&GrpcEvent {
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
status: Some(Code::Unknown as i32),
|
status: Some(Code::Unknown as i32),
|
||||||
content: "Failed to connect".to_string(),
|
content: "Request failed".to_string(),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
},
|
},
|
||||||
@@ -781,7 +785,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
Some(s) => GrpcEvent {
|
Some(s) => GrpcEvent {
|
||||||
error: Some(s.message().to_string()),
|
error: Some(s.message().to_string()),
|
||||||
status: Some(s.code() as i32),
|
status: Some(s.code() as i32),
|
||||||
content: "Failed to connect".to_string(),
|
content: "Stream failed".to_string(),
|
||||||
metadata: metadata_to_map(s.metadata().clone()),
|
metadata: metadata_to_map(s.metadata().clone()),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
@@ -789,7 +793,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
None => GrpcEvent {
|
None => GrpcEvent {
|
||||||
error: Some(e.message),
|
error: Some(e.message),
|
||||||
status: Some(Code::Unknown as i32),
|
status: Some(Code::Unknown as i32),
|
||||||
content: "Failed to connect".to_string(),
|
content: "Stream failed".to_string(),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
},
|
},
|
||||||
@@ -806,7 +810,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&GrpcEvent {
|
&GrpcEvent {
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
status: Some(Code::Unknown as i32),
|
status: Some(Code::Unknown as i32),
|
||||||
content: "Failed to connect".to_string(),
|
content: "Stream failed".to_string(),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
},
|
},
|
||||||
@@ -878,7 +882,8 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
.db()
|
.db()
|
||||||
.upsert_grpc_event(
|
.upsert_grpc_event(
|
||||||
&GrpcEvent {
|
&GrpcEvent {
|
||||||
content: status.to_string(),
|
content: "Stream failed".to_string(),
|
||||||
|
error: Some(status.message().to_string()),
|
||||||
status: Some(status.code() as i32),
|
status: Some(status.code() as i32),
|
||||||
metadata: metadata_to_map(status.metadata().clone()),
|
metadata: metadata_to_map(status.metadata().clone()),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
@@ -887,6 +892,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&UpdateSource::from_window_label(window.label()),
|
&UpdateSource::from_window_label(window.label()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1384,24 +1390,14 @@ async fn cmd_export_data<R: Runtime>(
|
|||||||
workspace_ids: Vec<&str>,
|
workspace_ids: Vec<&str>,
|
||||||
include_private_environments: bool,
|
include_private_environments: bool,
|
||||||
) -> YaakResult<()> {
|
) -> YaakResult<()> {
|
||||||
let db = app_handle.db();
|
|
||||||
let version = app_handle.package_info().version.to_string();
|
let version = app_handle.package_info().version.to_string();
|
||||||
let export_data =
|
Ok(export::export_data(ExportDataParams {
|
||||||
get_workspace_export_resources(&db, &version, workspace_ids, include_private_environments)?;
|
query_manager: &app_handle.db_manager(),
|
||||||
let f = File::options()
|
yaak_version: &version,
|
||||||
.create(true)
|
export_path: Path::new(export_path),
|
||||||
.truncate(true)
|
workspace_ids,
|
||||||
.write(true)
|
include_private_environments,
|
||||||
.open(export_path)
|
})?)
|
||||||
.expect("Unable to create file");
|
|
||||||
|
|
||||||
serde_json::to_writer_pretty(&f, &export_data)
|
|
||||||
.map_err(|e| GenericError(e.to_string()))
|
|
||||||
.expect("Failed to write");
|
|
||||||
|
|
||||||
f.sync_all().expect("Failed to sync");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1425,11 +1421,10 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
environment_id: Option<&str>,
|
environment_id: Option<&str>,
|
||||||
cookie_jar_id: Option<&str>,
|
cookie_jar_id: Option<&str>,
|
||||||
// NOTE: We receive the entire request because to account for the race
|
request_id: String,
|
||||||
// condition where the user may have just edited a field before sending
|
|
||||||
// that has not yet been saved in the DB.
|
|
||||||
request: HttpRequest,
|
|
||||||
) -> YaakResult<HttpResponse> {
|
) -> YaakResult<HttpResponse> {
|
||||||
|
let request = app_handle.db().get_http_request(&request_id)?;
|
||||||
|
|
||||||
let blobs = app_handle.blob_manager();
|
let blobs = app_handle.blob_manager();
|
||||||
let response = app_handle.db().upsert_http_response(
|
let response = app_handle.db().upsert_http_response(
|
||||||
&HttpResponse {
|
&HttpResponse {
|
||||||
@@ -1512,11 +1507,36 @@ async fn cmd_plugin_info<R: Runtime>(
|
|||||||
plugin_manager: State<'_, PluginManager>,
|
plugin_manager: State<'_, PluginManager>,
|
||||||
) -> YaakResult<PluginMetadata> {
|
) -> YaakResult<PluginMetadata> {
|
||||||
let plugin = app_handle.db().get_plugin(id)?;
|
let plugin = app_handle.db().get_plugin(id)?;
|
||||||
Ok(plugin_manager
|
if let Some(plugin_handle) = plugin_manager
|
||||||
.get_plugin_by_dir(plugin.directory.as_str())
|
.get_plugin_by_dir(plugin.directory.as_str())
|
||||||
.await
|
.await
|
||||||
.ok_or(GenericError("Failed to find plugin for info".to_string()))?
|
{
|
||||||
.info())
|
return Ok(plugin_handle.info());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
||||||
|
return Ok(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(fallback_plugin_metadata(&plugin.directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
||||||
|
let display_name = PathBuf::from(directory)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or(directory)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
PluginMetadata {
|
||||||
|
version: "Unavailable".to_string(),
|
||||||
|
name: directory.to_string(),
|
||||||
|
display_name,
|
||||||
|
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
||||||
|
homepage_url: None,
|
||||||
|
repository_url: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use crate::{
|
|||||||
workspace_from_window,
|
workspace_from_window,
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use cookie::Cookie;
|
|
||||||
use log::error;
|
use log::error;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
|
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
|
||||||
@@ -409,11 +408,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
|||||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||||
let names = match cookie_jar_from_window(&window) {
|
let names = match cookie_jar_from_window(&window) {
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
Some(j) => j
|
Some(j) => j.cookies.into_iter().map(|c| c.name).collect(),
|
||||||
.cookies
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|c| Cookie::parse(c.raw_cookie).ok().map(|c| c.name().to_string()))
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
||||||
names,
|
names,
|
||||||
|
|||||||
@@ -50,6 +50,37 @@ pub async fn cmd_ws_send<R: Runtime>(
|
|||||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||||
) -> Result<WebsocketConnection> {
|
) -> Result<WebsocketConnection> {
|
||||||
let connection = app_handle.db().get_websocket_connection(connection_id)?;
|
let connection = app_handle.db().get_websocket_connection(connection_id)?;
|
||||||
|
|
||||||
|
match send_websocket_message(&connection, environment_id, &app_handle, &window, &ws_manager)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(connection) => Ok(connection),
|
||||||
|
Err(e) => {
|
||||||
|
app_handle.db().upsert_websocket_event(
|
||||||
|
&WebsocketEvent {
|
||||||
|
connection_id: connection.id.clone(),
|
||||||
|
request_id: connection.request_id.clone(),
|
||||||
|
workspace_id: connection.workspace_id.clone(),
|
||||||
|
is_server: false,
|
||||||
|
message_type: WebsocketEventType::Error,
|
||||||
|
message: e.to_string().into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&UpdateSource::from_window_label(window.label()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_websocket_message<R: Runtime>(
|
||||||
|
connection: &WebsocketConnection,
|
||||||
|
environment_id: Option<&str>,
|
||||||
|
app_handle: &AppHandle<R>,
|
||||||
|
window: &WebviewWindow<R>,
|
||||||
|
ws_manager: &Mutex<WebsocketManager>,
|
||||||
|
) -> Result<WebsocketConnection> {
|
||||||
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
|
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
|
||||||
let environment_chain = app_handle.db().resolve_environments(
|
let environment_chain = app_handle.db().resolve_environments(
|
||||||
&unrendered_request.workspace_id,
|
&unrendered_request.workspace_id,
|
||||||
@@ -91,7 +122,7 @@ pub async fn cmd_ws_send<R: Runtime>(
|
|||||||
&UpdateSource::from_window_label(window.label()),
|
&UpdateSource::from_window_label(window.label()),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(connection)
|
Ok(connection.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -134,7 +165,8 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
unrendered_request.folder_id.as_deref(),
|
unrendered_request.folder_id.as_deref(),
|
||||||
environment_id,
|
environment_id,
|
||||||
)?;
|
)?;
|
||||||
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
|
let resolved_settings =
|
||||||
|
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
|
||||||
let settings = app_handle.db().get_settings();
|
let settings = app_handle.db().get_settings();
|
||||||
let (resolved_request, auth_context_id) =
|
let (resolved_request, auth_context_id) =
|
||||||
resolve_websocket_request(&window, &unrendered_request)?;
|
resolve_websocket_request(&window, &unrendered_request)?;
|
||||||
@@ -247,11 +279,18 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cookies to WS HTTP Upgrade
|
let mut cookie_jar = match (
|
||||||
if let Some(id) = cookie_jar_id {
|
resolved_settings.send_cookies.value || resolved_settings.store_cookies.value,
|
||||||
let cookie_jar = app_handle.db().get_cookie_jar(&id)?;
|
cookie_jar_id,
|
||||||
let store = CookieStore::from_cookies(cookie_jar.cookies);
|
) {
|
||||||
|
(true, Some(id)) => Some(app_handle.db().get_cookie_jar(id)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let cookie_store =
|
||||||
|
cookie_jar.as_ref().map(|jar| CookieStore::from_cookies(jar.cookies.clone()));
|
||||||
|
|
||||||
|
// Add cookies to WS HTTP Upgrade
|
||||||
|
if let (true, Some(store)) = (resolved_settings.send_cookies.value, cookie_store.as_ref()) {
|
||||||
// Convert WS URL -> HTTP URL because our cookie store matches based on
|
// Convert WS URL -> HTTP URL because our cookie store matches based on
|
||||||
// Path/HttpOnly/Secure attributes even though WS upgrades are HTTP requests
|
// Path/HttpOnly/Secure attributes even though WS upgrades are HTTP requests
|
||||||
let http_url = convert_ws_url_to_http(&url);
|
let http_url = convert_ws_url_to_http(&url);
|
||||||
@@ -289,8 +328,9 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
url.as_str(),
|
url.as_str(),
|
||||||
headers,
|
headers,
|
||||||
receive_tx,
|
receive_tx,
|
||||||
workspace.setting_validate_certificates,
|
resolved_settings.validate_certificates.value,
|
||||||
client_cert,
|
client_cert,
|
||||||
|
resolved_settings.request_message_size.value,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -328,6 +368,23 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<HttpResponseHeader>>();
|
.collect::<Vec<HttpResponseHeader>>();
|
||||||
|
|
||||||
|
if let (true, Some(cookie_jar), Some(store)) =
|
||||||
|
(resolved_settings.store_cookies.value, cookie_jar.as_mut(), cookie_store.as_ref())
|
||||||
|
{
|
||||||
|
let set_cookie_headers = response
|
||||||
|
.headers()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(name, _)| name.as_str().eq_ignore_ascii_case("set-cookie"))
|
||||||
|
.filter_map(|(_, value)| value.to_str().ok().map(ToString::to_string))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if !set_cookie_headers.is_empty() {
|
||||||
|
store.store_cookies_from_response(&convert_ws_url_to_http(&url), &set_cookie_headers);
|
||||||
|
cookie_jar.cookies = store.get_all_cookies();
|
||||||
|
app_handle.db().upsert_cookie_jar(cookie_jar, &UpdateSource::Background)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let connection = app_handle.db().upsert_websocket_connection(
|
let connection = app_handle.db().upsert_websocket_connection(
|
||||||
&WebsocketConnection {
|
&WebsocketConnection {
|
||||||
state: WebsocketConnectionState::Connected,
|
state: WebsocketConnectionState::Connected,
|
||||||
|
|||||||
Generated
+160
-33
@@ -1,45 +1,172 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
export type DnsOverride = {
|
||||||
|
hostname: string;
|
||||||
|
ipv4: Array<string>;
|
||||||
|
ipv6: Array<string>;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
export type Environment = {
|
||||||
/**
|
model: "environment";
|
||||||
* Variables defined in this environment scope.
|
id: string;
|
||||||
* Child environments override parent variables by name.
|
workspaceId: string;
|
||||||
*/
|
createdAt: string;
|
||||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
updatedAt: string;
|
||||||
|
name: string;
|
||||||
|
public: boolean;
|
||||||
|
parentModel: string;
|
||||||
|
parentId: string | null;
|
||||||
|
/**
|
||||||
|
* Variables defined in this environment scope.
|
||||||
|
* Child environments override parent variables by name.
|
||||||
|
*/
|
||||||
|
variables: Array<EnvironmentVariable>;
|
||||||
|
color: string | null;
|
||||||
|
sortPriority: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
|
|
||||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
|
export type Folder = {
|
||||||
|
model: "folder";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
export type GrpcRequest = {
|
||||||
/**
|
model: "grpc_request";
|
||||||
* Server URL (http for plaintext or https for secure)
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
url: string, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authenticationType: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
description: string;
|
||||||
|
message: string;
|
||||||
|
metadata: Array<HttpRequestHeader>;
|
||||||
|
method: string | null;
|
||||||
|
name: string;
|
||||||
|
service: string | null;
|
||||||
|
sortPriority: number;
|
||||||
|
/**
|
||||||
|
* Server URL (http for plaintext or https for secure)
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
|
export type HttpRequest = {
|
||||||
/**
|
model: "http_request";
|
||||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
urlParameters: Array<HttpUrlParameter>, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
body: Record<string, any>;
|
||||||
|
bodyType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
method: string;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
|
*/
|
||||||
|
urlParameters: Array<HttpUrlParameter>;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
|
|
||||||
export type HttpUrlParameter = { enabled?: boolean,
|
export type HttpUrlParameter = {
|
||||||
/**
|
enabled?: boolean;
|
||||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
/**
|
||||||
* Other entries are appended as query parameters
|
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||||
*/
|
* Other entries are appended as query parameters
|
||||||
name: string, value: string, id?: string, };
|
*/
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
/**
|
|
||||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
|
||||||
*/
|
|
||||||
urlParameters: Array<HttpUrlParameter>, };
|
|
||||||
|
|
||||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
|
export type SyncModel =
|
||||||
|
| ({ type: "workspace" } & Workspace)
|
||||||
|
| ({ type: "environment" } & Environment)
|
||||||
|
| ({ type: "folder" } & Folder)
|
||||||
|
| ({ type: "http_request" } & HttpRequest)
|
||||||
|
| ({ type: "grpc_request" } & GrpcRequest)
|
||||||
|
| ({ type: "websocket_request" } & WebsocketRequest);
|
||||||
|
|
||||||
|
export type WebsocketRequest = {
|
||||||
|
model: "websocket_request";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
message: string;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
|
*/
|
||||||
|
urlParameters: Array<HttpUrlParameter>;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Workspace = {
|
||||||
|
model: "workspace";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
name: string;
|
||||||
|
encryptionKeyChallenge: string | null;
|
||||||
|
settingValidateCertificates: boolean;
|
||||||
|
settingFollowRedirects: boolean;
|
||||||
|
settingRequestTimeout: number;
|
||||||
|
settingRequestMessageSize: number;
|
||||||
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
|
settingSendCookies: boolean;
|
||||||
|
settingStoreCookies: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@@ -33,15 +33,21 @@ impl AutoReflectionClient {
|
|||||||
uri: &Uri,
|
uri: &Uri,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
|
max_message_size: usize,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
|
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
|
||||||
get_transport(validate_certificates, client_cert.clone())?,
|
get_transport(validate_certificates, client_cert.clone())?,
|
||||||
uri.clone(),
|
uri.clone(),
|
||||||
);
|
)
|
||||||
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
|
.max_decoding_message_size(max_message_size)
|
||||||
get_transport(validate_certificates, client_cert.clone())?,
|
.max_encoding_message_size(max_message_size);
|
||||||
uri.clone(),
|
let client_v1alpha =
|
||||||
);
|
v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
|
||||||
|
get_transport(validate_certificates, client_cert.clone())?,
|
||||||
|
uri.clone(),
|
||||||
|
)
|
||||||
|
.max_decoding_message_size(max_message_size)
|
||||||
|
.max_encoding_message_size(max_message_size);
|
||||||
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
|
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub struct GrpcConnection {
|
|||||||
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
||||||
pub uri: Uri,
|
pub uri: Uri,
|
||||||
use_reflection: bool,
|
use_reflection: bool,
|
||||||
|
max_message_size: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Default, Debug)]
|
||||||
@@ -97,8 +98,15 @@ impl GrpcConnection {
|
|||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
) -> Result<Response<DynamicMessage>> {
|
) -> Result<Response<DynamicMessage>> {
|
||||||
if self.use_reflection {
|
if self.use_reflection {
|
||||||
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)
|
reflect_types_for_message(
|
||||||
.await?;
|
self.pool.clone(),
|
||||||
|
&self.uri,
|
||||||
|
message,
|
||||||
|
metadata,
|
||||||
|
client_cert,
|
||||||
|
self.max_message_size,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
let method = &self.method(&service, &method).await?;
|
let method = &self.method(&service, &method).await?;
|
||||||
let input_message = method.input();
|
let input_message = method.input();
|
||||||
@@ -107,7 +115,7 @@ impl GrpcConnection {
|
|||||||
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
||||||
deserializer.end()?;
|
deserializer.end()?;
|
||||||
|
|
||||||
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
|
||||||
|
|
||||||
let mut req = req_message.into_request();
|
let mut req = req_message.into_request();
|
||||||
decorate_req(metadata, &mut req)?;
|
decorate_req(metadata, &mut req)?;
|
||||||
@@ -132,6 +140,7 @@ impl GrpcConnection {
|
|||||||
message,
|
message,
|
||||||
metadata,
|
metadata,
|
||||||
client_cert,
|
client_cert,
|
||||||
|
self.max_message_size,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -171,6 +180,7 @@ impl GrpcConnection {
|
|||||||
let md = metadata.clone();
|
let md = metadata.clone();
|
||||||
let use_reflection = self.use_reflection.clone();
|
let use_reflection = self.use_reflection.clone();
|
||||||
let client_cert = client_cert.clone();
|
let client_cert = client_cert.clone();
|
||||||
|
let max_message_size = self.max_message_size;
|
||||||
stream
|
stream
|
||||||
.then(move |json| {
|
.then(move |json| {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
@@ -183,8 +193,15 @@ impl GrpcConnection {
|
|||||||
let json_clone = json.clone();
|
let json_clone = json.clone();
|
||||||
async move {
|
async move {
|
||||||
if use_reflection {
|
if use_reflection {
|
||||||
if let Err(e) =
|
if let Err(e) = reflect_types_for_message(
|
||||||
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
pool,
|
||||||
|
&uri,
|
||||||
|
&json,
|
||||||
|
&md,
|
||||||
|
client_cert,
|
||||||
|
max_message_size,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
warn!("Failed to resolve Any types: {e}");
|
warn!("Failed to resolve Any types: {e}");
|
||||||
}
|
}
|
||||||
@@ -206,7 +223,7 @@ impl GrpcConnection {
|
|||||||
.filter_map(|x| x)
|
.filter_map(|x| x)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
|
||||||
let path = method_desc_to_path(method);
|
let path = method_desc_to_path(method);
|
||||||
let codec = DynamicCodec::new(method.clone());
|
let codec = DynamicCodec::new(method.clone());
|
||||||
|
|
||||||
@@ -237,6 +254,7 @@ impl GrpcConnection {
|
|||||||
let md = metadata.clone();
|
let md = metadata.clone();
|
||||||
let use_reflection = self.use_reflection.clone();
|
let use_reflection = self.use_reflection.clone();
|
||||||
let client_cert = client_cert.clone();
|
let client_cert = client_cert.clone();
|
||||||
|
let max_message_size = self.max_message_size;
|
||||||
stream
|
stream
|
||||||
.then(move |json| {
|
.then(move |json| {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
@@ -249,8 +267,15 @@ impl GrpcConnection {
|
|||||||
let json_clone = json.clone();
|
let json_clone = json.clone();
|
||||||
async move {
|
async move {
|
||||||
if use_reflection {
|
if use_reflection {
|
||||||
if let Err(e) =
|
if let Err(e) = reflect_types_for_message(
|
||||||
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
pool,
|
||||||
|
&uri,
|
||||||
|
&json,
|
||||||
|
&md,
|
||||||
|
client_cert,
|
||||||
|
max_message_size,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
warn!("Failed to resolve Any types: {e}");
|
warn!("Failed to resolve Any types: {e}");
|
||||||
}
|
}
|
||||||
@@ -272,7 +297,7 @@ impl GrpcConnection {
|
|||||||
.filter_map(|x| x)
|
.filter_map(|x| x)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
|
||||||
let path = method_desc_to_path(method);
|
let path = method_desc_to_path(method);
|
||||||
let codec = DynamicCodec::new(method.clone());
|
let codec = DynamicCodec::new(method.clone());
|
||||||
|
|
||||||
@@ -300,7 +325,7 @@ impl GrpcConnection {
|
|||||||
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
||||||
deserializer.end()?;
|
deserializer.end()?;
|
||||||
|
|
||||||
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
|
||||||
|
|
||||||
let mut req = req_message.into_request();
|
let mut req = req_message.into_request();
|
||||||
decorate_req(metadata, &mut req)?;
|
decorate_req(metadata, &mut req)?;
|
||||||
@@ -312,6 +337,23 @@ impl GrpcConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn grpc_client(
|
||||||
|
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
||||||
|
uri: Uri,
|
||||||
|
max_message_size: usize,
|
||||||
|
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, BoxBody>> {
|
||||||
|
tonic::client::Grpc::with_origin(conn, uri)
|
||||||
|
.max_decoding_message_size(max_message_size)
|
||||||
|
.max_encoding_message_size(max_message_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_size_limit(setting: i32) -> usize {
|
||||||
|
match setting.try_into() {
|
||||||
|
Ok(0) | Err(_) => usize::MAX,
|
||||||
|
Ok(limit) => limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for GrpcHandle to compile proto files
|
/// Configuration for GrpcHandle to compile proto files
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct GrpcConfig {
|
pub struct GrpcConfig {
|
||||||
@@ -348,6 +390,7 @@ impl GrpcHandle {
|
|||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
|
request_message_size: i32,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let server_reflection = proto_files.is_empty();
|
let server_reflection = proto_files.is_empty();
|
||||||
let key = make_pool_key(id, uri, proto_files);
|
let key = make_pool_key(id, uri, proto_files);
|
||||||
@@ -359,7 +402,14 @@ impl GrpcHandle {
|
|||||||
|
|
||||||
let pool = if server_reflection {
|
let pool = if server_reflection {
|
||||||
let full_uri = uri_from_str(uri)?;
|
let full_uri = uri_from_str(uri)?;
|
||||||
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await
|
fill_pool_from_reflection(
|
||||||
|
&full_uri,
|
||||||
|
metadata,
|
||||||
|
validate_certificates,
|
||||||
|
client_cert,
|
||||||
|
message_size_limit(request_message_size),
|
||||||
|
)
|
||||||
|
.await
|
||||||
} else {
|
} else {
|
||||||
fill_pool_from_files(&self.config, proto_files).await
|
fill_pool_from_files(&self.config, proto_files).await
|
||||||
}?;
|
}?;
|
||||||
@@ -376,12 +426,21 @@ impl GrpcHandle {
|
|||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
|
request_message_size: i32,
|
||||||
) -> Result<Vec<ServiceDefinition>> {
|
) -> Result<Vec<ServiceDefinition>> {
|
||||||
// Ensure we have a pool; reflect only if missing
|
// Ensure we have a pool; reflect only if missing
|
||||||
if self.get_pool(id, uri, proto_files).is_none() {
|
if self.get_pool(id, uri, proto_files).is_none() {
|
||||||
info!("Reflecting gRPC services for {} at {}", id, uri);
|
info!("Reflecting gRPC services for {} at {}", id, uri);
|
||||||
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
|
self.reflect(
|
||||||
.await?;
|
id,
|
||||||
|
uri,
|
||||||
|
proto_files,
|
||||||
|
metadata,
|
||||||
|
validate_certificates,
|
||||||
|
client_cert,
|
||||||
|
request_message_size,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = self
|
let pool = self
|
||||||
@@ -421,8 +480,10 @@ impl GrpcHandle {
|
|||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
|
request_message_size: i32,
|
||||||
) -> Result<GrpcConnection> {
|
) -> Result<GrpcConnection> {
|
||||||
let use_reflection = proto_files.is_empty();
|
let use_reflection = proto_files.is_empty();
|
||||||
|
let max_message_size = message_size_limit(request_message_size);
|
||||||
if self.get_pool(id, uri, proto_files).is_none() {
|
if self.get_pool(id, uri, proto_files).is_none() {
|
||||||
self.reflect(
|
self.reflect(
|
||||||
id,
|
id,
|
||||||
@@ -431,6 +492,7 @@ impl GrpcHandle {
|
|||||||
metadata,
|
metadata,
|
||||||
validate_certificates,
|
validate_certificates,
|
||||||
client_cert.clone(),
|
client_cert.clone(),
|
||||||
|
request_message_size,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -440,7 +502,13 @@ impl GrpcHandle {
|
|||||||
.clone();
|
.clone();
|
||||||
let uri = uri_from_str(uri)?;
|
let uri = uri_from_str(uri)?;
|
||||||
let conn = get_transport(validate_certificates, client_cert.clone())?;
|
let conn = get_transport(validate_certificates, client_cert.clone())?;
|
||||||
Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri })
|
Ok(GrpcConnection {
|
||||||
|
pool: Arc::new(RwLock::new(pool)),
|
||||||
|
use_reflection,
|
||||||
|
conn,
|
||||||
|
uri,
|
||||||
|
max_message_size,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {
|
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {
|
||||||
|
|||||||
@@ -119,9 +119,11 @@ pub async fn fill_pool_from_reflection(
|
|||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
|
max_message_size: usize,
|
||||||
) -> Result<DescriptorPool> {
|
) -> Result<DescriptorPool> {
|
||||||
let mut pool = DescriptorPool::new();
|
let mut pool = DescriptorPool::new();
|
||||||
let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?;
|
let mut client =
|
||||||
|
AutoReflectionClient::new(uri, validate_certificates, client_cert, max_message_size)?;
|
||||||
|
|
||||||
for service in list_services(&mut client, metadata).await? {
|
for service in list_services(&mut client, metadata).await? {
|
||||||
if service == "grpc.reflection.v1alpha.ServerReflection" {
|
if service == "grpc.reflection.v1alpha.ServerReflection" {
|
||||||
@@ -192,6 +194,7 @@ pub(crate) async fn reflect_types_for_message(
|
|||||||
json: &str,
|
json: &str,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
|
max_message_size: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// 1. Collect all Any types in the JSON
|
// 1. Collect all Any types in the JSON
|
||||||
let mut extra_types = Vec::new();
|
let mut extra_types = Vec::new();
|
||||||
@@ -201,7 +204,7 @@ pub(crate) async fn reflect_types_for_message(
|
|||||||
return Ok(()); // nothing to do
|
return Ok(()); // nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
|
let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
|
||||||
for extra_type in extra_types {
|
for extra_type in extra_types {
|
||||||
{
|
{
|
||||||
let guard = pool.read().await;
|
let guard = pool.read().await;
|
||||||
@@ -239,6 +242,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
|
|||||||
message: &DynamicMessage,
|
message: &DynamicMessage,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
|
max_message_size: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut extra_types = HashSet::new();
|
let mut extra_types = HashSet::new();
|
||||||
collect_any_types_from_dynamic_message(message, &mut extra_types);
|
collect_any_types_from_dynamic_message(message, &mut extra_types);
|
||||||
@@ -247,7 +251,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
|
let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
|
||||||
for extra_type in extra_types {
|
for extra_type in extra_types {
|
||||||
{
|
{
|
||||||
let guard = pool.read().await;
|
let guard = pool.read().await;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use log::debug;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use yaak_models::models::{Cookie, CookieDomain, CookieExpires};
|
use yaak_models::models::{Cookie, CookieDomain, CookieExpires, CookieSameSite};
|
||||||
|
|
||||||
/// A thread-safe cookie store that can be shared across requests
|
/// A thread-safe cookie store that can be shared across requests
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -45,10 +45,7 @@ impl CookieStore {
|
|||||||
let matching_cookies: Vec<_> = cookies
|
let matching_cookies: Vec<_> = cookies
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|cookie| self.cookie_matches(cookie, url, &now))
|
.filter(|cookie| self.cookie_matches(cookie, url, &now))
|
||||||
.filter_map(|cookie| {
|
.map(|cookie| (cookie.name.clone(), cookie.value.clone()))
|
||||||
// Parse the raw cookie to get name=value
|
|
||||||
parse_cookie_name_value(&cookie.raw_cookie)
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if matching_cookies.is_empty() {
|
if matching_cookies.is_empty() {
|
||||||
@@ -72,13 +69,7 @@ impl CookieStore {
|
|||||||
if let Some(cookie) = parse_set_cookie(header_value, url) {
|
if let Some(cookie) = parse_set_cookie(header_value, url) {
|
||||||
// Remove any existing cookie with the same name and domain
|
// Remove any existing cookie with the same name and domain
|
||||||
cookies.retain(|existing| !cookies_match(existing, &cookie));
|
cookies.retain(|existing| !cookies_match(existing, &cookie));
|
||||||
debug!(
|
debug!("Storing cookie: {} for domain {:?}", cookie.name, cookie.domain);
|
||||||
"Storing cookie: {} for domain {:?}",
|
|
||||||
parse_cookie_name_value(&cookie.raw_cookie)
|
|
||||||
.map(|(n, _)| n)
|
|
||||||
.unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
cookie.domain
|
|
||||||
);
|
|
||||||
cookies.push(cookie);
|
cookies.push(cookie);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,10 +108,9 @@ impl CookieStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check path
|
// Check path
|
||||||
let (cookie_path, _) = &cookie.path;
|
|
||||||
let url_path = url.path();
|
let url_path = url.path();
|
||||||
|
|
||||||
path_matches(url_path, cookie_path)
|
path_matches(url_path, &cookie.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,8 +123,7 @@ pub fn get_cookie_value_from_jar(
|
|||||||
let domain = domain.and_then(normalize_cookie_domain_filter);
|
let domain = domain.and_then(normalize_cookie_domain_filter);
|
||||||
|
|
||||||
cookies.into_iter().find_map(|cookie| {
|
cookies.into_iter().find_map(|cookie| {
|
||||||
let (cookie_name, value) = parse_cookie_name_value(&cookie.raw_cookie)?;
|
if cookie.name != name {
|
||||||
if cookie_name != name {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,11 +133,12 @@ pub fn get_cookie_value_from_jar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(value)
|
Some(cookie.value)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse name=value from a cookie string (raw_cookie format)
|
/// Parse name=value from a cookie string (raw_cookie format)
|
||||||
|
#[cfg(test)]
|
||||||
fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
|
fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
|
||||||
// The raw_cookie typically looks like "name=value" or "name=value; attr1; attr2=..."
|
// The raw_cookie typically looks like "name=value" or "name=value; attr1; attr2=..."
|
||||||
let first_part = raw_cookie.split(';').next()?;
|
let first_part = raw_cookie.split(';').next()?;
|
||||||
@@ -177,8 +167,6 @@ fn cookie_domain_matches_filter(cookie_domain: &CookieDomain, domain: &str) -> b
|
|||||||
fn parse_set_cookie(header_value: &str, request_url: &Url) -> Option<Cookie> {
|
fn parse_set_cookie(header_value: &str, request_url: &Url) -> Option<Cookie> {
|
||||||
let parsed = cookie::Cookie::parse(header_value).ok()?;
|
let parsed = cookie::Cookie::parse(header_value).ok()?;
|
||||||
|
|
||||||
let raw_cookie = format!("{}={}", parsed.name(), parsed.value());
|
|
||||||
|
|
||||||
// Determine domain
|
// Determine domain
|
||||||
let domain = if let Some(domain_attr) = parsed.domain() {
|
let domain = if let Some(domain_attr) = parsed.domain() {
|
||||||
// Domain attribute present - this is a suffix match
|
// Domain attribute present - this is a suffix match
|
||||||
@@ -216,14 +204,28 @@ fn parse_set_cookie(header_value: &str, request_url: &Url) -> Option<Cookie> {
|
|||||||
|
|
||||||
// Determine path
|
// Determine path
|
||||||
let path = if let Some(path_attr) = parsed.path() {
|
let path = if let Some(path_attr) = parsed.path() {
|
||||||
(path_attr.to_string(), true)
|
path_attr.to_string()
|
||||||
} else {
|
} else {
|
||||||
// Default path is the directory of the request URI
|
// Default path is the directory of the request URI
|
||||||
let default_path = default_cookie_path(request_url.path());
|
default_cookie_path(request_url.path())
|
||||||
(default_path, false)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(Cookie { raw_cookie, domain, expires, path })
|
let same_site = parsed.same_site().map(|same_site| match same_site {
|
||||||
|
cookie::SameSite::Strict => CookieSameSite::Strict,
|
||||||
|
cookie::SameSite::Lax => CookieSameSite::Lax,
|
||||||
|
cookie::SameSite::None => CookieSameSite::None,
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(Cookie {
|
||||||
|
name: parsed.name().to_string(),
|
||||||
|
value: parsed.value().to_string(),
|
||||||
|
domain,
|
||||||
|
expires,
|
||||||
|
path,
|
||||||
|
secure: parsed.secure().unwrap_or(false),
|
||||||
|
http_only: parsed.http_only().unwrap_or(false),
|
||||||
|
same_site,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the default cookie path from a request path (RFC 6265 Section 5.1.4)
|
/// Get the default cookie path from a request path (RFC 6265 Section 5.1.4)
|
||||||
@@ -261,10 +263,7 @@ fn path_matches(request_path: &str, cookie_path: &str) -> bool {
|
|||||||
|
|
||||||
/// Check if two cookies match (same name and domain)
|
/// Check if two cookies match (same name and domain)
|
||||||
fn cookies_match(a: &Cookie, b: &Cookie) -> bool {
|
fn cookies_match(a: &Cookie, b: &Cookie) -> bool {
|
||||||
let name_a = parse_cookie_name_value(&a.raw_cookie).map(|(n, _)| n);
|
if a.name != b.name {
|
||||||
let name_b = parse_cookie_name_value(&b.raw_cookie).map(|(n, _)| n);
|
|
||||||
|
|
||||||
if name_a != name_b {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,11 +316,16 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn cookie(raw_cookie: &str, domain: CookieDomain) -> Cookie {
|
fn cookie(raw_cookie: &str, domain: CookieDomain) -> Cookie {
|
||||||
|
let (name, value) = parse_cookie_name_value(raw_cookie).unwrap();
|
||||||
Cookie {
|
Cookie {
|
||||||
raw_cookie: raw_cookie.to_string(),
|
name,
|
||||||
|
value,
|
||||||
domain,
|
domain,
|
||||||
expires: CookieExpires::SessionEnd,
|
expires: CookieExpires::SessionEnd,
|
||||||
path: ("/".to_string(), false),
|
path: "/".to_string(),
|
||||||
|
secure: false,
|
||||||
|
http_only: false,
|
||||||
|
same_site: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ pub enum RedirectBehavior {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum HttpResponseEvent {
|
pub enum HttpResponseEvent {
|
||||||
Setting(String, String),
|
Setting {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
source_model: Option<String>,
|
||||||
|
source_id: Option<String>,
|
||||||
|
source_name: Option<String>,
|
||||||
|
},
|
||||||
Info(String),
|
Info(String),
|
||||||
Redirect {
|
Redirect {
|
||||||
url: String,
|
url: String,
|
||||||
@@ -67,7 +73,9 @@ pub enum HttpResponseEvent {
|
|||||||
impl Display for HttpResponseEvent {
|
impl Display for HttpResponseEvent {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
HttpResponseEvent::Setting { name, value, .. } => {
|
||||||
|
write!(f, "* Setting {}={}", name, value)
|
||||||
|
}
|
||||||
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
|
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
|
||||||
HttpResponseEvent::Redirect {
|
HttpResponseEvent::Redirect {
|
||||||
url,
|
url,
|
||||||
@@ -146,7 +154,9 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
|
|||||||
fn from(event: HttpResponseEvent) -> Self {
|
fn from(event: HttpResponseEvent) -> Self {
|
||||||
use yaak_models::models::HttpResponseEventData as D;
|
use yaak_models::models::HttpResponseEventData as D;
|
||||||
match event {
|
match event {
|
||||||
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
|
HttpResponseEvent::Setting { name, value, source_model, source_id, source_name } => {
|
||||||
|
D::Setting { name, value, source_model, source_id, source_name }
|
||||||
|
}
|
||||||
HttpResponseEvent::Info(message) => D::Info { message },
|
HttpResponseEvent::Info(message) => D::Info { message },
|
||||||
HttpResponseEvent::Redirect {
|
HttpResponseEvent::Redirect {
|
||||||
url,
|
url,
|
||||||
@@ -483,15 +493,6 @@ impl HttpSender for ReqwestSender {
|
|||||||
|
|
||||||
// Send the request
|
// Send the request
|
||||||
let sendable_req = req_builder.build()?;
|
let sendable_req = req_builder.build()?;
|
||||||
send_event(HttpResponseEvent::Setting(
|
|
||||||
"timeout".to_string(),
|
|
||||||
if request.options.timeout.unwrap_or_default().is_zero() {
|
|
||||||
"Infinity".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{:?}", request.options.timeout)
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
send_event(HttpResponseEvent::SendUrl {
|
send_event(HttpResponseEvent::SendUrl {
|
||||||
method: sendable_req.method().to_string(),
|
method: sendable_req.method().to_string(),
|
||||||
scheme: sendable_req.url().scheme().to_string(),
|
scheme: sendable_req.url().scheme().to_string(),
|
||||||
|
|||||||
@@ -12,22 +12,58 @@ pub struct HttpTransaction<S: HttpSender> {
|
|||||||
sender: S,
|
sender: S,
|
||||||
max_redirects: usize,
|
max_redirects: usize,
|
||||||
cookie_store: Option<CookieStore>,
|
cookie_store: Option<CookieStore>,
|
||||||
|
send_cookies: bool,
|
||||||
|
store_cookies: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S: HttpSender> HttpTransaction<S> {
|
impl<S: HttpSender> HttpTransaction<S> {
|
||||||
/// Create a new transaction with default settings
|
/// Create a new transaction with default settings
|
||||||
pub fn new(sender: S) -> Self {
|
pub fn new(sender: S) -> Self {
|
||||||
Self { sender, max_redirects: 10, cookie_store: None }
|
Self {
|
||||||
|
sender,
|
||||||
|
max_redirects: 10,
|
||||||
|
cookie_store: None,
|
||||||
|
send_cookies: false,
|
||||||
|
store_cookies: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new transaction with custom max redirects
|
/// Create a new transaction with custom max redirects
|
||||||
pub fn with_max_redirects(sender: S, max_redirects: usize) -> Self {
|
pub fn with_max_redirects(sender: S, max_redirects: usize) -> Self {
|
||||||
Self { sender, max_redirects, cookie_store: None }
|
Self {
|
||||||
|
sender,
|
||||||
|
max_redirects,
|
||||||
|
cookie_store: None,
|
||||||
|
send_cookies: false,
|
||||||
|
store_cookies: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new transaction with a cookie store
|
/// Create a new transaction with a cookie store
|
||||||
pub fn with_cookie_store(sender: S, cookie_store: CookieStore) -> Self {
|
pub fn with_cookie_store(sender: S, cookie_store: CookieStore) -> Self {
|
||||||
Self { sender, max_redirects: 10, cookie_store: Some(cookie_store) }
|
Self {
|
||||||
|
sender,
|
||||||
|
max_redirects: 10,
|
||||||
|
cookie_store: Some(cookie_store),
|
||||||
|
send_cookies: true,
|
||||||
|
store_cookies: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new transaction with a cookie store and explicit send/store behavior
|
||||||
|
pub fn with_cookie_behavior(
|
||||||
|
sender: S,
|
||||||
|
cookie_store: CookieStore,
|
||||||
|
send_cookies: bool,
|
||||||
|
store_cookies: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
sender,
|
||||||
|
max_redirects: 10,
|
||||||
|
cookie_store: Some(cookie_store),
|
||||||
|
send_cookies,
|
||||||
|
store_cookies,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new transaction with custom max redirects and a cookie store
|
/// Create a new transaction with custom max redirects and a cookie store
|
||||||
@@ -36,7 +72,13 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
max_redirects: usize,
|
max_redirects: usize,
|
||||||
cookie_store: Option<CookieStore>,
|
cookie_store: Option<CookieStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { sender, max_redirects, cookie_store }
|
Self {
|
||||||
|
sender,
|
||||||
|
max_redirects,
|
||||||
|
send_cookies: cookie_store.is_some(),
|
||||||
|
store_cookies: cookie_store.is_some(),
|
||||||
|
cookie_store,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute the request with cancellation support.
|
/// Execute the request with cancellation support.
|
||||||
@@ -66,9 +108,11 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inject cookies into headers if we have a cookie store
|
// Inject cookies into headers if we have a cookie store
|
||||||
let headers_with_cookies = if let Some(cookie_store) = &self.cookie_store {
|
let headers_with_cookies = if self.send_cookies {
|
||||||
let mut headers = current_headers.clone();
|
let mut headers = current_headers.clone();
|
||||||
if let Ok(url) = Url::parse(¤t_url) {
|
if let (Some(cookie_store), Ok(url)) =
|
||||||
|
(&self.cookie_store, Url::parse(¤t_url))
|
||||||
|
{
|
||||||
if let Some(cookie_header) = cookie_store.get_cookie_header(&url) {
|
if let Some(cookie_header) = cookie_store.get_cookie_header(&url) {
|
||||||
debug!("Injecting Cookie header: {}", cookie_header);
|
debug!("Injecting Cookie header: {}", cookie_header);
|
||||||
// Check if there's already a Cookie header and merge if so
|
// Check if there's already a Cookie header and merge if so
|
||||||
@@ -100,12 +144,6 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
options: request.options.clone(),
|
options: request.options.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send the request
|
|
||||||
send_event(HttpResponseEvent::Setting(
|
|
||||||
"redirects".to_string(),
|
|
||||||
request.options.follow_redirects.to_string(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Execute with cancellation support
|
// Execute with cancellation support
|
||||||
let response = tokio::select! {
|
let response = tokio::select! {
|
||||||
result = self.sender.send(req, event_tx.clone()) => result?,
|
result = self.sender.send(req, event_tx.clone()) => result?,
|
||||||
@@ -115,8 +153,10 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parse Set-Cookie headers and store cookies
|
// Parse Set-Cookie headers and store cookies
|
||||||
if let Some(cookie_store) = &self.cookie_store {
|
if self.store_cookies {
|
||||||
if let Ok(url) = Url::parse(¤t_url) {
|
if let (Some(cookie_store), Ok(url)) =
|
||||||
|
(&self.cookie_store, Url::parse(¤t_url))
|
||||||
|
{
|
||||||
let set_cookie_headers: Vec<String> = response
|
let set_cookie_headers: Vec<String> = response
|
||||||
.headers
|
.headers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -579,10 +619,14 @@ mod tests {
|
|||||||
|
|
||||||
// Create a cookie store with a test cookie
|
// Create a cookie store with a test cookie
|
||||||
let cookie = Cookie {
|
let cookie = Cookie {
|
||||||
raw_cookie: "session=abc123".to_string(),
|
name: "session".to_string(),
|
||||||
|
value: "abc123".to_string(),
|
||||||
domain: CookieDomain::HostOnly("example.com".to_string()),
|
domain: CookieDomain::HostOnly("example.com".to_string()),
|
||||||
expires: CookieExpires::SessionEnd,
|
expires: CookieExpires::SessionEnd,
|
||||||
path: ("/".to_string(), false),
|
path: "/".to_string(),
|
||||||
|
secure: false,
|
||||||
|
http_only: false,
|
||||||
|
same_site: None,
|
||||||
};
|
};
|
||||||
let cookie_store = CookieStore::from_cookies(vec![cookie]);
|
let cookie_store = CookieStore::from_cookies(vec![cookie]);
|
||||||
|
|
||||||
@@ -602,6 +646,67 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cookie_injection_can_be_disabled() {
|
||||||
|
struct CookieRejectingSender;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl HttpSender for CookieRejectingSender {
|
||||||
|
async fn send(
|
||||||
|
&self,
|
||||||
|
request: SendableHttpRequest,
|
||||||
|
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let cookie_header =
|
||||||
|
request.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("cookie"));
|
||||||
|
assert!(cookie_header.is_none(), "Cookie header should not be present");
|
||||||
|
|
||||||
|
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||||
|
Box::pin(std::io::Cursor::new(vec![]));
|
||||||
|
Ok(HttpResponse::new(
|
||||||
|
200,
|
||||||
|
None,
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
None,
|
||||||
|
Some("HTTP/1.1".to_string()),
|
||||||
|
body_stream,
|
||||||
|
ContentEncoding::Identity,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use yaak_models::models::{Cookie, CookieDomain, CookieExpires};
|
||||||
|
|
||||||
|
let cookie = Cookie {
|
||||||
|
name: "session".to_string(),
|
||||||
|
value: "abc123".to_string(),
|
||||||
|
domain: CookieDomain::HostOnly("example.com".to_string()),
|
||||||
|
expires: CookieExpires::SessionEnd,
|
||||||
|
path: "/".to_string(),
|
||||||
|
secure: false,
|
||||||
|
http_only: false,
|
||||||
|
same_site: None,
|
||||||
|
};
|
||||||
|
let cookie_store = CookieStore::from_cookies(vec![cookie]);
|
||||||
|
let transaction =
|
||||||
|
HttpTransaction::with_cookie_behavior(CookieRejectingSender, cookie_store, false, true);
|
||||||
|
|
||||||
|
let request = SendableHttpRequest {
|
||||||
|
url: "https://example.com/api".to_string(),
|
||||||
|
method: "GET".to_string(),
|
||||||
|
headers: vec![],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||||
|
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_set_cookie_parsing() {
|
async fn test_set_cookie_parsing() {
|
||||||
// Create a cookie store
|
// Create a cookie store
|
||||||
@@ -655,7 +760,62 @@ mod tests {
|
|||||||
// Verify the cookie was stored
|
// Verify the cookie was stored
|
||||||
let cookies = cookie_store.get_all_cookies();
|
let cookies = cookie_store.get_all_cookies();
|
||||||
assert_eq!(cookies.len(), 1);
|
assert_eq!(cookies.len(), 1);
|
||||||
assert!(cookies[0].raw_cookie.contains("session=xyz789"));
|
assert_eq!(cookies[0].name, "session");
|
||||||
|
assert_eq!(cookies[0].value, "xyz789");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_set_cookie_storage_can_be_disabled() {
|
||||||
|
let cookie_store = CookieStore::new();
|
||||||
|
|
||||||
|
struct SetCookieSender;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl HttpSender for SetCookieSender {
|
||||||
|
async fn send(
|
||||||
|
&self,
|
||||||
|
_request: SendableHttpRequest,
|
||||||
|
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
let headers =
|
||||||
|
vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
|
||||||
|
|
||||||
|
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||||
|
Box::pin(std::io::Cursor::new(vec![]));
|
||||||
|
Ok(HttpResponse::new(
|
||||||
|
200,
|
||||||
|
None,
|
||||||
|
headers,
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
"https://example.com".to_string(),
|
||||||
|
None,
|
||||||
|
Some("HTTP/1.1".to_string()),
|
||||||
|
body_stream,
|
||||||
|
ContentEncoding::Identity,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let transaction = HttpTransaction::with_cookie_behavior(
|
||||||
|
SetCookieSender,
|
||||||
|
cookie_store.clone(),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = SendableHttpRequest {
|
||||||
|
url: "https://example.com/login".to_string(),
|
||||||
|
method: "POST".to_string(),
|
||||||
|
headers: vec![],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||||
|
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(cookie_store.get_all_cookies().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -719,17 +879,15 @@ mod tests {
|
|||||||
let cookies = cookie_store.get_all_cookies();
|
let cookies = cookie_store.get_all_cookies();
|
||||||
assert_eq!(cookies.len(), 3, "All three Set-Cookie headers should be parsed and stored");
|
assert_eq!(cookies.len(), 3, "All three Set-Cookie headers should be parsed and stored");
|
||||||
|
|
||||||
let cookie_values: Vec<&str> = cookies.iter().map(|c| c.raw_cookie.as_str()).collect();
|
let cookie_values: Vec<_> =
|
||||||
|
cookies.iter().map(|c| format!("{}={}", c.name, c.value)).collect();
|
||||||
assert!(
|
assert!(
|
||||||
cookie_values.iter().any(|c| c.contains("session=abc123")),
|
cookie_values.iter().any(|c| c == "session=abc123"),
|
||||||
"session cookie should be stored"
|
"session cookie should be stored"
|
||||||
);
|
);
|
||||||
|
assert!(cookie_values.iter().any(|c| c == "user_id=42"), "user_id cookie should be stored");
|
||||||
assert!(
|
assert!(
|
||||||
cookie_values.iter().any(|c| c.contains("user_id=42")),
|
cookie_values.iter().any(|c| c == "preferences=dark"),
|
||||||
"user_id cookie should be stored"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
cookie_values.iter().any(|c| c.contains("preferences=dark")),
|
|
||||||
"preferences cookie should be stored"
|
"preferences cookie should be stored"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+460
-62
@@ -1,121 +1,519 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { ModelChangeEvent } from "./ModelChangeEvent";
|
import type { ModelChangeEvent } from "./ModelChangeEvent";
|
||||||
|
|
||||||
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
export type AnyModel =
|
||||||
|
| CookieJar
|
||||||
|
| Environment
|
||||||
|
| Folder
|
||||||
|
| GraphQlIntrospection
|
||||||
|
| GrpcConnection
|
||||||
|
| GrpcEvent
|
||||||
|
| GrpcRequest
|
||||||
|
| HttpRequest
|
||||||
|
| HttpResponse
|
||||||
|
| HttpResponseEvent
|
||||||
|
| KeyValue
|
||||||
|
| Plugin
|
||||||
|
| Settings
|
||||||
|
| SyncState
|
||||||
|
| WebsocketConnection
|
||||||
|
| WebsocketEvent
|
||||||
|
| WebsocketRequest
|
||||||
|
| Workspace
|
||||||
|
| WorkspaceMeta;
|
||||||
|
|
||||||
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
export type ClientCertificate = {
|
||||||
|
host: string;
|
||||||
|
port: number | null;
|
||||||
|
crtFile: string | null;
|
||||||
|
keyFile: string | null;
|
||||||
|
pfxFile: string | null;
|
||||||
|
passphrase: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
|
export type Cookie = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: CookieDomain;
|
||||||
|
expires: CookieExpires;
|
||||||
|
path: string;
|
||||||
|
secure: boolean;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: CookieSameSite | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||||
|
|
||||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||||
|
|
||||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
export type CookieJar = {
|
||||||
|
model: "cookie_jar";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
cookies: Array<Cookie>;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||||
|
|
||||||
|
export type DnsOverride = {
|
||||||
|
hostname: string;
|
||||||
|
ipv4: Array<string>;
|
||||||
|
ipv6: Array<string>;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||||
|
|
||||||
export type EncryptedKey = { encryptedKey: string, };
|
export type EncryptedKey = { encryptedKey: string };
|
||||||
|
|
||||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
export type Environment = {
|
||||||
/**
|
model: "environment";
|
||||||
* Variables defined in this environment scope.
|
id: string;
|
||||||
* Child environments override parent variables by name.
|
workspaceId: string;
|
||||||
*/
|
createdAt: string;
|
||||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
updatedAt: string;
|
||||||
|
name: string;
|
||||||
|
public: boolean;
|
||||||
|
parentModel: string;
|
||||||
|
parentId: string | null;
|
||||||
|
/**
|
||||||
|
* Variables defined in this environment scope.
|
||||||
|
* Child environments override parent variables by name.
|
||||||
|
*/
|
||||||
|
variables: Array<EnvironmentVariable>;
|
||||||
|
color: string | null;
|
||||||
|
sortPriority: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
|
|
||||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
|
export type Folder = {
|
||||||
|
model: "folder";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
export type GraphQlIntrospection = {
|
||||||
|
model: "graphql_introspection";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
content: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
|
export type GrpcConnection = {
|
||||||
|
model: "grpc_connection";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
elapsed: number;
|
||||||
|
error: string | null;
|
||||||
|
method: string;
|
||||||
|
service: string;
|
||||||
|
status: number;
|
||||||
|
state: GrpcConnectionState;
|
||||||
|
trailers: { [key in string]?: string };
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||||
|
|
||||||
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
|
export type GrpcEvent = {
|
||||||
|
model: "grpc_event";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
connectionId: string;
|
||||||
|
content: string;
|
||||||
|
error: string | null;
|
||||||
|
eventType: GrpcEventType;
|
||||||
|
metadata: { [key in string]?: string };
|
||||||
|
status: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
export type GrpcEventType =
|
||||||
|
| "info"
|
||||||
|
| "error"
|
||||||
|
| "client_message"
|
||||||
|
| "server_message"
|
||||||
|
| "connection_start"
|
||||||
|
| "connection_end";
|
||||||
|
|
||||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
export type GrpcRequest = {
|
||||||
/**
|
model: "grpc_request";
|
||||||
* Server URL (http for plaintext or https for secure)
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
url: string, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authenticationType: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
description: string;
|
||||||
|
message: string;
|
||||||
|
metadata: Array<HttpRequestHeader>;
|
||||||
|
method: string | null;
|
||||||
|
name: string;
|
||||||
|
service: string | null;
|
||||||
|
sortPriority: number;
|
||||||
|
/**
|
||||||
|
* Server URL (http for plaintext or https for secure)
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
|
export type HttpRequest = {
|
||||||
/**
|
model: "http_request";
|
||||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
urlParameters: Array<HttpUrlParameter>, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
body: Record<string, any>;
|
||||||
|
bodyType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
method: string;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
|
*/
|
||||||
|
urlParameters: Array<HttpUrlParameter>;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
|
|
||||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
export type HttpResponse = {
|
||||||
|
model: "http_response";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
bodyPath: string | null;
|
||||||
|
contentLength: number | null;
|
||||||
|
contentLengthCompressed: number | null;
|
||||||
|
elapsed: number;
|
||||||
|
elapsedHeaders: number;
|
||||||
|
elapsedDns: number;
|
||||||
|
error: string | null;
|
||||||
|
headers: Array<HttpResponseHeader>;
|
||||||
|
remoteAddr: string | null;
|
||||||
|
requestContentLength: number | null;
|
||||||
|
requestHeaders: Array<HttpResponseHeader>;
|
||||||
|
status: number;
|
||||||
|
statusReason: string | null;
|
||||||
|
state: HttpResponseState;
|
||||||
|
url: string;
|
||||||
|
version: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
export type HttpResponseEvent = {
|
||||||
|
model: "http_response_event";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
responseId: string;
|
||||||
|
event: HttpResponseEventData;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializable representation of HTTP response events for DB storage.
|
* Serializable representation of HTTP response events for DB storage.
|
||||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||||
*/
|
*/
|
||||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
export type HttpResponseEventData =
|
||||||
|
| {
|
||||||
|
type: "setting";
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
source_model?: string;
|
||||||
|
source_id?: string;
|
||||||
|
source_name?: string;
|
||||||
|
}
|
||||||
|
| { type: "info"; message: string }
|
||||||
|
| {
|
||||||
|
type: "redirect";
|
||||||
|
url: string;
|
||||||
|
status: number;
|
||||||
|
behavior: string;
|
||||||
|
dropped_body: boolean;
|
||||||
|
dropped_headers: Array<string>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "send_url";
|
||||||
|
method: string;
|
||||||
|
scheme: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
path: string;
|
||||||
|
query: string;
|
||||||
|
fragment: string;
|
||||||
|
}
|
||||||
|
| { type: "receive_url"; version: string; status: string }
|
||||||
|
| { type: "header_up"; name: string; value: string }
|
||||||
|
| { type: "header_down"; name: string; value: string }
|
||||||
|
| { type: "chunk_sent"; bytes: number }
|
||||||
|
| { type: "chunk_received"; bytes: number }
|
||||||
|
| {
|
||||||
|
type: "dns_resolved";
|
||||||
|
hostname: string;
|
||||||
|
addresses: Array<string>;
|
||||||
|
duration: bigint;
|
||||||
|
overridden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string; value: string };
|
||||||
|
|
||||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||||
|
|
||||||
export type HttpUrlParameter = { enabled?: boolean,
|
export type HttpUrlParameter = {
|
||||||
/**
|
enabled?: boolean;
|
||||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
/**
|
||||||
* Other entries are appended as query parameters
|
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||||
*/
|
* Other entries are appended as query parameters
|
||||||
name: string, value: string, id?: string, };
|
*/
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, };
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
|
|
||||||
export type ParentAuthentication = { authentication: Record<string, any>, authenticationType: string | null, };
|
export type KeyValue = {
|
||||||
|
model: "key_value";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
key: string;
|
||||||
|
namespace: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
|
export type ModelPayload = {
|
||||||
|
model: AnyModel;
|
||||||
|
updateSource: UpdateSource;
|
||||||
|
change: ModelChangeEvent;
|
||||||
|
};
|
||||||
|
|
||||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
export type ParentAuthentication = {
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
|
export type ParentHeaders = { headers: Array<HttpRequestHeader> };
|
||||||
|
|
||||||
|
export type Plugin = {
|
||||||
|
model: "plugin";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
checkedAt: string | null;
|
||||||
|
directory: string;
|
||||||
|
enabled: boolean;
|
||||||
|
url: string | null;
|
||||||
|
source: PluginSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginKeyValue = {
|
||||||
|
model: "plugin_key_value";
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
pluginName: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||||
|
|
||||||
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
export type ProxySetting =
|
||||||
|
| {
|
||||||
|
type: "enabled";
|
||||||
|
http: string;
|
||||||
|
https: string;
|
||||||
|
auth: ProxySettingAuth | null;
|
||||||
|
bypass: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
| { type: "disabled" };
|
||||||
|
|
||||||
export type ProxySettingAuth = { user: string, password: string, };
|
export type ProxySettingAuth = { user: string; password: string };
|
||||||
|
|
||||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
|
export type Settings = {
|
||||||
|
model: "settings";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
appearance: string;
|
||||||
|
clientCertificates: Array<ClientCertificate>;
|
||||||
|
coloredMethods: boolean;
|
||||||
|
editorFont: string | null;
|
||||||
|
editorFontSize: number;
|
||||||
|
editorKeymap: EditorKeymap;
|
||||||
|
editorSoftWrap: boolean;
|
||||||
|
hideWindowControls: boolean;
|
||||||
|
useNativeTitlebar: boolean;
|
||||||
|
interfaceFont: string | null;
|
||||||
|
interfaceFontSize: number;
|
||||||
|
interfaceScale: number;
|
||||||
|
openWorkspaceNewWindow: boolean | null;
|
||||||
|
proxy: ProxySetting | null;
|
||||||
|
themeDark: string;
|
||||||
|
themeLight: string;
|
||||||
|
updateChannel: string;
|
||||||
|
hideLicenseBadge: boolean;
|
||||||
|
autoupdate: boolean;
|
||||||
|
autoDownloadUpdates: boolean;
|
||||||
|
checkNotifications: boolean;
|
||||||
|
hotkeys: { [key in string]?: Array<string> };
|
||||||
|
};
|
||||||
|
|
||||||
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
export type SyncState = {
|
||||||
|
model: "sync_state";
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
flushedAt: string;
|
||||||
|
modelId: string;
|
||||||
|
checksum: string;
|
||||||
|
relPath: string;
|
||||||
|
syncDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateSource = { "type": "background" } | { "type": "import" } | { "type": "plugin" } | { "type": "sync" } | { "type": "window", label: string, };
|
export type UpdateSource =
|
||||||
|
| { type: "background" }
|
||||||
|
| { type: "import" }
|
||||||
|
| { type: "plugin" }
|
||||||
|
| { type: "sync" }
|
||||||
|
| { type: "window"; label: string };
|
||||||
|
|
||||||
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
|
export type WebsocketConnection = {
|
||||||
|
model: "websocket_connection";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
elapsed: number;
|
||||||
|
error: string | null;
|
||||||
|
headers: Array<HttpResponseHeader>;
|
||||||
|
state: WebsocketConnectionState;
|
||||||
|
status: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||||
|
|
||||||
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
|
export type WebsocketEvent = {
|
||||||
|
model: "websocket_event";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
connectionId: string;
|
||||||
|
isServer: boolean;
|
||||||
|
message: Array<number>;
|
||||||
|
messageType: WebsocketEventType;
|
||||||
|
};
|
||||||
|
|
||||||
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
|
export type WebsocketEventType =
|
||||||
|
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
||||||
|
|
||||||
export type WebsocketMessageType = "text" | "binary";
|
export type WebsocketMessageType = "text" | "binary";
|
||||||
|
|
||||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
export type WebsocketRequest = {
|
||||||
/**
|
model: "websocket_request";
|
||||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
urlParameters: Array<HttpUrlParameter>, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
message: string;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
|
*/
|
||||||
|
urlParameters: Array<HttpUrlParameter>;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
|
export type Workspace = {
|
||||||
|
model: "workspace";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
name: string;
|
||||||
|
encryptionKeyChallenge: string | null;
|
||||||
|
settingValidateCertificates: boolean;
|
||||||
|
settingFollowRedirects: boolean;
|
||||||
|
settingRequestTimeout: number;
|
||||||
|
settingRequestMessageSize: number;
|
||||||
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
|
settingSendCookies: boolean;
|
||||||
|
settingStoreCookies: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
export type WorkspaceMeta = {
|
||||||
|
model: "workspace_meta";
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
encryptionKey: EncryptedKey | null;
|
||||||
|
settingSyncDir: string | null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { newStoreData } from "./util";
|
|||||||
|
|
||||||
let _store: JotaiStore | null = null;
|
let _store: JotaiStore | null = null;
|
||||||
|
|
||||||
|
const pendingModelWrites = new Set<Promise<unknown>>();
|
||||||
|
|
||||||
export function initModelStore(store: JotaiStore) {
|
export function initModelStore(store: JotaiStore) {
|
||||||
_store = store;
|
_store = store;
|
||||||
|
|
||||||
@@ -42,6 +44,23 @@ function mustStore(): JotaiStore {
|
|||||||
return _store;
|
return _store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackModelWrite<T>(write: Promise<T>): Promise<T> {
|
||||||
|
const tracked = write.finally(() => {
|
||||||
|
pendingModelWrites.delete(tracked);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingModelWrites.add(tracked);
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushAllModelWrites(): Promise<void> {
|
||||||
|
const results = await Promise.allSettled(pendingModelWrites);
|
||||||
|
const rejected = results.find((result) => result.status === "rejected");
|
||||||
|
if (rejected?.status === "rejected") {
|
||||||
|
throw rejected.reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _activeWorkspaceId: string | null = null;
|
let _activeWorkspaceId: string | null = null;
|
||||||
|
|
||||||
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
||||||
@@ -117,7 +136,7 @@ export async function patchModel<M extends AnyModel["model"], T extends ExtractM
|
|||||||
export async function updateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
export async function updateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||||
model: T,
|
model: T,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return invoke<string>("models_upsert", { model });
|
return trackModelWrite(invoke<string>("models_upsert", { model }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModelById<
|
export async function deleteModelById<
|
||||||
@@ -134,7 +153,7 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
|
|||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw new Error("Failed to delete null model");
|
throw new Error("Failed to delete null model");
|
||||||
}
|
}
|
||||||
await invoke<string>("models_delete", { model });
|
await trackModelWrite(invoke<string>("models_delete", { model }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||||
@@ -174,19 +193,19 @@ export function duplicateModel<M extends AnyModel["model"], T extends ExtractMod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return invoke<string>("models_duplicate", { model: { ...model, name } });
|
return trackModelWrite(invoke<string>("models_duplicate", { model: { ...model, name } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
|
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
|
||||||
patch: Partial<T> & Pick<T, "model">,
|
patch: Partial<T> & Pick<T, "model">,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return invoke<string>("models_upsert", { model: patch });
|
return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(
|
export async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(
|
||||||
patch: Partial<T> & Pick<T, "model" | "workspaceId">,
|
patch: Partial<T> & Pick<T, "model" | "workspaceId">,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return invoke<string>("models_upsert", { model: patch });
|
return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceModelsInStore<
|
export function replaceModelsInStore<
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN setting_send_cookies BOOLEAN DEFAULT TRUE NOT NULL;
|
||||||
|
ALTER TABLE workspaces ADD COLUMN setting_store_cookies BOOLEAN DEFAULT TRUE NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE folders ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
ALTER TABLE folders ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
ALTER TABLE folders ADD COLUMN setting_request_timeout TEXT DEFAULT '{"enabled":false,"value":0}' NOT NULL;
|
||||||
|
ALTER TABLE folders ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
ALTER TABLE folders ADD COLUMN setting_follow_redirects TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE http_requests ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
ALTER TABLE http_requests ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
ALTER TABLE http_requests ADD COLUMN setting_request_timeout TEXT DEFAULT '{"enabled":false,"value":0}' NOT NULL;
|
||||||
|
ALTER TABLE http_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
ALTER TABLE http_requests ADD COLUMN setting_follow_redirects TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE websocket_requests ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
ALTER TABLE websocket_requests ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE websocket_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE grpc_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN setting_request_message_size INTEGER DEFAULT 67108864 NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE folders ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE websocket_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE grpc_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::HttpRequestIden::{
|
use crate::models::HttpRequestIden::{
|
||||||
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
|
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
|
||||||
Method, Name, SortPriority, UpdatedAt, Url, UrlParameters, WorkspaceId,
|
Method, Name, SettingFollowRedirects, SettingRequestTimeout, SettingSendCookies,
|
||||||
|
SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt, Url, UrlParameters,
|
||||||
|
WorkspaceId,
|
||||||
};
|
};
|
||||||
use crate::util::generate_prefixed_id;
|
use crate::util::generate_prefixed_id;
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
@@ -19,6 +21,8 @@ use ts_rs::TS;
|
|||||||
use yaak_database::{Result as DbResult, UpdateSource};
|
use yaak_database::{Result as DbResult, UpdateSource};
|
||||||
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
||||||
|
|
||||||
|
pub const DEFAULT_REQUEST_MESSAGE_SIZE: i32 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! impl_model {
|
macro_rules! impl_model {
|
||||||
($t:ty, $variant:ident) => {
|
($t:ty, $variant:ident) => {
|
||||||
@@ -90,6 +94,86 @@ pub struct DnsOverride {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct ResolvedSetting<T> {
|
||||||
|
pub value: T,
|
||||||
|
pub source_model: String,
|
||||||
|
pub source_id: Option<String>,
|
||||||
|
pub source_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> ResolvedSetting<T> {
|
||||||
|
pub fn from_model(value: T, model: AnyModel) -> Self {
|
||||||
|
Self {
|
||||||
|
value,
|
||||||
|
source_model: model.model().to_string(),
|
||||||
|
source_id: Some(model.id().to_string()),
|
||||||
|
source_name: Some(model.resolved_name()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_source(value: T) -> Self {
|
||||||
|
Self { value, source_model: "default".to_string(), source_id: None, source_name: None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResolvedHttpRequestSettings {
|
||||||
|
pub validate_certificates: ResolvedSetting<bool>,
|
||||||
|
pub follow_redirects: ResolvedSetting<bool>,
|
||||||
|
pub request_timeout: ResolvedSetting<i32>,
|
||||||
|
pub request_message_size: ResolvedSetting<i32>,
|
||||||
|
pub send_cookies: ResolvedSetting<bool>,
|
||||||
|
pub store_cookies: ResolvedSetting<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ResolvedHttpRequestSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
validate_certificates: ResolvedSetting::default_source(true),
|
||||||
|
follow_redirects: ResolvedSetting::default_source(true),
|
||||||
|
request_timeout: ResolvedSetting::default_source(0),
|
||||||
|
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
|
||||||
|
send_cookies: ResolvedSetting::default_source(true),
|
||||||
|
store_cookies: ResolvedSetting::default_source(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
|
pub struct InheritedBoolSetting {
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional, as = "Option<bool>")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub value: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InheritedBoolSetting {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { enabled: false, value: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
|
pub struct InheritedIntSetting {
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional, as = "Option<bool>")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InheritedIntSetting {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { enabled: false, value: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
@@ -320,8 +404,14 @@ pub struct Workspace {
|
|||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub setting_follow_redirects: bool,
|
pub setting_follow_redirects: bool,
|
||||||
pub setting_request_timeout: i32,
|
pub setting_request_timeout: i32,
|
||||||
|
#[serde(default = "default_request_message_size")]
|
||||||
|
pub setting_request_message_size: i32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub setting_dns_overrides: Vec<DnsOverride>,
|
pub setting_dns_overrides: Vec<DnsOverride>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub setting_send_cookies: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub setting_store_cookies: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for Workspace {
|
impl UpsertModelInfo for Workspace {
|
||||||
@@ -361,8 +451,11 @@ impl UpsertModelInfo for Workspace {
|
|||||||
(EncryptionKeyChallenge, self.encryption_key_challenge.into()),
|
(EncryptionKeyChallenge, self.encryption_key_challenge.into()),
|
||||||
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
||||||
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
||||||
|
(SettingRequestMessageSize, self.setting_request_message_size.into()),
|
||||||
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
||||||
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
||||||
|
(SettingSendCookies, self.setting_send_cookies.into()),
|
||||||
|
(SettingStoreCookies, self.setting_store_cookies.into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +470,11 @@ impl UpsertModelInfo for Workspace {
|
|||||||
WorkspaceIden::EncryptionKeyChallenge,
|
WorkspaceIden::EncryptionKeyChallenge,
|
||||||
WorkspaceIden::SettingRequestTimeout,
|
WorkspaceIden::SettingRequestTimeout,
|
||||||
WorkspaceIden::SettingFollowRedirects,
|
WorkspaceIden::SettingFollowRedirects,
|
||||||
WorkspaceIden::SettingRequestTimeout,
|
WorkspaceIden::SettingRequestMessageSize,
|
||||||
WorkspaceIden::SettingValidateCertificates,
|
WorkspaceIden::SettingValidateCertificates,
|
||||||
WorkspaceIden::SettingDnsOverrides,
|
WorkspaceIden::SettingDnsOverrides,
|
||||||
|
WorkspaceIden::SettingSendCookies,
|
||||||
|
WorkspaceIden::SettingStoreCookies,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,8 +498,11 @@ impl UpsertModelInfo for Workspace {
|
|||||||
authentication_type: row.get("authentication_type")?,
|
authentication_type: row.get("authentication_type")?,
|
||||||
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
||||||
setting_request_timeout: row.get("setting_request_timeout")?,
|
setting_request_timeout: row.get("setting_request_timeout")?,
|
||||||
|
setting_request_message_size: row.get("setting_request_message_size")?,
|
||||||
setting_validate_certificates: row.get("setting_validate_certificates")?,
|
setting_validate_certificates: row.get("setting_validate_certificates")?,
|
||||||
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
||||||
|
setting_send_cookies: row.get("setting_send_cookies")?,
|
||||||
|
setting_store_cookies: row.get("setting_store_cookies")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,11 +607,127 @@ pub enum CookieExpires {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
|
pub enum CookieSameSite {
|
||||||
|
Strict,
|
||||||
|
Lax,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
pub struct Cookie {
|
pub struct Cookie {
|
||||||
pub raw_cookie: String,
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
pub domain: CookieDomain,
|
pub domain: CookieDomain,
|
||||||
pub expires: CookieExpires,
|
pub expires: CookieExpires,
|
||||||
pub path: (String, bool),
|
pub path: String,
|
||||||
|
pub secure: bool,
|
||||||
|
pub http_only: bool,
|
||||||
|
pub same_site: Option<CookieSameSite>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CookieFields {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
domain: CookieDomain,
|
||||||
|
expires: CookieExpires,
|
||||||
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
secure: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
http_only: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
same_site: Option<CookieSameSite>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LegacyCookie {
|
||||||
|
raw_cookie: String,
|
||||||
|
domain: CookieDomain,
|
||||||
|
expires: CookieExpires,
|
||||||
|
path: (String, bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum CookieCompat {
|
||||||
|
New(CookieFields),
|
||||||
|
Legacy(LegacyCookie),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Cookie {
|
||||||
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(match CookieCompat::deserialize(deserializer)? {
|
||||||
|
CookieCompat::New(cookie) => Self {
|
||||||
|
name: cookie.name,
|
||||||
|
value: cookie.value,
|
||||||
|
domain: cookie.domain,
|
||||||
|
expires: cookie.expires,
|
||||||
|
path: cookie.path,
|
||||||
|
secure: cookie.secure,
|
||||||
|
http_only: cookie.http_only,
|
||||||
|
same_site: cookie.same_site,
|
||||||
|
},
|
||||||
|
CookieCompat::Legacy(cookie) => {
|
||||||
|
let (name, value, secure, http_only, same_site) =
|
||||||
|
parse_legacy_cookie_parts(&cookie.raw_cookie);
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
domain: cookie.domain,
|
||||||
|
expires: cookie.expires,
|
||||||
|
path: cookie.path.0,
|
||||||
|
secure,
|
||||||
|
http_only,
|
||||||
|
same_site,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_legacy_cookie_parts(
|
||||||
|
raw_cookie: &str,
|
||||||
|
) -> (String, String, bool, bool, Option<CookieSameSite>) {
|
||||||
|
let mut parts = raw_cookie.split(';').map(str::trim);
|
||||||
|
let (name, value) = parts
|
||||||
|
.next()
|
||||||
|
.and_then(|part| {
|
||||||
|
let mut nv = part.splitn(2, '=');
|
||||||
|
Some((nv.next()?.trim().to_string(), nv.next().unwrap_or("").trim().to_string()))
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut secure = false;
|
||||||
|
let mut http_only = false;
|
||||||
|
let mut same_site = None;
|
||||||
|
|
||||||
|
for part in parts {
|
||||||
|
let mut attr = part.splitn(2, '=');
|
||||||
|
let key = attr.next().unwrap_or("").trim().to_lowercase();
|
||||||
|
let value = attr.next().unwrap_or("").trim().to_lowercase();
|
||||||
|
match key.as_str() {
|
||||||
|
"secure" => secure = true,
|
||||||
|
"httponly" => http_only = true,
|
||||||
|
"samesite" => {
|
||||||
|
same_site = match value.as_str() {
|
||||||
|
"strict" => Some(CookieSameSite::Strict),
|
||||||
|
"lax" => Some(CookieSameSite::Lax),
|
||||||
|
"none" => Some(CookieSameSite::None),
|
||||||
|
_ => same_site,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(name, value, secure, http_only, same_site)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
||||||
@@ -751,6 +965,13 @@ pub struct Folder {
|
|||||||
pub headers: Vec<HttpRequestHeader>,
|
pub headers: Vec<HttpRequestHeader>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub sort_priority: f64,
|
pub sort_priority: f64,
|
||||||
|
pub setting_send_cookies: InheritedBoolSetting,
|
||||||
|
pub setting_store_cookies: InheritedBoolSetting,
|
||||||
|
pub setting_validate_certificates: InheritedBoolSetting,
|
||||||
|
pub setting_follow_redirects: InheritedBoolSetting,
|
||||||
|
pub setting_request_timeout: InheritedIntSetting,
|
||||||
|
#[serde(default = "default_request_message_size_setting")]
|
||||||
|
pub setting_request_message_size: InheritedIntSetting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for Folder {
|
impl UpsertModelInfo for Folder {
|
||||||
@@ -790,6 +1011,18 @@ impl UpsertModelInfo for Folder {
|
|||||||
(Description, self.description.into()),
|
(Description, self.description.into()),
|
||||||
(Name, self.name.trim().into()),
|
(Name, self.name.trim().into()),
|
||||||
(SortPriority, self.sort_priority.into()),
|
(SortPriority, self.sort_priority.into()),
|
||||||
|
(SettingSendCookies, serde_json::to_string(&self.setting_send_cookies)?.into()),
|
||||||
|
(SettingStoreCookies, serde_json::to_string(&self.setting_store_cookies)?.into()),
|
||||||
|
(
|
||||||
|
SettingValidateCertificates,
|
||||||
|
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
||||||
|
),
|
||||||
|
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
|
||||||
|
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
|
||||||
|
(
|
||||||
|
SettingRequestMessageSize,
|
||||||
|
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +1036,12 @@ impl UpsertModelInfo for Folder {
|
|||||||
FolderIden::Description,
|
FolderIden::Description,
|
||||||
FolderIden::FolderId,
|
FolderIden::FolderId,
|
||||||
FolderIden::SortPriority,
|
FolderIden::SortPriority,
|
||||||
|
FolderIden::SettingSendCookies,
|
||||||
|
FolderIden::SettingStoreCookies,
|
||||||
|
FolderIden::SettingValidateCertificates,
|
||||||
|
FolderIden::SettingFollowRedirects,
|
||||||
|
FolderIden::SettingRequestTimeout,
|
||||||
|
FolderIden::SettingRequestMessageSize,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,6 +1051,12 @@ impl UpsertModelInfo for Folder {
|
|||||||
{
|
{
|
||||||
let headers: String = row.get("headers")?;
|
let headers: String = row.get("headers")?;
|
||||||
let authentication: String = row.get("authentication")?;
|
let authentication: String = row.get("authentication")?;
|
||||||
|
let setting_send_cookies: String = row.get("setting_send_cookies")?;
|
||||||
|
let setting_store_cookies: String = row.get("setting_store_cookies")?;
|
||||||
|
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||||
|
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
||||||
|
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
||||||
|
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -825,6 +1070,16 @@ impl UpsertModelInfo for Folder {
|
|||||||
headers: serde_json::from_str(&headers).unwrap_or_default(),
|
headers: serde_json::from_str(&headers).unwrap_or_default(),
|
||||||
authentication_type: row.get("authentication_type")?,
|
authentication_type: row.get("authentication_type")?,
|
||||||
authentication: serde_json::from_str(&authentication).unwrap_or_default(),
|
authentication: serde_json::from_str(&authentication).unwrap_or_default(),
|
||||||
|
setting_send_cookies: serde_json::from_str(&setting_send_cookies).unwrap_or_default(),
|
||||||
|
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
|
||||||
|
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
setting_follow_redirects: serde_json::from_str(&setting_follow_redirects)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
||||||
|
.unwrap_or_else(|_| default_request_message_size_setting()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -885,6 +1140,11 @@ pub struct HttpRequest {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
/// URL parameters used for both path placeholders (`:id`) and query string entries.
|
/// URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
pub url_parameters: Vec<HttpUrlParameter>,
|
pub url_parameters: Vec<HttpUrlParameter>,
|
||||||
|
pub setting_send_cookies: InheritedBoolSetting,
|
||||||
|
pub setting_store_cookies: InheritedBoolSetting,
|
||||||
|
pub setting_validate_certificates: InheritedBoolSetting,
|
||||||
|
pub setting_follow_redirects: InheritedBoolSetting,
|
||||||
|
pub setting_request_timeout: InheritedIntSetting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for HttpRequest {
|
impl UpsertModelInfo for HttpRequest {
|
||||||
@@ -928,6 +1188,14 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
(AuthenticationType, self.authentication_type.into()),
|
(AuthenticationType, self.authentication_type.into()),
|
||||||
(Headers, serde_json::to_string(&self.headers)?.into()),
|
(Headers, serde_json::to_string(&self.headers)?.into()),
|
||||||
(SortPriority, self.sort_priority.into()),
|
(SortPriority, self.sort_priority.into()),
|
||||||
|
(SettingSendCookies, serde_json::to_string(&self.setting_send_cookies)?.into()),
|
||||||
|
(SettingStoreCookies, serde_json::to_string(&self.setting_store_cookies)?.into()),
|
||||||
|
(
|
||||||
|
SettingValidateCertificates,
|
||||||
|
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
||||||
|
),
|
||||||
|
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
|
||||||
|
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,6 +1215,11 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
Url,
|
Url,
|
||||||
UrlParameters,
|
UrlParameters,
|
||||||
SortPriority,
|
SortPriority,
|
||||||
|
SettingSendCookies,
|
||||||
|
SettingStoreCookies,
|
||||||
|
SettingValidateCertificates,
|
||||||
|
SettingFollowRedirects,
|
||||||
|
SettingRequestTimeout,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,6 +1228,11 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
let body: String = row.get("body")?;
|
let body: String = row.get("body")?;
|
||||||
let authentication: String = row.get("authentication")?;
|
let authentication: String = row.get("authentication")?;
|
||||||
let headers: String = row.get("headers")?;
|
let headers: String = row.get("headers")?;
|
||||||
|
let setting_send_cookies: String = row.get("setting_send_cookies")?;
|
||||||
|
let setting_store_cookies: String = row.get("setting_store_cookies")?;
|
||||||
|
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||||
|
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
||||||
|
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -973,6 +1251,14 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
sort_priority: row.get("sort_priority")?,
|
sort_priority: row.get("sort_priority")?,
|
||||||
url: row.get("url")?,
|
url: row.get("url")?,
|
||||||
url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),
|
url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),
|
||||||
|
setting_send_cookies: serde_json::from_str(&setting_send_cookies).unwrap_or_default(),
|
||||||
|
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
|
||||||
|
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
setting_follow_redirects: serde_json::from_str(&setting_follow_redirects)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
|
||||||
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1127,6 +1413,11 @@ pub struct WebsocketRequest {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
/// URL parameters used for both path placeholders (`:id`) and query string entries.
|
/// URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
pub url_parameters: Vec<HttpUrlParameter>,
|
pub url_parameters: Vec<HttpUrlParameter>,
|
||||||
|
pub setting_send_cookies: InheritedBoolSetting,
|
||||||
|
pub setting_store_cookies: InheritedBoolSetting,
|
||||||
|
pub setting_validate_certificates: InheritedBoolSetting,
|
||||||
|
#[serde(default = "default_request_message_size_setting")]
|
||||||
|
pub setting_request_message_size: InheritedIntSetting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for WebsocketRequest {
|
impl UpsertModelInfo for WebsocketRequest {
|
||||||
@@ -1169,6 +1460,16 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
(SortPriority, self.sort_priority.into()),
|
(SortPriority, self.sort_priority.into()),
|
||||||
(Url, self.url.into()),
|
(Url, self.url.into()),
|
||||||
(UrlParameters, serde_json::to_string(&self.url_parameters)?.into()),
|
(UrlParameters, serde_json::to_string(&self.url_parameters)?.into()),
|
||||||
|
(SettingSendCookies, serde_json::to_string(&self.setting_send_cookies)?.into()),
|
||||||
|
(SettingStoreCookies, serde_json::to_string(&self.setting_store_cookies)?.into()),
|
||||||
|
(
|
||||||
|
SettingValidateCertificates,
|
||||||
|
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SettingRequestMessageSize,
|
||||||
|
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,6 +1487,10 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
WebsocketRequestIden::SortPriority,
|
WebsocketRequestIden::SortPriority,
|
||||||
WebsocketRequestIden::Url,
|
WebsocketRequestIden::Url,
|
||||||
WebsocketRequestIden::UrlParameters,
|
WebsocketRequestIden::UrlParameters,
|
||||||
|
WebsocketRequestIden::SettingSendCookies,
|
||||||
|
WebsocketRequestIden::SettingStoreCookies,
|
||||||
|
WebsocketRequestIden::SettingValidateCertificates,
|
||||||
|
WebsocketRequestIden::SettingRequestMessageSize,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,6 +1501,10 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
let url_parameters: String = row.get("url_parameters")?;
|
let url_parameters: String = row.get("url_parameters")?;
|
||||||
let authentication: String = row.get("authentication")?;
|
let authentication: String = row.get("authentication")?;
|
||||||
let headers: String = row.get("headers")?;
|
let headers: String = row.get("headers")?;
|
||||||
|
let setting_send_cookies: String = row.get("setting_send_cookies")?;
|
||||||
|
let setting_store_cookies: String = row.get("setting_store_cookies")?;
|
||||||
|
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||||
|
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -1212,6 +1521,12 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
|
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
|
||||||
folder_id: row.get("folder_id")?,
|
folder_id: row.get("folder_id")?,
|
||||||
name: row.get("name")?,
|
name: row.get("name")?,
|
||||||
|
setting_send_cookies: serde_json::from_str(&setting_send_cookies).unwrap_or_default(),
|
||||||
|
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
|
||||||
|
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
||||||
|
.unwrap_or_else(|_| default_request_message_size_setting()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1222,6 +1537,7 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
pub enum WebsocketEventType {
|
pub enum WebsocketEventType {
|
||||||
Binary,
|
Binary,
|
||||||
Close,
|
Close,
|
||||||
|
Error,
|
||||||
Frame,
|
Frame,
|
||||||
Open,
|
Open,
|
||||||
Ping,
|
Ping,
|
||||||
@@ -1493,6 +1809,15 @@ pub enum HttpResponseEventData {
|
|||||||
Setting {
|
Setting {
|
||||||
name: String,
|
name: String,
|
||||||
value: String,
|
value: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional, as = "Option<String>")]
|
||||||
|
source_model: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional, as = "Option<String>")]
|
||||||
|
source_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional, as = "Option<String>")]
|
||||||
|
source_name: Option<String>,
|
||||||
},
|
},
|
||||||
Info {
|
Info {
|
||||||
message: String,
|
message: String,
|
||||||
@@ -1742,6 +2067,9 @@ pub struct GrpcRequest {
|
|||||||
pub sort_priority: f64,
|
pub sort_priority: f64,
|
||||||
/// Server URL (http for plaintext or https for secure)
|
/// Server URL (http for plaintext or https for secure)
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
pub setting_validate_certificates: InheritedBoolSetting,
|
||||||
|
#[serde(default = "default_request_message_size_setting")]
|
||||||
|
pub setting_request_message_size: InheritedIntSetting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for GrpcRequest {
|
impl UpsertModelInfo for GrpcRequest {
|
||||||
@@ -1785,6 +2113,14 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
(AuthenticationType, self.authentication_type.into()),
|
(AuthenticationType, self.authentication_type.into()),
|
||||||
(Authentication, serde_json::to_string(&self.authentication)?.into()),
|
(Authentication, serde_json::to_string(&self.authentication)?.into()),
|
||||||
(Metadata, serde_json::to_string(&self.metadata)?.into()),
|
(Metadata, serde_json::to_string(&self.metadata)?.into()),
|
||||||
|
(
|
||||||
|
SettingValidateCertificates,
|
||||||
|
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SettingRequestMessageSize,
|
||||||
|
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1803,6 +2139,8 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
GrpcRequestIden::AuthenticationType,
|
GrpcRequestIden::AuthenticationType,
|
||||||
GrpcRequestIden::Authentication,
|
GrpcRequestIden::Authentication,
|
||||||
GrpcRequestIden::Metadata,
|
GrpcRequestIden::Metadata,
|
||||||
|
GrpcRequestIden::SettingValidateCertificates,
|
||||||
|
GrpcRequestIden::SettingRequestMessageSize,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1812,6 +2150,8 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
{
|
{
|
||||||
let authentication: String = row.get("authentication")?;
|
let authentication: String = row.get("authentication")?;
|
||||||
let metadata: String = row.get("metadata")?;
|
let metadata: String = row.get("metadata")?;
|
||||||
|
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||||
|
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -1829,6 +2169,10 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
url: row.get("url")?,
|
url: row.get("url")?,
|
||||||
sort_priority: row.get("sort_priority")?,
|
sort_priority: row.get("sort_priority")?,
|
||||||
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
|
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
|
||||||
|
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
||||||
|
.unwrap_or_else(|_| default_request_message_size_setting()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2379,6 +2723,14 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_request_message_size() -> i32 {
|
||||||
|
DEFAULT_REQUEST_MESSAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_request_message_size_setting() -> InheritedIntSetting {
|
||||||
|
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
|
||||||
|
}
|
||||||
|
|
||||||
fn default_http_method() -> String {
|
fn default_http_method() -> String {
|
||||||
"GET".to_string()
|
"GET".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ use crate::client_db::ClientDb;
|
|||||||
use crate::connection_or_tx::ConnectionOrTx;
|
use crate::connection_or_tx::ConnectionOrTx;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest,
|
AnyModel, Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden,
|
||||||
HttpRequestHeader, HttpRequestIden, WebsocketRequest, WebsocketRequestIden,
|
HttpRequest, HttpRequestHeader, HttpRequestIden, ResolvedHttpRequestSettings, ResolvedSetting,
|
||||||
|
WebsocketRequest, WebsocketRequestIden,
|
||||||
};
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -141,4 +142,68 @@ impl<'a> ClientDb<'a> {
|
|||||||
|
|
||||||
Ok(headers)
|
Ok(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_settings_for_folder(
|
||||||
|
&self,
|
||||||
|
folder: &Folder,
|
||||||
|
) -> Result<ResolvedHttpRequestSettings> {
|
||||||
|
let parent = if let Some(folder_id) = folder.folder_id.clone() {
|
||||||
|
let parent_folder = self.get_folder(&folder_id)?;
|
||||||
|
self.resolve_settings_for_folder(&parent_folder)?
|
||||||
|
} else {
|
||||||
|
let workspace = self.get_workspace(&folder.workspace_id)?;
|
||||||
|
self.resolve_settings_for_workspace(&workspace)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ResolvedHttpRequestSettings {
|
||||||
|
validate_certificates: if folder.setting_validate_certificates.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
folder.setting_validate_certificates.value,
|
||||||
|
AnyModel::Folder(folder.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.validate_certificates
|
||||||
|
},
|
||||||
|
follow_redirects: if folder.setting_follow_redirects.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
folder.setting_follow_redirects.value,
|
||||||
|
AnyModel::Folder(folder.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.follow_redirects
|
||||||
|
},
|
||||||
|
request_timeout: if folder.setting_request_timeout.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
folder.setting_request_timeout.value,
|
||||||
|
AnyModel::Folder(folder.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.request_timeout
|
||||||
|
},
|
||||||
|
request_message_size: if folder.setting_request_message_size.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
folder.setting_request_message_size.value,
|
||||||
|
AnyModel::Folder(folder.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.request_message_size
|
||||||
|
},
|
||||||
|
send_cookies: if folder.setting_send_cookies.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
folder.setting_send_cookies.value,
|
||||||
|
AnyModel::Folder(folder.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.send_cookies
|
||||||
|
},
|
||||||
|
store_cookies: if folder.setting_store_cookies.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
folder.setting_store_cookies.value,
|
||||||
|
AnyModel::Folder(folder.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.store_cookies
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use super::dedupe_headers;
|
use super::dedupe_headers;
|
||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader};
|
use crate::models::{
|
||||||
|
AnyModel, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader,
|
||||||
|
ResolvedHttpRequestSettings, ResolvedSetting,
|
||||||
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -104,4 +107,37 @@ impl<'a> ClientDb<'a> {
|
|||||||
|
|
||||||
Ok(dedupe_headers(metadata))
|
Ok(dedupe_headers(metadata))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_settings_for_grpc_request(
|
||||||
|
&self,
|
||||||
|
grpc_request: &GrpcRequest,
|
||||||
|
) -> Result<ResolvedHttpRequestSettings> {
|
||||||
|
let parent = if let Some(folder_id) = grpc_request.folder_id.clone() {
|
||||||
|
let folder = self.get_folder(&folder_id)?;
|
||||||
|
self.resolve_settings_for_folder(&folder)?
|
||||||
|
} else {
|
||||||
|
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
|
||||||
|
self.resolve_settings_for_workspace(&workspace)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ResolvedHttpRequestSettings {
|
||||||
|
validate_certificates: if grpc_request.setting_validate_certificates.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
grpc_request.setting_validate_certificates.value,
|
||||||
|
AnyModel::GrpcRequest(grpc_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.validate_certificates
|
||||||
|
},
|
||||||
|
request_message_size: if grpc_request.setting_request_message_size.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
grpc_request.setting_request_message_size.value,
|
||||||
|
AnyModel::GrpcRequest(grpc_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.request_message_size
|
||||||
|
},
|
||||||
|
..parent
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use super::dedupe_headers;
|
use super::dedupe_headers;
|
||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
|
use crate::models::{
|
||||||
|
AnyModel, Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||||
|
ResolvedHttpRequestSettings, ResolvedSetting,
|
||||||
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -91,6 +94,63 @@ impl<'a> ClientDb<'a> {
|
|||||||
Ok(dedupe_headers(headers))
|
Ok(dedupe_headers(headers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_settings_for_http_request(
|
||||||
|
&self,
|
||||||
|
http_request: &HttpRequest,
|
||||||
|
) -> Result<ResolvedHttpRequestSettings> {
|
||||||
|
let parent = if let Some(folder_id) = http_request.folder_id.clone() {
|
||||||
|
let folder = self.get_folder(&folder_id)?;
|
||||||
|
self.resolve_settings_for_folder(&folder)?
|
||||||
|
} else {
|
||||||
|
let workspace = self.get_workspace(&http_request.workspace_id)?;
|
||||||
|
self.resolve_settings_for_workspace(&workspace)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ResolvedHttpRequestSettings {
|
||||||
|
validate_certificates: if http_request.setting_validate_certificates.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
http_request.setting_validate_certificates.value,
|
||||||
|
AnyModel::HttpRequest(http_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.validate_certificates
|
||||||
|
},
|
||||||
|
follow_redirects: if http_request.setting_follow_redirects.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
http_request.setting_follow_redirects.value,
|
||||||
|
AnyModel::HttpRequest(http_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.follow_redirects
|
||||||
|
},
|
||||||
|
request_timeout: if http_request.setting_request_timeout.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
http_request.setting_request_timeout.value,
|
||||||
|
AnyModel::HttpRequest(http_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.request_timeout
|
||||||
|
},
|
||||||
|
request_message_size: parent.request_message_size,
|
||||||
|
send_cookies: if http_request.setting_send_cookies.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
http_request.setting_send_cookies.value,
|
||||||
|
AnyModel::HttpRequest(http_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.send_cookies
|
||||||
|
},
|
||||||
|
store_cookies: if http_request.setting_store_cookies.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
http_request.setting_store_cookies.value,
|
||||||
|
AnyModel::HttpRequest(http_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.store_cookies
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_http_requests_for_folder_recursive(
|
pub fn list_http_requests_for_folder_recursive(
|
||||||
&self,
|
&self,
|
||||||
folder_id: &str,
|
folder_id: &str,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use super::dedupe_headers;
|
|||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Folder, FolderIden, HttpRequestHeader, WebsocketRequest, WebsocketRequestIden,
|
AnyModel, Folder, FolderIden, HttpRequestHeader, ResolvedHttpRequestSettings, ResolvedSetting,
|
||||||
|
WebsocketRequest, WebsocketRequestIden,
|
||||||
};
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -116,4 +117,53 @@ impl<'a> ClientDb<'a> {
|
|||||||
|
|
||||||
Ok(dedupe_headers(headers))
|
Ok(dedupe_headers(headers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_settings_for_websocket_request(
|
||||||
|
&self,
|
||||||
|
websocket_request: &WebsocketRequest,
|
||||||
|
) -> Result<ResolvedHttpRequestSettings> {
|
||||||
|
let parent = if let Some(folder_id) = websocket_request.folder_id.clone() {
|
||||||
|
let folder = self.get_folder(&folder_id)?;
|
||||||
|
self.resolve_settings_for_folder(&folder)?
|
||||||
|
} else {
|
||||||
|
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
|
||||||
|
self.resolve_settings_for_workspace(&workspace)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ResolvedHttpRequestSettings {
|
||||||
|
validate_certificates: if websocket_request.setting_validate_certificates.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
websocket_request.setting_validate_certificates.value,
|
||||||
|
AnyModel::WebsocketRequest(websocket_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.validate_certificates
|
||||||
|
},
|
||||||
|
request_message_size: if websocket_request.setting_request_message_size.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
websocket_request.setting_request_message_size.value,
|
||||||
|
AnyModel::WebsocketRequest(websocket_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.request_message_size
|
||||||
|
},
|
||||||
|
send_cookies: if websocket_request.setting_send_cookies.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
websocket_request.setting_send_cookies.value,
|
||||||
|
AnyModel::WebsocketRequest(websocket_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.send_cookies
|
||||||
|
},
|
||||||
|
store_cookies: if websocket_request.setting_store_cookies.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
websocket_request.setting_store_cookies.value,
|
||||||
|
AnyModel::WebsocketRequest(websocket_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.store_cookies
|
||||||
|
},
|
||||||
|
..parent
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::client_db::ClientDb;
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
|
AnyModel, EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
|
||||||
WebsocketRequestIden, Workspace, WorkspaceIden,
|
ResolvedHttpRequestSettings, ResolvedSetting, WebsocketRequestIden, Workspace, WorkspaceIden,
|
||||||
};
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -21,6 +21,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
&Workspace {
|
&Workspace {
|
||||||
name: "Yaak".to_string(),
|
name: "Yaak".to_string(),
|
||||||
setting_follow_redirects: true,
|
setting_follow_redirects: true,
|
||||||
|
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||||
setting_validate_certificates: true,
|
setting_validate_certificates: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -84,6 +85,38 @@ impl<'a> ClientDb<'a> {
|
|||||||
headers.extend(workspace.headers.clone());
|
headers.extend(workspace.headers.clone());
|
||||||
headers
|
headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_settings_for_workspace(
|
||||||
|
&self,
|
||||||
|
workspace: &Workspace,
|
||||||
|
) -> ResolvedHttpRequestSettings {
|
||||||
|
ResolvedHttpRequestSettings {
|
||||||
|
validate_certificates: ResolvedSetting::from_model(
|
||||||
|
workspace.setting_validate_certificates,
|
||||||
|
AnyModel::Workspace(workspace.clone()),
|
||||||
|
),
|
||||||
|
follow_redirects: ResolvedSetting::from_model(
|
||||||
|
workspace.setting_follow_redirects,
|
||||||
|
AnyModel::Workspace(workspace.clone()),
|
||||||
|
),
|
||||||
|
request_timeout: ResolvedSetting::from_model(
|
||||||
|
workspace.setting_request_timeout,
|
||||||
|
AnyModel::Workspace(workspace.clone()),
|
||||||
|
),
|
||||||
|
request_message_size: ResolvedSetting::from_model(
|
||||||
|
workspace.setting_request_message_size,
|
||||||
|
AnyModel::Workspace(workspace.clone()),
|
||||||
|
),
|
||||||
|
send_cookies: ResolvedSetting::from_model(
|
||||||
|
workspace.setting_send_cookies,
|
||||||
|
AnyModel::Workspace(workspace.clone()),
|
||||||
|
),
|
||||||
|
store_cookies: ResolvedSetting::from_model(
|
||||||
|
workspace.setting_store_cookies,
|
||||||
|
AnyModel::Workspace(workspace.clone()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global default headers that are always sent with requests unless overridden.
|
/// Global default headers that are always sent with requests unless overridden.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user