mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-18 13:47:08 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8d235f5cb |
@@ -1,2 +1 @@
|
|||||||
vp lint
|
vp lint
|
||||||
vp staged
|
|
||||||
|
|||||||
Generated
+640
-412
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -47,10 +47,10 @@ schemars = { version = "0.8.22", features = ["chrono"] }
|
|||||||
serde = "1.0.228"
|
serde = "1.0.228"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
tauri = "2.11.1"
|
tauri = "2.9.5"
|
||||||
tauri-plugin = "2.6.1"
|
tauri-plugin = "2.5.2"
|
||||||
tauri-plugin-dialog = "2.7.1"
|
tauri-plugin-dialog = "2.4.2"
|
||||||
tauri-plugin-shell = "2.3.5"
|
tauri-plugin-shell = "2.3.3"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tokio = "1.48.0"
|
tokio = "1.48.0"
|
||||||
ts-rs = "11.1.0"
|
ts-rs = "11.1.0"
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
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;
|
||||||
WorkspaceSettingsDialog.show(workspaceId, tab);
|
showDialog({
|
||||||
|
id: "workspace-settings",
|
||||||
|
size: "md",
|
||||||
|
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
|
||||||
|
noPadding: true,
|
||||||
|
render: ({ hide }) => (
|
||||||
|
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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";
|
||||||
@@ -37,6 +36,7 @@ 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,12 +99,6 @@ 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",
|
||||||
@@ -134,7 +128,12 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
key: "cookies.show",
|
key: "cookies.show",
|
||||||
label: "Show Cookies",
|
label: "Show Cookies",
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
CookieDialog.show(activeCookieJar?.id ?? null);
|
showDialog({
|
||||||
|
id: "cookies",
|
||||||
|
title: "Manage Cookies",
|
||||||
|
size: "full",
|
||||||
|
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,31 +1,9 @@
|
|||||||
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, useMemo, 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 {
|
import { Banner, InlineCode } from "@yaakapp-internal/ui";
|
||||||
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;
|
||||||
@@ -34,626 +12,56 @@ 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 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 = () => {
|
|
||||||
if (cookieJar == null || draftCookie == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextCookie = normalizeCookie(draftCookie);
|
|
||||||
if (nextCookie.name.trim().length === 0) {
|
|
||||||
showAlert({
|
|
||||||
id: "invalid-cookie-name",
|
|
||||||
title: "Invalid Cookie",
|
|
||||||
body: "Cookie name is required.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (cookieJar.cookies.length === 0) {
|
||||||
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
|
return (
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
<Banner>
|
||||||
<PlainInput
|
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
|
||||||
name="cookie-filter"
|
</Banner>
|
||||||
label="Filter cookies"
|
);
|
||||||
hideLabel
|
}
|
||||||
placeholder="Filter cookies"
|
|
||||||
defaultValue={filter}
|
|
||||||
forceUpdateKey={filterUpdateKey}
|
|
||||||
onChange={setFilter}
|
|
||||||
rightSlot={
|
|
||||||
filter.length > 0 && (
|
|
||||||
<IconButton
|
|
||||||
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
|
|
||||||
icon="x"
|
|
||||||
title="Clear filter"
|
|
||||||
onClick={() => {
|
|
||||||
setFilter("");
|
|
||||||
setFilterUpdateKey((key) => key + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
|
|
||||||
</div>
|
|
||||||
{cookieJar.cookies.length === 0 && detailCookie == null ? (
|
|
||||||
<EmptyStateText>
|
|
||||||
Cookies will appear when a response includes a Set-Cookie header.
|
|
||||||
</EmptyStateText>
|
|
||||||
) : filteredCookies.length === 0 && detailCookie == null ? (
|
|
||||||
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<SplitLayout
|
|
||||||
layout="vertical"
|
|
||||||
storageKey="cookie-dialog-details"
|
|
||||||
defaultRatio={0.65}
|
|
||||||
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("");
|
|
||||||
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 (
|
return (
|
||||||
<TableRow
|
<div className="pb-2">
|
||||||
key={key}
|
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||||
className={classNames(
|
<thead>
|
||||||
"group/tr cursor-default",
|
<tr>
|
||||||
isSelected && "[&_td]:bg-surface-highlight",
|
<th className="py-2 text-left">Domain</th>
|
||||||
!isSelected && "hover:[&_td]:bg-surface-hover",
|
<th className="py-2 text-left pl-4">Cookie</th>
|
||||||
)}
|
<th className="py-2 pl-4" />
|
||||||
onClick={() => {
|
</tr>
|
||||||
setSelectedCookieKey(key);
|
</thead>
|
||||||
setEditingCookieKey(null);
|
<tbody className="divide-y divide-surface-highlight">
|
||||||
setDraftCookie(null);
|
{cookieJar?.cookies.map((c: Cookie) => (
|
||||||
setDraftExpiresInput("");
|
<tr key={JSON.stringify(c)}>
|
||||||
}}
|
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
|
||||||
>
|
{cookieDomain(c)}
|
||||||
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
|
</td>
|
||||||
{c.name}
|
<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">
|
||||||
</TableCell>
|
{c.raw_cookie}
|
||||||
<TruncatedWideTableCell className="min-w-[10rem]">
|
</td>
|
||||||
{c.value}
|
<td className="max-w-0 w-10">
|
||||||
</TruncatedWideTableCell>
|
<IconButton
|
||||||
<TableCell>{cookieDomain(c)}</TableCell>
|
icon="trash"
|
||||||
<TableCell>{c.path}</TableCell>
|
size="xs"
|
||||||
<TableCell>{cookieExpires(c)}</TableCell>
|
iconSize="sm"
|
||||||
<TableCell>{cookieSize(c)}</TableCell>
|
title="Delete"
|
||||||
<TableCell>
|
className="ml-auto"
|
||||||
<Icon
|
onClick={() =>
|
||||||
icon={c.httpOnly ? "check" : "x"}
|
patchModel(cookieJar, {
|
||||||
className={classNames(!c.httpOnly && "opacity-10")}
|
cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c),
|
||||||
/>
|
})
|
||||||
</TableCell>
|
}
|
||||||
<TableCell>
|
/>
|
||||||
<Icon
|
</td>
|
||||||
icon={c.secure ? "check" : "x"}
|
</tr>
|
||||||
className={classNames(!c.secure && "opacity-10")}
|
))}
|
||||||
/>
|
</tbody>
|
||||||
</TableCell>
|
</table>
|
||||||
<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("");
|
|
||||||
}
|
|
||||||
patchModel(cookieJar, {
|
|
||||||
cookies: cookieJar.cookies.filter(
|
|
||||||
(c2: Cookie) => cookieKey(c2) !== key,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
secondSlot={
|
|
||||||
detailCookie == null
|
|
||||||
? null
|
|
||||||
: ({ style }) => (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2"
|
|
||||||
>
|
|
||||||
<EventDetailHeader
|
|
||||||
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
|
|
||||||
copyText={isEditingCookie ? undefined : detailCookie.value}
|
|
||||||
actions={
|
|
||||||
isEditingCookie
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: "save",
|
|
||||||
label: isCreatingCookie ? "Create" : "Save",
|
|
||||||
onClick: handleSaveCookie,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cancel",
|
|
||||||
label: "Cancel",
|
|
||||||
onClick: handleCancelEdit,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
key: "edit",
|
|
||||||
label: "Edit",
|
|
||||||
onClick: handleEditCookie,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
onClose={handleCloseDetails}
|
|
||||||
/>
|
|
||||||
{isEditingCookie ? (
|
|
||||||
<CookieEditor
|
|
||||||
cookie={detailCookie}
|
|
||||||
expiresInputValue={draftExpiresInput}
|
|
||||||
onChange={setDraftCookie}
|
|
||||||
onExpiresInputChange={setDraftExpiresInput}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CookieDetails cookie={detailCookie} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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
|
|
||||||
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
|
|
||||||
value={cookieDomainInputValue(cookie)}
|
|
||||||
placeholder="n/a"
|
|
||||||
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,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
autoFocus?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
className={cookieInputClassName}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
required={required}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEW_COOKIE_KEY = "__new-cookie__";
|
|
||||||
const cookieInputClassName = classNames(
|
|
||||||
"w-full min-w-0 rounded bg-transparent px-1 py-0.5",
|
|
||||||
"border border-transparent outline-none",
|
|
||||||
"hover:border-border-subtle focus:border-border-focus",
|
|
||||||
"disabled:opacity-disabled disabled:border-dotted",
|
|
||||||
);
|
|
||||||
|
|
||||||
function cookieSize(cookie: Cookie) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newCookieDraft(): Cookie {
|
|
||||||
return {
|
|
||||||
name: "",
|
|
||||||
value: "",
|
|
||||||
domain: "NotPresent",
|
|
||||||
expires: "SessionEnd",
|
|
||||||
path: "/",
|
|
||||||
secure: false,
|
|
||||||
httpOnly: false,
|
|
||||||
sameSite: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCookie(cookie: Cookie): Cookie {
|
|
||||||
return {
|
|
||||||
...cookie,
|
|
||||||
name: cookie.name.trim(),
|
|
||||||
path: cookie.path.trim() || "/",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieDomainInputValue(cookie: Cookie) {
|
|
||||||
const domain = cookieDomain(cookie);
|
|
||||||
return domain === "n/a" ? "" : domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
|
|
||||||
const trimmedDomain = domain.trim();
|
|
||||||
if (trimmedDomain.length === 0) {
|
|
||||||
return { ...cookie, domain: "NotPresent" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
|
|
||||||
return { ...cookie, domain: { Suffix: trimmedDomain } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...cookie, domain: { HostOnly: trimmedDomain } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpires(cookie: Cookie) {
|
|
||||||
if (cookie.expires === "SessionEnd") {
|
|
||||||
return "Session";
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
|
||||||
if (!Number.isFinite(expiresSeconds)) {
|
|
||||||
return cookie.expires.AtUtc;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(expiresSeconds * 1000);
|
|
||||||
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpiresInputValue(cookie: Cookie) {
|
|
||||||
if (cookie.expires === "SessionEnd") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresSeconds = Number(cookie.expires.AtUtc);
|
|
||||||
if (!Number.isFinite(expiresSeconds)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(expiresSeconds * 1000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultCookieExpiresInputValue() {
|
|
||||||
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cookieExpiresFromInput(value: string): Cookie["expires"] | 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 JSON.stringify([cookie.name, cookieDomain(cookie), cookie.path]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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";
|
||||||
@@ -35,7 +36,12 @@ export const CookieDropdown = memo(function CookieDropdown() {
|
|||||||
leftSlot: <Icon icon="cookie" />,
|
leftSlot: <Icon icon="cookie" />,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
if (activeCookieJar == null) return;
|
if (activeCookieJar == null) return;
|
||||||
CookieDialog.show(activeCookieJar.id);
|
showDialog({
|
||||||
|
id: "cookies",
|
||||||
|
title: "Manage Cookies",
|
||||||
|
size: "full",
|
||||||
|
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
|||||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
||||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { atomFamily } from "jotai-family";
|
import { atomFamily } from "jotai/utils";
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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;
|
||||||
@@ -30,7 +29,6 @@ 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";
|
||||||
|
|
||||||
@@ -38,7 +36,6 @@ 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) {
|
||||||
@@ -54,7 +51,6 @@ 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 [];
|
||||||
@@ -64,11 +60,6 @@ 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,
|
||||||
{
|
{
|
||||||
@@ -77,7 +68,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
|
}, [authTab, folder, headersTab, numVars]);
|
||||||
|
|
||||||
if (folder == null) return null;
|
if (folder == null) return null;
|
||||||
|
|
||||||
@@ -168,9 +159,6 @@ 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,7 +20,6 @@ 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 {
|
||||||
@@ -48,7 +47,6 @@ 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({
|
||||||
@@ -68,7 +66,6 @@ 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);
|
||||||
@@ -131,18 +128,13 @@ 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, numSettingsOverrides],
|
[activeRequest.description, authTab, metadataTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMetadataChange = useCallback(
|
const handleMetadataChange = useCallback(
|
||||||
@@ -286,9 +278,6 @@ 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
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ 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";
|
||||||
|
|
||||||
@@ -70,7 +69,6 @@ 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";
|
||||||
|
|
||||||
@@ -94,7 +92,6 @@ 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(
|
||||||
@@ -237,11 +234,6 @@ 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",
|
||||||
@@ -254,7 +246,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
handleContentTypeChange,
|
handleContentTypeChange,
|
||||||
headersTab,
|
headersTab,
|
||||||
numParams,
|
numParams,
|
||||||
numSettingsOverrides,
|
|
||||||
urlParameterPairs.length,
|
urlParameterPairs.length,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -381,9 +372,6 @@ 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,15 +1,10 @@
|
|||||||
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";
|
||||||
@@ -100,7 +95,6 @@ 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[] = [
|
||||||
{
|
{
|
||||||
@@ -217,9 +211,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -324,35 +315,6 @@ 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})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EventDisplay = {
|
type EventDisplay = {
|
||||||
icon: IconProps["icon"];
|
icon: IconProps["icon"];
|
||||||
color: IconProps["color"];
|
color: IconProps["color"];
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
import type {
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
InheritedBoolSetting,
|
|
||||||
InheritedIntSetting,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
|
||||||
import { useModelAncestors } from "../hooks/useModelAncestors";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import {
|
|
||||||
SettingOverrideRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowNumber,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "./core/SettingRow";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
showSectionTitles?: boolean;
|
|
||||||
model: ModelWithSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
|
||||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
|
||||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
|
||||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
|
||||||
type IntegerSetting = number | InheritedIntSetting;
|
|
||||||
type CookieSettingsPatch = {
|
|
||||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
|
||||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
|
||||||
};
|
|
||||||
type HttpSettingsPatch = {
|
|
||||||
settingValidateCertificates?: ModelWithHttpSettings["settingValidateCertificates"];
|
|
||||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
|
||||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
|
||||||
const ancestors = useModelAncestors(model);
|
|
||||||
const supportsHttpSettings =
|
|
||||||
model.model === "workspace" || model.model === "folder" || model.model === "http_request";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsList className="space-y-8">
|
|
||||||
{supportsHttpSettings && (
|
|
||||||
<SettingsSection title={showSectionTitles ? "Requests" : null}>
|
|
||||||
<IntegerSettingRow
|
|
||||||
title="Request Timeout"
|
|
||||||
description="Maximum request duration in milliseconds. Set to 0 to disable the timeout."
|
|
||||||
name="settingRequestTimeout"
|
|
||||||
setting={model.settingRequestTimeout}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingRequestTimeout",
|
|
||||||
model.settingRequestTimeout,
|
|
||||||
)}
|
|
||||||
onChange={(settingRequestTimeout) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingRequestTimeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BooleanSettingRow
|
|
||||||
title="Validate TLS certificates"
|
|
||||||
description="When disabled, skip validation of server certificates."
|
|
||||||
setting={model.settingValidateCertificates}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingValidateCertificates",
|
|
||||||
model.settingValidateCertificates,
|
|
||||||
)}
|
|
||||||
onChange={(settingValidateCertificates) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingValidateCertificates,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BooleanSettingRow
|
|
||||||
title="Follow redirects"
|
|
||||||
description="Follow HTTP redirects automatically."
|
|
||||||
setting={model.settingFollowRedirects}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingFollowRedirects",
|
|
||||||
model.settingFollowRedirects,
|
|
||||||
)}
|
|
||||||
onChange={(settingFollowRedirects) =>
|
|
||||||
patchHttpSettings(model, {
|
|
||||||
settingFollowRedirects,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
)}
|
|
||||||
<SettingsSection title={supportsHttpSettings || showSectionTitles ? "Cookies" : null}>
|
|
||||||
<BooleanSettingRow
|
|
||||||
title="Automatically send cookies"
|
|
||||||
description="Attach matching cookies from the active cookie jar to outgoing requests."
|
|
||||||
setting={model.settingSendCookies}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingSendCookies",
|
|
||||||
model.settingSendCookies,
|
|
||||||
)}
|
|
||||||
onChange={(settingSendCookies) =>
|
|
||||||
patchCookieSettings(model, {
|
|
||||||
settingSendCookies,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<BooleanSettingRow
|
|
||||||
title="Automatically store cookies"
|
|
||||||
description="Save cookies from Set-Cookie response headers to the active cookie jar."
|
|
||||||
setting={model.settingStoreCookies}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
"settingStoreCookies",
|
|
||||||
model.settingStoreCookies,
|
|
||||||
)}
|
|
||||||
onChange={(settingStoreCookies) =>
|
|
||||||
patchCookieSettings(model, {
|
|
||||||
settingStoreCookies,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
|
||||||
const settings: (BooleanSetting | IntegerSetting)[] = [
|
|
||||||
model.settingSendCookies,
|
|
||||||
model.settingStoreCookies,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (model.model === "workspace" || model.model === "folder" || model.model === "http_request") {
|
|
||||||
settings.push(
|
|
||||||
model.settingValidateCertificates,
|
|
||||||
model.settingFollowRedirects,
|
|
||||||
model.settingRequestTimeout,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
|
||||||
.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
|
||||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
|
||||||
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
if (model.model === "websocket_request")
|
|
||||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
|
||||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
|
||||||
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
|
||||||
return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BooleanSettingRow({
|
|
||||||
description,
|
|
||||||
inheritedValue,
|
|
||||||
setting,
|
|
||||||
title,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
description: string;
|
|
||||||
inheritedValue: boolean;
|
|
||||||
setting: BooleanSetting;
|
|
||||||
title: string;
|
|
||||||
onChange: (setting: BooleanSetting) => void;
|
|
||||||
}) {
|
|
||||||
const inherited = isInheritedSetting(setting);
|
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
|
||||||
return (
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={value}
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
overridden={overridden}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
hideLabel
|
|
||||||
size="md"
|
|
||||||
title={title}
|
|
||||||
checked={value}
|
|
||||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IntegerSettingRow({
|
|
||||||
description,
|
|
||||||
inheritedValue,
|
|
||||||
name,
|
|
||||||
setting,
|
|
||||||
title,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
description: string;
|
|
||||||
inheritedValue: number;
|
|
||||||
name: string;
|
|
||||||
setting: IntegerSetting;
|
|
||||||
title: string;
|
|
||||||
onChange: (setting: IntegerSetting) => void;
|
|
||||||
}) {
|
|
||||||
const inherited = isInheritedSetting(setting);
|
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
|
||||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
|
||||||
const showReset = overridden && value !== inheritedValue;
|
|
||||||
|
|
||||||
if (!inherited) {
|
|
||||||
return (
|
|
||||||
<SettingRowNumber
|
|
||||||
name={name}
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
value={value}
|
|
||||||
placeholder="0"
|
|
||||||
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
|
||||||
onChange={(value) => onChange(value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
overridden={showReset}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<PlainInput
|
|
||||||
hideLabel
|
|
||||||
name={name}
|
|
||||||
label={title}
|
|
||||||
size="sm"
|
|
||||||
type="number"
|
|
||||||
placeholder="0"
|
|
||||||
defaultValue={`${value}`}
|
|
||||||
containerClassName="!w-48"
|
|
||||||
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
|
||||||
onChange={(value) =>
|
|
||||||
onChange({
|
|
||||||
...setting,
|
|
||||||
enabled: true,
|
|
||||||
value: Number.parseInt(value, 10) || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInheritedSetting<T>(
|
|
||||||
setting: T | { enabled?: boolean; value: T },
|
|
||||||
): setting is { enabled?: boolean; value: T } {
|
|
||||||
return typeof setting === "object" && setting != null && "value" in setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: "settingRequestTimeout",
|
|
||||||
fallback: IntegerSetting,
|
|
||||||
): number;
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: BooleanWorkspaceSettingKey,
|
|
||||||
fallback: BooleanSetting,
|
|
||||||
): boolean;
|
|
||||||
function resolveInheritedValue(
|
|
||||||
ancestors: (Folder | Workspace)[],
|
|
||||||
key: keyof WorkspaceSettings,
|
|
||||||
fallback: BooleanSetting | IntegerSetting,
|
|
||||||
) {
|
|
||||||
for (const ancestor of ancestors) {
|
|
||||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
|
||||||
if (isInheritedSetting(setting)) {
|
|
||||||
if (setting.enabled === true) {
|
|
||||||
return setting.value;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return setting;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isInheritedSetting(fallback) ? fallback.value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceSettings = Pick<
|
|
||||||
Workspace,
|
|
||||||
| "settingFollowRedirects"
|
|
||||||
| "settingRequestTimeout"
|
|
||||||
| "settingSendCookies"
|
|
||||||
| "settingStoreCookies"
|
|
||||||
| "settingValidateCertificates"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
|
|
||||||
@@ -19,7 +19,6 @@ type Props = Omit<ButtonProps, "type"> & {
|
|||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
noun?: string;
|
noun?: string;
|
||||||
help?: ReactNode;
|
help?: ReactNode;
|
||||||
hideLabel?: boolean;
|
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ export function SelectFile({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
label,
|
label,
|
||||||
help,
|
help,
|
||||||
hideLabel,
|
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
@@ -97,7 +95,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} visuallyHidden={hideLabel}>
|
<Label htmlFor={null} help={help}>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,18 +7,12 @@ 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 { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import {
|
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow";
|
||||||
ModelSettingRowBoolean,
|
import { PlainInput } from "../core/PlainInput";
|
||||||
ModelSettingRowNumber,
|
import { Select } from "../core/Select";
|
||||||
ModelSettingSelectControl,
|
import { Separator } from "../core/Separator";
|
||||||
SettingValue,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
export function SettingsGeneral() {
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
@@ -35,159 +29,147 @@ export function SettingsGeneral() {
|
|||||||
<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>
|
||||||
<SettingsList className="space-y-8">
|
<CargoFeature feature="updater">
|
||||||
<CargoFeature feature="updater">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
||||||
<SettingsSection title="Updates">
|
<Select
|
||||||
<SettingRow
|
name="updateChannel"
|
||||||
title="Update Channel"
|
label="Update Channel"
|
||||||
description="Choose whether Yaak should use stable releases or beta releases."
|
labelPosition="left"
|
||||||
>
|
labelClassName="w-[14rem]"
|
||||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
size="sm"
|
||||||
<ModelSettingSelectControl
|
value={settings.updateChannel}
|
||||||
model={settings}
|
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
|
||||||
modelKey="updateChannel"
|
options={[
|
||||||
label="Update Channel"
|
{ label: "Stable", value: "stable" },
|
||||||
selectClassName="!w-full"
|
{ label: "Beta (more frequent)", value: "beta" },
|
||||||
options={[
|
]}
|
||||||
{ label: "Stable", value: "stable" },
|
/>
|
||||||
{ label: "Beta", value: "beta" },
|
<IconButton
|
||||||
]}
|
variant="border"
|
||||||
/>
|
size="sm"
|
||||||
<IconButton
|
title="Check for updates"
|
||||||
variant="border"
|
icon="refresh"
|
||||||
size="sm"
|
spin={checkForUpdates.isPending}
|
||||||
title="Check for updates"
|
onClick={() => checkForUpdates.mutateAsync()}
|
||||||
icon="refresh"
|
/>
|
||||||
spin={checkForUpdates.isPending}
|
</div>
|
||||||
onClick={() => checkForUpdates.mutateAsync()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRowSelect
|
<Select
|
||||||
title="Update Behavior"
|
name="autoupdate"
|
||||||
description="Choose whether updates are installed automatically or manually."
|
value={settings.autoupdate ? "auto" : "manual"}
|
||||||
name="autoupdate"
|
label="Update Behavior"
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
labelPosition="left"
|
||||||
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
size="sm"
|
||||||
options={[
|
labelClassName="w-[14rem]"
|
||||||
{ label: "Automatic", value: "auto" },
|
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
||||||
{ label: "Manual", value: "manual" },
|
options={[
|
||||||
]}
|
{ label: "Automatic", value: "auto" },
|
||||||
|
{ label: "Manual", value: "manual" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
className="pl-2 mt-1 ml-[14rem]"
|
||||||
|
checked={settings.autoDownloadUpdates}
|
||||||
|
disabled={!settings.autoupdate}
|
||||||
|
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
|
||||||
|
title="Automatically download updates"
|
||||||
|
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
className="pl-2 mt-1 ml-[14rem]"
|
||||||
|
checked={settings.checkNotifications}
|
||||||
|
title="Check for notifications"
|
||||||
|
help="Periodically ping Yaak servers to check for relevant notifications."
|
||||||
|
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
disabled
|
||||||
|
className="pl-2 mt-1 ml-[14rem]"
|
||||||
|
checked={false}
|
||||||
|
title="Send anonymous usage statistics"
|
||||||
|
help="Yaak is local-first and does not collect analytics or usage data 🔐"
|
||||||
|
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||||
|
/>
|
||||||
|
</CargoFeature>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<Heading level={2}>
|
||||||
|
Workspace{" "}
|
||||||
|
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
|
||||||
|
{workspace.name}
|
||||||
|
</div>
|
||||||
|
</Heading>
|
||||||
|
<VStack className="mt-1 w-full" space={3}>
|
||||||
|
<PlainInput
|
||||||
|
required
|
||||||
|
size="sm"
|
||||||
|
name="requestTimeout"
|
||||||
|
label="Request Timeout (ms)"
|
||||||
|
labelClassName="w-[14rem]"
|
||||||
|
placeholder="0"
|
||||||
|
labelPosition="left"
|
||||||
|
defaultValue={`${workspace.settingRequestTimeout}`}
|
||||||
|
validate={(value) => Number.parseInt(value, 10) >= 0}
|
||||||
|
onChange={(v) =>
|
||||||
|
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
|
||||||
|
}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={workspace.settingValidateCertificates}
|
||||||
|
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
|
||||||
|
title="Validate TLS certificates"
|
||||||
|
onChange={(settingValidateCertificates) =>
|
||||||
|
patchModel(workspace, { settingValidateCertificates })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={workspace.settingFollowRedirects}
|
||||||
|
title="Follow redirects"
|
||||||
|
onChange={(settingFollowRedirects) =>
|
||||||
|
patchModel(workspace, {
|
||||||
|
settingFollowRedirects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
<Heading level={2}>App Info</Heading>
|
||||||
|
<KeyValueRows>
|
||||||
|
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
|
||||||
|
<KeyValueRow
|
||||||
|
label="Data Directory"
|
||||||
|
rightSlot={
|
||||||
|
<IconButton
|
||||||
|
title={revealInFinderText}
|
||||||
|
icon="folder_open"
|
||||||
|
size="2xs"
|
||||||
|
onClick={() => revealItemInDir(appInfo.appDataDir)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="autoDownloadUpdates"
|
|
||||||
title="Automatically download updates"
|
|
||||||
description="Download Yaak updates in the background so they are ready to install."
|
|
||||||
disabled={!settings.autoupdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={settings}
|
|
||||||
modelKey="checkNotifications"
|
|
||||||
title="Check for notifications"
|
|
||||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRowBoolean
|
|
||||||
title="Send anonymous usage statistics"
|
|
||||||
description="Yaak is local-first and does not collect analytics or usage data."
|
|
||||||
disabled
|
|
||||||
checked={false}
|
|
||||||
onChange={() => {}}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</CargoFeature>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Workspace{" "}
|
|
||||||
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
|
|
||||||
{workspace.name}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ModelSettingRowNumber
|
{appInfo.appDataDir}
|
||||||
model={workspace}
|
</KeyValueRow>
|
||||||
modelKey="settingRequestTimeout"
|
<KeyValueRow
|
||||||
title="Request Timeout"
|
label="Logs Directory"
|
||||||
description="Maximum request duration in milliseconds. Set to 0 to disable the timeout."
|
rightSlot={
|
||||||
placeholder="0"
|
<IconButton
|
||||||
required
|
title={revealInFinderText}
|
||||||
validate={(value) => Number.parseInt(value, 10) >= 0}
|
icon="folder_open"
|
||||||
/>
|
size="2xs"
|
||||||
|
onClick={() => revealItemInDir(appInfo.appLogDir)}
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingValidateCertificates"
|
|
||||||
title="Validate TLS certificates"
|
|
||||||
description="When disabled, skip validation of server certificates."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingFollowRedirects"
|
|
||||||
title="Follow redirects"
|
|
||||||
description="Follow HTTP redirects automatically."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingSendCookies"
|
|
||||||
title="Automatically send cookies"
|
|
||||||
description="Attach matching cookies from the active cookie jar to outgoing requests."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ModelSettingRowBoolean
|
|
||||||
model={workspace}
|
|
||||||
modelKey="settingStoreCookies"
|
|
||||||
title="Automatically store cookies"
|
|
||||||
description="Save cookies from Set-Cookie response headers to the active cookie jar."
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="App Info">
|
|
||||||
<SettingRow title="Version" description="Current Yaak version.">
|
|
||||||
<SettingValue value={appInfo.version} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow
|
|
||||||
title="Data Directory"
|
|
||||||
description="Where Yaak stores application data."
|
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
|
||||||
>
|
|
||||||
<SettingValue
|
|
||||||
value={appInfo.appDataDir}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
title: revealInFinderText,
|
|
||||||
icon: "folder_open",
|
|
||||||
onClick: () => revealItemInDir(appInfo.appDataDir),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
}
|
||||||
<SettingRow
|
>
|
||||||
title="Logs Directory"
|
{appInfo.appLogDir}
|
||||||
description="Where Yaak writes application logs."
|
</KeyValueRow>
|
||||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
</KeyValueRows>
|
||||||
>
|
|
||||||
<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, VStack } from "@yaakapp-internal/ui";
|
import { clamp, Heading, HStack, Icon, 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,16 +13,7 @@ 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 {
|
import { Select } from "../core/Select";
|
||||||
ModelSettingRowBoolean,
|
|
||||||
ModelSettingRowSelect,
|
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
|
||||||
SettingRowSelect,
|
|
||||||
SettingSelectControl,
|
|
||||||
SettingsList,
|
|
||||||
SettingsSection,
|
|
||||||
} from "../core/SettingRow";
|
|
||||||
|
|
||||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
const NULL_FONT_VALUE = "__NULL_FONT__";
|
||||||
|
|
||||||
@@ -47,172 +38,154 @@ export function SettingsInterface() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<VStack space={3} 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>
|
||||||
<SettingsList className="space-y-8">
|
<Select
|
||||||
<SettingsSection title="Workspaces">
|
name="switchWorkspaceBehavior"
|
||||||
<SettingRowSelect
|
label="Open workspace behavior"
|
||||||
title="Open workspace behavior"
|
size="sm"
|
||||||
description="Choose what happens when opening another workspace."
|
help="When opening a workspace, should it open in the current window or a new window?"
|
||||||
name="switchWorkspaceBehavior"
|
value={
|
||||||
value={
|
settings.openWorkspaceNewWindow === true
|
||||||
settings.openWorkspaceNewWindow === true
|
? "new"
|
||||||
? "new"
|
: settings.openWorkspaceNewWindow === false
|
||||||
: settings.openWorkspaceNewWindow === false
|
? "current"
|
||||||
? "current"
|
: "ask"
|
||||||
: "ask"
|
}
|
||||||
}
|
onChange={async (v) => {
|
||||||
onChange={async (v) => {
|
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||||
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
|
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||||
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
|
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
||||||
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={[
|
options={[
|
||||||
{ label: "Always ask", value: "ask" },
|
{ label: "System default", value: NULL_FONT_VALUE },
|
||||||
{ label: "Open in current window", value: "current" },
|
...(fonts.data.uiFonts.map((f) => ({
|
||||||
{ label: "Open in new window", value: "new" },
|
label: f,
|
||||||
|
value: f,
|
||||||
|
})) ?? []),
|
||||||
|
// Some people like monospace fonts for the UI
|
||||||
|
...(fonts.data.editorFonts.map((f) => ({
|
||||||
|
label: f,
|
||||||
|
value: f,
|
||||||
|
})) ?? []),
|
||||||
]}
|
]}
|
||||||
|
onChange={async (v) => {
|
||||||
|
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
||||||
|
await patchModel(settings, { interfaceFont });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
)}
|
||||||
|
<Select
|
||||||
<SettingsSection title="Fonts">
|
hideLabel
|
||||||
<SettingRow
|
size="sm"
|
||||||
title="Interface font"
|
name="interfaceFontSize"
|
||||||
description="Font used for Yaak interface controls."
|
label="Interface Font Size"
|
||||||
controlClassName="gap-1"
|
defaultValue="14"
|
||||||
>
|
value={`${settings.interfaceFontSize}`}
|
||||||
{fonts.data && (
|
options={fontSizeOptions}
|
||||||
<SettingSelectControl
|
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
||||||
name="uiFont"
|
/>
|
||||||
label="Interface font"
|
</HStack>
|
||||||
selectClassName="!w-72"
|
<HStack space={2} alignItems="end">
|
||||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
{fonts.data && (
|
||||||
defaultValue={NULL_FONT_VALUE}
|
<Select
|
||||||
options={[
|
size="sm"
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
name="editorFont"
|
||||||
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
|
label="Editor font"
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
value={settings.editorFont ?? NULL_FONT_VALUE}
|
||||||
]}
|
options={[
|
||||||
onChange={async (v) => {
|
{ label: "System default", value: NULL_FONT_VALUE },
|
||||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
...(fonts.data.editorFonts.map((f) => ({
|
||||||
await patchModel(settings, { interfaceFont });
|
label: f,
|
||||||
}}
|
value: f,
|
||||||
/>
|
})) ?? []),
|
||||||
)}
|
]}
|
||||||
<SettingSelectControl
|
onChange={async (v) => {
|
||||||
name="interfaceFontSize"
|
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
||||||
label="Interface Font Size"
|
await patchModel(settings, { editorFont });
|
||||||
selectClassName="!w-20"
|
}}
|
||||||
value={`${settings.interfaceFontSize}`}
|
|
||||||
defaultValue="14"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Editor font"
|
|
||||||
description="Font used in request and response editors."
|
|
||||||
controlClassName="gap-1"
|
|
||||||
>
|
|
||||||
{fonts.data && (
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFont"
|
|
||||||
label="Editor font"
|
|
||||||
selectClassName="!w-72"
|
|
||||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
|
||||||
defaultValue={NULL_FONT_VALUE}
|
|
||||||
options={[
|
|
||||||
{ label: "System default", value: NULL_FONT_VALUE },
|
|
||||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
|
||||||
]}
|
|
||||||
onChange={async (v) => {
|
|
||||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
|
||||||
await patchModel(settings, { editorFont });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingSelectControl
|
|
||||||
name="editorFontSize"
|
|
||||||
label="Editor Font Size"
|
|
||||||
selectClassName="!w-20"
|
|
||||||
value={`${settings.editorFontSize}`}
|
|
||||||
defaultValue="12"
|
|
||||||
options={fontSizeOptions}
|
|
||||||
onChange={(v) =>
|
|
||||||
patchModel(settings, {
|
|
||||||
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Editor">
|
|
||||||
<ModelSettingRowSelect
|
|
||||||
model={settings}
|
|
||||||
modelKey="editorKeymap"
|
|
||||||
title="Editor keymap"
|
|
||||||
description="Keyboard shortcut preset used by text editors."
|
|
||||||
options={keymaps}
|
|
||||||
/>
|
/>
|
||||||
<ModelSettingRowBoolean
|
)}
|
||||||
model={settings}
|
<Select
|
||||||
modelKey="editorSoftWrap"
|
hideLabel
|
||||||
title="Wrap editor lines"
|
size="sm"
|
||||||
description="Wrap long lines in request and response editors."
|
name="editorFontSize"
|
||||||
/>
|
label="Editor Font Size"
|
||||||
<ModelSettingRowBoolean
|
defaultValue="12"
|
||||||
model={settings}
|
value={`${settings.editorFontSize}`}
|
||||||
modelKey="coloredMethods"
|
options={fontSizeOptions}
|
||||||
title="Colorize request methods"
|
onChange={(v) =>
|
||||||
description="Use method-specific colors for HTTP request methods."
|
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
|
||||||
/>
|
}
|
||||||
</SettingsSection>
|
/>
|
||||||
|
</HStack>
|
||||||
|
<Select
|
||||||
|
leftSlot={<Icon icon="keyboard" color="secondary" />}
|
||||||
|
size="sm"
|
||||||
|
name="editorKeymap"
|
||||||
|
label="Editor keymap"
|
||||||
|
value={`${settings.editorKeymap}`}
|
||||||
|
options={keymaps}
|
||||||
|
onChange={(v) => patchModel(settings, { editorKeymap: v })}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={settings.editorSoftWrap}
|
||||||
|
title="Wrap editor lines"
|
||||||
|
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={settings.coloredMethods}
|
||||||
|
title="Colorize request methods"
|
||||||
|
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
|
||||||
|
/>
|
||||||
|
<CargoFeature feature="license">
|
||||||
|
<LicenseSettings settings={settings} />
|
||||||
|
</CargoFeature>
|
||||||
|
|
||||||
<SettingsSection title="Window">
|
<NativeTitlebarSetting settings={settings} />
|
||||||
<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">
|
{type() !== "macos" && (
|
||||||
<LicenseSettings settings={settings} />
|
<Checkbox
|
||||||
</CargoFeature>
|
checked={settings.hideWindowControls}
|
||||||
</SettingsList>
|
title="Hide window controls"
|
||||||
|
help="Hide the close/maximize/minimize controls on Windows or Linux"
|
||||||
|
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</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 (
|
||||||
<SettingRow
|
<div className="flex gap-1 overflow-hidden h-2xs">
|
||||||
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="xs"
|
size="2xs"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||||
await invokeCmd("cmd_restart");
|
await invokeCmd("cmd_restart");
|
||||||
@@ -221,7 +194,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|||||||
Apply and Restart
|
Apply and Restart
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</SettingRow>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,40 +205,37 @@ function LicenseSettings({ settings }: { settings: Settings }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="License">
|
<Checkbox
|
||||||
<SettingRowBoolean
|
checked={settings.hideLicenseBadge}
|
||||||
checked={settings.hideLicenseBadge}
|
title="Hide personal use badge"
|
||||||
title="Hide personal use badge"
|
onChange={async (hideLicenseBadge) => {
|
||||||
description="Hide the personal-use badge from the interface."
|
if (hideLicenseBadge) {
|
||||||
onChange={async (hideLicenseBadge) => {
|
const confirmed = await showConfirm({
|
||||||
if (hideLicenseBadge) {
|
id: "hide-license-badge",
|
||||||
const confirmed = await showConfirm({
|
title: "Confirm Personal Use",
|
||||||
id: "hide-license-badge",
|
confirmText: "Confirm",
|
||||||
title: "Confirm Personal Use",
|
description: (
|
||||||
confirmText: "Confirm",
|
<VStack space={3}>
|
||||||
description: (
|
<p>Hey there 👋🏼</p>
|
||||||
<VStack space={3}>
|
<p>
|
||||||
<p>Hey there 👋🏼</p>
|
Yaak is free for personal projects and learning.{" "}
|
||||||
<p>
|
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||||
Yaak is free for personal projects and learning.{" "}
|
</p>
|
||||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
<p>
|
||||||
</p>
|
Licenses help keep Yaak independent and sustainable.{" "}
|
||||||
<p>
|
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
</p>
|
||||||
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
</VStack>
|
||||||
</p>
|
),
|
||||||
</VStack>
|
requireTyping: "Personal Use",
|
||||||
),
|
color: "info",
|
||||||
requireTyping: "Personal Use",
|
});
|
||||||
color: "info",
|
if (!confirmed) {
|
||||||
});
|
return; // Cancel
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await patchModel(settings, { hideLicenseBadge });
|
}
|
||||||
}}
|
await patchModel(settings, { hideLicenseBadge });
|
||||||
/>
|
}}
|
||||||
</SettingsSection>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import { Checkbox } from "../core/Checkbox";
|
||||||
SettingRowBoolean,
|
import { PlainInput } from "../core/PlainInput";
|
||||||
SettingRowSelect,
|
import { Select } from "../core/Select";
|
||||||
SettingRowText,
|
import { Separator } from "../core/Separator";
|
||||||
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">
|
||||||
@@ -33,146 +18,188 @@ export function SettingsProxy() {
|
|||||||
traffic, or routing through specific infrastructure.
|
traffic, or routing through specific infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<SettingsList className="space-y-8">
|
<Select
|
||||||
<SettingsSection title="Proxy">
|
name="proxy"
|
||||||
<SettingRowSelect
|
label="Proxy"
|
||||||
title="Proxy"
|
hideLabel
|
||||||
description="Choose how Yaak should discover or use proxy settings."
|
size="sm"
|
||||||
name="proxy"
|
value={settings.proxy?.type ?? "automatic"}
|
||||||
value={settings.proxy?.type ?? "automatic"}
|
onChange={async (v) => {
|
||||||
onChange={async (v) => {
|
if (v === "automatic") {
|
||||||
if (v === "automatic") {
|
await patchModel(settings, { proxy: undefined });
|
||||||
await patchModel(settings, { proxy: undefined });
|
} else if (v === "enabled") {
|
||||||
} else if (v === "enabled") {
|
await patchModel(settings, {
|
||||||
await patchModel(settings, { proxy });
|
proxy: {
|
||||||
} else {
|
disabled: false,
|
||||||
await patchModel(settings, { proxy: { type: "disabled" } });
|
type: "enabled",
|
||||||
}
|
http: "",
|
||||||
|
https: "",
|
||||||
|
auth: { user: "", password: "" },
|
||||||
|
bypass: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await patchModel(settings, { proxy: { type: "disabled" } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ label: "Automatic proxy detection", value: "automatic" },
|
||||||
|
{ label: "Custom proxy configuration", value: "enabled" },
|
||||||
|
{ label: "No proxy", value: "disabled" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{settings.proxy?.type === "enabled" && (
|
||||||
|
<VStack space={1.5}>
|
||||||
|
<Checkbox
|
||||||
|
className="my-3"
|
||||||
|
checked={!settings.proxy.disabled}
|
||||||
|
title="Enable proxy"
|
||||||
|
help="Use this to temporarily disable the proxy without losing the configuration"
|
||||||
|
onChange={async (enabled) => {
|
||||||
|
const { proxy } = settings;
|
||||||
|
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||||
|
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||||
|
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||||
|
const auth = proxy?.type === "enabled" ? proxy.auth : null;
|
||||||
|
const disabled = !enabled;
|
||||||
|
await patchModel(settings, {
|
||||||
|
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
options={[
|
|
||||||
{ label: "Automatic proxy detection", value: "automatic" },
|
|
||||||
{ label: "Custom proxy configuration", value: "enabled" },
|
|
||||||
{ label: "No proxy", value: "disabled" },
|
|
||||||
]}
|
|
||||||
selectClassName="!w-64"
|
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
<HStack space={1.5}>
|
||||||
|
<PlainInput
|
||||||
{settings.proxy?.type === "enabled" && (
|
size="sm"
|
||||||
<>
|
label={
|
||||||
<SettingsSection title="Custom Proxy">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={!settings.proxy.disabled}
|
|
||||||
title="Enable proxy"
|
|
||||||
description="Temporarily disable the proxy without losing the configuration."
|
|
||||||
onChange={(enabled) => patchProxy({ disabled: !enabled })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyHttp"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
description="Proxy host used for unencrypted HTTP traffic."
|
|
||||||
value={settings.proxy.http}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
onChange={(http) => patchProxy({ http })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyHttps"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
description="Proxy host used for HTTPS traffic."
|
|
||||||
value={settings.proxy.https}
|
|
||||||
placeholder="localhost:9090"
|
|
||||||
onChange={(https) => patchProxy({ https })}
|
|
||||||
/>
|
|
||||||
<SettingRowText
|
|
||||||
name="proxyBypass"
|
|
||||||
title="Proxy Bypass"
|
|
||||||
description="Comma-separated list of hosts that should bypass the proxy."
|
|
||||||
value={settings.proxy.bypass}
|
|
||||||
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
|
||||||
inputWidthClassName="!w-96"
|
|
||||||
onChange={(bypass) => patchProxy({ bypass })}
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Authentication">
|
|
||||||
<SettingRowBoolean
|
|
||||||
checked={settings.proxy.auth != null}
|
|
||||||
title="Enable authentication"
|
|
||||||
description="Send proxy credentials with proxied requests."
|
|
||||||
onChange={(enabled) =>
|
|
||||||
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{settings.proxy.auth != null && (
|
|
||||||
<>
|
<>
|
||||||
<SettingRowText
|
Proxy for <InlineCode>http://</InlineCode> traffic
|
||||||
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>
|
placeholder="localhost:9090"
|
||||||
</>
|
defaultValue={settings.proxy?.http}
|
||||||
)}
|
onChange={async (http) => {
|
||||||
</SettingsList>
|
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 },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{settings.proxy.auth != null && (
|
||||||
|
<HStack space={1.5}>
|
||||||
|
<PlainInput
|
||||||
|
required
|
||||||
|
size="sm"
|
||||||
|
label="User"
|
||||||
|
placeholder="myUser"
|
||||||
|
defaultValue={settings.proxy.auth.user}
|
||||||
|
onChange={async (user) => {
|
||||||
|
const { proxy } = settings;
|
||||||
|
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||||
|
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||||
|
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||||
|
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||||
|
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
|
||||||
|
const auth = { user, password };
|
||||||
|
await patchModel(settings, {
|
||||||
|
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PlainInput
|
||||||
|
size="sm"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="s3cretPassw0rd"
|
||||||
|
defaultValue={settings.proxy.auth.password}
|
||||||
|
onChange={async (password) => {
|
||||||
|
const { proxy } = settings;
|
||||||
|
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||||
|
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||||
|
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||||
|
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
|
||||||
|
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
|
||||||
|
const auth = { user, password };
|
||||||
|
await patchModel(settings, {
|
||||||
|
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
{settings.proxy.type === "enabled" && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<PlainInput
|
||||||
|
label="Proxy Bypass"
|
||||||
|
help="Comma-separated list to bypass the proxy."
|
||||||
|
defaultValue={settings.proxy.bypass}
|
||||||
|
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
||||||
|
onChange={async (bypass) => {
|
||||||
|
const { proxy } = settings;
|
||||||
|
const http = proxy?.type === "enabled" ? proxy.http : "";
|
||||||
|
const https = proxy?.type === "enabled" ? proxy.https : "";
|
||||||
|
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
|
||||||
|
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
|
||||||
|
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
|
||||||
|
const auth = { user, password };
|
||||||
|
await patchModel(settings, {
|
||||||
|
proxy: { type: "enabled", http, https, auth, disabled, bypass },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
</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,12 +9,7 @@ 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 {
|
import { Select } from "../core/Select";
|
||||||
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 })));
|
||||||
|
|
||||||
@@ -72,7 +67,7 @@ export function SettingsTheme() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<VStack space={3} 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">
|
||||||
@@ -82,92 +77,96 @@ export function SettingsTheme() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<SettingsList className="space-y-8">
|
<Select
|
||||||
<SettingsSection title="Theme">
|
name="appearance"
|
||||||
<ModelSettingRowSelect
|
label="Appearance"
|
||||||
model={settings}
|
labelPosition="top"
|
||||||
modelKey="appearance"
|
size="sm"
|
||||||
title="Appearance"
|
value={settings.appearance}
|
||||||
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
onChange={(appearance) => patchModel(settings, { appearance })}
|
||||||
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") && (
|
)}
|
||||||
<SettingRowSelect
|
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
||||||
name="lightTheme"
|
<Select
|
||||||
title="Light theme"
|
hideLabel
|
||||||
description="Theme used when Yaak is in light mode."
|
name="darkTheme"
|
||||||
value={activeTheme.data.light.id}
|
className="flex-1"
|
||||||
options={lightThemes}
|
label="Dark Theme"
|
||||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
leftSlot={<Icon icon="moon" color="secondary" />}
|
||||||
/>
|
size="sm"
|
||||||
)}
|
value={activeTheme.data.dark.id}
|
||||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
options={darkThemes}
|
||||||
<SettingRowSelect
|
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||||
name="darkTheme"
|
/>
|
||||||
title="Dark theme"
|
)}
|
||||||
description="Theme used when Yaak is in dark mode."
|
</HStack>
|
||||||
value={activeTheme.data.dark.id}
|
|
||||||
options={darkThemes}
|
|
||||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title="Preview">
|
<VStack
|
||||||
<VStack
|
space={3}
|
||||||
space={3}
|
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
|
||||||
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}>
|
||||||
<HStack className="text" space={1.5}>
|
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
||||||
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
<strong>{activeTheme.data.active.label}</strong>
|
||||||
<strong>{activeTheme.data.active.label}</strong>
|
<em>(preview)</em>
|
||||||
<em>(preview)</em>
|
</HStack>
|
||||||
</HStack>
|
<HStack space={1.5} className="w-full">
|
||||||
<HStack space={1.5} className="w-full">
|
{buttonColors.map((c, i) => (
|
||||||
{buttonColors.map((c, i) => (
|
<IconButton
|
||||||
<IconButton
|
key={c}
|
||||||
key={c}
|
color={c}
|
||||||
color={c}
|
size="2xs"
|
||||||
size="2xs"
|
iconSize="xs"
|
||||||
iconSize="xs"
|
icon={icons[i % icons.length] ?? "info"}
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
iconClassName="text"
|
||||||
iconClassName="text"
|
title={`${c}`}
|
||||||
title={`${c}`}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
{buttonColors.map((c, i) => (
|
||||||
{buttonColors.map((c, i) => (
|
<IconButton
|
||||||
<IconButton
|
key={c}
|
||||||
key={c}
|
color={c}
|
||||||
color={c}
|
variant="border"
|
||||||
variant="border"
|
size="2xs"
|
||||||
size="2xs"
|
iconSize="xs"
|
||||||
iconSize="xs"
|
icon={icons[i % icons.length] ?? "info"}
|
||||||
icon={icons[i % icons.length] ?? "info"}
|
iconClassName="text"
|
||||||
iconClassName="text"
|
title={`${c}`}
|
||||||
title={`${c}`}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</HStack>
|
||||||
</HStack>
|
<Suspense>
|
||||||
<Suspense>
|
<Editor
|
||||||
<Editor
|
defaultValue={[
|
||||||
defaultValue={[
|
"let foo = { // Demo code editor",
|
||||||
"let foo = { // Demo code editor",
|
' foo: ("bar" || "baz" ?? \'qux\'),',
|
||||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
" baz: [1, 10.2, null, false, true],",
|
||||||
" baz: [1, 10.2, null, false, true],",
|
"};",
|
||||||
"};",
|
].join("\n")}
|
||||||
].join("\n")}
|
heightMode="auto"
|
||||||
heightMode="auto"
|
language="javascript"
|
||||||
language="javascript"
|
stateKey={null}
|
||||||
stateKey={null}
|
/>
|
||||||
/>
|
</Suspense>
|
||||||
</Suspense>
|
</VStack>
|
||||||
</VStack>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsList>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Extension } from "@codemirror/state";
|
import type { Extension } from "@codemirror/state";
|
||||||
import { Compartment } from "@codemirror/state";
|
import { Compartment } from "@codemirror/state";
|
||||||
import { debounce } from "@yaakapp-internal/lib";
|
import { debounce } from "@yaakapp-internal/lib";
|
||||||
import { gitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { GitStatus } from "@yaakapp-internal/git";
|
|
||||||
import type {
|
import type {
|
||||||
AnyModel,
|
AnyModel,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -25,18 +23,13 @@ import {
|
|||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { atomFamily } from "jotai-family";
|
import { atomFamily, selectAtom } from "jotai/utils";
|
||||||
import { selectAtom } from "jotai/utils";
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { moveToWorkspace } from "../commands/moveToWorkspace";
|
import { moveToWorkspace } from "../commands/moveToWorkspace";
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||||
import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
|
import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
|
||||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||||
import {
|
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
activeWorkspaceAtom,
|
|
||||||
activeWorkspaceIdAtom,
|
|
||||||
activeWorkspaceMetaAtom,
|
|
||||||
} from "../hooks/useActiveWorkspace";
|
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||||
import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
|
import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
|
||||||
import { getFolderActions } from "../hooks/useFolderActions";
|
import { getFolderActions } from "../hooks/useFolderActions";
|
||||||
@@ -49,13 +42,7 @@ import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|||||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||||
import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
|
import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
import { deepEqualAtom } from "../lib/atoms";
|
||||||
import { showConfirm } from "../lib/confirm";
|
|
||||||
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||||
import { showDialog } from "../lib/dialog";
|
|
||||||
import {
|
|
||||||
gitWorktreeStatusByModelIdAtom,
|
|
||||||
gitWorktreeStatusFamily,
|
|
||||||
} from "../lib/gitWorktreeStatus";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { isSidebarFocused } from "../lib/scopes";
|
import { isSidebarFocused } from "../lib/scopes";
|
||||||
@@ -81,9 +68,6 @@ import type { InputHandle } from "./core/Input";
|
|||||||
import { Input } from "./core/Input";
|
import { Input } from "./core/Input";
|
||||||
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 { FileHistoryDialog } from "./git/FileHistoryDialog";
|
|
||||||
import { sync } from "../init/sync";
|
|
||||||
|
|
||||||
const collapsedFamily = atomFamily((treeId: string) => {
|
const collapsedFamily = atomFamily((treeId: string) => {
|
||||||
const key = ["sidebar_collapsed", treeId ?? "n/a"];
|
const key = ["sidebar_collapsed", treeId ?? "n/a"];
|
||||||
@@ -391,8 +375,6 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaces = jotaiStore.get(workspacesAtom);
|
const workspaces = jotaiStore.get(workspacesAtom);
|
||||||
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir;
|
|
||||||
const gitItems = getGitContextMenuItems({ items, syncDir });
|
|
||||||
const onlyHttpRequests = items.every((i) => i.model === "http_request");
|
const onlyHttpRequests = items.every((i) => i.model === "http_request");
|
||||||
const requestItems = items.filter(
|
const requestItems = items.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
@@ -476,10 +458,8 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
...initialItems,
|
...initialItems,
|
||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
hidden: initialItems.filter((v) => !v.hidden).length === 0 || gitItems.length === 0,
|
hidden: initialItems.filter((v) => !v.hidden).length === 0,
|
||||||
},
|
},
|
||||||
...gitItems,
|
|
||||||
{ type: "separator", hidden: gitItems.length === 0 },
|
|
||||||
{
|
{
|
||||||
label: "Rename",
|
label: "Rename",
|
||||||
leftSlot: <Icon icon="pencil" />,
|
leftSlot: <Icon icon="pencil" />,
|
||||||
@@ -681,73 +661,6 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
|
|
||||||
export default Sidebar;
|
export default Sidebar;
|
||||||
|
|
||||||
function getGitContextMenuItems({
|
|
||||||
items,
|
|
||||||
syncDir,
|
|
||||||
}: {
|
|
||||||
items: SidebarModel[];
|
|
||||||
syncDir: string | null | undefined;
|
|
||||||
}): DropdownItem[] {
|
|
||||||
if (syncDir == null) return [];
|
|
||||||
|
|
||||||
const gitStatusEntries = items.flatMap((item) => {
|
|
||||||
const status = jotaiStore.get(gitWorktreeStatusFamily(item.id));
|
|
||||||
return status == null || status.status === "current" ? [] : [status];
|
|
||||||
});
|
|
||||||
const historyItem = items.length === 1 ? items[0] : null;
|
|
||||||
const historyPath =
|
|
||||||
historyItem == null
|
|
||||||
? null
|
|
||||||
: (jotaiStore.get(gitWorktreeStatusFamily(historyItem.id))?.relaPath ??
|
|
||||||
syncPathForModel(historyItem));
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "View History",
|
|
||||||
leftSlot: <Icon icon="history" />,
|
|
||||||
hidden: historyPath == null,
|
|
||||||
onSelect: () => {
|
|
||||||
if (historyPath == null) return;
|
|
||||||
showDialog({
|
|
||||||
id: "git-history",
|
|
||||||
size: "lg",
|
|
||||||
title: "File History",
|
|
||||||
noPadding: true,
|
|
||||||
noScroll: true,
|
|
||||||
render: () => <FileHistoryDialog dir={syncDir} relaPath={historyPath} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Restore Changes",
|
|
||||||
leftSlot: <Icon icon="rotate_ccw" />,
|
|
||||||
hidden: gitStatusEntries.length === 0,
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-restore-sidebar-items",
|
|
||||||
title: "Restore Changes",
|
|
||||||
description:
|
|
||||||
gitStatusEntries.length === 1
|
|
||||||
? "This will discard uncommitted changes for the selected item."
|
|
||||||
: `This will discard uncommitted changes for ${gitStatusEntries.length} selected items.`,
|
|
||||||
confirmText: "Restore",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await gitMutations(syncDir, gitCallbacks(syncDir)).restore.mutateAsync({
|
|
||||||
relaPaths: gitStatusEntries.map((entry) => entry.relaPath),
|
|
||||||
});
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncPathForModel(item: SidebarModel) {
|
|
||||||
return `yaak.${item.id}.yaml`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIdAtom = atom<string | null>((get) => {
|
const activeIdAtom = atom<string | null>((get) => {
|
||||||
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
||||||
});
|
});
|
||||||
@@ -877,64 +790,6 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
|||||||
return [root, fields] as const;
|
return [root, fields] as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
|
||||||
const allModels = get(memoAllPotentialChildrenAtom);
|
|
||||||
const activeWorkspace = get(activeWorkspaceAtom);
|
|
||||||
const gitStatusByModelId = get(gitWorktreeStatusByModelIdAtom);
|
|
||||||
const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};
|
|
||||||
const statusByModelId: Record<string, GitStatus> = {};
|
|
||||||
|
|
||||||
for (const item of allModels) {
|
|
||||||
if ("folderId" in item && item.folderId == null) {
|
|
||||||
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
|
|
||||||
childrenMap[item.workspaceId]?.push(item);
|
|
||||||
} else if ("folderId" in item && item.folderId != null) {
|
|
||||||
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
|
|
||||||
childrenMap[item.folderId]?.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visit = (item: SidebarModel): GitStatus | null => {
|
|
||||||
const statuses: GitStatus[] = [];
|
|
||||||
const directStatus = gitStatusByModelId[item.id]?.status;
|
|
||||||
if (directStatus != null && directStatus !== "current") {
|
|
||||||
statuses.push(directStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of childrenMap[item.id] ?? []) {
|
|
||||||
const childStatus = visit(child);
|
|
||||||
if (childStatus != null) statuses.push(childStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = summarizeGitStatuses(statuses);
|
|
||||||
if (status != null) {
|
|
||||||
statusByModelId[item.id] = status;
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activeWorkspace != null) {
|
|
||||||
visit(activeWorkspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
return statusByModelId;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sidebarGitStatusFamily = atomFamily(
|
|
||||||
(modelId: string) =>
|
|
||||||
selectAtom(sidebarGitStatusByModelIdAtom, (statusByModelId) => statusByModelId[modelId] ?? null),
|
|
||||||
Object.is,
|
|
||||||
);
|
|
||||||
|
|
||||||
function summarizeGitStatuses(statuses: GitStatus[]): GitStatus | null {
|
|
||||||
if (statuses.length === 0) return null;
|
|
||||||
const firstStatus = statuses[0];
|
|
||||||
if (firstStatus != null && statuses.every((status) => status === firstStatus)) {
|
|
||||||
return firstStatus;
|
|
||||||
}
|
|
||||||
return "modified";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemKey(item: SidebarModel) {
|
function getItemKey(item: SidebarModel) {
|
||||||
const responses = jotaiStore.get(httpResponsesAtom);
|
const responses = jotaiStore.get(httpResponsesAtom);
|
||||||
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
||||||
@@ -981,7 +836,6 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
|||||||
treeId: string;
|
treeId: string;
|
||||||
item: SidebarModel;
|
item: SidebarModel;
|
||||||
}) {
|
}) {
|
||||||
const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id));
|
|
||||||
const response = useAtomValue(
|
const response = useAtomValue(
|
||||||
useMemo(
|
useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1000,16 +854,7 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
||||||
<div
|
<div className="truncate">{resolvedModelName(item)}</div>
|
||||||
className={classNames(
|
|
||||||
"truncate",
|
|
||||||
gitStatus === "modified" && "text-info",
|
|
||||||
gitStatus === "untracked" && "text-success",
|
|
||||||
gitStatus === "removed" && "text-danger",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{resolvedModelName(item)}
|
|
||||||
</div>
|
|
||||||
{response != null && (
|
{response != null && (
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
{response.state !== "closed" ? (
|
{response.state !== "closed" ? (
|
||||||
|
|||||||
@@ -4,79 +4,20 @@ 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 && (
|
||||||
@@ -106,7 +47,18 @@ 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 }) => handleFilePathChange(filePath)}
|
onChange={async ({ 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,7 +34,6 @@ 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";
|
||||||
|
|
||||||
@@ -49,7 +48,6 @@ 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";
|
||||||
|
|
||||||
@@ -71,7 +69,6 @@ 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(
|
||||||
@@ -112,17 +109,12 @@ 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, numSettingsOverrides, urlParameterPairs.length]);
|
}, [authTab, headersTab, urlParameterPairs.length]);
|
||||||
|
|
||||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||||
@@ -274,9 +266,6 @@ 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
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function Workspace() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full h-full grid-rows-[auto_minmax(0,1fr)]">
|
<div className="grid w-full h-full grid-rows-[auto_1fr]">
|
||||||
{header}
|
{header}
|
||||||
<SidebarLayout
|
<SidebarLayout
|
||||||
width={width ?? 250}
|
width={width ?? 250}
|
||||||
|
|||||||
@@ -20,24 +20,16 @@ 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({
|
export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {
|
||||||
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);
|
||||||
|
|
||||||
@@ -74,7 +66,7 @@ export function WorkspaceEncryptionSetting({
|
|||||||
key.error != null ||
|
key.error != null ||
|
||||||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
|
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
|
||||||
) {
|
) {
|
||||||
const enterKey = (
|
return (
|
||||||
<EnterWorkspaceKey
|
<EnterWorkspaceKey
|
||||||
workspaceMeta={workspaceMeta}
|
workspaceMeta={workspaceMeta}
|
||||||
error={key.error}
|
error={key.error}
|
||||||
@@ -87,8 +79,6 @@ export function WorkspaceEncryptionSetting({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return enterKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the key if it exists
|
// Show the key if it exists
|
||||||
@@ -100,8 +90,7 @@ export function WorkspaceEncryptionSetting({
|
|||||||
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">
|
||||||
@@ -122,43 +111,9 @@ export function WorkspaceEncryptionSetting({
|
|||||||
)}
|
)}
|
||||||
</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
|
||||||
|
|||||||
@@ -5,19 +5,16 @@ 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";
|
||||||
|
|
||||||
@@ -28,17 +25,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_SETTINGS;
|
| typeof TAB_DATA;
|
||||||
|
|
||||||
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
||||||
|
|
||||||
@@ -74,8 +71,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
tabs={[
|
tabs={[
|
||||||
{ value: TAB_GENERAL, label: "Workspace" },
|
{ value: TAB_GENERAL, label: "Workspace" },
|
||||||
{
|
{
|
||||||
value: TAB_SETTINGS,
|
value: TAB_DATA,
|
||||||
label: "Settings",
|
label: "Storage",
|
||||||
},
|
},
|
||||||
...headersTab,
|
...headersTab,
|
||||||
...authTab,
|
...authTab,
|
||||||
@@ -103,20 +100,6 @@ 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 })}
|
|
||||||
/>
|
|
||||||
<WorkspaceEncryptionSetting layout="settings" size="xs" />
|
|
||||||
</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
|
||||||
@@ -169,21 +152,19 @@ 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} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export interface CheckboxProps {
|
|||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
help?: ReactNode;
|
help?: ReactNode;
|
||||||
size?: "sm" | "md";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Checkbox({
|
export function Checkbox({
|
||||||
@@ -26,7 +25,6 @@ export function Checkbox({
|
|||||||
hideLabel,
|
hideLabel,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
help,
|
help,
|
||||||
size = "sm",
|
|
||||||
}: CheckboxProps) {
|
}: CheckboxProps) {
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
@@ -39,9 +37,7 @@ export function Checkbox({
|
|||||||
<input
|
<input
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"appearance-none flex-shrink-0 border border-border",
|
"appearance-none w-4 h-4 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",
|
||||||
@@ -54,7 +50,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={size}
|
size="sm"
|
||||||
className={classNames(disabled && "opacity-disabled")}
|
className={classNames(disabled && "opacity-disabled")}
|
||||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface Props {
|
|||||||
|
|
||||||
export function HttpResponseDurationTag({ response }: Props) {
|
export function HttpResponseDurationTag({ response }: Props) {
|
||||||
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
|
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
|
||||||
const timeout = useRef<ReturnType<typeof setInterval>>(undefined);
|
const timeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
|
||||||
// Calculate the duration of the response for use when the response hasn't finished yet
|
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
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, selectable }: Props) {
|
export function KeyValueRows({ children }: Props) {
|
||||||
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
|
||||||
return (
|
return (
|
||||||
<table
|
<table className="text-editor font-mono min-w-0 w-full mb-auto">
|
||||||
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
|
||||||
@@ -34,11 +26,8 @@ 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({
|
||||||
@@ -46,34 +35,14 @@ 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 resolvedRightSlot =
|
|
||||||
rightSlot ??
|
|
||||||
(enableCopy && textToCopy != null ? (
|
|
||||||
<CopyIconButton
|
|
||||||
text={textToCopy}
|
|
||||||
className="text-text-subtle"
|
|
||||||
size="2xs"
|
|
||||||
title={`Copy ${label}`}
|
|
||||||
iconSize="sm"
|
|
||||||
/>
|
|
||||||
) : null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td
|
<td
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"select-none py-0.5 pr-2 h-full max-w-[10rem]",
|
"select-none py-0.5 pr-2 h-full align-top 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",
|
||||||
@@ -82,21 +51,11 @@ export function KeyValueRow({
|
|||||||
>
|
>
|
||||||
<span className="select-text cursor-text">{label}</span>
|
<span className="select-text cursor-text">{label}</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td className="select-none py-0.5 break-all align-top max-w-[15rem]">
|
||||||
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}
|
||||||
{resolvedRightSlot ? (
|
{rightSlot ? <div className="ml-1.5">{rightSlot}</div> : <span aria-hidden />}
|
||||||
<div className="ml-1.5">{resolvedRightSlot}</div>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
disabled,
|
|
||||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||||
help,
|
help,
|
||||||
hideLabel,
|
hideLabel,
|
||||||
@@ -164,8 +163,7 @@ 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 && !disabled ? "border-border-focus" : "border-border-subtle",
|
focused ? "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",
|
||||||
@@ -200,13 +198,12 @@ 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"
|
||||||
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 disabled:opacity-disabled")}
|
className={classNames(commonClassName, "h-full")}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required={required}
|
required={required}
|
||||||
|
|||||||
@@ -109,15 +109,7 @@ 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
|
<RadioDropdown value={value} onChange={handleChange} items={options}>
|
||||||
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(
|
||||||
"opacity-60",
|
"h-0 border-t 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-0 border-t",
|
orientation === "horizontal" && "w-full h-[1px]",
|
||||||
orientation === "vertical" && "h-full w-0 border-l",
|
orientation === "vertical" && "h-full w-[1px]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,522 +0,0 @@
|
|||||||
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,
|
|
||||||
name = String(modelKey),
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
name?: string;
|
|
||||||
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowNumber
|
|
||||||
name={name}
|
|
||||||
value={model[modelKey] as number}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowText({
|
|
||||||
inputClassName,
|
|
||||||
inputWidthClassName = "!w-80",
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
title,
|
|
||||||
type = "text",
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
inputClassName?: string;
|
|
||||||
inputWidthClassName?: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
type?: "text" | "password";
|
|
||||||
value: string;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<PlainInput
|
|
||||||
required={required}
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={value}
|
|
||||||
onChange={onChange}
|
|
||||||
type={type}
|
|
||||||
className={inputClassName}
|
|
||||||
containerClassName={inputWidthClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
name = String(modelKey),
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
name?: string;
|
|
||||||
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowText
|
|
||||||
name={name}
|
|
||||||
value={model[modelKey] as string}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowFile({
|
|
||||||
buttonClassName,
|
|
||||||
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
|
|
||||||
directory,
|
|
||||||
filePath,
|
|
||||||
nameOverride,
|
|
||||||
noun,
|
|
||||||
onChange,
|
|
||||||
size = "xs",
|
|
||||||
title,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
buttonClassName?: string;
|
|
||||||
directory?: boolean;
|
|
||||||
filePath: string | null;
|
|
||||||
nameOverride?: string | null;
|
|
||||||
noun?: string;
|
|
||||||
onChange: (filePath: string | null) => void | Promise<void>;
|
|
||||||
size?: Parameters<typeof SelectFile>[0]["size"];
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} controlClassName={controlClassName} {...props}>
|
|
||||||
<SelectFile
|
|
||||||
directory={directory}
|
|
||||||
inline
|
|
||||||
hideLabel
|
|
||||||
label={typeof title === "string" ? title : noun}
|
|
||||||
size={size}
|
|
||||||
noun={noun}
|
|
||||||
nameOverride={nameOverride}
|
|
||||||
filePath={filePath}
|
|
||||||
className={buttonClassName}
|
|
||||||
onChange={({ filePath }) => onChange(filePath)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowDirectory({
|
|
||||||
noun = "Directory",
|
|
||||||
...props
|
|
||||||
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
|
|
||||||
return <SettingRowFile directory noun={noun} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingRowSelect<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "!w-48",
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
} & SettingRowBaseProps) {
|
|
||||||
return (
|
|
||||||
<SettingRow title={title} {...props}>
|
|
||||||
<SettingSelectControl
|
|
||||||
name={name}
|
|
||||||
label={typeof title === "string" ? title : name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
selectClassName={selectClassName}
|
|
||||||
disabled={props.disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingSelectControl<T extends string>({
|
|
||||||
defaultValue,
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
selectClassName = "!w-48",
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
defaultValue?: T;
|
|
||||||
disabled?: boolean;
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
options: RadioDropdownItem<T>[];
|
|
||||||
selectClassName?: string;
|
|
||||||
value: T;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
hideLabel
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
label={label}
|
|
||||||
size="sm"
|
|
||||||
className={selectClassName}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingSelectControl<
|
|
||||||
M extends AnyModel,
|
|
||||||
K extends ModelKeyOfValue<M, string>,
|
|
||||||
V extends M[K] & string,
|
|
||||||
>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
name = String(modelKey),
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
name?: string;
|
|
||||||
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingSelectControl
|
|
||||||
name={name}
|
|
||||||
value={model[modelKey] as V}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModelSettingRowSelect<
|
|
||||||
M extends AnyModel,
|
|
||||||
K extends ModelKeyOfValue<M, string>,
|
|
||||||
V extends M[K] & string,
|
|
||||||
>({
|
|
||||||
model,
|
|
||||||
modelKey,
|
|
||||||
name = String(modelKey),
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
model: M;
|
|
||||||
modelKey: K;
|
|
||||||
name?: string;
|
|
||||||
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
|
|
||||||
return (
|
|
||||||
<SettingRowSelect
|
|
||||||
name={name}
|
|
||||||
value={model[modelKey] as V}
|
|
||||||
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingOverrideRow({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
controlClassName,
|
|
||||||
description,
|
|
||||||
disabled,
|
|
||||||
onResetOverride,
|
|
||||||
overridden,
|
|
||||||
resetTitle = "Reset override",
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
controlClassName?: string;
|
|
||||||
description?: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
onResetOverride: () => void;
|
|
||||||
overridden: boolean;
|
|
||||||
resetTitle?: string;
|
|
||||||
title: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
className={className}
|
|
||||||
controlClassName={controlClassName}
|
|
||||||
description={description}
|
|
||||||
disabled={disabled}
|
|
||||||
title={
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{title}
|
|
||||||
{overridden && (
|
|
||||||
<IconButton
|
|
||||||
icon="undo_2"
|
|
||||||
size="2xs"
|
|
||||||
iconSize="sm"
|
|
||||||
title={resetTitle}
|
|
||||||
className="text-text-subtle"
|
|
||||||
onClick={onResetOverride}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,7 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
|
|||||||
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const showTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
|
||||||
const handleOpenImmediate = () => {
|
const handleOpenImmediate = () => {
|
||||||
if (triggerRef.current == null || tooltipRef.current == null) return;
|
if (triggerRef.current == null || tooltipRef.current == null) return;
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
|
||||||
import type { GitCommit } from "@yaakapp-internal/git";
|
|
||||||
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
|
||||||
import { useGitCallbacks } from "./callbacks";
|
|
||||||
|
|
||||||
export function FileHistoryDialog({ dir, relaPath }: { dir: string; relaPath: string }) {
|
|
||||||
const callbacks = useGitCallbacks(dir);
|
|
||||||
const { restoreFileFromCommit } = useGitMutations(dir, callbacks);
|
|
||||||
const log = useGitLog(dir, undefined, relaPath);
|
|
||||||
const commits = log.data ?? [];
|
|
||||||
const [selectedOid, setSelectedOid] = useState<string | null>(null);
|
|
||||||
const selectedCommit = useMemo(
|
|
||||||
() => commits.find((commit) => commit.oid === selectedOid) ?? null,
|
|
||||||
[commits, selectedOid],
|
|
||||||
);
|
|
||||||
const diff = useGitFileDiffForCommit(dir, relaPath, selectedCommit?.oid);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (commits.length === 0) {
|
|
||||||
setSelectedOid(null);
|
|
||||||
} else if (selectedOid == null || !commits.some((commit) => commit.oid === selectedOid)) {
|
|
||||||
setSelectedOid(commits[0]?.oid ?? null);
|
|
||||||
}
|
|
||||||
}, [commits, selectedOid]);
|
|
||||||
|
|
||||||
const handleRestoreCommit = useCallback(
|
|
||||||
async (commit: GitCommit) => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-restore-file-history-entry",
|
|
||||||
title: "Restore File",
|
|
||||||
description: "This will restore the file to the selected commit.",
|
|
||||||
confirmText: "Restore",
|
|
||||||
color: "warning",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await restoreFileFromCommit.mutateAsync({ commitOid: commit.oid, relaPath });
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
[relaPath, restoreFileFromCommit],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (commits.length === 0 && !log.isLoading) {
|
|
||||||
return <EmptyStateText>No history for this file</EmptyStateText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full px-2 pb-4">
|
|
||||||
<SplitLayout
|
|
||||||
storageKey="git-file-history-horizontal"
|
|
||||||
layout="horizontal"
|
|
||||||
defaultRatio={0.6}
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full overflow-y-auto px-4 pb-2 transform-cpu">
|
|
||||||
<div className="flex flex-col pt-1.5">
|
|
||||||
{commits.map((commit) => (
|
|
||||||
<CommitListItem
|
|
||||||
key={commit.oid}
|
|
||||||
commit={commit}
|
|
||||||
selected={commit.oid === selectedCommit?.oid}
|
|
||||||
onSelect={() => setSelectedOid(commit.oid)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={({ style }) => (
|
|
||||||
<div style={style} className="h-full min-w-0 border-l border-l-border-subtle px-4">
|
|
||||||
{selectedCommit == null ? (
|
|
||||||
<EmptyStateText>Select a commit to view diff</EmptyStateText>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="mb-2 min-w-0 text-text-subtle grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
|
|
||||||
<div className="min-w-0 truncate">{selectedCommit.message || "No message"}</div>
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
color="warning"
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
onClick={() => handleRestoreCommit(selectedCommit)}
|
|
||||||
>
|
|
||||||
Restore File
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DiffViewer
|
|
||||||
original={diff.data?.original ?? ""}
|
|
||||||
modified={diff.data?.modified ?? ""}
|
|
||||||
className="flex-1 min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommitListItem({
|
|
||||||
commit,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
commit: GitCommit;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={classNames(
|
|
||||||
"w-full min-w-0 text-left rounded px-2 py-1.5",
|
|
||||||
selected && "bg-surface-active",
|
|
||||||
)}
|
|
||||||
onClick={onSelect}
|
|
||||||
>
|
|
||||||
<div className="truncate flex-1">{commit.message || "No message"}</div>
|
|
||||||
<div className="text-text-subtle text-sm truncate">
|
|
||||||
{commit.author.name || "Unknown"} - {formatDistanceToNowStrict(commit.when)} ago - <span className="shrink-0 text-2xs text-text-subtle font-mono">{commit.oid.slice(0, 7)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,14 +8,12 @@ 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";
|
||||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
|
||||||
import { showErrorToast } from "../../lib/toast";
|
import { showErrorToast } from "../../lib/toast";
|
||||||
import { sync } from "../../init/sync";
|
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import type { CheckboxProps } from "../core/Checkbox";
|
import type { CheckboxProps } from "../core/Checkbox";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
@@ -23,7 +21,7 @@ import { DiffViewer } from "../core/Editor/DiffViewer";
|
|||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
import { EmptyStateText } from "../EmptyStateText";
|
||||||
import { useGitCallbacks } from "./callbacks";
|
import { gitCallbacks } from "./callbacks";
|
||||||
import { handlePushResult } from "./git-util";
|
import { handlePushResult } from "./git-util";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,10 +38,9 @@ interface CommitTreeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||||
const callbacks = useGitCallbacks(syncDir);
|
const [{ status }, { commit, commitAndPush, add, unstage }] = useGit(
|
||||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
|
|
||||||
syncDir,
|
syncDir,
|
||||||
callbacks,
|
gitCallbacks(syncDir),
|
||||||
);
|
);
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
const [commitError, setCommitError] = useState<string | null>(null);
|
const [commitError, setCommitError] = useState<string | null>(null);
|
||||||
@@ -168,24 +165,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
[selectedEntry],
|
[selectedEntry],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDiscardChanges = useCallback(
|
|
||||||
async (entry: GitStatusEntry) => {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-restore-commit-entry",
|
|
||||||
title: "Discard Changes",
|
|
||||||
description: "Do you really want to discard uncommitted changes for the selected item?",
|
|
||||||
confirmText: "Discard",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await restore.mutateAsync({ relaPaths: [entry.relaPath] });
|
|
||||||
await sync({ force: true });
|
|
||||||
setSelectedEntry(null);
|
|
||||||
},
|
|
||||||
[restore],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (tree == null) {
|
if (tree == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -280,7 +259,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
secondSlot={({ style }) => (
|
secondSlot={({ style }) => (
|
||||||
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
|
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
|
||||||
{selectedEntry ? (
|
{selectedEntry ? (
|
||||||
<DiffPanel entry={selectedEntry} onDiscardChanges={handleDiscardChanges} />
|
<DiffPanel entry={selectedEntry} />
|
||||||
) : (
|
) : (
|
||||||
<EmptyStateText>Select a change to view diff</EmptyStateText>
|
<EmptyStateText>Select a change to view diff</EmptyStateText>
|
||||||
)}
|
)}
|
||||||
@@ -487,35 +466,16 @@ function isNodeRelevant(node: CommitTreeNode): boolean {
|
|||||||
return node.children.some((c) => isNodeRelevant(c));
|
return node.children.some((c) => isNodeRelevant(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffPanel({
|
function DiffPanel({ entry }: { entry: GitStatusEntry }) {
|
||||||
entry,
|
|
||||||
onDiscardChanges,
|
|
||||||
}: {
|
|
||||||
entry: GitStatusEntry;
|
|
||||||
onDiscardChanges: (entry: GitStatusEntry) => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const prevYaml = modelToYaml(entry.prev);
|
const prevYaml = modelToYaml(entry.prev);
|
||||||
const nextYaml = modelToYaml(entry.next);
|
const nextYaml = modelToYaml(entry.next);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="text-text-subtle mb-2 px-1 grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
|
<div className="text-sm text-text-subtle mb-2 px-1">
|
||||||
<div className="min-w-0 truncate">
|
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
|
||||||
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
color="warning"
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
onClick={() => onDiscardChanges(entry)}
|
|
||||||
>Discard Changes</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<DiffViewer
|
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
|
||||||
original={prevYaml ?? ""}
|
|
||||||
modified={nextYaml ?? ""}
|
|
||||||
className="flex-1 min-h-0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
|
import { useGit } from "@yaakapp-internal/git";
|
||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type { HTMLAttributes } from "react";
|
import type { HTMLAttributes } from "react";
|
||||||
import { forwardRef, useCallback, useMemo } from "react";
|
import { forwardRef } from "react";
|
||||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
@@ -12,20 +12,17 @@ import { sync } from "../../init/sync";
|
|||||||
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
|
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
import { fireAndForget } from "../../lib/fireAndForget";
|
||||||
import { showDialog } from "../../lib/dialog";
|
import { showDialog } from "../../lib/dialog";
|
||||||
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
|
|
||||||
import { showPrompt } from "../../lib/prompt";
|
import { showPrompt } from "../../lib/prompt";
|
||||||
import { showErrorToast, showToast } from "../../lib/toast";
|
import { showErrorToast, showToast } from "../../lib/toast";
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
import type { DropdownItem } from "../core/Dropdown";
|
||||||
import { Dropdown } from "../core/Dropdown";
|
import { Dropdown } from "../core/Dropdown";
|
||||||
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { useGitCallbacks } from "./callbacks";
|
import { gitCallbacks } from "./callbacks";
|
||||||
import { GitCommitDialog } from "./GitCommitDialog";
|
import { GitCommitDialog } from "./GitCommitDialog";
|
||||||
import { GitRemotesDialog } from "./GitRemotesDialog";
|
import { GitRemotesDialog } from "./GitRemotesDialog";
|
||||||
import { handlePullResult, handlePushResult } from "./git-util";
|
import { handlePullResult, handlePushResult } from "./git-util";
|
||||||
import { HistoryDialog } from "./HistoryDialog";
|
import { HistoryDialog } from "./HistoryDialog";
|
||||||
|
|
||||||
const EMPTY_BRANCHES: string[] = [];
|
|
||||||
|
|
||||||
export function GitDropdown() {
|
export function GitDropdown() {
|
||||||
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
|
||||||
if (workspaceMeta == null) return null;
|
if (workspaceMeta == null) return null;
|
||||||
@@ -39,493 +36,469 @@ export function GitDropdown() {
|
|||||||
|
|
||||||
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
|
||||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
|
|
||||||
const [refreshKey, regenerateKey] = useRandomKey();
|
const [refreshKey, regenerateKey] = useRandomKey();
|
||||||
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
|
const [
|
||||||
const callbacks = useGitCallbacks(syncDir);
|
{ status, log },
|
||||||
const {
|
{
|
||||||
createBranch,
|
createBranch,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
deleteRemoteBranch,
|
deleteRemoteBranch,
|
||||||
renameBranch,
|
renameBranch,
|
||||||
mergeBranch,
|
mergeBranch,
|
||||||
push,
|
push,
|
||||||
pull,
|
pull,
|
||||||
checkout,
|
checkout,
|
||||||
resetChanges,
|
resetChanges,
|
||||||
init,
|
init,
|
||||||
} = useGitMutations(syncDir, callbacks);
|
},
|
||||||
|
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
|
||||||
|
|
||||||
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
|
const localBranches = status.data?.localBranches ?? [];
|
||||||
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
|
const remoteBranches = status.data?.remoteBranches ?? [];
|
||||||
const remoteOnlyBranches = useMemo(
|
const remoteOnlyBranches = remoteBranches.filter(
|
||||||
() => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))),
|
(b) => !localBranches.includes(b.replace(/^origin\//, "")),
|
||||||
[localBranches, remoteBranches],
|
|
||||||
);
|
);
|
||||||
const currentBranch = branchInfo.data?.headRefShorthand;
|
|
||||||
const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false;
|
|
||||||
const ahead = branchInfo.data?.ahead ?? 0;
|
|
||||||
const behind = branchInfo.data?.behind ?? 0;
|
|
||||||
const initRepo = useCallback(() => {
|
|
||||||
init.mutate();
|
|
||||||
}, [init]);
|
|
||||||
|
|
||||||
const items: DropdownItem[] = useMemo(() => {
|
|
||||||
if (workspace == null || branchInfo.data == null) return [];
|
|
||||||
|
|
||||||
const tryCheckout = (branch: string, force: boolean) => {
|
|
||||||
checkout.mutate(
|
|
||||||
{ branch, force },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
async onError(err) {
|
|
||||||
if (!force) {
|
|
||||||
// Checkout failed so ask user if they want to force it
|
|
||||||
const forceCheckout = await showConfirm({
|
|
||||||
id: "git-force-checkout",
|
|
||||||
title: "Conflicts Detected",
|
|
||||||
description:
|
|
||||||
"Your branch has conflicts. Either make a commit or force checkout to discard changes.",
|
|
||||||
confirmText: "Force Checkout",
|
|
||||||
color: "warning",
|
|
||||||
});
|
|
||||||
if (forceCheckout) {
|
|
||||||
tryCheckout(branch, true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Checkout failed
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-checkout-error",
|
|
||||||
title: "Error checking out branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async onSuccess(branchName) {
|
|
||||||
showToast({
|
|
||||||
id: "git-checkout-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Switched branch <InlineCode>{branchName}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
await sync({ force: true });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "View History...",
|
|
||||||
leftSlot: <Icon icon="history" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
showDialog({
|
|
||||||
id: "git-history",
|
|
||||||
size: "md",
|
|
||||||
title: "Commit History",
|
|
||||||
noPadding: true,
|
|
||||||
render: () => <HistoryDialog dir={syncDir} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Manage Remotes...",
|
|
||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
|
||||||
onSelect: () => GitRemotesDialog.show(syncDir),
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "New Branch...",
|
|
||||||
leftSlot: <Icon icon="git_branch_plus" />,
|
|
||||||
async onSelect() {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "git-branch-name",
|
|
||||||
title: "Create Branch",
|
|
||||||
label: "Branch Name",
|
|
||||||
});
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
await createBranch.mutateAsync(
|
|
||||||
{ branch: name },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError: (err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-branch-error",
|
|
||||||
title: "Error creating branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tryCheckout(name, false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator" },
|
|
||||||
{
|
|
||||||
label: "Push",
|
|
||||||
leftSlot: <Icon icon="arrow_up_from_line" />,
|
|
||||||
waitForOnSelect: true,
|
|
||||||
async onSelect() {
|
|
||||||
await push.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess: handlePushResult,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-push-error",
|
|
||||||
title: "Error pushing changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Pull",
|
|
||||||
leftSlot: <Icon icon="arrow_down_to_line" />,
|
|
||||||
waitForOnSelect: true,
|
|
||||||
async onSelect() {
|
|
||||||
await pull.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess: handlePullResult,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-pull-error",
|
|
||||||
title: "Error pulling changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Commit...",
|
|
||||||
|
|
||||||
leftSlot: <Icon icon="git_commit_vertical" />,
|
|
||||||
onSelect() {
|
|
||||||
showDialog({
|
|
||||||
id: "commit",
|
|
||||||
title: "Commit Changes",
|
|
||||||
size: "full",
|
|
||||||
noPadding: true,
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Reset Changes",
|
|
||||||
hidden: !hasChanges,
|
|
||||||
leftSlot: <Icon icon="rotate_ccw" />,
|
|
||||||
color: "danger",
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "git-reset-changes",
|
|
||||||
title: "Reset Changes",
|
|
||||||
description: "This will discard all uncommitted changes. This cannot be undone.",
|
|
||||||
confirmText: "Reset",
|
|
||||||
color: "danger",
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await resetChanges.mutateAsync(undefined, {
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-reset-success",
|
|
||||||
message: "Changes have been reset",
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
fireAndForget(sync({ force: true }));
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-reset-error",
|
|
||||||
title: "Error resetting changes",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
|
|
||||||
...localBranches.map((branch) => {
|
|
||||||
const isCurrent = currentBranch === branch;
|
|
||||||
return {
|
|
||||||
label: branch,
|
|
||||||
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
|
||||||
submenuOpenOnClick: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Checkout",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: () => tryCheckout(branch, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
Merge into <InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
hidden: isCurrent,
|
|
||||||
async onSelect() {
|
|
||||||
await mergeBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-merged-branch",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Merged <InlineCode>{branch}</InlineCode> into{" "}
|
|
||||||
<InlineCode>{currentBranch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
fireAndForget(sync({ force: true }));
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-merged-branch-error",
|
|
||||||
title: "Error merging branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "New Branch...",
|
|
||||||
async onSelect() {
|
|
||||||
const name = await showPrompt({
|
|
||||||
id: "git-new-branch-from",
|
|
||||||
title: "New Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Create a new branch from <InlineCode>{branch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
label: "Branch Name",
|
|
||||||
});
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
await createBranch.mutateAsync(
|
|
||||||
{ branch: name, base: branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError: (err) => {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-branch-error",
|
|
||||||
title: "Error creating branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tryCheckout(name, false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Rename...",
|
|
||||||
async onSelect() {
|
|
||||||
const newName = await showPrompt({
|
|
||||||
id: "git-rename-branch",
|
|
||||||
title: "Rename Branch",
|
|
||||||
label: "New Branch Name",
|
|
||||||
defaultValue: branch,
|
|
||||||
});
|
|
||||||
if (!newName || newName === branch) return;
|
|
||||||
|
|
||||||
await renameBranch.mutateAsync(
|
|
||||||
{ oldName: branch, newName },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-rename-branch-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Renamed <InlineCode>{branch}</InlineCode> to{" "}
|
|
||||||
<InlineCode>{newName}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-rename-branch-error",
|
|
||||||
title: "Error renaming branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: "separator", hidden: isCurrent },
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
color: "danger",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: async () => {
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "git-delete-branch",
|
|
||||||
title: "Delete Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{branch}</InlineCode>?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deleteBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-delete-branch-error",
|
|
||||||
title: "Error deleting branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.type === "not_fully_merged") {
|
|
||||||
const confirmed = await showConfirm({
|
|
||||||
id: "force-branch-delete",
|
|
||||||
title: "Branch not fully merged",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
|
|
||||||
</p>
|
|
||||||
<p>Do you want to delete it anyway?</p>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (confirmed) {
|
|
||||||
await deleteBranch.mutateAsync(
|
|
||||||
{ branch, force: true },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-force-delete-branch-error",
|
|
||||||
title: "Error force deleting branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies DropdownItem;
|
|
||||||
}),
|
|
||||||
...remoteOnlyBranches.map((branch) => {
|
|
||||||
const isCurrent = currentBranch === branch;
|
|
||||||
return {
|
|
||||||
label: branch,
|
|
||||||
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
|
||||||
submenuOpenOnClick: true,
|
|
||||||
submenu: [
|
|
||||||
{
|
|
||||||
label: "Checkout",
|
|
||||||
hidden: isCurrent,
|
|
||||||
onSelect: () => tryCheckout(branch, false),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
color: "danger",
|
|
||||||
async onSelect() {
|
|
||||||
const confirmed = await showConfirmDelete({
|
|
||||||
id: "git-delete-remote-branch",
|
|
||||||
title: "Delete Remote Branch",
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
await deleteRemoteBranch.mutateAsync(
|
|
||||||
{ branch },
|
|
||||||
{
|
|
||||||
disableToastError: true,
|
|
||||||
onSuccess() {
|
|
||||||
showToast({
|
|
||||||
id: "git-delete-remote-branch-success",
|
|
||||||
message: (
|
|
||||||
<>
|
|
||||||
Deleted remote branch <InlineCode>{branch}</InlineCode>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
color: "success",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
showErrorToast({
|
|
||||||
id: "git-delete-remote-branch-error",
|
|
||||||
title: "Error deleting remote branch",
|
|
||||||
message: String(err),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies DropdownItem;
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}, [
|
|
||||||
branchInfo.data,
|
|
||||||
checkout,
|
|
||||||
createBranch,
|
|
||||||
currentBranch,
|
|
||||||
deleteBranch,
|
|
||||||
deleteRemoteBranch,
|
|
||||||
hasChanges,
|
|
||||||
localBranches,
|
|
||||||
mergeBranch,
|
|
||||||
pull,
|
|
||||||
push,
|
|
||||||
remoteOnlyBranches,
|
|
||||||
renameBranch,
|
|
||||||
resetChanges,
|
|
||||||
syncDir,
|
|
||||||
workspace,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (workspace == null) {
|
if (workspace == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noRepo = branchInfo.error?.includes("not found");
|
const noRepo = status.error?.includes("not found");
|
||||||
if (noRepo) {
|
if (noRepo) {
|
||||||
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
|
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still loading
|
// Still loading
|
||||||
if (branchInfo.data == null) {
|
if (status.data == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentBranch = status.data.headRefShorthand;
|
||||||
|
const hasChanges = status.data.entries.some((e) => e.status !== "current");
|
||||||
|
const _hasRemotes = (status.data.origins ?? []).length > 0;
|
||||||
|
const { ahead, behind } = status.data;
|
||||||
|
|
||||||
|
const tryCheckout = (branch: string, force: boolean) => {
|
||||||
|
checkout.mutate(
|
||||||
|
{ branch, force },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
async onError(err) {
|
||||||
|
if (!force) {
|
||||||
|
// Checkout failed so ask user if they want to force it
|
||||||
|
const forceCheckout = await showConfirm({
|
||||||
|
id: "git-force-checkout",
|
||||||
|
title: "Conflicts Detected",
|
||||||
|
description:
|
||||||
|
"Your branch has conflicts. Either make a commit or force checkout to discard changes.",
|
||||||
|
confirmText: "Force Checkout",
|
||||||
|
color: "warning",
|
||||||
|
});
|
||||||
|
if (forceCheckout) {
|
||||||
|
tryCheckout(branch, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Checkout failed
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-checkout-error",
|
||||||
|
title: "Error checking out branch",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onSuccess(branchName) {
|
||||||
|
showToast({
|
||||||
|
id: "git-checkout-success",
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Switched branch <InlineCode>{branchName}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
color: "success",
|
||||||
|
});
|
||||||
|
await sync({ force: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const items: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
label: "View History...",
|
||||||
|
hidden: (log.data ?? []).length === 0,
|
||||||
|
leftSlot: <Icon icon="history" />,
|
||||||
|
onSelect: async () => {
|
||||||
|
showDialog({
|
||||||
|
id: "git-history",
|
||||||
|
size: "md",
|
||||||
|
title: "Commit History",
|
||||||
|
noPadding: true,
|
||||||
|
render: () => <HistoryDialog log={log.data ?? []} />,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Manage Remotes...",
|
||||||
|
leftSlot: <Icon icon="hard_drive_download" />,
|
||||||
|
onSelect: () => GitRemotesDialog.show(syncDir),
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "New Branch...",
|
||||||
|
leftSlot: <Icon icon="git_branch_plus" />,
|
||||||
|
async onSelect() {
|
||||||
|
const name = await showPrompt({
|
||||||
|
id: "git-branch-name",
|
||||||
|
title: "Create Branch",
|
||||||
|
label: "Branch Name",
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
await createBranch.mutateAsync(
|
||||||
|
{ branch: name },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onError: (err) => {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-branch-error",
|
||||||
|
title: "Error creating branch",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tryCheckout(name, false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "Push",
|
||||||
|
leftSlot: <Icon icon="arrow_up_from_line" />,
|
||||||
|
waitForOnSelect: true,
|
||||||
|
async onSelect() {
|
||||||
|
await push.mutateAsync(undefined, {
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess: handlePushResult,
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-push-error",
|
||||||
|
title: "Error pushing changes",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pull",
|
||||||
|
leftSlot: <Icon icon="arrow_down_to_line" />,
|
||||||
|
waitForOnSelect: true,
|
||||||
|
async onSelect() {
|
||||||
|
await pull.mutateAsync(undefined, {
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess: handlePullResult,
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-pull-error",
|
||||||
|
title: "Error pulling changes",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Commit...",
|
||||||
|
|
||||||
|
leftSlot: <Icon icon="git_commit_vertical" />,
|
||||||
|
onSelect() {
|
||||||
|
showDialog({
|
||||||
|
id: "commit",
|
||||||
|
title: "Commit Changes",
|
||||||
|
size: "full",
|
||||||
|
noPadding: true,
|
||||||
|
render: ({ hide }) => (
|
||||||
|
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reset Changes",
|
||||||
|
hidden: !hasChanges,
|
||||||
|
leftSlot: <Icon icon="rotate_ccw" />,
|
||||||
|
color: "danger",
|
||||||
|
async onSelect() {
|
||||||
|
const confirmed = await showConfirm({
|
||||||
|
id: "git-reset-changes",
|
||||||
|
title: "Reset Changes",
|
||||||
|
description: "This will discard all uncommitted changes. This cannot be undone.",
|
||||||
|
confirmText: "Reset",
|
||||||
|
color: "danger",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await resetChanges.mutateAsync(undefined, {
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess() {
|
||||||
|
showToast({
|
||||||
|
id: "git-reset-success",
|
||||||
|
message: "Changes have been reset",
|
||||||
|
color: "success",
|
||||||
|
});
|
||||||
|
fireAndForget(sync({ force: true }));
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-reset-error",
|
||||||
|
title: "Error resetting changes",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "separator", label: "Branches", hidden: localBranches.length < 1 },
|
||||||
|
...localBranches.map((branch) => {
|
||||||
|
const isCurrent = currentBranch === branch;
|
||||||
|
return {
|
||||||
|
label: branch,
|
||||||
|
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
||||||
|
submenuOpenOnClick: true,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: "Checkout",
|
||||||
|
hidden: isCurrent,
|
||||||
|
onSelect: () => tryCheckout(branch, false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
Merge into <InlineCode>{currentBranch}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
hidden: isCurrent,
|
||||||
|
async onSelect() {
|
||||||
|
await mergeBranch.mutateAsync(
|
||||||
|
{ branch },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess() {
|
||||||
|
showToast({
|
||||||
|
id: "git-merged-branch",
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Merged <InlineCode>{branch}</InlineCode> into{" "}
|
||||||
|
<InlineCode>{currentBranch}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
fireAndForget(sync({ force: true }));
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-merged-branch-error",
|
||||||
|
title: "Error merging branch",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New Branch...",
|
||||||
|
async onSelect() {
|
||||||
|
const name = await showPrompt({
|
||||||
|
id: "git-new-branch-from",
|
||||||
|
title: "New Branch",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Create a new branch from <InlineCode>{branch}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
label: "Branch Name",
|
||||||
|
});
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
await createBranch.mutateAsync(
|
||||||
|
{ branch: name, base: branch },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onError: (err) => {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-branch-error",
|
||||||
|
title: "Error creating branch",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tryCheckout(name, false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Rename...",
|
||||||
|
async onSelect() {
|
||||||
|
const newName = await showPrompt({
|
||||||
|
id: "git-rename-branch",
|
||||||
|
title: "Rename Branch",
|
||||||
|
label: "New Branch Name",
|
||||||
|
defaultValue: branch,
|
||||||
|
});
|
||||||
|
if (!newName || newName === branch) return;
|
||||||
|
|
||||||
|
await renameBranch.mutateAsync(
|
||||||
|
{ oldName: branch, newName },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess() {
|
||||||
|
showToast({
|
||||||
|
id: "git-rename-branch-success",
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Renamed <InlineCode>{branch}</InlineCode> to{" "}
|
||||||
|
<InlineCode>{newName}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
color: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-rename-branch-error",
|
||||||
|
title: "Error renaming branch",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "separator", hidden: isCurrent },
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
color: "danger",
|
||||||
|
hidden: isCurrent,
|
||||||
|
onSelect: async () => {
|
||||||
|
const confirmed = await showConfirmDelete({
|
||||||
|
id: "git-delete-branch",
|
||||||
|
title: "Delete Branch",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Permanently delete <InlineCode>{branch}</InlineCode>?
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteBranch.mutateAsync(
|
||||||
|
{ branch },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-delete-branch-error",
|
||||||
|
title: "Error deleting branch",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.type === "not_fully_merged") {
|
||||||
|
const confirmed = await showConfirm({
|
||||||
|
id: "force-branch-delete",
|
||||||
|
title: "Branch not fully merged",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Branch <InlineCode>{branch}</InlineCode> is not fully merged.
|
||||||
|
</p>
|
||||||
|
<p>Do you want to delete it anyway?</p>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
await deleteBranch.mutateAsync(
|
||||||
|
{ branch, force: true },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-force-delete-branch-error",
|
||||||
|
title: "Error force deleting branch",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies DropdownItem;
|
||||||
|
}),
|
||||||
|
...remoteOnlyBranches.map((branch) => {
|
||||||
|
const isCurrent = currentBranch === branch;
|
||||||
|
return {
|
||||||
|
label: branch,
|
||||||
|
leftSlot: <Icon icon={isCurrent ? "check" : "empty"} />,
|
||||||
|
submenuOpenOnClick: true,
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: "Checkout",
|
||||||
|
hidden: isCurrent,
|
||||||
|
onSelect: () => tryCheckout(branch, false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
color: "danger",
|
||||||
|
async onSelect() {
|
||||||
|
const confirmed = await showConfirmDelete({
|
||||||
|
id: "git-delete-remote-branch",
|
||||||
|
title: "Delete Remote Branch",
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Permanently delete <InlineCode>{branch}</InlineCode> from the remote?
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await deleteRemoteBranch.mutateAsync(
|
||||||
|
{ branch },
|
||||||
|
{
|
||||||
|
disableToastError: true,
|
||||||
|
onSuccess() {
|
||||||
|
showToast({
|
||||||
|
id: "git-delete-remote-branch-success",
|
||||||
|
message: (
|
||||||
|
<>
|
||||||
|
Deleted remote branch <InlineCode>{branch}</InlineCode>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
color: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showErrorToast({
|
||||||
|
id: "git-delete-remote-branch-error",
|
||||||
|
title: "Error deleting remote branch",
|
||||||
|
message: String(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies DropdownItem;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
|
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
|
||||||
<GitMenuButton>
|
<GitMenuButton>
|
||||||
@@ -596,7 +569,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("settings"),
|
onSelect: () => openWorkspaceSettings("data"),
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import { useGitCallbacks } from "./callbacks";
|
import { gitCallbacks } from "./callbacks";
|
||||||
import { addGitRemote } from "./showAddRemoteDialog";
|
import { addGitRemote } from "./showAddRemoteDialog";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,8 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GitRemotesDialog({ dir }: Props) {
|
export function GitRemotesDialog({ dir }: Props) {
|
||||||
const callbacks = useGitCallbacks(dir);
|
const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
|
||||||
const [{ remotes }, { rmRemote }] = useGit(dir, callbacks);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table scrollable>
|
<Table scrollable>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useGitLog } from "@yaakapp-internal/git";
|
import type { GitCommit } from "@yaakapp-internal/git";
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { formatDistanceToNowStrict } from "date-fns";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -10,9 +10,11 @@ import {
|
|||||||
TruncatedWideTableCell,
|
TruncatedWideTableCell,
|
||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
export function HistoryDialog({ dir }: { dir: string }) {
|
interface Props {
|
||||||
const log = useGitLog(dir);
|
log: GitCommit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryDialog({ log }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="pl-5 pr-1 pb-1">
|
<div className="pl-5 pr-1 pb-1">
|
||||||
<Table scrollable className="px-1">
|
<Table scrollable className="px-1">
|
||||||
@@ -24,8 +26,8 @@ export function HistoryDialog({ dir }: { dir: string }) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(log.data ?? []).map((l) => (
|
{log.map((l) => (
|
||||||
<TableRow key={l.oid}>
|
<TableRow key={(l.author.name ?? "") + (l.message ?? "n/a") + l.when}>
|
||||||
<TruncatedWideTableCell>
|
<TruncatedWideTableCell>
|
||||||
{l.message || <em className="text-text-subtle">No message</em>}
|
{l.message || <em className="text-text-subtle">No message</em>}
|
||||||
</TruncatedWideTableCell>
|
</TruncatedWideTableCell>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { GitCallbacks } from "@yaakapp-internal/git";
|
import type { GitCallbacks } from "@yaakapp-internal/git";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { sync } from "../../init/sync";
|
import { sync } from "../../init/sync";
|
||||||
import { promptCredentials } from "./credentials";
|
import { promptCredentials } from "./credentials";
|
||||||
import { promptDivergedStrategy } from "./diverged";
|
import { promptDivergedStrategy } from "./diverged";
|
||||||
@@ -25,7 +24,3 @@ export function gitCallbacks(dir: string): GitCallbacks {
|
|||||||
forceSync: () => sync({ force: true }),
|
forceSync: () => sync({ force: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGitCallbacks(dir: string): GitCallbacks {
|
|
||||||
return useMemo(() => gitCallbacks(dir), [dir]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
|
|||||||
{authentication.find((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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { watchGitWorktreeStatus, type GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
|
||||||
import { activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { gitWorktreeStatusAtom, gitWorktreeStatusByModelIdAtom } from "../lib/gitWorktreeStatus";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
|
|
||||||
export function initGit() {
|
|
||||||
let watchedDir: string | null = null;
|
|
||||||
let unwatch: null | ReturnType<typeof watchGitWorktreeStatus> = null;
|
|
||||||
|
|
||||||
const watchActiveWorkspace = () => {
|
|
||||||
const syncDir = jotaiStore.get(activeWorkspaceMetaAtom)?.settingSyncDir ?? null;
|
|
||||||
if (syncDir === watchedDir) return;
|
|
||||||
|
|
||||||
void unwatch?.();
|
|
||||||
unwatch = null;
|
|
||||||
watchedDir = syncDir;
|
|
||||||
jotaiStore.set(gitWorktreeStatusAtom, null);
|
|
||||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, {});
|
|
||||||
|
|
||||||
if (syncDir == null) return;
|
|
||||||
|
|
||||||
unwatch = watchGitWorktreeStatus(syncDir, (status) => {
|
|
||||||
if (syncDir !== watchedDir) return;
|
|
||||||
|
|
||||||
jotaiStore.set(gitWorktreeStatusAtom, status);
|
|
||||||
|
|
||||||
const statusByModelId: Record<string, GitWorktreeStatusEntry> = {};
|
|
||||||
for (const entry of status.entries) {
|
|
||||||
if (entry.modelId == null) continue;
|
|
||||||
statusByModelId[entry.modelId] = entry;
|
|
||||||
}
|
|
||||||
jotaiStore.set(gitWorktreeStatusByModelIdAtom, statusByModelId);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
watchActiveWorkspace();
|
|
||||||
jotaiStore.sub(activeWorkspaceMetaAtom, watchActiveWorkspace);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { debounce, eagerDebounceAsync } from "@yaakapp-internal/lib";
|
import { debounce } from "@yaakapp-internal/lib";
|
||||||
import type { AnyModel, ModelPayload } from "@yaakapp-internal/models";
|
import type { AnyModel, ModelPayload } from "@yaakapp-internal/models";
|
||||||
import { watchWorkspaceFiles } from "@yaakapp-internal/sync";
|
import { watchWorkspaceFiles } from "@yaakapp-internal/sync";
|
||||||
import { syncWorkspace } from "../commands/commands";
|
import { syncWorkspace } from "../commands/commands";
|
||||||
@@ -25,8 +25,9 @@ export async function sync({ force }: { force?: boolean } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncAfterFileChange = debounce(sync, 1000);
|
const debouncedSync = debounce(async () => {
|
||||||
const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
|
await sync();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to model change events. Since we check the workspace ID on sync, we can
|
* Subscribe to model change events. Since we check the workspace ID on sync, we can
|
||||||
@@ -34,7 +35,7 @@ const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
|
|||||||
*/
|
*/
|
||||||
function initModelListeners() {
|
function initModelListeners() {
|
||||||
listenToTauriEvent<ModelPayload>("model_write", (p) => {
|
listenToTauriEvent<ModelPayload>("model_write", (p) => {
|
||||||
if (isModelRelevant(p.payload.model)) syncAfterModelWrite();
|
if (isModelRelevant(p.payload.model)) debouncedSync();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,11 +50,11 @@ function initFileChangeListeners() {
|
|||||||
await unsub?.(); // Unsub to previous
|
await unsub?.(); // Unsub to previous
|
||||||
const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);
|
const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);
|
||||||
if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) return;
|
if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) return;
|
||||||
syncAfterFileChange(); // Perform an initial sync when switching workspace
|
debouncedSync(); // Perform an initial sync when switching workspace
|
||||||
unsub = watchWorkspaceFiles(
|
unsub = watchWorkspaceFiles(
|
||||||
workspaceMeta.workspaceId,
|
workspaceMeta.workspaceId,
|
||||||
workspaceMeta.settingSyncDir,
|
workspaceMeta.settingSyncDir,
|
||||||
syncAfterFileChange,
|
debouncedSync,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { SyncModel } from "@yaakapp-internal/git";
|
|||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a SyncModel to a YAML string for diffing.
|
* Convert a SyncModel to a clean YAML string for diffing.
|
||||||
|
* Removes noisy fields like updatedAt that change on every edit.
|
||||||
*/
|
*/
|
||||||
export function modelToYaml(model: SyncModel | null): string {
|
export function modelToYaml(model: SyncModel | null): string {
|
||||||
if (!model) return "";
|
if (!model) return "";
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { GitWorktreeStatus, GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
|
||||||
import { atom } from "jotai";
|
|
||||||
import { atomFamily } from "jotai-family";
|
|
||||||
import { selectAtom } from "jotai/utils";
|
|
||||||
|
|
||||||
export const gitWorktreeStatusAtom = atom<GitWorktreeStatus | null>(null);
|
|
||||||
|
|
||||||
export const gitWorktreeStatusByModelIdAtom = atom<Record<string, GitWorktreeStatusEntry>>({});
|
|
||||||
|
|
||||||
export const gitWorktreeStatusFamily = atomFamily(
|
|
||||||
(modelId: string) =>
|
|
||||||
selectAtom(
|
|
||||||
gitWorktreeStatusByModelIdAtom,
|
|
||||||
(statusByModelId) => statusByModelId[modelId] ?? null,
|
|
||||||
(a, b) =>
|
|
||||||
a?.relaPath === b?.relaPath &&
|
|
||||||
a?.status === b?.status &&
|
|
||||||
a?.staged === b?.staged &&
|
|
||||||
a?.modelId === b?.modelId,
|
|
||||||
),
|
|
||||||
Object.is,
|
|
||||||
);
|
|
||||||
@@ -5,7 +5,6 @@ import { changeModelStoreWorkspace, initModelStore } from "@yaakapp-internal/mod
|
|||||||
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
|
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { initGit } from "./init/git";
|
|
||||||
import { initSync } from "./init/sync";
|
import { initSync } from "./init/sync";
|
||||||
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
||||||
import { jotaiStore } from "./lib/jotai";
|
import { jotaiStore } from "./lib/jotai";
|
||||||
@@ -32,7 +31,6 @@ window.addEventListener("keydown", (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Initialize a bunch of watchers
|
// Initialize a bunch of watchers
|
||||||
initGit();
|
|
||||||
initSync();
|
initSync();
|
||||||
initModelStore(jotaiStore);
|
initModelStore(jotaiStore);
|
||||||
initGlobalListeners();
|
initGlobalListeners();
|
||||||
|
|||||||
@@ -31,14 +31,14 @@
|
|||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@tanstack/react-router": "^1.133.13",
|
"@tanstack/react-router": "^1.133.13",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.4.4",
|
||||||
"@tauri-apps/plugin-log": "^2.8.0",
|
"@tauri-apps/plugin-log": "^2.7.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.2",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
"@tauri-apps/plugin-shell": "^2.3.3",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"cm6-graphql": "^0.2.1",
|
"cm6-graphql": "^0.2.1",
|
||||||
@@ -52,7 +52,6 @@
|
|||||||
"hexy": "^0.3.5",
|
"hexy": "^0.3.5",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
"jotai-family": "^1.0.1",
|
|
||||||
"js-md5": "^0.8.3",
|
"js-md5": "^0.8.3",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"mime": "^4.0.4",
|
"mime": "^4.0.4",
|
||||||
@@ -98,7 +97,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { defineConfig, normalizePath } from "vite-plus";
|
import { defineConfig, normalizePath } from "vite";
|
||||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||||
import svgr from "vite-plugin-svgr";
|
import svgr from "vite-plugin-svgr";
|
||||||
import topLevelAwait from "vite-plugin-top-level-await";
|
import topLevelAwait from "vite-plugin-top-level-await";
|
||||||
@@ -42,15 +42,12 @@ export default defineConfig(async () => {
|
|||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
outDir: "../../dist/apps/yaak-client",
|
outDir: "../../dist/apps/yaak-client",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
rolldownOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
// Make chunk names readable
|
// Make chunk names readable
|
||||||
chunkFileNames: "assets/chunk-[name]-[hash].js",
|
chunkFileNames: "assets/chunk-[name]-[hash].js",
|
||||||
entryFileNames: "assets/entry-[name]-[hash].js",
|
entryFileNames: "assets/entry-[name]-[hash].js",
|
||||||
assetFileNames: "assets/asset-[name]-[hash][extname]",
|
assetFileNames: "assets/asset-[name]-[hash][extname]",
|
||||||
// Vite-Plus/Rolldown 0.1.20 can emit a stale style-mod export when
|
|
||||||
// top-level var rewriting combines with OXC minification.
|
|
||||||
topLevelVar: false,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
|
|||||||
import type { TreeNode } from "@yaakapp-internal/ui";
|
import type { TreeNode } from "@yaakapp-internal/ui";
|
||||||
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { atomFamily } from "jotai-family";
|
import { atomFamily } from "jotai/utils";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { httpExchangesAtom } from "../lib/store";
|
import { httpExchangesAtom } from "../lib/store";
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@tauri-apps/api": "^2.11.0",
|
"@tauri-apps/api": "^2.9.1",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@yaakapp-internal/model-store": "^1.0.0",
|
"@yaakapp-internal/model-store": "^1.0.0",
|
||||||
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
"@yaakapp-internal/proxy-lib": "^1.0.0",
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
"@yaakapp-internal/ui": "^1.0.0",
|
"@yaakapp-internal/ui": "^1.0.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"jotai": "^2.18.0",
|
"jotai": "^2.18.0",
|
||||||
"jotai-family": "^1.0.1",
|
|
||||||
"motion": "^12.4.7",
|
"motion": "^12.4.7",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ async fn build_plugin_reply(
|
|||||||
let names = cookie_jar
|
let names = cookie_jar
|
||||||
.cookies
|
.cookies
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| c.name)
|
.filter_map(|c| parse_cookie_name_value(&c.raw_cookie).map(|(name, _)| name))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
||||||
@@ -531,6 +531,12 @@ 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())
|
||||||
|
|||||||
@@ -7,6 +7,34 @@ 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");
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ pub struct ActionMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_hotkey(mac: &str, other: &str) -> Option<String> {
|
fn default_hotkey(mac: &str, other: &str) -> Option<String> {
|
||||||
if cfg!(target_os = "macos") { Some(mac.into()) } else { Some(other.into()) }
|
if cfg!(target_os = "macos") {
|
||||||
|
Some(mac.into())
|
||||||
|
} else {
|
||||||
|
Some(other.into())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All global actions with their metadata, used by `list_actions` RPC.
|
/// All global actions with their metadata, used by `list_actions` RPC.
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ pub struct ProxyQueryManager {
|
|||||||
impl ProxyQueryManager {
|
impl ProxyQueryManager {
|
||||||
pub fn new(db_path: &Path) -> Self {
|
pub fn new(db_path: &Path) -> Self {
|
||||||
let manager = SqliteConnectionManager::file(db_path);
|
let manager = SqliteConnectionManager::file(db_path);
|
||||||
let pool =
|
let pool = Pool::builder()
|
||||||
Pool::builder().max_size(5).build(manager).expect("Failed to create proxy DB pool");
|
.max_size(5)
|
||||||
|
.build(manager)
|
||||||
|
.expect("Failed to create proxy DB pool");
|
||||||
run_migrations(&pool, &MIGRATIONS).expect("Failed to run proxy DB migrations");
|
run_migrations(&pool, &MIGRATIONS).expect("Failed to run proxy DB migrations");
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ pub mod actions;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
|
||||||
use crate::db::ProxyQueryManager;
|
|
||||||
use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
|
|
||||||
use log::warn;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use log::warn;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_database::{ModelChangeEvent, UpdateSource};
|
use yaak_database::{ModelChangeEvent, UpdateSource};
|
||||||
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
|
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
|
||||||
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
|
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
|
||||||
|
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
||||||
|
use crate::db::ProxyQueryManager;
|
||||||
|
use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
|
||||||
|
|
||||||
// -- Context --
|
// -- Context --
|
||||||
|
|
||||||
@@ -25,7 +25,11 @@ pub struct ProxyCtx {
|
|||||||
|
|
||||||
impl ProxyCtx {
|
impl ProxyCtx {
|
||||||
pub fn new(db_path: &Path, events: RpcEventEmitter) -> Self {
|
pub fn new(db_path: &Path, events: RpcEventEmitter) -> Self {
|
||||||
Self { handle: Mutex::new(None), db: ProxyQueryManager::new(db_path), events }
|
Self {
|
||||||
|
handle: Mutex::new(None),
|
||||||
|
db: ProxyQueryManager::new(db_path),
|
||||||
|
events,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,15 +88,17 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
|
|||||||
match invocation {
|
match invocation {
|
||||||
ActionInvocation::Global { action } => match action {
|
ActionInvocation::Global { action } => match action {
|
||||||
GlobalAction::ProxyStart => {
|
GlobalAction::ProxyStart => {
|
||||||
let mut handle =
|
let mut handle = ctx
|
||||||
ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
.handle
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
||||||
|
|
||||||
if handle.is_some() {
|
if handle.is_some() {
|
||||||
return Ok(true); // already running
|
return Ok(true); // already running
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut proxy_handle =
|
let mut proxy_handle = yaak_proxy::start_proxy(9090)
|
||||||
yaak_proxy::start_proxy(9090).map_err(|e| RpcError { message: e })?;
|
.map_err(|e| RpcError { message: e })?;
|
||||||
|
|
||||||
if let Some(event_rx) = proxy_handle.take_event_rx() {
|
if let Some(event_rx) = proxy_handle.take_event_rx() {
|
||||||
let db = ctx.db.clone();
|
let db = ctx.db.clone();
|
||||||
@@ -101,43 +107,49 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
|
|||||||
}
|
}
|
||||||
|
|
||||||
*handle = Some(proxy_handle);
|
*handle = Some(proxy_handle);
|
||||||
ctx.events
|
ctx.events.emit("proxy_state_changed", &ProxyStatePayload {
|
||||||
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running });
|
state: ProxyState::Running,
|
||||||
|
});
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
GlobalAction::ProxyStop => {
|
GlobalAction::ProxyStop => {
|
||||||
let mut handle =
|
let mut handle = ctx
|
||||||
ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
.handle
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
||||||
handle.take();
|
handle.take();
|
||||||
ctx.events
|
ctx.events.emit("proxy_state_changed", &ProxyStatePayload {
|
||||||
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped });
|
state: ProxyState::Stopped,
|
||||||
|
});
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_proxy_state(
|
fn get_proxy_state(ctx: &ProxyCtx, _req: GetProxyStateRequest) -> Result<GetProxyStateResponse, RpcError> {
|
||||||
ctx: &ProxyCtx,
|
let handle = ctx
|
||||||
_req: GetProxyStateRequest,
|
.handle
|
||||||
) -> Result<GetProxyStateResponse, RpcError> {
|
.lock()
|
||||||
let handle = ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
|
||||||
let state = if handle.is_some() { ProxyState::Running } else { ProxyState::Stopped };
|
let state = if handle.is_some() {
|
||||||
|
ProxyState::Running
|
||||||
|
} else {
|
||||||
|
ProxyState::Stopped
|
||||||
|
};
|
||||||
Ok(GetProxyStateResponse { state })
|
Ok(GetProxyStateResponse { state })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_actions(
|
fn list_actions(_ctx: &ProxyCtx, _req: ListActionsRequest) -> Result<ListActionsResponse, RpcError> {
|
||||||
_ctx: &ProxyCtx,
|
Ok(ListActionsResponse {
|
||||||
_req: ListActionsRequest,
|
actions: crate::actions::all_global_actions(),
|
||||||
) -> Result<ListActionsResponse, RpcError> {
|
})
|
||||||
Ok(ListActionsResponse { actions: crate::actions::all_global_actions() })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResponse, RpcError> {
|
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResponse, RpcError> {
|
||||||
ctx.db.with_conn(|db| {
|
ctx.db.with_conn(|db| {
|
||||||
Ok(ListModelsResponse {
|
Ok(ListModelsResponse {
|
||||||
http_exchanges: db
|
http_exchanges: db.find_all::<HttpExchange>()
|
||||||
.find_all::<HttpExchange>()
|
|
||||||
.map_err(|e| RpcError { message: e.to_string() })?,
|
.map_err(|e| RpcError { message: e.to_string() })?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -145,35 +157,28 @@ fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResp
|
|||||||
|
|
||||||
// -- Event loop --
|
// -- Event loop --
|
||||||
|
|
||||||
fn run_event_loop(
|
fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManager, events: RpcEventEmitter) {
|
||||||
rx: std::sync::mpsc::Receiver<ProxyEvent>,
|
|
||||||
db: ProxyQueryManager,
|
|
||||||
events: RpcEventEmitter,
|
|
||||||
) {
|
|
||||||
let mut in_flight: HashMap<u64, CapturedRequest> = HashMap::new();
|
let mut in_flight: HashMap<u64, CapturedRequest> = HashMap::new();
|
||||||
|
|
||||||
while let Ok(event) = rx.recv() {
|
while let Ok(event) = rx.recv() {
|
||||||
match event {
|
match event {
|
||||||
ProxyEvent::RequestStart { id, method, url, http_version } => {
|
ProxyEvent::RequestStart { id, method, url, http_version } => {
|
||||||
in_flight.insert(
|
in_flight.insert(id, CapturedRequest {
|
||||||
id,
|
id,
|
||||||
CapturedRequest {
|
method,
|
||||||
id,
|
url,
|
||||||
method,
|
http_version,
|
||||||
url,
|
status: None,
|
||||||
http_version,
|
elapsed_ms: None,
|
||||||
status: None,
|
remote_http_version: None,
|
||||||
elapsed_ms: None,
|
request_headers: vec![],
|
||||||
remote_http_version: None,
|
request_body: None,
|
||||||
request_headers: vec![],
|
response_headers: vec![],
|
||||||
request_body: None,
|
response_body: None,
|
||||||
response_headers: vec![],
|
response_body_size: 0,
|
||||||
response_body: None,
|
state: RequestState::Sending,
|
||||||
response_body_size: 0,
|
error: None,
|
||||||
state: RequestState::Sending,
|
});
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ProxyEvent::RequestHeader { id, name, value } => {
|
ProxyEvent::RequestHeader { id, name, value } => {
|
||||||
if let Some(r) = in_flight.get_mut(&id) {
|
if let Some(r) = in_flight.get_mut(&id) {
|
||||||
@@ -225,30 +230,28 @@ fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedReq
|
|||||||
let entry = HttpExchange {
|
let entry = HttpExchange {
|
||||||
url: r.url.clone(),
|
url: r.url.clone(),
|
||||||
method: r.method.clone(),
|
method: r.method.clone(),
|
||||||
req_headers: r
|
req_headers: r.request_headers.iter()
|
||||||
.request_headers
|
|
||||||
.iter()
|
|
||||||
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
|
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
|
||||||
.collect(),
|
.collect(),
|
||||||
req_body: r.request_body.clone(),
|
req_body: r.request_body.clone(),
|
||||||
res_status: r.status.map(|s| s as i32),
|
res_status: r.status.map(|s| s as i32),
|
||||||
res_headers: r
|
res_headers: r.response_headers.iter()
|
||||||
.response_headers
|
|
||||||
.iter()
|
|
||||||
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
|
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
|
||||||
.collect(),
|
.collect(),
|
||||||
res_body: r.response_body.clone(),
|
res_body: r.response_body.clone(),
|
||||||
error: r.error.clone(),
|
error: r.error.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
db.with_conn(|ctx| match ctx.upsert(&entry, &UpdateSource::Background) {
|
db.with_conn(|ctx| {
|
||||||
Ok((saved, created)) => {
|
match ctx.upsert(&entry, &UpdateSource::Background) {
|
||||||
events.emit(
|
Ok((saved, created)) => {
|
||||||
"model_write",
|
events.emit("model_write", &ModelPayload {
|
||||||
&ModelPayload { model: saved, change: ModelChangeEvent::Upsert { created } },
|
model: saved,
|
||||||
);
|
change: ModelChangeEvent::Upsert { created },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => warn!("Failed to write proxy entry: {e}"),
|
||||||
}
|
}
|
||||||
Err(e) => warn!("Failed to write proxy entry: {e}"),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ use rusqlite::Row;
|
|||||||
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};
|
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_database::{
|
use yaak_database::{ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id, upsert_date};
|
||||||
ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id,
|
|
||||||
upsert_date,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ updater = []
|
|||||||
license = ["yaak-license"]
|
license = ["yaak-license"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.6.1", features = [] }
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
|
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
|
||||||
@@ -30,7 +30,6 @@ eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client
|
|||||||
http = { version = "1.2.0", default-features = false }
|
http = { version = "1.2.0", default-features = false }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
md5 = "0.8.0"
|
md5 = "0.8.0"
|
||||||
notify = "8.0.0"
|
|
||||||
pretty_graphql = "0.2"
|
pretty_graphql = "0.2"
|
||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
r2d2_sqlite = "0.25.0"
|
r2d2_sqlite = "0.25.0"
|
||||||
@@ -50,15 +49,15 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json = { workspace = true, features = ["raw_value"] }
|
serde_json = { workspace = true, features = ["raw_value"] }
|
||||||
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
|
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
|
||||||
tauri-plugin-clipboard-manager = "2.3.2"
|
tauri-plugin-clipboard-manager = "2.3.2"
|
||||||
tauri-plugin-deep-link = "2.4.9"
|
tauri-plugin-deep-link = "2.4.5"
|
||||||
tauri-plugin-dialog = { workspace = true }
|
tauri-plugin-dialog = { workspace = true }
|
||||||
tauri-plugin-fs = "2.5.1"
|
tauri-plugin-fs = "2.4.4"
|
||||||
tauri-plugin-log = { version = "2.8.0", features = ["colored"] }
|
tauri-plugin-log = { version = "2.7.1", features = ["colored"] }
|
||||||
tauri-plugin-opener = "2.5.4"
|
tauri-plugin-opener = "2.5.2"
|
||||||
tauri-plugin-os = "2.3.2"
|
tauri-plugin-os = "2.3.2"
|
||||||
tauri-plugin-shell = { workspace = true }
|
tauri-plugin-shell = { workspace = true }
|
||||||
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
|
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
|
||||||
tauri-plugin-updater = "2.10.1"
|
tauri-plugin-updater = "2.9.0"
|
||||||
tauri-plugin-window-state = "2.4.1"
|
tauri-plugin-window-state = "2.4.1"
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync"] }
|
tokio = { workspace = true, features = ["sync"] }
|
||||||
|
|||||||
-2
@@ -12,8 +12,6 @@ export type UpdateResponseAction = "install" | "skip";
|
|||||||
|
|
||||||
export type WatchResult = { unlistenEvent: string, };
|
export type WatchResult = { unlistenEvent: string, };
|
||||||
|
|
||||||
export type GitWatchResult = { unlistenEvent: string, };
|
|
||||||
|
|
||||||
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
||||||
|
|
||||||
export type YaakNotificationAction = { label: string, url: string, };
|
export type YaakNotificationAction = { label: string, url: string, };
|
||||||
|
|||||||
@@ -3,18 +3,14 @@
|
|||||||
//! This module provides the Tauri commands for git functionality.
|
//! This module provides the Tauri commands for git functionality.
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::git_watcher::{GitWatchResult, watch_git_worktree_status};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::ipc::Channel;
|
use tauri::command;
|
||||||
use tauri::{AppHandle, Runtime, command};
|
|
||||||
use yaak_git::{
|
use yaak_git::{
|
||||||
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
|
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
||||||
GitStatusSummary, GitWorktreeStatus, PullResult, PushResult, git_add, git_add_credential,
|
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
||||||
git_add_remote, git_branch_info, git_checkout_branch, git_clone, git_commit, git_create_branch,
|
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
|
||||||
git_delete_branch, git_delete_remote_branch, git_fetch_all, git_file_diff_for_commit, git_init,
|
git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
|
||||||
git_log, git_log_for_file, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge,
|
git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
|
||||||
git_push, git_remotes, git_rename_branch, git_reset_changes, git_restore,
|
|
||||||
git_restore_file_from_commit, git_rm_remote, git_status, git_unstage, git_worktree_status,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||||
@@ -58,44 +54,11 @@ pub async fn cmd_git_status(dir: &Path) -> Result<GitStatusSummary> {
|
|||||||
Ok(git_status(dir)?)
|
Ok(git_status(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn cmd_git_branch_info(dir: &Path) -> Result<GitBranchInfo> {
|
|
||||||
Ok(git_branch_info(dir)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn cmd_git_worktree_status(dir: &Path) -> Result<GitWorktreeStatus> {
|
|
||||||
Ok(git_worktree_status(dir)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn cmd_git_watch_worktree_status<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
dir: &Path,
|
|
||||||
channel: Channel<GitWorktreeStatus>,
|
|
||||||
) -> Result<GitWatchResult> {
|
|
||||||
watch_git_worktree_status(app_handle, dir, channel).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {
|
pub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {
|
||||||
Ok(git_log(dir)?)
|
Ok(git_log(dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn cmd_git_log_for_file(dir: &Path, rela_path: PathBuf) -> Result<Vec<GitCommit>> {
|
|
||||||
Ok(git_log_for_file(dir, &rela_path)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn cmd_git_file_diff_for_commit(
|
|
||||||
dir: &Path,
|
|
||||||
commit_oid: &str,
|
|
||||||
rela_path: PathBuf,
|
|
||||||
) -> Result<GitFileDiff> {
|
|
||||||
Ok(git_file_diff_for_commit(dir, commit_oid, &rela_path)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
||||||
Ok(git_init(dir)?)
|
Ok(git_init(dir)?)
|
||||||
@@ -161,23 +124,6 @@ pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
|
|||||||
Ok(git_reset_changes(dir).await?)
|
Ok(git_reset_changes(dir).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn cmd_git_restore_files(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
|
||||||
for path in rela_paths {
|
|
||||||
git_restore(dir, &path)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
|
||||||
pub async fn cmd_git_restore_file_from_commit(
|
|
||||||
dir: &Path,
|
|
||||||
commit_oid: &str,
|
|
||||||
rela_path: PathBuf,
|
|
||||||
) -> Result<()> {
|
|
||||||
Ok(git_restore_file_from_commit(dir, commit_oid, &rela_path)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn cmd_git_add_credential(
|
pub async fn cmd_git_add_credential(
|
||||||
remote_url: &str,
|
remote_url: &str,
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
use crate::error::{Error, Result};
|
|
||||||
use chrono::Utc;
|
|
||||||
use log::{debug, error, warn};
|
|
||||||
use notify::Watcher;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tauri::ipc::Channel;
|
|
||||||
use tauri::{AppHandle, Listener, Runtime};
|
|
||||||
use tokio::select;
|
|
||||||
use tokio::sync::watch;
|
|
||||||
use tokio::time::sleep;
|
|
||||||
use ts_rs::TS;
|
|
||||||
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
|
|
||||||
|
|
||||||
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "index.ts")]
|
|
||||||
pub(crate) struct GitWatchResult {
|
|
||||||
unlisten_event: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn watch_git_worktree_status<R: Runtime>(
|
|
||||||
app_handle: AppHandle<R>,
|
|
||||||
dir: &Path,
|
|
||||||
channel: Channel<GitWorktreeStatus>,
|
|
||||||
) -> Result<GitWatchResult> {
|
|
||||||
let paths = git_repository_paths(dir)?;
|
|
||||||
let repo_dir = dir.to_path_buf();
|
|
||||||
let workdir = paths.workdir;
|
|
||||||
let gitdir = paths.gitdir;
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
|
|
||||||
let mut watcher = notify::recommended_watcher(tx)
|
|
||||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?;
|
|
||||||
|
|
||||||
watcher
|
|
||||||
.watch(&workdir, notify::RecursiveMode::Recursive)
|
|
||||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?;
|
|
||||||
if gitdir != workdir {
|
|
||||||
watcher
|
|
||||||
.watch(&gitdir, notify::RecursiveMode::Recursive)
|
|
||||||
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
for res in rx {
|
|
||||||
if async_tx.blocking_send(res).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let (cancel_tx, cancel_rx) = watch::channel(());
|
|
||||||
let mut cancel_rx = cancel_rx;
|
|
||||||
send_worktree_status(&repo_dir, &channel);
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let _watcher = watcher;
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
Some(event_res) = async_rx.recv() => {
|
|
||||||
handle_git_watch_event(
|
|
||||||
event_res,
|
|
||||||
&mut async_rx,
|
|
||||||
&repo_dir,
|
|
||||||
&workdir,
|
|
||||||
&gitdir,
|
|
||||||
&channel,
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
_ = cancel_rx.changed() => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let app_handle_inner = app_handle.clone();
|
|
||||||
let unlisten_event = format!("git-watch-unlisten-{}", Utc::now().timestamp_millis());
|
|
||||||
app_handle.listen_any(unlisten_event.clone(), move |event| {
|
|
||||||
app_handle_inner.unlisten(event.id());
|
|
||||||
if let Err(e) = cancel_tx.send(()) {
|
|
||||||
warn!("Failed to send git watch cancel signal {e:?}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(GitWatchResult { unlisten_event })
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_git_watch_event(
|
|
||||||
event_res: notify::Result<notify::Event>,
|
|
||||||
async_rx: &mut tokio::sync::mpsc::Receiver<notify::Result<notify::Event>>,
|
|
||||||
repo_dir: &Path,
|
|
||||||
workdir: &Path,
|
|
||||||
gitdir: &Path,
|
|
||||||
channel: &Channel<GitWorktreeStatus>,
|
|
||||||
) {
|
|
||||||
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send_worktree_status(repo_dir, channel);
|
|
||||||
|
|
||||||
let settle_window = sleep(GIT_STATUS_COALESCE_WINDOW);
|
|
||||||
tokio::pin!(settle_window);
|
|
||||||
loop {
|
|
||||||
select! {
|
|
||||||
Some(event_res) = async_rx.recv() => {
|
|
||||||
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir);
|
|
||||||
}
|
|
||||||
_ = &mut settle_window => {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
send_worktree_status(repo_dir, channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_relevant_git_watch_event(
|
|
||||||
event_res: notify::Result<notify::Event>,
|
|
||||||
repo_dir: &Path,
|
|
||||||
workdir: &Path,
|
|
||||||
gitdir: &Path,
|
|
||||||
) -> bool {
|
|
||||||
let event = match event_res {
|
|
||||||
Ok(event) => event,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Git watch error: {:?}", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for path in event.paths {
|
|
||||||
if path.strip_prefix(gitdir).is_ok() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(rela_path) = path.strip_prefix(workdir) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
match git_path_is_ignored(repo_dir, rela_path) {
|
|
||||||
Ok(true) => {}
|
|
||||||
Ok(false) => return true,
|
|
||||||
Err(e) => {
|
|
||||||
debug!("Failed to check Git ignore status for {:?}: {e}", rela_path);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_worktree_status(repo_dir: &Path, channel: &Channel<GitWorktreeStatus>) {
|
|
||||||
match git_worktree_status(repo_dir) {
|
|
||||||
Ok(status) => {
|
|
||||||
if let Err(e) = channel.send(status) {
|
|
||||||
warn!("Failed to send git worktree status: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to get git worktree status: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -67,7 +67,6 @@ mod commands;
|
|||||||
mod encoding;
|
mod encoding;
|
||||||
mod error;
|
mod error;
|
||||||
mod git_ext;
|
mod git_ext;
|
||||||
mod git_watcher;
|
|
||||||
mod grpc;
|
mod grpc;
|
||||||
mod history;
|
mod history;
|
||||||
mod http_request;
|
mod http_request;
|
||||||
@@ -122,7 +121,9 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commands for development
|
// Commands for development
|
||||||
"dev.reset_size" => webview_window.set_size(LogicalSize::new(1100.0, 600.0)).unwrap(),
|
"dev.reset_size" => webview_window
|
||||||
|
.set_size(LogicalSize::new(1100.0, 600.0))
|
||||||
|
.unwrap(),
|
||||||
"dev.reset_size_16x9" => {
|
"dev.reset_size_16x9" => {
|
||||||
let width = webview_window.outer_size().unwrap().width;
|
let width = webview_window.outer_size().unwrap().width;
|
||||||
let height = width * 9 / 16;
|
let height = width * 9 / 16;
|
||||||
@@ -1505,6 +1506,7 @@ async fn cmd_reload_plugins<R: Runtime>(
|
|||||||
Ok(errors)
|
Ok(errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cmd_plugin_info<R: Runtime>(
|
async fn cmd_plugin_info<R: Runtime>(
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -1577,14 +1579,7 @@ async fn cmd_new_child_window(
|
|||||||
inner_size: (f64, f64),
|
inner_size: (f64, f64),
|
||||||
) -> YaakResult<()> {
|
) -> YaakResult<()> {
|
||||||
let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar;
|
let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar;
|
||||||
let win = yaak_window::window::create_child_window(
|
let win = yaak_window::window::create_child_window(&parent_window, url, label, title, inner_size, use_native_titlebar)?;
|
||||||
&parent_window,
|
|
||||||
url,
|
|
||||||
label,
|
|
||||||
title,
|
|
||||||
inner_size,
|
|
||||||
use_native_titlebar,
|
|
||||||
)?;
|
|
||||||
setup_window_menu(&win)?;
|
setup_window_menu(&win)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1836,13 +1831,8 @@ pub fn run() {
|
|||||||
git_ext::cmd_git_delete_remote_branch,
|
git_ext::cmd_git_delete_remote_branch,
|
||||||
git_ext::cmd_git_merge_branch,
|
git_ext::cmd_git_merge_branch,
|
||||||
git_ext::cmd_git_rename_branch,
|
git_ext::cmd_git_rename_branch,
|
||||||
git_ext::cmd_git_branch_info,
|
|
||||||
git_ext::cmd_git_status,
|
git_ext::cmd_git_status,
|
||||||
git_ext::cmd_git_worktree_status,
|
|
||||||
git_ext::cmd_git_watch_worktree_status,
|
|
||||||
git_ext::cmd_git_log,
|
git_ext::cmd_git_log,
|
||||||
git_ext::cmd_git_log_for_file,
|
|
||||||
git_ext::cmd_git_file_diff_for_commit,
|
|
||||||
git_ext::cmd_git_initialize,
|
git_ext::cmd_git_initialize,
|
||||||
git_ext::cmd_git_clone,
|
git_ext::cmd_git_clone,
|
||||||
git_ext::cmd_git_commit,
|
git_ext::cmd_git_commit,
|
||||||
@@ -1854,8 +1844,6 @@ pub fn run() {
|
|||||||
git_ext::cmd_git_add,
|
git_ext::cmd_git_add,
|
||||||
git_ext::cmd_git_unstage,
|
git_ext::cmd_git_unstage,
|
||||||
git_ext::cmd_git_reset_changes,
|
git_ext::cmd_git_reset_changes,
|
||||||
git_ext::cmd_git_restore_files,
|
|
||||||
git_ext::cmd_git_restore_file_from_commit,
|
|
||||||
git_ext::cmd_git_add_credential,
|
git_ext::cmd_git_add_credential,
|
||||||
git_ext::cmd_git_remotes,
|
git_ext::cmd_git_remotes,
|
||||||
git_ext::cmd_git_add_remote,
|
git_ext::cmd_git_add_remote,
|
||||||
@@ -1882,11 +1870,7 @@ pub fn run() {
|
|||||||
match event {
|
match event {
|
||||||
RunEvent::Ready => {
|
RunEvent::Ready => {
|
||||||
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
|
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
|
||||||
if let Ok(win) = yaak_window::window::create_main_window(
|
if let Ok(win) = yaak_window::window::create_main_window(app_handle, "/", use_native_titlebar) {
|
||||||
app_handle,
|
|
||||||
"/",
|
|
||||||
use_native_titlebar,
|
|
||||||
) {
|
|
||||||
let _ = setup_window_menu(&win);
|
let _ = setup_window_menu(&win);
|
||||||
}
|
}
|
||||||
let h = app_handle.clone();
|
let h = app_handle.clone();
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ use crate::http_request::send_http_request_with_context;
|
|||||||
use crate::models_ext::BlobManagerExt;
|
use crate::models_ext::BlobManagerExt;
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
use crate::render::{render_grpc_request, render_http_request, render_json_value};
|
use crate::render::{render_grpc_request, render_http_request, render_json_value};
|
||||||
|
use yaak_window::window::{CreateWindowConfig, create_window};
|
||||||
use crate::{
|
use crate::{
|
||||||
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
||||||
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};
|
||||||
@@ -34,7 +36,6 @@ use yaak_plugins::plugin_handle::PluginHandle;
|
|||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||||
use yaak_window::window::{CreateWindowConfig, create_window};
|
|
||||||
|
|
||||||
pub(crate) async fn handle_plugin_event<R: Runtime>(
|
pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||||
app_handle: &AppHandle<R>,
|
app_handle: &AppHandle<R>,
|
||||||
@@ -408,7 +409,11 @@ 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.cookies.into_iter().map(|c| c.name).collect(),
|
Some(j) => j
|
||||||
|
.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,
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ async fn start_integrated_update<R: Runtime>(
|
|||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
update: &Update,
|
update: &Update,
|
||||||
) -> Result<UpdateResponseAction> {
|
) -> Result<UpdateResponseAction> {
|
||||||
let download_path = ensure_download_dir(window)?.join(download_file_name(update));
|
let download_path = ensure_download_path(window, update)?;
|
||||||
debug!("Download path: {}", download_path.display());
|
debug!("Download path: {}", download_path.display());
|
||||||
let downloaded = download_path.exists();
|
let downloaded = download_path.exists();
|
||||||
let ack_wait = Duration::from_secs(3);
|
let ack_wait = Duration::from_secs(3);
|
||||||
@@ -345,7 +345,7 @@ pub async fn download_update_idempotent<R: Runtime>(
|
|||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
update: &Update,
|
update: &Update,
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
let dl_path = ensure_download_dir(window)?.join(download_file_name(update));
|
let dl_path = ensure_download_path(window, update)?;
|
||||||
|
|
||||||
if dl_path.exists() {
|
if dl_path.exists() {
|
||||||
info!("{} already downloaded to {}", update.version, dl_path.display());
|
info!("{} already downloaded to {}", update.version, dl_path.display());
|
||||||
@@ -385,36 +385,21 @@ pub async fn install_update_maybe_download<R: Runtime>(
|
|||||||
let dl_path = download_update_idempotent(window, update).await?;
|
let dl_path = download_update_idempotent(window, update).await?;
|
||||||
let update_bytes = std::fs::read(&dl_path)?;
|
let update_bytes = std::fs::read(&dl_path)?;
|
||||||
update.install(update_bytes.as_slice())?;
|
update.install(update_bytes.as_slice())?;
|
||||||
delete_download_dir(window);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
pub fn ensure_download_path<R: Runtime>(
|
||||||
Ok(window.path().app_cache_dir()?.join("updates"))
|
window: &WebviewWindow<R>,
|
||||||
}
|
update: &Update,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
pub fn ensure_download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
// Ensure dir exists
|
||||||
let base_dir = download_dir(window)?;
|
let base_dir = window.path().app_cache_dir()?.join("updates");
|
||||||
std::fs::create_dir_all(&base_dir)?;
|
std::fs::create_dir_all(&base_dir)?;
|
||||||
Ok(base_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn download_file_name(update: &Update) -> String {
|
// Generate name based on signature
|
||||||
let sig_digest = md5::compute(&update.signature);
|
let sig_digest = md5::compute(&update.signature);
|
||||||
format!("yaak-{}-{:x}", update.version, sig_digest)
|
let name = format!("yaak-{}-{:x}", update.version, sig_digest);
|
||||||
}
|
let dl_path = base_dir.join(name);
|
||||||
|
|
||||||
pub fn delete_download_dir<R: Runtime>(window: &WebviewWindow<R>) {
|
Ok(dl_path)
|
||||||
let base_dir = match download_dir(window) {
|
|
||||||
Ok(dir) => dir,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to locate update downloads dir: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match std::fs::remove_dir_all(&base_dir) {
|
|
||||||
Ok(()) => info!("Removed update downloads dir {}", base_dir.display()),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(e) => warn!("Failed to remove update downloads dir {}: {}", base_dir.display(), e),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,8 +134,7 @@ 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 resolved_settings =
|
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
|
||||||
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)?;
|
||||||
@@ -248,18 +247,11 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cookie_jar = match (
|
|
||||||
resolved_settings.send_cookies.value || resolved_settings.store_cookies.value,
|
|
||||||
cookie_jar_id,
|
|
||||||
) {
|
|
||||||
(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
|
// Add cookies to WS HTTP Upgrade
|
||||||
if let (true, Some(store)) = (resolved_settings.send_cookies.value, cookie_store.as_ref()) {
|
if let Some(id) = cookie_jar_id {
|
||||||
|
let cookie_jar = app_handle.db().get_cookie_jar(&id)?;
|
||||||
|
let store = CookieStore::from_cookies(cookie_jar.cookies);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -297,7 +289,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
url.as_str(),
|
url.as_str(),
|
||||||
headers,
|
headers,
|
||||||
receive_tx,
|
receive_tx,
|
||||||
resolved_settings.validate_certificates.value,
|
workspace.setting_validate_certificates,
|
||||||
client_cert,
|
client_cert,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -336,23 +328,6 @@ 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,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ name = "tauri_app_proxy_lib"
|
|||||||
crate-type = ["staticlib", "cdylib", "lib"]
|
crate-type = ["staticlib", "cdylib", "lib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.6.1", features = [] }
|
tauri-build = { version = "2.5.3", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
|
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
|
||||||
|
use tauri::Runtime;
|
||||||
use yaak_proxy_lib::ProxyCtx;
|
use yaak_proxy_lib::ProxyCtx;
|
||||||
use yaak_rpc::{RpcEventEmitter, RpcRouter};
|
use yaak_rpc::{RpcEventEmitter, RpcRouter};
|
||||||
use yaak_window::window::CreateWindowConfig;
|
use yaak_window::window::CreateWindowConfig;
|
||||||
|
|||||||
@@ -109,16 +109,19 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64,
|
|||||||
// we've modified it. This avoids the height growing on repeated calls.
|
// we've modified it. This avoids the height growing on repeated calls.
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
static DEFAULT_TITLEBAR_HEIGHT: OnceLock<f64> = OnceLock::new();
|
static DEFAULT_TITLEBAR_HEIGHT: OnceLock<f64> = OnceLock::new();
|
||||||
let default_height = *DEFAULT_TITLEBAR_HEIGHT
|
let default_height =
|
||||||
.get_or_init(|| NSView::frame(title_bar_container_view).size.height);
|
*DEFAULT_TITLEBAR_HEIGHT.get_or_init(|| NSView::frame(title_bar_container_view).size.height);
|
||||||
|
|
||||||
// On pre-Tahoe, button_height + y is larger than the default title bar
|
// On pre-Tahoe, button_height + y is larger than the default title bar
|
||||||
// height, so the resize works as before. On Tahoe (26+), the default is
|
// height, so the resize works as before. On Tahoe (26+), the default is
|
||||||
// already 32px and button_height + y = 32, so nothing changes. In that
|
// already 32px and button_height + y = 32, so nothing changes. In that
|
||||||
// case, add TITLEBAR_EXTRA_HEIGHT extra pixels to push the buttons down.
|
// case, add TITLEBAR_EXTRA_HEIGHT extra pixels to push the buttons down.
|
||||||
let desired = button_height + y;
|
let desired = button_height + y;
|
||||||
let title_bar_frame_height =
|
let title_bar_frame_height = if desired > default_height {
|
||||||
if desired > default_height { desired } else { default_height + TITLEBAR_EXTRA_HEIGHT };
|
desired
|
||||||
|
} else {
|
||||||
|
default_height + TITLEBAR_EXTRA_HEIGHT
|
||||||
|
};
|
||||||
|
|
||||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||||
title_bar_rect.size.height = title_bar_frame_height;
|
title_bar_rect.size.height = title_bar_frame_height;
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ impl<'a> DbContext<'a> {
|
|||||||
.cond_where(Expr::col(col).eq(value))
|
.cond_where(Expr::col(col).eq(value))
|
||||||
.build_rusqlite(SqliteQueryBuilder);
|
.build_rusqlite(SqliteQueryBuilder);
|
||||||
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
|
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
|
||||||
stmt.query_row(&*params.as_params(), M::from_row).ok()
|
stmt.query_row(&*params.as_params(), M::from_row)
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_all<M>(&self) -> Result<Vec<M>>
|
pub fn find_all<M>(&self) -> Result<Vec<M>>
|
||||||
@@ -125,8 +126,9 @@ impl<'a> DbContext<'a> {
|
|||||||
let other_values = model.clone().insert_values(source)?;
|
let other_values = model.clone().insert_values(source)?;
|
||||||
|
|
||||||
let mut column_vec = vec![id_iden.clone()];
|
let mut column_vec = vec![id_iden.clone()];
|
||||||
let mut value_vec =
|
let mut value_vec = vec![
|
||||||
vec![if id_val.is_empty() { M::generate_id().into() } else { id_val.into() }];
|
if id_val.is_empty() { M::generate_id().into() } else { id_val.into() },
|
||||||
|
];
|
||||||
|
|
||||||
for (col, val) in other_values {
|
for (col, val) in other_values {
|
||||||
value_vec.push(val.into());
|
value_vec.push(val.into());
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Re
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sql = entry.as_file().unwrap().contents_utf8().expect("Failed to read migration file");
|
let sql =
|
||||||
|
entry.as_file().unwrap().contents_utf8().expect("Failed to read migration file");
|
||||||
|
|
||||||
info!("Applying migration: {}", filename);
|
info!("Applying migration: {}", filename);
|
||||||
let conn = pool.get()?;
|
let conn = pool.get()?;
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ pub fn generate_id() -> String {
|
|||||||
|
|
||||||
pub fn generate_id_of_length(n: usize) -> String {
|
pub fn generate_id_of_length(n: usize) -> String {
|
||||||
let alphabet: [char; 57] = [
|
let alphabet: [char; 57] = [
|
||||||
'2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
|
'2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
|
||||||
'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C',
|
'j', 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A',
|
||||||
'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
|
'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T',
|
||||||
'X', 'Y', 'Z',
|
'U', 'V', 'W', 'X', 'Y', 'Z',
|
||||||
];
|
];
|
||||||
|
|
||||||
nanoid!(n, &alphabet)
|
nanoid!(n, &alphabet)
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
/// Type-erased handler function: takes context + JSON payload, returns JSON or error.
|
/// Type-erased handler function: takes context + JSON payload, returns JSON or error.
|
||||||
type HandlerFn<Ctx> =
|
type HandlerFn<Ctx> = Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + Send + Sync>;
|
||||||
Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + Send + Sync>;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct RpcError {
|
pub struct RpcError {
|
||||||
@@ -58,7 +57,9 @@ pub struct RpcRouter<Ctx> {
|
|||||||
|
|
||||||
impl<Ctx> RpcRouter<Ctx> {
|
impl<Ctx> RpcRouter<Ctx> {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { handlers: HashMap::new() }
|
Self {
|
||||||
|
handlers: HashMap::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a handler for a command name.
|
/// Register a handler for a command name.
|
||||||
@@ -76,15 +77,23 @@ impl<Ctx> RpcRouter<Ctx> {
|
|||||||
) -> Result<serde_json::Value, RpcError> {
|
) -> Result<serde_json::Value, RpcError> {
|
||||||
match self.handlers.get(cmd) {
|
match self.handlers.get(cmd) {
|
||||||
Some(handler) => handler(ctx, payload),
|
Some(handler) => handler(ctx, payload),
|
||||||
None => Err(RpcError { message: format!("unknown command: {cmd}") }),
|
None => Err(RpcError {
|
||||||
|
message: format!("unknown command: {cmd}"),
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a full `RpcRequest`, returning an `RpcResponse`.
|
/// Handle a full `RpcRequest`, returning an `RpcResponse`.
|
||||||
pub fn handle(&self, req: RpcRequest, ctx: &Ctx) -> RpcResponse {
|
pub fn handle(&self, req: RpcRequest, ctx: &Ctx) -> RpcResponse {
|
||||||
match self.dispatch(&req.cmd, req.payload, ctx) {
|
match self.dispatch(&req.cmd, req.payload, ctx) {
|
||||||
Ok(payload) => RpcResponse::Success { id: req.id, payload },
|
Ok(payload) => RpcResponse::Success {
|
||||||
Err(e) => RpcResponse::Error { id: req.id, error: e.message },
|
id: req.id,
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
Err(e) => RpcResponse::Error {
|
||||||
|
id: req.id,
|
||||||
|
error: e.message,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-9
@@ -7,11 +7,7 @@ export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "t
|
|||||||
|
|
||||||
export type GitAuthor = { name: string | null, email: string | null, };
|
export type GitAuthor = { name: string | null, email: string | null, };
|
||||||
|
|
||||||
export type GitBranchInfo = { path: string, headRef: string | null, headRefShorthand: string | null, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
export type GitCommit = { author: GitAuthor, when: string, message: string | null, };
|
||||||
|
|
||||||
export type GitCommit = { oid: string, author: GitAuthor, when: string, message: string | null, };
|
|
||||||
|
|
||||||
export type GitFileDiff = { original: string, modified: string, };
|
|
||||||
|
|
||||||
export type GitRemote = { name: string, url: string | null, };
|
export type GitRemote = { name: string, url: string | null, };
|
||||||
|
|
||||||
@@ -21,10 +17,6 @@ export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: bool
|
|||||||
|
|
||||||
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
|
||||||
|
|
||||||
export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, };
|
|
||||||
|
|
||||||
export type GitWorktreeStatusEntry = { relaPath: string, modelId: string | null, status: GitStatus, staged: boolean, };
|
|
||||||
|
|
||||||
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
|
export type PullResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, } | { "type": "diverged", remote: string, branch: string, } | { "type": "uncommitted_changes" };
|
||||||
|
|
||||||
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
export type PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
|
||||||
|
|||||||
Generated
+33
-156
@@ -1,168 +1,45 @@
|
|||||||
// 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 = {
|
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||||
hostname: string;
|
|
||||||
ipv4: Array<string>;
|
|
||||||
ipv6: Array<string>;
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Environment = {
|
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||||
model: "environment";
|
/**
|
||||||
id: string;
|
* Variables defined in this environment scope.
|
||||||
workspaceId: string;
|
* Child environments override parent variables by name.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||||
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 = {
|
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, };
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GrpcRequest = {
|
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,
|
||||||
model: "grpc_request";
|
/**
|
||||||
id: string;
|
* Server URL (http for plaintext or https for secure)
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
url: 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;
|
|
||||||
settingSendCookies: InheritedBoolSetting;
|
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HttpRequest = {
|
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,
|
||||||
model: "http_request";
|
/**
|
||||||
id: string;
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
urlParameters: Array<HttpUrlParameter>, };
|
||||||
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 = {
|
export type HttpUrlParameter = { enabled?: boolean,
|
||||||
enabled?: boolean;
|
/**
|
||||||
/**
|
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
* Other entries are appended as query parameters
|
||||||
* Other entries are appended as query parameters
|
*/
|
||||||
*/
|
name: string, value: string, id?: string, };
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
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 InheritedIntSetting = { enabled?: boolean; value: number };
|
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>, };
|
||||||
|
|
||||||
export type SyncModel =
|
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>, };
|
||||||
| ({ 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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>;
|
|
||||||
settingSendCookies: boolean;
|
|
||||||
settingStoreCookies: boolean;
|
|
||||||
};
|
|
||||||
|
|||||||
+9
-145
@@ -1,18 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
|
||||||
import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation";
|
import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation";
|
||||||
import { queryClient } from "@yaakapp/yaak-client/lib/queryClient";
|
import { queryClient } from "@yaakapp/yaak-client/lib/queryClient";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
BranchDeleteResult,
|
BranchDeleteResult,
|
||||||
CloneResult,
|
CloneResult,
|
||||||
GitBranchInfo,
|
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitFileDiff,
|
|
||||||
GitRemote,
|
GitRemote,
|
||||||
GitStatusSummary,
|
GitStatusSummary,
|
||||||
GitWorktreeStatus,
|
|
||||||
PullResult,
|
PullResult,
|
||||||
PushResult,
|
PushResult,
|
||||||
} from "./bindings/gen_git";
|
} from "./bindings/gen_git";
|
||||||
@@ -30,10 +26,6 @@ export type DivergedStrategy = "force_reset" | "merge" | "cancel";
|
|||||||
|
|
||||||
export type UncommittedChangesStrategy = "reset" | "cancel";
|
export type UncommittedChangesStrategy = "reset" | "cancel";
|
||||||
|
|
||||||
interface GitWatchResult {
|
|
||||||
unlistenEvent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitCallbacks {
|
export interface GitCallbacks {
|
||||||
addRemote: () => Promise<GitRemote | null>;
|
addRemote: () => Promise<GitRemote | null>;
|
||||||
promptCredentials: (
|
promptCredentials: (
|
||||||
@@ -46,98 +38,13 @@ export interface GitCallbacks {
|
|||||||
|
|
||||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] });
|
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] });
|
||||||
|
|
||||||
function gitWorktreeStatusQueryKey(dir?: string, refreshKey?: string) {
|
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
||||||
return refreshKey == null
|
const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
||||||
? (["git", "worktree_status", dir] as const)
|
const fetchAll = useQuery<void, string>({
|
||||||
: (["git", "worktree_status", dir, refreshKey] as const);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalidateGitWorktreeStatus(dir?: string) {
|
|
||||||
return queryClient.invalidateQueries({ queryKey: gitWorktreeStatusQueryKey(dir) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGitWorktreeStatus(dir: string, refreshKey?: string) {
|
|
||||||
return useQuery<GitWorktreeStatus, string>({
|
|
||||||
queryKey: gitWorktreeStatusQueryKey(dir, refreshKey),
|
|
||||||
queryFn: () => invoke("cmd_git_worktree_status", { dir }),
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function watchGitWorktreeStatus(dir: string, callback: (status: GitWorktreeStatus) => void) {
|
|
||||||
const channel = new Channel<GitWorktreeStatus>();
|
|
||||||
channel.onmessage = callback;
|
|
||||||
const unlistenPromise = invoke<GitWatchResult>("cmd_git_watch_worktree_status", {
|
|
||||||
dir,
|
|
||||||
channel,
|
|
||||||
});
|
|
||||||
|
|
||||||
void unlistenPromise
|
|
||||||
.then(({ unlistenEvent }) => {
|
|
||||||
addGitWatchKey(unlistenEvent);
|
|
||||||
})
|
|
||||||
.catch(console.debug);
|
|
||||||
|
|
||||||
return () =>
|
|
||||||
unlistenPromise
|
|
||||||
.then(async ({ unlistenEvent }) => {
|
|
||||||
unlistenGitWatcher(unlistenEvent);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useGitFetchAll(dir: string, refreshKey?: string) {
|
|
||||||
return useQuery<void, string>({
|
|
||||||
queryKey: ["git", "fetch_all", dir, refreshKey],
|
queryKey: ["git", "fetch_all", dir, refreshKey],
|
||||||
queryFn: () => invoke("cmd_git_fetch_all", { dir }),
|
queryFn: () => invoke("cmd_git_fetch_all", { dir }),
|
||||||
refetchInterval: 10 * 60_000,
|
refetchInterval: 10 * 60_000,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function useGitBranchInfoQuery(dir: string, refreshKey?: string, fetchAllUpdatedAt?: number) {
|
|
||||||
return useQuery<GitBranchInfo, string>({
|
|
||||||
refetchOnMount: true,
|
|
||||||
queryKey: ["git", "branch_info", dir, refreshKey, fetchAllUpdatedAt],
|
|
||||||
queryFn: () => invoke("cmd_git_branch_info", { dir }),
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGitBranchInfo(dir: string, refreshKey?: string) {
|
|
||||||
const fetchAll = useGitFetchAll(dir, refreshKey);
|
|
||||||
return useGitBranchInfoQuery(dir, refreshKey, fetchAll.dataUpdatedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGitLog(dir: string, refreshKey?: string, relaPath?: string) {
|
|
||||||
return useQuery<GitCommit[], string>({
|
|
||||||
queryKey: ["git", "log", dir, refreshKey, relaPath],
|
|
||||||
queryFn: () =>
|
|
||||||
relaPath == null
|
|
||||||
? invoke("cmd_git_log", { dir })
|
|
||||||
: invoke("cmd_git_log_for_file", { dir, relaPath }),
|
|
||||||
placeholderData: (prev) => prev,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGitFileDiffForCommit(
|
|
||||||
dir: string,
|
|
||||||
relaPath: string,
|
|
||||||
commitOid: string | null | undefined,
|
|
||||||
) {
|
|
||||||
return useQuery<GitFileDiff, string>({
|
|
||||||
enabled: commitOid != null,
|
|
||||||
queryKey: ["git", "file_diff_for_commit", dir, relaPath, commitOid],
|
|
||||||
queryFn: () => {
|
|
||||||
if (commitOid == null) throw new Error("Missing commit oid");
|
|
||||||
return invoke("cmd_git_file_diff_for_commit", { dir, relaPath, commitOid });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
|
||||||
const mutations = useGitMutations(dir, callbacks);
|
|
||||||
const fetchAll = useGitFetchAll(dir, refreshKey);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
remotes: useQuery<GitRemote[], string>({
|
remotes: useQuery<GitRemote[], string>({
|
||||||
@@ -145,7 +52,11 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
|
|||||||
queryFn: () => getRemotes(dir),
|
queryFn: () => getRemotes(dir),
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
}),
|
}),
|
||||||
log: useGitLog(dir, refreshKey),
|
log: useQuery<GitCommit[], string>({
|
||||||
|
queryKey: ["git", "log", dir, refreshKey],
|
||||||
|
queryFn: () => invoke("cmd_git_log", { dir }),
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
}),
|
||||||
status: useQuery<GitStatusSummary, string>({
|
status: useQuery<GitStatusSummary, string>({
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
|
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
|
||||||
@@ -157,10 +68,6 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
|
|||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGitMutations(dir: string, callbacks: GitCallbacks) {
|
|
||||||
return useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||||
const push = async () => {
|
const push = async () => {
|
||||||
const remotes = await getRemotes(dir);
|
const remotes = await getRemotes(dir);
|
||||||
@@ -343,20 +250,6 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
|||||||
mutationFn: () => invoke("cmd_git_reset_changes", { dir }),
|
mutationFn: () => invoke("cmd_git_reset_changes", { dir }),
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}),
|
}),
|
||||||
restore: createFastMutation<void, string, { relaPaths: string[] }>({
|
|
||||||
mutationKey: ["git", "restore", dir],
|
|
||||||
mutationFn: (args) => invoke("cmd_git_restore_files", { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
restoreFileFromCommit: createFastMutation<
|
|
||||||
void,
|
|
||||||
string,
|
|
||||||
{ commitOid: string; relaPath: string }
|
|
||||||
>({
|
|
||||||
mutationKey: ["git", "restore-file-from-commit", dir],
|
|
||||||
mutationFn: (args) => invoke("cmd_git_restore_file_from_commit", { dir, ...args }),
|
|
||||||
onSuccess,
|
|
||||||
}),
|
|
||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -364,35 +257,6 @@ async function getRemotes(dir: string) {
|
|||||||
return invoke<GitRemote[]>("cmd_git_remotes", { dir });
|
return invoke<GitRemote[]>("cmd_git_remotes", { dir });
|
||||||
}
|
}
|
||||||
|
|
||||||
function unlistenGitWatcher(unlistenEvent: string) {
|
|
||||||
void emit(unlistenEvent).then(() => {
|
|
||||||
removeGitWatchKey(unlistenEvent);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGitWatchKeys() {
|
|
||||||
return sessionStorage.getItem("git-worktree-watchers")?.split(",").filter(Boolean) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function setGitWatchKeys(keys: string[]) {
|
|
||||||
sessionStorage.setItem("git-worktree-watchers", keys.join(","));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGitWatchKey(key: string) {
|
|
||||||
const keys = getGitWatchKeys();
|
|
||||||
setGitWatchKeys([...keys, key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeGitWatchKey(key: string) {
|
|
||||||
const keys = getGitWatchKeys();
|
|
||||||
setGitWatchKeys(keys.filter((k) => k !== key));
|
|
||||||
}
|
|
||||||
|
|
||||||
const gitWatchKeys = getGitWatchKeys();
|
|
||||||
if (gitWatchKeys.length > 0) {
|
|
||||||
gitWatchKeys.forEach(unlistenGitWatcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone a git repository, prompting for credentials if needed.
|
* Clone a git repository, prompting for credentials if needed.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ mod push;
|
|||||||
mod remotes;
|
mod remotes;
|
||||||
mod repository;
|
mod repository;
|
||||||
mod reset;
|
mod reset;
|
||||||
mod restore;
|
|
||||||
mod status;
|
mod status;
|
||||||
mod unstage;
|
mod unstage;
|
||||||
mod util;
|
mod util;
|
||||||
@@ -30,15 +29,10 @@ pub use commit::git_commit;
|
|||||||
pub use credential::git_add_credential;
|
pub use credential::git_add_credential;
|
||||||
pub use fetch::git_fetch_all;
|
pub use fetch::git_fetch_all;
|
||||||
pub use init::git_init;
|
pub use init::git_init;
|
||||||
pub use log::{GitCommit, GitFileDiff, git_file_diff_for_commit, git_log, git_log_for_file};
|
pub use log::{GitCommit, git_log};
|
||||||
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
|
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
|
||||||
pub use push::{PushResult, git_push};
|
pub use push::{PushResult, git_push};
|
||||||
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
||||||
pub use repository::{GitRepositoryPaths, git_path_is_ignored, git_repository_paths};
|
|
||||||
pub use reset::git_reset_changes;
|
pub use reset::git_reset_changes;
|
||||||
pub use restore::{git_restore, git_restore_file_from_commit};
|
pub use status::{GitStatusSummary, git_status};
|
||||||
pub use status::{
|
|
||||||
GitBranchInfo, GitStatusSummary, GitWorktreeStatus, git_branch_info, git_status,
|
|
||||||
git_worktree_status,
|
|
||||||
};
|
|
||||||
pub use unstage::git_unstage;
|
pub use unstage::git_unstage;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use ts_rs::TS;
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
pub struct GitCommit {
|
pub struct GitCommit {
|
||||||
pub oid: String,
|
|
||||||
pub author: GitAuthor,
|
pub author: GitAuthor,
|
||||||
pub when: DateTime<Utc>,
|
pub when: DateTime<Utc>,
|
||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
@@ -22,23 +21,7 @@ pub struct GitAuthor {
|
|||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub struct GitFileDiff {
|
|
||||||
pub original: String,
|
|
||||||
pub modified: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
||||||
git_log_inner(dir, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_log_for_file(dir: &Path, rela_path: &Path) -> crate::error::Result<Vec<GitCommit>> {
|
|
||||||
git_log_inner(dir, Some(rela_path))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_log_inner(dir: &Path, rela_path: Option<&Path>) -> crate::error::Result<Vec<GitCommit>> {
|
|
||||||
let repo = open_repo(dir)?;
|
let repo = open_repo(dir)?;
|
||||||
|
|
||||||
// Return empty if empty repo or no head (new repo)
|
// Return empty if empty repo or no head (new repo)
|
||||||
@@ -63,16 +46,8 @@ fn git_log_inner(dir: &Path, rela_path: Option<&Path>) -> crate::error::Result<V
|
|||||||
.filter_map(|oid| {
|
.filter_map(|oid| {
|
||||||
let oid = filter_try!(oid);
|
let oid = filter_try!(oid);
|
||||||
let commit = filter_try!(repo.find_commit(oid));
|
let commit = filter_try!(repo.find_commit(oid));
|
||||||
if let Some(rela_path) = rela_path {
|
|
||||||
let touches_path = filter_try!(commit_touches_path(&repo, &commit, rela_path));
|
|
||||||
if !touches_path {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let author = commit.author();
|
let author = commit.author();
|
||||||
Some(GitCommit {
|
Some(GitCommit {
|
||||||
oid: oid.to_string(),
|
|
||||||
author: GitAuthor {
|
author: GitAuthor {
|
||||||
name: author.name().map(|s| s.to_string()),
|
name: author.name().map(|s| s.to_string()),
|
||||||
email: author.email().map(|s| s.to_string()),
|
email: author.email().map(|s| s.to_string()),
|
||||||
@@ -86,53 +61,6 @@ fn git_log_inner(dir: &Path, rela_path: Option<&Path>) -> crate::error::Result<V
|
|||||||
Ok(log)
|
Ok(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn git_file_diff_for_commit(
|
|
||||||
dir: &Path,
|
|
||||||
commit_oid: &str,
|
|
||||||
rela_path: &Path,
|
|
||||||
) -> crate::error::Result<GitFileDiff> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
let oid = git2::Oid::from_str(commit_oid)?;
|
|
||||||
let commit = repo.find_commit(oid)?;
|
|
||||||
let new_tree = commit.tree()?;
|
|
||||||
let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
|
|
||||||
|
|
||||||
Ok(GitFileDiff {
|
|
||||||
original: blob_text_at_path(&repo, old_tree.as_ref(), rela_path)?,
|
|
||||||
modified: blob_text_at_path(&repo, Some(&new_tree), rela_path)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn commit_touches_path(
|
|
||||||
repo: &git2::Repository,
|
|
||||||
commit: &git2::Commit,
|
|
||||||
rela_path: &Path,
|
|
||||||
) -> crate::error::Result<bool> {
|
|
||||||
let new_tree = commit.tree()?;
|
|
||||||
let old_tree = if commit.parent_count() > 0 { Some(commit.parent(0)?.tree()?) } else { None };
|
|
||||||
|
|
||||||
let mut opts = git2::DiffOptions::new();
|
|
||||||
opts.pathspec(rela_path);
|
|
||||||
|
|
||||||
let diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
|
|
||||||
Ok(diff.deltas().len() > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn blob_text_at_path(
|
|
||||||
repo: &git2::Repository,
|
|
||||||
tree: Option<&git2::Tree>,
|
|
||||||
rela_path: &Path,
|
|
||||||
) -> crate::error::Result<String> {
|
|
||||||
let Some(tree) = tree else {
|
|
||||||
return Ok(String::new());
|
|
||||||
};
|
|
||||||
let Ok(entry) = tree.get_path(rela_path) else {
|
|
||||||
return Ok(String::new());
|
|
||||||
};
|
|
||||||
let blob = entry.to_object(repo)?.peel_to_blob()?;
|
|
||||||
Ok(String::from_utf8(blob.content().to_vec())?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
|
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
|
||||||
DateTime::from_timestamp(0, 0).unwrap()
|
DateTime::from_timestamp(0, 0).unwrap()
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
use crate::error::Error::{GitRepoNotFound, GitUnknown};
|
use crate::error::Error::{GitRepoNotFound, GitUnknown};
|
||||||
use crate::error::{Error, Result};
|
use std::path::Path;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GitRepositoryPaths {
|
|
||||||
pub workdir: PathBuf,
|
|
||||||
pub gitdir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
|
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
|
||||||
match git2::Repository::discover(dir) {
|
match git2::Repository::discover(dir) {
|
||||||
@@ -15,17 +8,3 @@ pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
|
|||||||
Err(e) => Err(GitUnknown(e)),
|
Err(e) => Err(GitUnknown(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn git_repository_paths(dir: &Path) -> Result<GitRepositoryPaths> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
let workdir = repo
|
|
||||||
.workdir()
|
|
||||||
.ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))?
|
|
||||||
.to_path_buf();
|
|
||||||
Ok(GitRepositoryPaths { workdir, gitdir: repo.path().to_path_buf() })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result<bool> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
Ok(repo.status_should_ignore(rela_path)?)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
use crate::error::Result;
|
|
||||||
use crate::repository::open_repo;
|
|
||||||
use log::info;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Component, Path};
|
|
||||||
|
|
||||||
pub fn git_restore(dir: &Path, rela_path: &Path) -> Result<()> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
validate_relative_path(rela_path)?;
|
|
||||||
|
|
||||||
let status = repo.status_file(rela_path).ok();
|
|
||||||
let is_untracked = status
|
|
||||||
.is_some_and(|s| s.contains(git2::Status::WT_NEW) || s.contains(git2::Status::INDEX_NEW));
|
|
||||||
|
|
||||||
info!("Restoring file {rela_path:?} in {dir:?}");
|
|
||||||
if is_untracked {
|
|
||||||
let mut index = repo.index()?;
|
|
||||||
let _ = index.remove_path(rela_path);
|
|
||||||
index.write()?;
|
|
||||||
|
|
||||||
let path = repo.workdir().unwrap_or(dir).join(rela_path);
|
|
||||||
if path.is_dir() {
|
|
||||||
fs::remove_dir_all(path)?;
|
|
||||||
} else if path.exists() {
|
|
||||||
fs::remove_file(path)?;
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let head = repo.head()?;
|
|
||||||
let commit = head.peel_to_commit()?;
|
|
||||||
repo.reset_default(Some(commit.as_object()), &[rela_path])?;
|
|
||||||
|
|
||||||
let mut checkout = git2::build::CheckoutBuilder::new();
|
|
||||||
checkout.force().path(rela_path);
|
|
||||||
repo.checkout_head(Some(&mut checkout))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_restore_file_from_commit(dir: &Path, commit_oid: &str, rela_path: &Path) -> Result<()> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
validate_relative_path(rela_path)?;
|
|
||||||
|
|
||||||
let oid = git2::Oid::from_str(commit_oid)?;
|
|
||||||
let commit = repo.find_commit(oid)?;
|
|
||||||
let tree = commit.tree()?;
|
|
||||||
let path = repo.workdir().unwrap_or(dir).join(rela_path);
|
|
||||||
|
|
||||||
info!("Restoring file {rela_path:?} from commit {commit_oid} in {dir:?}");
|
|
||||||
if tree.get_path(rela_path).is_err() {
|
|
||||||
if path.is_dir() {
|
|
||||||
fs::remove_dir_all(path)?;
|
|
||||||
} else if path.exists() {
|
|
||||||
fs::remove_file(path)?;
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut checkout = git2::build::CheckoutBuilder::new();
|
|
||||||
checkout.force().path(rela_path);
|
|
||||||
repo.checkout_tree(commit.as_object(), Some(&mut checkout))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_relative_path(path: &Path) -> Result<()> {
|
|
||||||
let is_safe = !path.as_os_str().is_empty()
|
|
||||||
&& !path.is_absolute()
|
|
||||||
&& path.components().all(|c| matches!(c, Component::Normal(_)));
|
|
||||||
if is_safe {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(crate::error::Error::GenericError(format!("Invalid restore path {}", path.display())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+73
-161
@@ -22,20 +22,6 @@ pub struct GitStatusSummary {
|
|||||||
pub behind: u32,
|
pub behind: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub struct GitBranchInfo {
|
|
||||||
pub path: String,
|
|
||||||
pub head_ref: Option<String>,
|
|
||||||
pub head_ref_shorthand: Option<String>,
|
|
||||||
pub origins: Vec<String>,
|
|
||||||
pub local_branches: Vec<String>,
|
|
||||||
pub remote_branches: Vec<String>,
|
|
||||||
pub ahead: u32,
|
|
||||||
pub behind: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
@@ -47,23 +33,6 @@ pub struct GitStatusEntry {
|
|||||||
pub next: Option<SyncModel>,
|
pub next: Option<SyncModel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub struct GitWorktreeStatus {
|
|
||||||
pub entries: Vec<GitWorktreeStatusEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
|
||||||
pub struct GitWorktreeStatusEntry {
|
|
||||||
pub rela_path: String,
|
|
||||||
pub model_id: Option<String>,
|
|
||||||
pub status: GitStatus,
|
|
||||||
pub staged: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[ts(export, export_to = "gen_git.ts")]
|
#[ts(export, export_to = "gen_git.ts")]
|
||||||
@@ -77,43 +46,31 @@ pub enum GitStatus {
|
|||||||
TypeChange,
|
TypeChange,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
let mut opts = git2::StatusOptions::new();
|
|
||||||
opts.include_ignored(false)
|
|
||||||
.include_untracked(true)
|
|
||||||
.recurse_untracked_dirs(true)
|
|
||||||
.include_unmodified(false);
|
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
|
||||||
let Some(rela_path) = entry.path() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
entries.push(GitWorktreeStatusEntry {
|
|
||||||
rela_path: rela_path.to_string(),
|
|
||||||
model_id: model_id_from_rela_path(Path::new(rela_path)),
|
|
||||||
status,
|
|
||||||
staged,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(GitWorktreeStatus { entries })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_branch_info(dir: &Path) -> crate::error::Result<GitBranchInfo> {
|
|
||||||
let repo = open_repo(dir)?;
|
|
||||||
git_branch_info_for_repo(&repo, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
||||||
let repo = open_repo(dir)?;
|
let repo = open_repo(dir)?;
|
||||||
let branch_info = git_branch_info_for_repo(&repo, dir)?;
|
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
|
||||||
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
|
Ok(head) => {
|
||||||
|
let tree = head.peel_to_tree().ok();
|
||||||
|
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
||||||
|
let head_ref = head.name().map(|s| s.to_string());
|
||||||
|
|
||||||
|
(tree, head_ref, head_ref_shorthand)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// For "unborn" repos, reading from HEAD is the only way to get the branch name
|
||||||
|
// See https://github.com/starship/starship/pull/1336
|
||||||
|
let head_path = repo.path().join("HEAD");
|
||||||
|
let head_ref = fs::read_to_string(&head_path)
|
||||||
|
.ok()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.map(|s| s.trim_start_matches("ref:").trim().to_string());
|
||||||
|
let head_ref_shorthand =
|
||||||
|
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
|
||||||
|
(None, head_ref, head_ref_shorthand)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut opts = git2::StatusOptions::new();
|
let mut opts = git2::StatusOptions::new();
|
||||||
opts.include_ignored(false)
|
opts.include_ignored(false)
|
||||||
@@ -126,8 +83,51 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
|||||||
let mut entries: Vec<GitStatusEntry> = Vec::new();
|
let mut entries: Vec<GitStatusEntry> = Vec::new();
|
||||||
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
|
||||||
let rela_path = entry.path().unwrap().to_string();
|
let rela_path = entry.path().unwrap().to_string();
|
||||||
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
|
let status = entry.status();
|
||||||
continue;
|
let index_status = match status {
|
||||||
|
// Note: order matters here, since we're checking a bitmap!
|
||||||
|
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||||
|
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
||||||
|
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
||||||
|
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
||||||
|
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
||||||
|
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
|
||||||
|
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||||
|
s => {
|
||||||
|
warn!("Unknown index status {s:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let worktree_status = match status {
|
||||||
|
// Note: order matters here, since we're checking a bitmap!
|
||||||
|
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
||||||
|
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
||||||
|
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
||||||
|
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
||||||
|
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
||||||
|
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
|
||||||
|
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
||||||
|
s => {
|
||||||
|
warn!("Unknown worktree status {s:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = if index_status == GitStatus::Current {
|
||||||
|
worktree_status.clone()
|
||||||
|
} else {
|
||||||
|
index_status.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current
|
||||||
|
{
|
||||||
|
// No change, so can't be added
|
||||||
|
false
|
||||||
|
} else if index_status != GitStatus::Current {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get previous content from Git, if it's in there
|
// Get previous content from Git, if it's in there
|
||||||
@@ -158,27 +158,9 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(GitStatusSummary {
|
|
||||||
entries,
|
|
||||||
path: branch_info.path,
|
|
||||||
head_ref: branch_info.head_ref,
|
|
||||||
head_ref_shorthand: branch_info.head_ref_shorthand,
|
|
||||||
origins: branch_info.origins,
|
|
||||||
local_branches: branch_info.local_branches,
|
|
||||||
remote_branches: branch_info.remote_branches,
|
|
||||||
ahead: branch_info.ahead,
|
|
||||||
behind: branch_info.behind,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_branch_info_for_repo(
|
|
||||||
repo: &git2::Repository,
|
|
||||||
dir: &Path,
|
|
||||||
) -> crate::error::Result<GitBranchInfo> {
|
|
||||||
let (head_ref, head_ref_shorthand) = git_head_refs(repo);
|
|
||||||
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
|
let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();
|
||||||
let local_branches = local_branch_names(repo)?;
|
let local_branches = local_branch_names(&repo)?;
|
||||||
let remote_branches = remote_branch_names(repo)?;
|
let remote_branches = remote_branch_names(&repo)?;
|
||||||
|
|
||||||
// Compute ahead/behind relative to remote tracking branch
|
// Compute ahead/behind relative to remote tracking branch
|
||||||
let (ahead, behind) = (|| -> Option<(usize, usize)> {
|
let (ahead, behind) = (|| -> Option<(usize, usize)> {
|
||||||
@@ -192,85 +174,15 @@ fn git_branch_info_for_repo(
|
|||||||
})()
|
})()
|
||||||
.unwrap_or((0, 0));
|
.unwrap_or((0, 0));
|
||||||
|
|
||||||
Ok(GitBranchInfo {
|
Ok(GitStatusSummary {
|
||||||
|
entries,
|
||||||
|
origins,
|
||||||
path: dir.to_string_lossy().to_string(),
|
path: dir.to_string_lossy().to_string(),
|
||||||
head_ref,
|
head_ref,
|
||||||
head_ref_shorthand,
|
head_ref_shorthand,
|
||||||
origins,
|
|
||||||
local_branches,
|
local_branches,
|
||||||
remote_branches,
|
remote_branches,
|
||||||
ahead: ahead as u32,
|
ahead: ahead as u32,
|
||||||
behind: behind as u32,
|
behind: behind as u32,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn git_head_refs(repo: &git2::Repository) -> (Option<String>, Option<String>) {
|
|
||||||
match repo.head() {
|
|
||||||
Ok(head) => {
|
|
||||||
let head_ref = head.name().map(|s| s.to_string());
|
|
||||||
let head_ref_shorthand = head.shorthand().map(|s| s.to_string());
|
|
||||||
(head_ref, head_ref_shorthand)
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// For "unborn" repos, reading from HEAD is the only way to get the branch name
|
|
||||||
// See https://github.com/starship/starship/pull/1336
|
|
||||||
let head_path = repo.path().join("HEAD");
|
|
||||||
let head_ref = fs::read_to_string(&head_path)
|
|
||||||
.ok()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.lines()
|
|
||||||
.next()
|
|
||||||
.map(|s| s.trim_start_matches("ref:").trim().to_string());
|
|
||||||
let head_ref_shorthand =
|
|
||||||
head_ref.clone().map(|r| r.split('/').last().unwrap_or("unknown").to_string());
|
|
||||||
(head_ref, head_ref_shorthand)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_status_from_raw(status: git2::Status) -> Option<(GitStatus, bool)> {
|
|
||||||
let index_status = match status {
|
|
||||||
// Note: order matters here, since we're checking a bitmap!
|
|
||||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
|
||||||
s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,
|
|
||||||
s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,
|
|
||||||
s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,
|
|
||||||
s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,
|
|
||||||
s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,
|
|
||||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
|
||||||
s => {
|
|
||||||
warn!("Unknown index status {s:?}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let worktree_status = match status {
|
|
||||||
// Note: order matters here, since we're checking a bitmap!
|
|
||||||
s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,
|
|
||||||
s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,
|
|
||||||
s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,
|
|
||||||
s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,
|
|
||||||
s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,
|
|
||||||
s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,
|
|
||||||
s if s.contains(git2::Status::CURRENT) => GitStatus::Current,
|
|
||||||
s => {
|
|
||||||
warn!("Unknown worktree status {s:?}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let status =
|
|
||||||
if index_status == GitStatus::Current { worktree_status } else { index_status.clone() };
|
|
||||||
let staged = index_status != GitStatus::Current;
|
|
||||||
|
|
||||||
Some((status, staged))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn model_id_from_rela_path(path: &Path) -> Option<String> {
|
|
||||||
let ext = path.extension()?.to_str()?;
|
|
||||||
if ext != "yaml" && ext != "yml" && ext != "json" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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, CookieSameSite};
|
use yaak_models::models::{Cookie, CookieDomain, CookieExpires};
|
||||||
|
|
||||||
/// 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,7 +45,10 @@ 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))
|
||||||
.map(|cookie| (cookie.name.clone(), cookie.value.clone()))
|
.filter_map(|cookie| {
|
||||||
|
// 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() {
|
||||||
@@ -69,7 +72,13 @@ 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!("Storing cookie: {} for domain {:?}", cookie.name, cookie.domain);
|
debug!(
|
||||||
|
"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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,9 +117,10 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +133,8 @@ 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| {
|
||||||
if cookie.name != name {
|
let (cookie_name, value) = parse_cookie_name_value(&cookie.raw_cookie)?;
|
||||||
|
if cookie_name != name {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +144,11 @@ pub fn get_cookie_value_from_jar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(cookie.value)
|
Some(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()?;
|
||||||
@@ -167,6 +177,8 @@ 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
|
||||||
@@ -204,28 +216,14 @@ 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()
|
(path_attr.to_string(), true)
|
||||||
} else {
|
} else {
|
||||||
// Default path is the directory of the request URI
|
// Default path is the directory of the request URI
|
||||||
default_cookie_path(request_url.path())
|
let default_path = default_cookie_path(request_url.path());
|
||||||
|
(default_path, false)
|
||||||
};
|
};
|
||||||
|
|
||||||
let same_site = parsed.same_site().map(|same_site| match same_site {
|
Some(Cookie { raw_cookie, domain, expires, path })
|
||||||
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)
|
||||||
@@ -263,7 +261,10 @@ 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 {
|
||||||
if a.name != b.name {
|
let name_a = parse_cookie_name_value(&a.raw_cookie).map(|(n, _)| n);
|
||||||
|
let name_b = parse_cookie_name_value(&b.raw_cookie).map(|(n, _)| n);
|
||||||
|
|
||||||
|
if name_a != name_b {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,16 +317,11 @@ 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 {
|
||||||
name,
|
raw_cookie: raw_cookie.to_string(),
|
||||||
value,
|
|
||||||
domain,
|
domain,
|
||||||
expires: CookieExpires::SessionEnd,
|
expires: CookieExpires::SessionEnd,
|
||||||
path: "/".to_string(),
|
path: ("/".to_string(), false),
|
||||||
secure: false,
|
|
||||||
http_only: false,
|
|
||||||
same_site: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,7 @@ pub enum RedirectBehavior {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum HttpResponseEvent {
|
pub enum HttpResponseEvent {
|
||||||
Setting {
|
Setting(String, String),
|
||||||
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,
|
||||||
@@ -73,9 +67,7 @@ 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, .. } => {
|
HttpResponseEvent::Setting(name, value) => write!(f, "* 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,
|
||||||
@@ -154,9 +146,7 @@ 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, source_model, source_id, source_name } => {
|
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
|
||||||
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,
|
||||||
@@ -493,6 +483,15 @@ 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,58 +12,22 @@ 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 {
|
Self { sender, max_redirects: 10, cookie_store: None }
|
||||||
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 {
|
Self { sender, max_redirects, cookie_store: None }
|
||||||
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 {
|
Self { sender, max_redirects: 10, cookie_store: Some(cookie_store) }
|
||||||
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
|
||||||
@@ -72,13 +36,7 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
max_redirects: usize,
|
max_redirects: usize,
|
||||||
cookie_store: Option<CookieStore>,
|
cookie_store: Option<CookieStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self { sender, max_redirects, cookie_store }
|
||||||
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.
|
||||||
@@ -108,11 +66,9 @@ 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 self.send_cookies {
|
let headers_with_cookies = if let Some(cookie_store) = &self.cookie_store {
|
||||||
let mut headers = current_headers.clone();
|
let mut headers = current_headers.clone();
|
||||||
if let (Some(cookie_store), Ok(url)) =
|
if let Ok(url) = Url::parse(¤t_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
|
||||||
@@ -144,6 +100,12 @@ 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?,
|
||||||
@@ -153,10 +115,8 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parse Set-Cookie headers and store cookies
|
// Parse Set-Cookie headers and store cookies
|
||||||
if self.store_cookies {
|
if let Some(cookie_store) = &self.cookie_store {
|
||||||
if let (Some(cookie_store), Ok(url)) =
|
if let Ok(url) = Url::parse(¤t_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()
|
||||||
@@ -619,14 +579,10 @@ mod tests {
|
|||||||
|
|
||||||
// Create a cookie store with a test cookie
|
// Create a cookie store with a test cookie
|
||||||
let cookie = Cookie {
|
let cookie = Cookie {
|
||||||
name: "session".to_string(),
|
raw_cookie: "session=abc123".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(),
|
path: ("/".to_string(), false),
|
||||||
secure: false,
|
|
||||||
http_only: false,
|
|
||||||
same_site: None,
|
|
||||||
};
|
};
|
||||||
let cookie_store = CookieStore::from_cookies(vec![cookie]);
|
let cookie_store = CookieStore::from_cookies(vec![cookie]);
|
||||||
|
|
||||||
@@ -646,67 +602,6 @@ 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
|
||||||
@@ -760,62 +655,7 @@ 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_eq!(cookies[0].name, "session");
|
assert!(cookies[0].raw_cookie.contains("session=xyz789"));
|
||||||
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]
|
||||||
@@ -879,15 +719,17 @@ 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<_> =
|
let cookie_values: Vec<&str> = cookies.iter().map(|c| c.raw_cookie.as_str()).collect();
|
||||||
cookies.iter().map(|c| format!("{}={}", c.name, c.value)).collect();
|
|
||||||
assert!(
|
assert!(
|
||||||
cookie_values.iter().any(|c| c == "session=abc123"),
|
cookie_values.iter().any(|c| c.contains("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 == "preferences=dark"),
|
cookie_values.iter().any(|c| c.contains("user_id=42")),
|
||||||
|
"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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,10 +304,7 @@ async fn build_binary_body(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_text_body(
|
fn build_text_body(body: &BTreeMap<String, serde_json::Value>, body_type: &str) -> Option<SendableBodyWithMeta> {
|
||||||
body: &BTreeMap<String, serde_json::Value>,
|
|
||||||
body_type: &str,
|
|
||||||
) -> Option<SendableBodyWithMeta> {
|
|
||||||
let text = get_str_map(body, "text");
|
let text = get_str_map(body, "text");
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
+61
-454
@@ -1,514 +1,121 @@
|
|||||||
// 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 =
|
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||||
| CookieJar
|
|
||||||
| Environment
|
|
||||||
| Folder
|
|
||||||
| GraphQlIntrospection
|
|
||||||
| GrpcConnection
|
|
||||||
| GrpcEvent
|
|
||||||
| GrpcRequest
|
|
||||||
| HttpRequest
|
|
||||||
| HttpResponse
|
|
||||||
| HttpResponseEvent
|
|
||||||
| KeyValue
|
|
||||||
| Plugin
|
|
||||||
| Settings
|
|
||||||
| SyncState
|
|
||||||
| WebsocketConnection
|
|
||||||
| WebsocketEvent
|
|
||||||
| WebsocketRequest
|
|
||||||
| Workspace
|
|
||||||
| WorkspaceMeta;
|
|
||||||
|
|
||||||
export type ClientCertificate = {
|
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
||||||
host: string;
|
|
||||||
port: number | null;
|
|
||||||
crtFile: string | null;
|
|
||||||
keyFile: string | null;
|
|
||||||
pfxFile: string | null;
|
|
||||||
passphrase: string | null;
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Cookie = {
|
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
|
||||||
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 = {
|
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||||
model: "cookie_jar";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
workspaceId: string;
|
|
||||||
cookies: Array<Cookie>;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
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 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 = {
|
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||||
model: "environment";
|
/**
|
||||||
id: string;
|
* Variables defined in this environment scope.
|
||||||
workspaceId: string;
|
* Child environments override parent variables by name.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||||
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 = {
|
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, };
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GraphQlIntrospection = {
|
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
||||||
model: "graphql_introspection";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
workspaceId: string;
|
|
||||||
requestId: string;
|
|
||||||
content: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GrpcConnection = {
|
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, };
|
||||||
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 = {
|
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, };
|
||||||
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 =
|
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
||||||
| "info"
|
|
||||||
| "error"
|
|
||||||
| "client_message"
|
|
||||||
| "server_message"
|
|
||||||
| "connection_start"
|
|
||||||
| "connection_end";
|
|
||||||
|
|
||||||
export type GrpcRequest = {
|
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,
|
||||||
model: "grpc_request";
|
/**
|
||||||
id: string;
|
* Server URL (http for plaintext or https for secure)
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
url: 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;
|
|
||||||
settingSendCookies: InheritedBoolSetting;
|
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HttpRequest = {
|
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,
|
||||||
model: "http_request";
|
/**
|
||||||
id: string;
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
urlParameters: Array<HttpUrlParameter>, };
|
||||||
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 = {
|
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, };
|
||||||
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 = {
|
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||||
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 =
|
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, };
|
||||||
| {
|
|
||||||
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 = {
|
export type HttpUrlParameter = { enabled?: boolean,
|
||||||
enabled?: boolean;
|
/**
|
||||||
/**
|
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
* Other entries are appended as query parameters
|
||||||
* Other entries are appended as query parameters
|
*/
|
||||||
*/
|
name: string, value: string, id?: string, };
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||||
|
|
||||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, };
|
||||||
|
|
||||||
export type KeyValue = {
|
export type ParentAuthentication = { authentication: Record<string, any>, authenticationType: string | null, };
|
||||||
model: "key_value";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
key: string;
|
|
||||||
namespace: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ModelPayload = {
|
export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
|
||||||
model: AnyModel;
|
|
||||||
updateSource: UpdateSource;
|
|
||||||
change: ModelChangeEvent;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ParentAuthentication = {
|
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
||||||
authentication: Record<string, any>;
|
|
||||||
authenticationType: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ParentHeaders = { headers: Array<HttpRequestHeader> };
|
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
|
||||||
|
|
||||||
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 =
|
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
||||||
| {
|
|
||||||
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 = {
|
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> }, };
|
||||||
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 = {
|
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||||
model: "sync_state";
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
flushedAt: string;
|
|
||||||
modelId: string;
|
|
||||||
checksum: string;
|
|
||||||
relPath: string;
|
|
||||||
syncDir: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateSource =
|
export type UpdateSource = { "type": "background" } | { "type": "import" } | { "type": "plugin" } | { "type": "sync" } | { "type": "window", label: string, };
|
||||||
| { type: "background" }
|
|
||||||
| { type: "import" }
|
|
||||||
| { type: "plugin" }
|
|
||||||
| { type: "sync" }
|
|
||||||
| { type: "window"; label: string };
|
|
||||||
|
|
||||||
export type WebsocketConnection = {
|
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, };
|
||||||
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 = {
|
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, };
|
||||||
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" | "frame" | "open" | "ping" | "pong" | "text";
|
||||||
|
|
||||||
export type WebsocketMessageType = "text" | "binary";
|
export type WebsocketMessageType = "text" | "binary";
|
||||||
|
|
||||||
export type 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,
|
||||||
model: "websocket_request";
|
/**
|
||||||
id: string;
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
urlParameters: Array<HttpUrlParameter>, };
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Workspace = {
|
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>, };
|
||||||
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>;
|
|
||||||
settingSendCookies: boolean;
|
|
||||||
settingStoreCookies: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceMeta = {
|
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||||
model: "workspace_meta";
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
encryptionKey: EncryptedKey | null;
|
|
||||||
settingSyncDir: string | null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
ALTER TABLE grpc_requests ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
|
||||||
ALTER TABLE grpc_requests ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
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, SettingFollowRedirects, SettingRequestTimeout, SettingSendCookies,
|
Method, Name, SortPriority, UpdatedAt, Url, UrlParameters, WorkspaceId,
|
||||||
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};
|
||||||
@@ -18,8 +16,8 @@ use std::collections::HashMap;
|
|||||||
use std::fmt::{Debug, Display};
|
use std::fmt::{Debug, Display};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_database::{Result as DbResult, UpdateSource};
|
|
||||||
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
||||||
|
use yaak_database::{UpdateSource, Result as DbResult};
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! impl_model {
|
macro_rules! impl_model {
|
||||||
@@ -92,84 +90,6 @@ 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 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),
|
|
||||||
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")]
|
||||||
@@ -402,10 +322,6 @@ pub struct Workspace {
|
|||||||
pub setting_request_timeout: i32,
|
pub setting_request_timeout: 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 {
|
||||||
@@ -447,8 +363,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
(SettingRequestTimeout, self.setting_request_timeout.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()),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,8 +380,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
WorkspaceIden::SettingRequestTimeout,
|
WorkspaceIden::SettingRequestTimeout,
|
||||||
WorkspaceIden::SettingValidateCertificates,
|
WorkspaceIden::SettingValidateCertificates,
|
||||||
WorkspaceIden::SettingDnsOverrides,
|
WorkspaceIden::SettingDnsOverrides,
|
||||||
WorkspaceIden::SettingSendCookies,
|
|
||||||
WorkspaceIden::SettingStoreCookies,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,8 +405,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
setting_request_timeout: row.get("setting_request_timeout")?,
|
setting_request_timeout: row.get("setting_request_timeout")?,
|
||||||
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")?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,127 +509,11 @@ 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 name: String,
|
pub raw_cookie: String,
|
||||||
pub value: String,
|
|
||||||
pub domain: CookieDomain,
|
pub domain: CookieDomain,
|
||||||
pub expires: CookieExpires,
|
pub expires: CookieExpires,
|
||||||
pub path: String,
|
pub path: (String, bool),
|
||||||
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)]
|
||||||
@@ -957,11 +751,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for Folder {
|
impl UpsertModelInfo for Folder {
|
||||||
@@ -1001,14 +790,6 @@ 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()),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,11 +803,6 @@ 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,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,11 +812,6 @@ 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")?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -1054,14 +825,6 @@ 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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1122,11 +885,6 @@ 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 {
|
||||||
@@ -1170,14 +928,6 @@ 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()),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1197,11 +947,6 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
Url,
|
Url,
|
||||||
UrlParameters,
|
UrlParameters,
|
||||||
SortPriority,
|
SortPriority,
|
||||||
SettingSendCookies,
|
|
||||||
SettingStoreCookies,
|
|
||||||
SettingValidateCertificates,
|
|
||||||
SettingFollowRedirects,
|
|
||||||
SettingRequestTimeout,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1210,11 +955,6 @@ 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")?,
|
||||||
@@ -1233,14 +973,6 @@ 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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1395,8 +1127,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for WebsocketRequest {
|
impl UpsertModelInfo for WebsocketRequest {
|
||||||
@@ -1439,8 +1169,6 @@ 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()),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,8 +1186,6 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
WebsocketRequestIden::SortPriority,
|
WebsocketRequestIden::SortPriority,
|
||||||
WebsocketRequestIden::Url,
|
WebsocketRequestIden::Url,
|
||||||
WebsocketRequestIden::UrlParameters,
|
WebsocketRequestIden::UrlParameters,
|
||||||
WebsocketRequestIden::SettingSendCookies,
|
|
||||||
WebsocketRequestIden::SettingStoreCookies,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1470,8 +1196,6 @@ 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")?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -1488,8 +1212,6 @@ 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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1771,15 +1493,6 @@ 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,
|
||||||
@@ -2029,8 +1742,6 @@ 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_send_cookies: InheritedBoolSetting,
|
|
||||||
pub setting_store_cookies: InheritedBoolSetting,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for GrpcRequest {
|
impl UpsertModelInfo for GrpcRequest {
|
||||||
@@ -2074,8 +1785,6 @@ 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()),
|
||||||
(SettingSendCookies, serde_json::to_string(&self.setting_send_cookies)?.into()),
|
|
||||||
(SettingStoreCookies, serde_json::to_string(&self.setting_store_cookies)?.into()),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2094,8 +1803,6 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
GrpcRequestIden::AuthenticationType,
|
GrpcRequestIden::AuthenticationType,
|
||||||
GrpcRequestIden::Authentication,
|
GrpcRequestIden::Authentication,
|
||||||
GrpcRequestIden::Metadata,
|
GrpcRequestIden::Metadata,
|
||||||
GrpcRequestIden::SettingSendCookies,
|
|
||||||
GrpcRequestIden::SettingStoreCookies,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2105,8 +1812,6 @@ 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_send_cookies: String = row.get("setting_send_cookies")?;
|
|
||||||
let setting_store_cookies: String = row.get("setting_store_cookies")?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -2124,8 +1829,6 @@ 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_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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2823,3 +2526,4 @@ impl AnyModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use crate::client_db::ClientDb;
|
|
||||||
use crate::connection_or_tx::ConnectionOrTx;
|
use crate::connection_or_tx::ConnectionOrTx;
|
||||||
|
use crate::client_db::ClientDb;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
AnyModel, Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden,
|
Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest,
|
||||||
HttpRequest, HttpRequestHeader, HttpRequestIden, ResolvedHttpRequestSettings, ResolvedSetting,
|
HttpRequestHeader, HttpRequestIden, WebsocketRequest, WebsocketRequestIden,
|
||||||
WebsocketRequest, WebsocketRequestIden,
|
|
||||||
};
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -142,60 +141,4 @@ 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
|
|
||||||
},
|
|
||||||
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,10 +1,7 @@
|
|||||||
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::{
|
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
|
||||||
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;
|
||||||
@@ -94,62 +91,6 @@ 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
|
|
||||||
},
|
|
||||||
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,
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
|
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
|
||||||
)
|
)
|
||||||
.build_rusqlite(SqliteQueryBuilder);
|
.build_rusqlite(SqliteQueryBuilder);
|
||||||
self.conn()
|
self.conn().resolve().query_row(sql.as_str(), &*params.as_params(), |row| row.try_into()).ok()
|
||||||
.resolve()
|
|
||||||
.query_row(sql.as_str(), &*params.as_params(), |row| row.try_into())
|
|
||||||
.ok()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_plugin_key_value(
|
pub fn set_plugin_key_value(
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ 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::{
|
||||||
AnyModel, Folder, FolderIden, HttpRequestHeader, ResolvedHttpRequestSettings, ResolvedSetting,
|
Folder, FolderIden, HttpRequestHeader, WebsocketRequest, WebsocketRequestIden,
|
||||||
WebsocketRequest, WebsocketRequestIden,
|
|
||||||
};
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -117,37 +116,4 @@ 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 {
|
|
||||||
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::{
|
||||||
AnyModel, EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
|
EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
|
||||||
ResolvedHttpRequestSettings, ResolvedSetting, WebsocketRequestIden, Workspace, WorkspaceIden,
|
WebsocketRequestIden, Workspace, WorkspaceIden,
|
||||||
};
|
};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -84,34 +84,6 @@ 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()),
|
|
||||||
),
|
|
||||||
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.
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ use std::collections::BTreeMap;
|
|||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_core::WorkspaceContext;
|
use yaak_core::WorkspaceContext;
|
||||||
|
|
||||||
pub use yaak_database::{
|
pub use yaak_database::{ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id};
|
||||||
ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
+56
-430
@@ -1,482 +1,108 @@
|
|||||||
// 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 AnyModel =
|
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
||||||
| CookieJar
|
|
||||||
| Environment
|
|
||||||
| Folder
|
|
||||||
| GraphQlIntrospection
|
|
||||||
| GrpcConnection
|
|
||||||
| GrpcEvent
|
|
||||||
| GrpcRequest
|
|
||||||
| HttpRequest
|
|
||||||
| HttpResponse
|
|
||||||
| HttpResponseEvent
|
|
||||||
| KeyValue
|
|
||||||
| Plugin
|
|
||||||
| Settings
|
|
||||||
| SyncState
|
|
||||||
| WebsocketConnection
|
|
||||||
| WebsocketEvent
|
|
||||||
| WebsocketRequest
|
|
||||||
| Workspace
|
|
||||||
| WorkspaceMeta;
|
|
||||||
|
|
||||||
export type ClientCertificate = {
|
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
||||||
host: string;
|
|
||||||
port: number | null;
|
|
||||||
crtFile: string | null;
|
|
||||||
keyFile: string | null;
|
|
||||||
pfxFile: string | null;
|
|
||||||
passphrase: string | null;
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Cookie = {
|
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
|
||||||
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 = {
|
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
||||||
model: "cookie_jar";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
workspaceId: string;
|
|
||||||
cookies: Array<Cookie>;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
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 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 = {
|
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||||
model: "environment";
|
/**
|
||||||
id: string;
|
* Variables defined in this environment scope.
|
||||||
workspaceId: string;
|
* Child environments override parent variables by name.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||||
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 = {
|
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, };
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GraphQlIntrospection = {
|
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
||||||
model: "graphql_introspection";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
workspaceId: string;
|
|
||||||
requestId: string;
|
|
||||||
content: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GrpcConnection = {
|
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, };
|
||||||
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 = {
|
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, };
|
||||||
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 =
|
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
||||||
| "info"
|
|
||||||
| "error"
|
|
||||||
| "client_message"
|
|
||||||
| "server_message"
|
|
||||||
| "connection_start"
|
|
||||||
| "connection_end";
|
|
||||||
|
|
||||||
export type GrpcRequest = {
|
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,
|
||||||
model: "grpc_request";
|
/**
|
||||||
id: string;
|
* Server URL (http for plaintext or https for secure)
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
url: 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;
|
|
||||||
settingSendCookies: InheritedBoolSetting;
|
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HttpRequest = {
|
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,
|
||||||
model: "http_request";
|
/**
|
||||||
id: string;
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
urlParameters: Array<HttpUrlParameter>, };
|
||||||
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 = {
|
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, };
|
||||||
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 = {
|
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||||
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 =
|
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, };
|
||||||
| {
|
|
||||||
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 = {
|
export type HttpUrlParameter = { enabled?: boolean,
|
||||||
enabled?: boolean;
|
/**
|
||||||
/**
|
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
* Other entries are appended as query parameters
|
||||||
* Other entries are appended as query parameters
|
*/
|
||||||
*/
|
name: string, value: string, id?: string, };
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
||||||
|
|
||||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
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 KeyValue = {
|
|
||||||
model: "key_value";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
key: string;
|
|
||||||
namespace: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 PluginSource = "bundled" | "filesystem" | "registry";
|
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||||
|
|
||||||
export type ProxySetting =
|
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
||||||
| {
|
|
||||||
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 = {
|
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> }, };
|
||||||
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 = {
|
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
||||||
model: "sync_state";
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
flushedAt: string;
|
|
||||||
modelId: string;
|
|
||||||
checksum: string;
|
|
||||||
relPath: string;
|
|
||||||
syncDir: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebsocketConnection = {
|
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, };
|
||||||
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 = {
|
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, };
|
||||||
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" | "frame" | "open" | "ping" | "pong" | "text";
|
||||||
|
|
||||||
export type 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,
|
||||||
model: "websocket_request";
|
/**
|
||||||
id: string;
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
urlParameters: Array<HttpUrlParameter>, };
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Workspace = {
|
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>, };
|
||||||
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>;
|
|
||||||
settingSendCookies: boolean;
|
|
||||||
settingStoreCookies: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceMeta = {
|
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
||||||
model: "workspace_meta";
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
encryptionKey: EncryptedKey | null;
|
|
||||||
settingSyncDir: string | null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -79,9 +79,10 @@ where
|
|||||||
let len = data.len();
|
let len = data.len();
|
||||||
self.bytes_count += len as u64;
|
self.bytes_count += len as u64;
|
||||||
self.chunks.push(data.clone());
|
self.chunks.push(data.clone());
|
||||||
let _ = self
|
let _ = self.event_tx.send(ProxyEvent::ResponseBodyChunk {
|
||||||
.event_tx
|
id: self.request_id,
|
||||||
.send(ProxyEvent::ResponseBodyChunk { id: self.request_id, bytes: len });
|
bytes: len,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Poll::Ready(Some(Ok(frame)))
|
Poll::Ready(Some(Ok(frame)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,14 +18,23 @@ impl CertificateAuthority {
|
|||||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||||
params.key_usages.push(KeyUsagePurpose::KeyCertSign);
|
params.key_usages.push(KeyUsagePurpose::KeyCertSign);
|
||||||
params.key_usages.push(KeyUsagePurpose::CrlSign);
|
params.key_usages.push(KeyUsagePurpose::CrlSign);
|
||||||
params.distinguished_name.push(rcgen::DnType::CommonName, "Debug Proxy CA");
|
params
|
||||||
params.distinguished_name.push(rcgen::DnType::OrganizationName, "Debug Proxy");
|
.distinguished_name
|
||||||
|
.push(rcgen::DnType::CommonName, "Debug Proxy CA");
|
||||||
|
params
|
||||||
|
.distinguished_name
|
||||||
|
.push(rcgen::DnType::OrganizationName, "Debug Proxy");
|
||||||
|
|
||||||
let key = KeyPair::generate()?;
|
let key = KeyPair::generate()?;
|
||||||
let ca_cert = params.self_signed(&key)?;
|
let ca_cert = params.self_signed(&key)?;
|
||||||
let ca_cert_der = ca_cert.der().clone();
|
let ca_cert_der = ca_cert.der().clone();
|
||||||
|
|
||||||
Ok(Self { ca_cert, ca_cert_der, ca_key: key, cache: Mutex::new(HashMap::new()) })
|
Ok(Self {
|
||||||
|
ca_cert,
|
||||||
|
ca_cert_der,
|
||||||
|
ca_key: key,
|
||||||
|
cache: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ca_pem(&self) -> String {
|
pub fn ca_pem(&self) -> String {
|
||||||
@@ -44,7 +53,9 @@ impl CertificateAuthority {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut params = CertificateParams::new(vec![domain.to_string()])?;
|
let mut params = CertificateParams::new(vec![domain.to_string()])?;
|
||||||
params.distinguished_name.push(rcgen::DnType::CommonName, domain);
|
params
|
||||||
|
.distinguished_name
|
||||||
|
.push(rcgen::DnType::CommonName, domain);
|
||||||
|
|
||||||
let leaf_key = KeyPair::generate()?;
|
let leaf_key = KeyPair::generate()?;
|
||||||
let leaf_cert = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key)?;
|
let leaf_cert = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key)?;
|
||||||
@@ -52,18 +63,20 @@ impl CertificateAuthority {
|
|||||||
let cert_der = leaf_cert.der().clone();
|
let cert_der = leaf_cert.der().clone();
|
||||||
let key_der = leaf_key.serialize_der();
|
let key_der = leaf_key.serialize_der();
|
||||||
|
|
||||||
let mut config =
|
let mut config = ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||||
ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
.with_safe_default_protocol_versions()?
|
||||||
.with_safe_default_protocol_versions()?
|
.with_no_client_auth()
|
||||||
.with_no_client_auth()
|
.with_single_cert(
|
||||||
.with_single_cert(
|
vec![cert_der, self.ca_cert_der.clone()],
|
||||||
vec![cert_der, self.ca_cert_der.clone()],
|
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
|
||||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
|
)?;
|
||||||
)?;
|
|
||||||
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
|
||||||
|
|
||||||
let config = Arc::new(config);
|
let config = Arc::new(config);
|
||||||
self.cache.lock().unwrap().insert(domain.to_string(), config.clone());
|
self.cache
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(domain.to_string(), config.clone());
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::sync::mpsc as std_mpsc;
|
use std::sync::mpsc as std_mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use hyper::server::conn::http1;
|
use hyper::server::conn::http1;
|
||||||
use hyper::service::service_fn;
|
use hyper::service::service_fn;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ mod connection;
|
|||||||
mod request;
|
mod request;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::AtomicU64;
|
use std::sync::atomic::AtomicU64;
|
||||||
use std::sync::mpsc as std_mpsc;
|
use std::sync::mpsc as std_mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use cert::CertificateAuthority;
|
use cert::CertificateAuthority;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
@@ -27,11 +27,7 @@ pub enum ProxyEvent {
|
|||||||
http_version: String,
|
http_version: String,
|
||||||
},
|
},
|
||||||
/// A request header sent to the upstream server.
|
/// A request header sent to the upstream server.
|
||||||
RequestHeader {
|
RequestHeader { id: u64, name: String, value: String },
|
||||||
id: u64,
|
|
||||||
name: String,
|
|
||||||
value: String,
|
|
||||||
},
|
|
||||||
/// The full request body (buffered before forwarding).
|
/// The full request body (buffered before forwarding).
|
||||||
RequestBody { id: u64, body: Vec<u8> },
|
RequestBody { id: u64, body: Vec<u8> },
|
||||||
/// Response headers received from upstream.
|
/// Response headers received from upstream.
|
||||||
@@ -42,11 +38,7 @@ pub enum ProxyEvent {
|
|||||||
elapsed_ms: u64,
|
elapsed_ms: u64,
|
||||||
},
|
},
|
||||||
/// A response header received from the upstream server.
|
/// A response header received from the upstream server.
|
||||||
ResponseHeader {
|
ResponseHeader { id: u64, name: String, value: String },
|
||||||
id: u64,
|
|
||||||
name: String,
|
|
||||||
value: String,
|
|
||||||
},
|
|
||||||
/// A chunk of the response body was received (emitted per-frame).
|
/// A chunk of the response body was received (emitted per-frame).
|
||||||
ResponseBodyChunk { id: u64, bytes: usize },
|
ResponseBodyChunk { id: u64, bytes: usize },
|
||||||
/// The response body stream has completed.
|
/// The response body stream has completed.
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ fn emit_request_events(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(body) = body {
|
if let Some(body) = body {
|
||||||
let _ = tx.send(ProxyEvent::RequestBody { id, body: body.clone() });
|
let _ = tx.send(ProxyEvent::RequestBody {
|
||||||
|
id,
|
||||||
|
body: body.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,13 +123,22 @@ async fn handle_http(
|
|||||||
let http_version = version_str(req.version());
|
let http_version = version_str(req.version());
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
let _ = event_tx.send(ProxyEvent::RequestStart { id, method, url: uri.clone(), http_version });
|
let _ = event_tx.send(ProxyEvent::RequestStart {
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
url: uri.clone(),
|
||||||
|
http_version,
|
||||||
|
});
|
||||||
|
|
||||||
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
|
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
|
||||||
|
|
||||||
let (parts, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
let body_bytes = body.collect().await?.to_bytes();
|
let body_bytes = body.collect().await?.to_bytes();
|
||||||
let request_body = if body_bytes.is_empty() { None } else { Some(body_bytes.to_vec()) };
|
let request_body = if body_bytes.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(body_bytes.to_vec())
|
||||||
|
};
|
||||||
emit_request_events(&event_tx, id, &parts.headers, &request_body);
|
emit_request_events(&event_tx, id, &parts.headers, &request_body);
|
||||||
|
|
||||||
let outgoing_req = Request::from_parts(parts, Full::new(body_bytes));
|
let outgoing_req = Request::from_parts(parts, Full::new(body_bytes));
|
||||||
@@ -136,10 +148,16 @@ async fn handle_http(
|
|||||||
emit_response_events(&event_tx, id, &resp, &start);
|
emit_response_events(&event_tx, id, &resp, &start);
|
||||||
|
|
||||||
let (parts, body) = resp.into_parts();
|
let (parts, body) = resp.into_parts();
|
||||||
Ok(Response::from_parts(parts, measured_incoming(body, id, start, event_tx)))
|
Ok(Response::from_parts(
|
||||||
|
parts,
|
||||||
|
measured_incoming(body, id, start, event_tx),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = event_tx.send(ProxyEvent::Error { id, error: e.to_string() });
|
let _ = event_tx.send(ProxyEvent::Error {
|
||||||
|
id,
|
||||||
|
error: e.to_string(),
|
||||||
|
});
|
||||||
Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +168,11 @@ async fn handle_connect(
|
|||||||
event_tx: std_mpsc::Sender<ProxyEvent>,
|
event_tx: std_mpsc::Sender<ProxyEvent>,
|
||||||
ca: Arc<CertificateAuthority>,
|
ca: Arc<CertificateAuthority>,
|
||||||
) -> Result<Response<BoxBody>, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<Response<BoxBody>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let authority = req.uri().authority().map(|a| a.to_string()).unwrap_or_default();
|
let authority = req
|
||||||
|
.uri()
|
||||||
|
.authority()
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
let (host, port) = parse_host_port(&authority);
|
let (host, port) = parse_host_port(&authority);
|
||||||
|
|
||||||
let server_config = ca.server_config(&host)?;
|
let server_config = ca.server_config(&host)?;
|
||||||
@@ -167,7 +189,10 @@ async fn handle_connect(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let tls_stream = match acceptor.accept(hyper_util::rt::TokioIo::new(upgraded)).await {
|
let tls_stream = match acceptor
|
||||||
|
.accept(hyper_util::rt::TokioIo::new(upgraded))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("TLS accept failed for {host}: {e}");
|
eprintln!("TLS accept failed for {host}: {e}");
|
||||||
@@ -178,7 +203,10 @@ async fn handle_connect(
|
|||||||
let tx = event_tx.clone();
|
let tx = event_tx.clone();
|
||||||
let host_for_requests = host.clone();
|
let host_for_requests = host.clone();
|
||||||
let mut builder = auto::Builder::new(TokioExecutor::new());
|
let mut builder = auto::Builder::new(TokioExecutor::new());
|
||||||
builder.http1().preserve_header_case(true).title_case_headers(true);
|
builder
|
||||||
|
.http1()
|
||||||
|
.preserve_header_case(true)
|
||||||
|
.title_case_headers(true);
|
||||||
if let Err(e) = builder
|
if let Err(e) = builder
|
||||||
.serve_connection_with_upgrades(
|
.serve_connection_with_upgrades(
|
||||||
hyper_util::rt::TokioIo::new(tls_stream),
|
hyper_util::rt::TokioIo::new(tls_stream),
|
||||||
@@ -243,12 +271,20 @@ async fn forward_https(
|
|||||||
let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed);
|
let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
let method = req.method().to_string();
|
let method = req.method().to_string();
|
||||||
let http_version = version_str(req.version());
|
let http_version = version_str(req.version());
|
||||||
let path = req.uri().path_and_query().map(|pq| pq.to_string()).unwrap_or_else(|| "/".into());
|
let path = req
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map(|pq| pq.to_string())
|
||||||
|
.unwrap_or_else(|| "/".into());
|
||||||
let uri_str = format!("https://{host}{path}");
|
let uri_str = format!("https://{host}{path}");
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
let _ =
|
let _ = event_tx.send(ProxyEvent::RequestStart {
|
||||||
event_tx.send(ProxyEvent::RequestStart { id, method, url: uri_str.clone(), http_version });
|
id,
|
||||||
|
method,
|
||||||
|
url: uri_str.clone(),
|
||||||
|
http_version,
|
||||||
|
});
|
||||||
|
|
||||||
// Connect to upstream with TLS
|
// Connect to upstream with TLS
|
||||||
let tcp_stream = TcpStream::connect(target_addr).await?;
|
let tcp_stream = TcpStream::connect(target_addr).await?;
|
||||||
@@ -269,13 +305,18 @@ async fn forward_https(
|
|||||||
let server_name = ServerName::try_from(host.to_string())?;
|
let server_name = ServerName::try_from(host.to_string())?;
|
||||||
let tls_stream = connector.connect(server_name, tcp_stream).await?;
|
let tls_stream = connector.connect(server_name, tcp_stream).await?;
|
||||||
|
|
||||||
let negotiated_h2 = tls_stream.get_ref().1.alpn_protocol().map_or(false, |p| p == b"h2");
|
let negotiated_h2 = tls_stream
|
||||||
|
.get_ref()
|
||||||
|
.1
|
||||||
|
.alpn_protocol()
|
||||||
|
.map_or(false, |p| p == b"h2");
|
||||||
|
|
||||||
let io = hyper_util::rt::TokioIo::new(tls_stream);
|
let io = hyper_util::rt::TokioIo::new(tls_stream);
|
||||||
|
|
||||||
let mut sender = if negotiated_h2 {
|
let mut sender = if negotiated_h2 {
|
||||||
let (sender, conn) =
|
let (sender, conn) = hyper::client::conn::http2::Builder::new(TokioExecutor::new())
|
||||||
hyper::client::conn::http2::Builder::new(TokioExecutor::new()).handshake(io).await?;
|
.handshake(io)
|
||||||
|
.await?;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = conn.await {
|
if let Err(e) = conn.await {
|
||||||
eprintln!("Upstream h2 connection error: {e}");
|
eprintln!("Upstream h2 connection error: {e}");
|
||||||
@@ -299,7 +340,11 @@ async fn forward_https(
|
|||||||
// Capture request metadata
|
// Capture request metadata
|
||||||
let (mut parts, body) = req.into_parts();
|
let (mut parts, body) = req.into_parts();
|
||||||
let body_bytes = body.collect().await?.to_bytes();
|
let body_bytes = body.collect().await?.to_bytes();
|
||||||
let request_body = if body_bytes.is_empty() { None } else { Some(body_bytes.to_vec()) };
|
let request_body = if body_bytes.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(body_bytes.to_vec())
|
||||||
|
};
|
||||||
emit_request_events(&event_tx, id, &parts.headers, &request_body);
|
emit_request_events(&event_tx, id, &parts.headers, &request_body);
|
||||||
|
|
||||||
if negotiated_h2 {
|
if negotiated_h2 {
|
||||||
@@ -320,10 +365,16 @@ async fn forward_https(
|
|||||||
emit_response_events(&event_tx, id, &resp, &start);
|
emit_response_events(&event_tx, id, &resp, &start);
|
||||||
|
|
||||||
let (parts, body) = resp.into_parts();
|
let (parts, body) = resp.into_parts();
|
||||||
Ok(Response::from_parts(parts, measured_incoming(body, id, start, event_tx)))
|
Ok(Response::from_parts(
|
||||||
|
parts,
|
||||||
|
measured_incoming(body, id, start, event_tx),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = event_tx.send(ProxyEvent::Error { id, error: e.to_string() });
|
let _ = event_tx.send(ProxyEvent::Error {
|
||||||
|
id,
|
||||||
|
error: e.to_string(),
|
||||||
|
});
|
||||||
Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+34
-168
@@ -1,181 +1,47 @@
|
|||||||
// 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 = {
|
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
||||||
hostname: string;
|
|
||||||
ipv4: Array<string>;
|
|
||||||
ipv6: Array<string>;
|
|
||||||
enabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Environment = {
|
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
||||||
model: "environment";
|
/**
|
||||||
id: string;
|
* Variables defined in this environment scope.
|
||||||
workspaceId: string;
|
* Child environments override parent variables by name.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
||||||
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 = {
|
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, };
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GrpcRequest = {
|
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,
|
||||||
model: "grpc_request";
|
/**
|
||||||
id: string;
|
* Server URL (http for plaintext or https for secure)
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
url: 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;
|
|
||||||
settingSendCookies: InheritedBoolSetting;
|
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HttpRequest = {
|
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,
|
||||||
model: "http_request";
|
/**
|
||||||
id: string;
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
createdAt: string;
|
*/
|
||||||
updatedAt: string;
|
urlParameters: Array<HttpUrlParameter>, };
|
||||||
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 = {
|
export type HttpUrlParameter = { enabled?: boolean,
|
||||||
enabled?: boolean;
|
/**
|
||||||
/**
|
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
* Other entries are appended as query parameters
|
||||||
* Other entries are appended as query parameters
|
*/
|
||||||
*/
|
name: string, value: string, id?: string, };
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
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 InheritedIntSetting = { enabled?: boolean; value: number };
|
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 SyncModel =
|
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,
|
||||||
| ({ type: "workspace" } & Workspace)
|
/**
|
||||||
| ({ type: "environment" } & Environment)
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
| ({ type: "folder" } & Folder)
|
*/
|
||||||
| ({ type: "http_request" } & HttpRequest)
|
urlParameters: Array<HttpUrlParameter>, };
|
||||||
| ({ type: "grpc_request" } & GrpcRequest)
|
|
||||||
| ({ type: "websocket_request" } & WebsocketRequest);
|
|
||||||
|
|
||||||
export type SyncState = {
|
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>, };
|
||||||
model: "sync_state";
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
flushedAt: string;
|
|
||||||
modelId: string;
|
|
||||||
checksum: string;
|
|
||||||
relPath: string;
|
|
||||||
syncDir: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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>;
|
|
||||||
settingSendCookies: boolean;
|
|
||||||
settingStoreCookies: boolean;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod escape;
|
pub mod escape;
|
||||||
pub mod format_json;
|
pub mod format_json;
|
||||||
|
pub mod strip_json_comments;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
pub mod strip_json_comments;
|
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
|
||||||
pub use parser::*;
|
pub use parser::*;
|
||||||
|
|||||||
@@ -113,8 +113,11 @@ pub fn strip_json_comments(text: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove lines that are now empty (were comment-only lines)
|
// Remove lines that are now empty (were comment-only lines)
|
||||||
let result =
|
let result = result
|
||||||
result.lines().filter(|line| !line.trim().is_empty()).collect::<Vec<&str>>().join("\n");
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
// Remove trailing commas before } or ]
|
// Remove trailing commas before } or ]
|
||||||
strip_trailing_commas(&result)
|
strip_trailing_commas(&result)
|
||||||
@@ -189,12 +192,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_trailing_line_comment() {
|
fn test_trailing_line_comment() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_json_comments(
|
strip_json_comments(r#"{
|
||||||
r#"{
|
|
||||||
"foo": "bar", // this is a comment
|
"foo": "bar", // this is a comment
|
||||||
"baz": 123
|
"baz": 123
|
||||||
}"#
|
}"#),
|
||||||
),
|
|
||||||
r#"{
|
r#"{
|
||||||
"foo": "bar",
|
"foo": "bar",
|
||||||
"baz": 123
|
"baz": 123
|
||||||
@@ -205,12 +206,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_whole_line_comment() {
|
fn test_whole_line_comment() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_json_comments(
|
strip_json_comments(r#"{
|
||||||
r#"{
|
|
||||||
// this is a comment
|
// this is a comment
|
||||||
"foo": "bar"
|
"foo": "bar"
|
||||||
}"#
|
}"#),
|
||||||
),
|
|
||||||
r#"{
|
r#"{
|
||||||
"foo": "bar"
|
"foo": "bar"
|
||||||
}"#
|
}"#
|
||||||
@@ -220,11 +219,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_inline_block_comment() {
|
fn test_inline_block_comment() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_json_comments(
|
strip_json_comments(r#"{
|
||||||
r#"{
|
|
||||||
"foo": /* a comment */ "bar"
|
"foo": /* a comment */ "bar"
|
||||||
}"#
|
}"#),
|
||||||
),
|
|
||||||
r#"{
|
r#"{
|
||||||
"foo": "bar"
|
"foo": "bar"
|
||||||
}"#
|
}"#
|
||||||
@@ -234,12 +231,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_whole_line_block_comment() {
|
fn test_whole_line_block_comment() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_json_comments(
|
strip_json_comments(r#"{
|
||||||
r#"{
|
|
||||||
/* a comment */
|
/* a comment */
|
||||||
"foo": "bar"
|
"foo": "bar"
|
||||||
}"#
|
}"#),
|
||||||
),
|
|
||||||
r#"{
|
r#"{
|
||||||
"foo": "bar"
|
"foo": "bar"
|
||||||
}"#
|
}"#
|
||||||
@@ -249,14 +244,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_multiline_block_comment() {
|
fn test_multiline_block_comment() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_json_comments(
|
strip_json_comments(r#"{
|
||||||
r#"{
|
|
||||||
/**
|
/**
|
||||||
* Hello World!
|
* Hello World!
|
||||||
*/
|
*/
|
||||||
"foo": "bar"
|
"foo": "bar"
|
||||||
}"#
|
}"#),
|
||||||
),
|
|
||||||
r#"{
|
r#"{
|
||||||
"foo": "bar"
|
"foo": "bar"
|
||||||
}"#
|
}"#
|
||||||
@@ -283,14 +276,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_multiple_comments() {
|
fn test_multiple_comments() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_json_comments(
|
strip_json_comments(r#"{
|
||||||
r#"{
|
|
||||||
// first comment
|
// first comment
|
||||||
"foo": "bar", // trailing
|
"foo": "bar", // trailing
|
||||||
/* block */
|
/* block */
|
||||||
"baz": 123
|
"baz": 123
|
||||||
}"#
|
}"#),
|
||||||
),
|
|
||||||
r#"{
|
r#"{
|
||||||
"foo": "bar",
|
"foo": "bar",
|
||||||
"baz": 123
|
"baz": 123
|
||||||
@@ -301,12 +292,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_trailing_comma_after_comment_removed() {
|
fn test_trailing_comma_after_comment_removed() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
strip_json_comments(
|
strip_json_comments(r#"{
|
||||||
r#"{
|
|
||||||
"a": "aaa",
|
"a": "aaa",
|
||||||
// "b": "bbb"
|
// "b": "bbb"
|
||||||
}"#
|
}"#),
|
||||||
),
|
|
||||||
r#"{
|
r#"{
|
||||||
"a": "aaa"
|
"a": "aaa"
|
||||||
}"#
|
}"#
|
||||||
@@ -315,7 +304,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_trailing_comma_in_array() {
|
fn test_trailing_comma_in_array() {
|
||||||
assert_eq!(strip_json_comments(r#"[1, 2, /* 3 */]"#), r#"[1, 2]"#);
|
assert_eq!(
|
||||||
|
strip_json_comments(r#"[1, 2, /* 3 */]"#),
|
||||||
|
r#"[1, 2]"#
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ use log::info;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||||
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
|
|
||||||
};
|
|
||||||
use yaak_models::render::make_vars_hashmap;
|
use yaak_models::render::make_vars_hashmap;
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user