Compare commits

...

11 Commits

Author SHA1 Message Date
Gregory Schier fa40ceaa31 Add cookie editing and inherited request settings (#463) 2026-05-18 08:59:49 -07:00
Gregory Schier dcfdf077e7 Add staged pre-commit checks 2026-05-13 14:22:43 -07:00
Gregory Schier bde5a474cc Fix production OXC bundle output 2026-05-10 08:18:32 -07:00
Gregory Schier 21f1dad7a4 Add macOS window controls (upgrade Tauri) (#460) 2026-05-09 06:57:44 -07:00
dependabot[bot] 6dac1265f3 Bump fast-uri from 3.1.0 to 3.1.2 (#459)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-05-09 06:57:28 -07:00
Gregory Schier 77ab293f87 Fix production client bundle exports 2026-05-09 06:15:27 -07:00
Gregory Schier 471a099b9b Refactor model sync scheduling 2026-05-08 12:57:22 -07:00
Gregory Schier b0b282535f Cargo fmt 2026-05-08 12:03:34 -07:00
Gregory Schier 19ed8c2f0d Clean up update downloads after install
Removes the update downloads cache directory after a successful integrated update install so cached artifacts do not accumulate.\n\nFeedback: https://yaak.app/feedback/posts/updates-cache-directory-cleanup
2026-05-08 12:00:25 -07:00
Gregory Schier d7e67cf13c Add live git status indicators (#458) 2026-05-08 11:25:39 -07:00
Gregory Schier 1b154ba550 Fix workspace content row sizing 2026-05-08 08:47:34 -07:00
118 changed files with 7647 additions and 2677 deletions
+1
View File
@@ -1 +1,2 @@
vp lint
vp staged
Generated
+394 -622
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -47,10 +47,10 @@ schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
sha2 = "0.10.9"
tauri = "2.9.5"
tauri-plugin = "2.5.2"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-shell = "2.3.3"
tauri = "2.11.1"
tauri-plugin = "2.6.1"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-shell = "2.3.5"
thiserror = "2.0.17"
tokio = "1.48.0"
ts-rs = "11.1.0"
@@ -1,19 +1,10 @@
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
showDialog({
id: "workspace-settings",
size: "md",
className: "h-[calc(100vh-5rem)] !max-h-[40rem]",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
WorkspaceSettingsDialog.show(workspaceId, tab);
}
@@ -15,6 +15,7 @@ import {
import { createFolder } from "../commands/commands";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { openSettings } from "../commands/openSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { switchWorkspace } from "../commands/switchWorkspace";
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useActiveEnvironment } from "../hooks/useActiveEnvironment";
@@ -36,7 +37,6 @@ import { appInfo } from "../lib/appInfo";
import { copyToClipboard } from "../lib/copy";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { editEnvironment } from "../lib/editEnvironment";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import {
@@ -99,6 +99,12 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
action: "settings.show",
onSelect: () => openSettings.mutate(null),
},
{
key: "workspace_settings.open",
label: "Open Workspace Settings",
action: "workspace_settings.show",
onSelect: () => openWorkspaceSettings(),
},
{
key: "app.create",
label: "Create Workspace",
@@ -127,13 +133,9 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{
key: "cookies.show",
label: "Show Cookies",
action: "cookies_editor.show",
onSelect: async () => {
showDialog({
id: "cookies",
title: "Manage Cookies",
size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
});
CookieDialog.show(activeCookieJar?.id ?? null);
},
},
{
+701 -41
View File
@@ -1,9 +1,40 @@
import type { Cookie } from "@yaakapp-internal/models";
import { cookieJarsAtom, patchModel } from "@yaakapp-internal/models";
import { formatDate } from "date-fns/format";
import { useAtomValue } from "jotai";
import {
type ComponentProps,
type CSSProperties,
type FormEvent,
type ReactNode,
type RefObject,
useMemo,
useRef,
useState,
} from "react";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { cookieDomain } from "../lib/model_util";
import { Banner, InlineCode } from "@yaakapp-internal/ui";
import {
Icon,
SplitLayout,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox";
import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { showAlert } from "../lib/alert";
interface Props {
cookieJarId: string | null;
@@ -12,56 +43,685 @@ interface Props {
export const CookieDialog = ({ cookieJarId }: Props) => {
const cookieJars = useAtomValue(cookieJarsAtom);
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
const [filter, setFilter] = useState("");
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const [selectedCookieKey, setSelectedCookieKey] = useState<string | null>(null);
const [editingCookieKey, setEditingCookieKey] = useState<string | null>(null);
const [draftCookie, setDraftCookie] = useState<Cookie | null>(null);
const [draftExpiresInput, setDraftExpiresInput] = useState("");
const editorFormRef = useRef<HTMLFormElement>(null);
const filteredCookies = useMemo(() => {
return cookieJar?.cookies.filter((cookie) => cookieMatchesFilter(cookie, filter)) ?? [];
}, [cookieJar?.cookies, filter]);
const selectedCookie = useMemo(
() =>
selectedCookieKey == null
? null
: (filteredCookies.find((cookie) => cookieKey(cookie) === selectedCookieKey) ?? null),
[filteredCookies, selectedCookieKey],
);
const detailCookie = draftCookie ?? selectedCookie;
const isCreatingCookie = editingCookieKey === NEW_COOKIE_KEY;
const isEditingCookie = draftCookie != null;
const handleAddCookie = () => {
setSelectedCookieKey(null);
setEditingCookieKey(NEW_COOKIE_KEY);
setDraftCookie(newCookieDraft());
setDraftExpiresInput("");
};
const handleEditCookie = () => {
if (selectedCookie == null) {
return;
}
setEditingCookieKey(cookieKey(selectedCookie));
setDraftCookie(selectedCookie);
setDraftExpiresInput(cookieExpiresInputValue(selectedCookie));
};
const handleCancelEdit = () => {
if (isCreatingCookie) {
setSelectedCookieKey(null);
}
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
const handleCloseDetails = () => {
if (isEditingCookie) {
handleCancelEdit();
return;
}
setSelectedCookieKey(null);
};
const handleSaveCookie = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (cookieJar == null || draftCookie == null) {
return;
}
let nextCookie = normalizeCookie(draftCookie);
if (nextCookie.expires !== "SessionEnd") {
const expires = cookieExpiresFromInput(draftExpiresInput);
if (expires == null) {
showAlert({
id: "invalid-cookie-expires",
title: "Invalid Cookie",
body: "Cookie expiration must be a valid date.",
});
return;
}
nextCookie = { ...nextCookie, expires };
}
const nextCookieKey = cookieKey(nextCookie);
const nextCookies = cookieJar.cookies.filter((cookie) => {
const key = cookieKey(cookie);
if (editingCookieKey != null && key === editingCookieKey) {
return false;
}
return key !== nextCookieKey;
});
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
setSelectedCookieKey(nextCookieKey);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
};
if (cookieJar == null) {
return <div>No cookie jar selected</div>;
}
if (cookieJar.cookies.length === 0) {
return (
<div className="pb-2 grid grid-rows-[auto_minmax(0,1fr)] space-y-2">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<PlainInput
name="cookie-filter"
label="Filter cookies"
hideLabel
placeholder="Filter cookies"
defaultValue={filter}
forceUpdateKey={filterUpdateKey}
onChange={setFilter}
rightSlot={
filter.length > 0 && (
<IconButton
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={() => {
setFilter("");
setFilterUpdateKey((key) => key + 1);
}}
/>
)
}
/>
<IconButton icon="plus" size="sm" title="Add cookie" onClick={handleAddCookie} />
</div>
{cookieJar.cookies.length === 0 && detailCookie == null ? (
<EmptyStateText>
Cookies will appear when a response includes a Set-Cookie header.
</EmptyStateText>
) : filteredCookies.length === 0 && detailCookie == null ? (
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
) : (
<SplitLayout
layout="vertical"
storageKey="cookie-dialog-details"
defaultRatio={0.5}
className="-mx-2"
minHeightPx={10}
firstSlot={({ style }) =>
filteredCookies.length === 0 ? (
<div style={style}>
<EmptyStateText>No cookies match the current filter.</EmptyStateText>
</div>
) : (
<Table scrollable style={style} className="pr-0.5">
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Value</TableHeaderCell>
<TableHeaderCell>Domain</TableHeaderCell>
<TableHeaderCell>Path</TableHeaderCell>
<TableHeaderCell>Expires</TableHeaderCell>
<TableHeaderCell>Size</TableHeaderCell>
<TableHeaderCell>HTTP Only</TableHeaderCell>
<TableHeaderCell>Secure</TableHeaderCell>
<TableHeaderCell>Same Site</TableHeaderCell>
<TableHeaderCell>
<IconButton
icon="list_x"
size="sm"
className="text-text-subtle"
title="Clear all cookies"
onClick={() => {
setSelectedCookieKey(null);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
patchModel(cookieJar, { cookies: [] });
}}
/>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody className="[&_td]:select-auto [&_td]:cursor-auto">
{filteredCookies.map((c: Cookie) => {
const key = cookieKey(c);
const isSelected = key === selectedCookieKey;
return (
<TableRow
key={key}
className={classNames(
"group/tr cursor-default",
isSelected && "[&_td]:bg-surface-highlight",
!isSelected && "hover:[&_td]:bg-surface-hover",
)}
onClick={() => {
setSelectedCookieKey(key);
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}}
>
<TableCell className={classNames("pl-2", isSelected && "rounded-l")}>
{c.name}
</TableCell>
<TruncatedWideTableCell className="min-w-[10rem]">
{c.value}
</TruncatedWideTableCell>
<TableCell>{cookieDomain(c)}</TableCell>
<TableCell>{c.path}</TableCell>
<TableCell>{cookieExpires(c)}</TableCell>
<TableCell>{cookieSize(c)}</TableCell>
<TableCell>
<Icon
icon={c.httpOnly ? "check" : "x"}
className={classNames(!c.httpOnly && "opacity-10")}
/>
</TableCell>
<TableCell>
<Icon
icon={c.secure ? "check" : "x"}
className={classNames(!c.secure && "opacity-10")}
/>
</TableCell>
<TableCell>{c.sameSite}</TableCell>
<TableCell className="rounded-r pr-2">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="text-text-subtlest ml-auto group-hover/tr:text-text transition-colors"
onClick={(event) => {
event.stopPropagation();
if (isSelected) {
setSelectedCookieKey(null);
}
if (editingCookieKey === key) {
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
}
patchModel(cookieJar, {
cookies: cookieJar.cookies.filter(
(c2: Cookie) => cookieKey(c2) !== key,
),
});
}}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)
}
secondSlot={
detailCookie == null
? null
: ({ style }) => (
<CookieDetailsPane
formRef={editorFormRef}
isEditing={isEditingCookie}
onSubmit={handleSaveCookie}
style={style}
>
<EventDetailHeader
title={isCreatingCookie ? "New Cookie" : detailCookie.name || "Cookie"}
copyText={isEditingCookie ? undefined : detailCookie.value}
actions={
isEditingCookie
? [
{
key: "save",
label: isCreatingCookie ? "Create" : "Save",
onClick: () => editorFormRef.current?.requestSubmit(),
},
{
key: "cancel",
label: "Cancel",
onClick: handleCancelEdit,
},
]
: [
{
key: "edit",
label: "Edit",
onClick: handleEditCookie,
},
]
}
onClose={handleCloseDetails}
/>
{isEditingCookie ? (
<CookieEditor
cookie={detailCookie}
expiresInputValue={draftExpiresInput}
onChange={setDraftCookie}
onExpiresInputChange={setDraftExpiresInput}
/>
) : (
<CookieDetails cookie={detailCookie} />
)}
</CookieDetailsPane>
)
}
/>
)}
</div>
);
};
function CookieDetailsPane({
children,
formRef,
isEditing,
onSubmit,
style,
}: {
children: ReactNode;
formRef: RefObject<HTMLFormElement | null>;
isEditing: boolean;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
style: CSSProperties;
}) {
const className = "grid grid-rows-[auto_minmax(0,1fr)] bg-surface border-t border-border pt-2";
if (isEditing) {
return (
<Banner>
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
</Banner>
<form ref={formRef} style={style} className={className} onSubmit={onSubmit}>
{children}
</form>
);
}
return (
<div className="pb-2">
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="py-2 text-left">Domain</th>
<th className="py-2 text-left pl-4">Cookie</th>
<th className="py-2 pl-4" />
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{cookieJar?.cookies.map((c: Cookie) => (
<tr key={JSON.stringify(c)}>
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
{cookieDomain(c)}
</td>
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
{c.raw_cookie}
</td>
<td className="max-w-0 w-10">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="ml-auto"
onClick={() =>
patchModel(cookieJar, {
cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c),
})
}
/>
</td>
</tr>
))}
</tbody>
</table>
<div style={style} className={className}>
{children}
</div>
);
}
CookieDialog.show = (cookieJarId: string | null) => {
const cookieJar = jotaiStore.get(cookieJarsAtom)?.find((jar) => jar.id === cookieJarId);
if (cookieJar == null) {
showAlert({
id: "invalid-jar",
body: `Failed to find cookie jar for ID: ${cookieJarId}`,
title: "Invalid Cookie Jar",
});
return;
}
showDialog({
id: "cookies",
title: `${cookieJar.name} Cookies`,
size: "full",
render: () => <CookieDialog cookieJarId={cookieJarId} />,
});
};
function CookieDetails({ cookie }: { cookie: Cookie }) {
return (
<div className="overflow-y-auto">
<KeyValueRows selectable>
<CookieKeyValueRow label="Name">{cookie.name}</CookieKeyValueRow>
<CookieKeyValueRow label="Value" enableCopy copyText={cookie.value}>
<pre className="whitespace-pre-wrap break-all">{cookie.value}</pre>
</CookieKeyValueRow>
<CookieKeyValueRow label="Domain">{cookieDomain(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Path">{cookie.path}</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">{cookieExpires(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow label="HTTP Only">{cookie.httpOnly ? "Yes" : "No"}</CookieKeyValueRow>
<CookieKeyValueRow label="Secure">{cookie.secure ? "Yes" : "No"}</CookieKeyValueRow>
{cookie.sameSite && (
<CookieKeyValueRow label="Same Site">{cookie.sameSite}</CookieKeyValueRow>
)}
</KeyValueRows>
</div>
);
}
function CookieEditor({
cookie,
expiresInputValue,
onChange,
onExpiresInputChange,
}: {
cookie: Cookie;
expiresInputValue: string;
onChange: (cookie: Cookie) => void;
onExpiresInputChange: (value: string) => void;
}) {
const sessionCookie = cookie.expires === "SessionEnd";
return (
<div className="overflow-y-auto">
<KeyValueRows>
<CookieKeyValueRow align="middle" label="Name">
<CookieTextInput
required
autoFocus
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookie.name}
onChange={(name) => onChange({ ...cookie, name })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Value">
<CookieTextarea
value={cookie.value}
onChange={(value) => onChange({ ...cookie, value })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Domain">
<CookieTextInput
required
pattern={NON_EMPTY_INPUT_PATTERN}
value={cookieDomainInputValue(cookie)}
placeholder="example.com"
onChange={(domain) => onChange(cookieWithDomain(cookie, domain))}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Path">
<CookieTextInput
value={cookie.path}
placeholder="/"
onChange={(path) => onChange({ ...cookie, path })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow label="Expires">
<div className="grid gap-1">
<Checkbox
checked={sessionCookie}
title="Session cookie"
onChange={(checked) => {
if (checked) {
onChange({ ...cookie, expires: "SessionEnd" });
return;
}
const expiresInput =
cookieExpiresFromInput(expiresInputValue) == null
? defaultCookieExpiresInputValue()
: expiresInputValue;
onExpiresInputChange(expiresInput);
onChange({
...cookie,
expires: cookieExpiresFromInput(expiresInput)!,
});
}}
/>
<CookieTextInput
value={sessionCookie ? "" : expiresInputValue}
disabled={sessionCookie}
onChange={(value) => {
onExpiresInputChange(value);
const expires = cookieExpiresFromInput(value);
if (expires != null) {
onChange({ ...cookie, expires });
}
}}
/>
</div>
</CookieKeyValueRow>
<CookieKeyValueRow label="Size">{cookieSize(cookie)}</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="HTTP Only">
<Checkbox
hideLabel
title="HTTP Only"
checked={cookie.httpOnly}
onChange={(httpOnly) => onChange({ ...cookie, httpOnly })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Secure">
<Checkbox
hideLabel
title="Secure"
checked={cookie.secure}
onChange={(secure) => onChange({ ...cookie, secure })}
/>
</CookieKeyValueRow>
<CookieKeyValueRow align="middle" label="Same Site">
<Select
hideLabel
name="cookie-same-site"
label="Same Site"
value={cookie.sameSite ?? ""}
size="xs"
className="w-full"
options={[
{ label: "n/a", value: "" },
{ label: "Lax", value: "Lax" },
{ label: "Strict", value: "Strict" },
{ label: "None", value: "None" },
]}
onChange={(sameSite) =>
onChange({
...cookie,
sameSite: sameSite === "" ? null : (sameSite as Cookie["sameSite"]),
})
}
/>
</CookieKeyValueRow>
</KeyValueRows>
</div>
);
}
function CookieKeyValueRow({ labelClassName, ...props }: ComponentProps<typeof KeyValueRow>) {
return <KeyValueRow labelClassName={classNames("w-[7rem]", labelClassName)} {...props} />;
}
function CookieTextInput({
autoFocus,
disabled,
onChange,
pattern,
placeholder,
required,
value,
}: {
autoFocus?: boolean;
disabled?: boolean;
onChange: (value: string) => void;
pattern?: string;
placeholder?: string;
required?: boolean;
value: string;
}) {
return (
<input
autoFocus={autoFocus}
className={cookieInputClassName}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
pattern={pattern}
placeholder={placeholder}
required={required}
type="text"
value={value}
/>
);
}
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
return (
<textarea
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
onChange={(event) => onChange(event.target.value)}
value={value}
/>
);
}
const NEW_COOKIE_KEY = "__new-cookie__";
const NON_EMPTY_INPUT_PATTERN = ".*\\S.*";
const cookieInputClassName = classNames(
"x-theme-input w-full min-w-0 min-h-sm rounded-md bg-transparent",
"border border-border-subtle outline-none",
"px-2 text-xs font-mono cursor-text placeholder:text-placeholder",
"focus:border-border-focus invalid:border-danger",
"disabled:opacity-disabled disabled:border-dotted",
);
function cookieSize(cookie: Cookie) {
const encoder = new TextEncoder();
return encoder.encode(cookie.name).length + encoder.encode(cookie.value).length;
}
function newCookieDraft(): Cookie {
return {
name: "",
value: "",
domain: "NotPresent",
expires: "SessionEnd",
path: "/",
secure: false,
httpOnly: false,
sameSite: null,
};
}
function normalizeCookie(cookie: Cookie): Cookie {
return {
...cookie,
domain: normalizeCookieDomain(cookie.domain),
name: cookie.name.trim(),
path: cookie.path.trim() || "/",
};
}
function normalizeCookieDomain(domain: Cookie["domain"]): Cookie["domain"] {
if (domain === "NotPresent" || domain === "Empty") {
return domain;
}
if ("Suffix" in domain) {
return { Suffix: domain.Suffix.trim() };
}
return { HostOnly: domain.HostOnly.trim() };
}
function cookieDomainInputValue(cookie: Cookie) {
const domain = cookieDomain(cookie);
return domain === "n/a" ? "" : domain;
}
function cookieWithDomain(cookie: Cookie, domain: string): Cookie {
const trimmedDomain = domain.trim();
if (trimmedDomain.length === 0) {
return { ...cookie, domain: "NotPresent" };
}
if (cookie.domain !== "NotPresent" && cookie.domain !== "Empty" && "Suffix" in cookie.domain) {
return { ...cookie, domain: { Suffix: trimmedDomain } };
}
return { ...cookie, domain: { HostOnly: trimmedDomain } };
}
function cookieExpires(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "Session";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return cookie.expires.AtUtc;
}
const date = new Date(expiresSeconds * 1000);
return formatDate(date, "MMM d, yyyy, h:mm:ss a");
}
function cookieExpiresInputValue(cookie: Cookie) {
if (cookie.expires === "SessionEnd") {
return "";
}
const expiresSeconds = Number(cookie.expires.AtUtc);
if (!Number.isFinite(expiresSeconds)) {
return "";
}
return new Date(expiresSeconds * 1000).toISOString();
}
function defaultCookieExpiresInputValue() {
return new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
}
function cookieExpiresFromInput(value: string): Cookie["expires"] | null {
const time = new Date(value).getTime();
if (!Number.isFinite(time)) {
return null;
}
return { AtUtc: `${Math.floor(time / 1000)}` };
}
function cookieMatchesFilter(cookie: Cookie, filter: string) {
const query = filter.trim().toLowerCase();
if (query.length === 0) {
return true;
}
return [cookie.name, cookie.value, cookieDomain(cookie)].some((value) =>
value.toLowerCase().includes(query),
);
}
function cookieKey(cookie: Cookie) {
return [cookie.name, cookieDomainKey(cookie.domain), cookie.path].join("|");
}
function cookieDomainKey(domain: Cookie["domain"]) {
if (typeof domain !== "string" && "HostOnly" in domain) {
return `HostOnly:${domain.HostOnly}`;
}
if (typeof domain !== "string" && "Suffix" in domain) {
return `Suffix:${domain.Suffix}`;
}
return domain;
}
@@ -4,7 +4,6 @@ import { memo, useMemo } from "react";
import { useActiveCookieJar } from "../hooks/useActiveCookieJar";
import { useCreateCookieJar } from "../hooks/useCreateCookieJar";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { showPrompt } from "../lib/prompt";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
import { CookieDialog } from "./CookieDialog";
@@ -36,12 +35,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
leftSlot: <Icon icon="cookie" />,
onSelect: () => {
if (activeCookieJar == null) return;
showDialog({
id: "cookies",
title: "Manage Cookies",
size: "full",
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
});
CookieDialog.show(activeCookieJar.id);
},
},
{
@@ -3,7 +3,7 @@ import { duplicateModel, patchModel } from "@yaakapp-internal/models";
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomFamily } from "jotai-family";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
@@ -21,6 +21,7 @@ import { EnvironmentEditor } from "./EnvironmentEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
interface Props {
folderId: string | null;
@@ -29,6 +30,7 @@ interface Props {
const TAB_AUTH = "auth";
const TAB_HEADERS = "headers";
const TAB_SETTINGS = "settings";
const TAB_VARIABLES = "variables";
const TAB_GENERAL = "general";
@@ -36,6 +38,7 @@ export type FolderSettingsTab =
| typeof TAB_AUTH
| typeof TAB_HEADERS
| typeof TAB_GENERAL
| typeof TAB_SETTINGS
| typeof TAB_VARIABLES;
export function FolderSettingsDialog({ folderId, tab }: Props) {
@@ -51,6 +54,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
(e) => e.parentModel === "folder" && e.parentId === folderId,
);
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
const numSettingsOverrides = folder == null ? 0 : countOverriddenSettings(folder);
const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return [];
@@ -60,6 +64,11 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
value: TAB_GENERAL,
label: "General",
},
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
...headersTab,
...authTab,
{
@@ -68,7 +77,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
},
];
}, [authTab, folder, headersTab, numVars]);
}, [authTab, folder, headersTab, numSettingsOverrides, numVars]);
if (folder == null) return null;
@@ -159,6 +168,9 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
stateKey={`headers.${folder.id}`}
/>
</TabContent>
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<ModelSettingsEditor model={folder} />
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
@@ -20,6 +20,7 @@ import { GrpcEditor } from "./GrpcEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
interface Props {
@@ -47,6 +48,7 @@ interface Props {
const TAB_MESSAGE = "message";
const TAB_METADATA = "metadata";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
export function GrpcRequestPane({
@@ -66,6 +68,7 @@ export function GrpcRequestPane({
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, "Metadata");
const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
const urlContainerEl = useRef<HTMLDivElement>(null);
@@ -128,13 +131,18 @@ export function GrpcRequestPane({
{ value: TAB_MESSAGE, label: "Message" },
...metadataTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: activeRequest.description && <CountBadge count={true} />,
},
],
[activeRequest.description, authTab, metadataTab],
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
);
const handleMetadataChange = useCallback(
@@ -278,6 +286,9 @@ export function GrpcRequestPane({
onChange={handleMetadataChange}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
@@ -51,6 +51,7 @@ import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
@@ -69,6 +70,7 @@ const TAB_BODY = "body";
const TAB_PARAMS = "params";
const TAB_HEADERS = "headers";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs";
@@ -92,6 +94,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent(
@@ -234,6 +237,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
},
...headersTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
@@ -246,6 +254,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
handleContentTypeChange,
headersTab,
numParams,
numSettingsOverrides,
urlParameterPairs.length,
],
);
@@ -372,6 +381,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? (
@@ -1,10 +1,15 @@
import type {
AnyModel,
HttpResponse,
HttpResponseEvent,
HttpResponseEventData,
} from "@yaakapp-internal/models";
import { foldersAtom, workspacesAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { type ReactNode, useMemo, useState } from "react";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { useAllRequests } from "../hooks/useAllRequests";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
@@ -95,6 +100,7 @@ function EventDetails({
}) {
const { label } = getEventDisplay(event.event);
const e = event.event;
const settingSourceModels = useSettingSourceModels();
const actions: EventDetailAction[] = [
{
@@ -211,6 +217,9 @@ function EventDetails({
<KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
{e.source_model != null ? (
<KeyValueRow label="Source">{formatSettingSource(e, settingSourceModels)}</KeyValueRow>
) : null}
</KeyValueRows>
);
}
@@ -315,6 +324,44 @@ function formatEventText(event: HttpResponseEventData, includePrefix: boolean):
return includePrefix ? `${prefix} ${text}` : text;
}
function useSettingSourceModels() {
const requests = useAllRequests();
const folders = useAtomValue(foldersAtom);
const workspaces = useAtomValue(workspacesAtom);
return useMemo<AnyModel[]>(
() => [...requests, ...folders, ...workspaces],
[requests, folders, workspaces],
);
}
function formatSettingSource(
event: Extract<HttpResponseEventData, { type: "setting" }>,
models: AnyModel[],
): string {
const sourceModel = event.source_model;
if (sourceModel == null || sourceModel === "default") {
return "Default";
}
const model =
event.source_id == null
? null
: (models.find((m) => m.model === sourceModel && m.id === event.source_id) ?? null);
const name = model == null ? event.source_name : resolvedModelName(model);
const label = sourceModel.replaceAll("_", " ");
return name == null || name.length === 0 ? label : `${name} (${label})`;
}
function formatSettingSourceModel(event: Extract<HttpResponseEventData, { type: "setting" }>) {
const sourceModel = event.source_model;
if (sourceModel == null || sourceModel === "default" || sourceModel === "workspace") {
return null;
}
return sourceModel;
}
type EventDisplay = {
icon: IconProps["icon"];
color: IconProps["color"];
@@ -325,11 +372,12 @@ type EventDisplay = {
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) {
case "setting":
const sourceModel = formatSettingSourceModel(event);
return {
icon: "settings",
color: "secondary",
label: "Setting",
summary: `${event.name} = ${event.value}`,
summary: `${event.name} = ${event.value}${sourceModel == null ? "" : ` (${sourceModel})`}`,
};
case "info":
return {
@@ -0,0 +1,347 @@
import type {
Folder,
GrpcRequest,
HttpRequest,
InheritedBoolSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { useModelAncestors } from "../hooks/useModelAncestors";
import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import {
SettingOverrideRow,
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 ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
};
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model);
return (
<SettingsList className="space-y-8">
{supportsTlsSettings && (
<SettingsSection title={showSectionTitles ? "Requests" : null}>
{supportsHttpSettings && (
<IntegerSettingRow
settingDefinition={SETTING_REQUEST_TIMEOUT}
setting={model.settingRequestTimeout}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_TIMEOUT.modelKey,
model.settingRequestTimeout,
)}
onChange={(settingRequestTimeout) =>
patchHttpSettings(model, {
settingRequestTimeout,
})
}
/>
)}
<BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_VALIDATE_CERTIFICATES.modelKey,
model.settingValidateCertificates,
)}
onChange={(settingValidateCertificates) =>
patchTlsSettings(model, {
settingValidateCertificates,
})
}
/>
{supportsHttpSettings && (
<BooleanSettingRow
settingDefinition={SETTING_FOLLOW_REDIRECTS}
setting={model.settingFollowRedirects}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_FOLLOW_REDIRECTS.modelKey,
model.settingFollowRedirects,
)}
onChange={(settingFollowRedirects) =>
patchHttpSettings(model, {
settingFollowRedirects,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_SEND_COOKIES.modelKey,
model.settingSendCookies,
)}
onChange={(settingSendCookies) =>
patchCookieSettings(model, {
settingSendCookies,
})
}
/>
<BooleanSettingRow
settingDefinition={SETTING_STORE_COOKIES}
setting={model.settingStoreCookies}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_STORE_COOKIES.modelKey,
model.settingStoreCookies,
)}
onChange={(settingStoreCookies) =>
patchCookieSettings(model, {
settingStoreCookies,
})
}
/>
</SettingsSection>
)}
</SettingsList>
);
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
}
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
}
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>);
throw new Error("Unsupported cookie settings model");
}
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 patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
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 modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
function BooleanSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: boolean;
setting: BooleanSetting;
settingDefinition: RequestSettingDefinition;
onChange: (setting: BooleanSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRowBoolean
checked={value}
title={settingDefinition.title}
description={settingDefinition.description}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Checkbox
hideLabel
size="md"
title={settingDefinition.title}
checked={value}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRowNumber
name={settingDefinition.modelKey}
title={settingDefinition.title}
description={settingDefinition.description}
value={value}
placeholder={`${settingDefinition.defaultValue}`}
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
onChange={(value) => onChange(value)}
/>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<PlainInput
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
type="number"
placeholder={`${settingDefinition.defaultValue}`}
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">;
+3 -1
View File
@@ -19,6 +19,7 @@ type Props = Omit<ButtonProps, "type"> & {
inline?: boolean;
noun?: string;
help?: ReactNode;
hideLabel?: boolean;
label?: ReactNode;
};
@@ -36,6 +37,7 @@ export function SelectFile({
size = "sm",
label,
help,
hideLabel,
...props
}: Props) {
const handleClick = async () => {
@@ -95,7 +97,7 @@ export function SelectFile({
return (
<div ref={ref} className="w-full">
{label && (
<Label htmlFor={null} help={help}>
<Label htmlFor={null} help={help} visuallyHidden={hideLabel}>
{label}
</Label>
)}
@@ -5,14 +5,27 @@ import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo";
import {
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../../lib/requestSettings";
import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature";
import { Checkbox } from "../core/Checkbox";
import { IconButton } from "../core/IconButton";
import { KeyValueRow, KeyValueRows } from "../core/KeyValueRow";
import { PlainInput } from "../core/PlainInput";
import { Select } from "../core/Select";
import { Separator } from "../core/Separator";
import {
ModelSettingRowBoolean,
ModelSettingRowNumber,
ModelSettingSelectControl,
SettingValue,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
@@ -29,147 +42,159 @@ export function SettingsGeneral() {
<Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
</div>
<CargoFeature feature="updater">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<Select
name="updateChannel"
label="Update Channel"
labelPosition="left"
labelClassName="w-[14rem]"
size="sm"
value={settings.updateChannel}
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta (more frequent)", value: "beta" },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
<SettingsList className="space-y-8">
<CargoFeature feature="updater">
<SettingsSection title="Updates">
<SettingRow
title="Update Channel"
description="Choose whether Yaak should use stable releases or beta releases."
>
<div className="grid grid-cols-[12rem_auto] gap-1">
<ModelSettingSelectControl
model={settings}
modelKey="updateChannel"
label="Update Channel"
selectClassName="!w-full"
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
</SettingRow>
<Select
name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"}
label="Update Behavior"
labelPosition="left"
size="sm"
labelClassName="w-[14rem]"
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
]}
/>
<Checkbox
className="pl-2 mt-1 ml-[14rem]"
checked={settings.autoDownloadUpdates}
disabled={!settings.autoupdate}
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
title="Automatically download updates"
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
/>
<Checkbox
className="pl-2 mt-1 ml-[14rem]"
checked={settings.checkNotifications}
title="Check for notifications"
help="Periodically ping Yaak servers to check for relevant notifications."
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/>
<Checkbox
disabled
className="pl-2 mt-1 ml-[14rem]"
checked={false}
title="Send anonymous usage statistics"
help="Yaak is local-first and does not collect analytics or usage data 🔐"
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/>
</CargoFeature>
<Separator className="my-4" />
<Heading level={2}>
Workspace{" "}
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
{workspace.name}
</div>
</Heading>
<VStack className="mt-1 w-full" space={3}>
<PlainInput
required
size="sm"
name="requestTimeout"
label="Request Timeout (ms)"
labelClassName="w-[14rem]"
placeholder="0"
labelPosition="left"
defaultValue={`${workspace.settingRequestTimeout}`}
validate={(value) => Number.parseInt(value, 10) >= 0}
onChange={(v) =>
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
}
type="number"
/>
<Checkbox
checked={workspace.settingValidateCertificates}
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
title="Validate TLS certificates"
onChange={(settingValidateCertificates) =>
patchModel(workspace, { settingValidateCertificates })
}
/>
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow redirects"
onChange={(settingFollowRedirects) =>
patchModel(workspace, {
settingFollowRedirects,
})
}
/>
</VStack>
<Separator className="my-4" />
<Heading level={2}>App Info</Heading>
<KeyValueRows>
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
<KeyValueRow
label="Data Directory"
rightSlot={
<IconButton
title={revealInFinderText}
icon="folder_open"
size="2xs"
onClick={() => revealItemInDir(appInfo.appDataDir)}
<SettingRowSelect
title="Update Behavior"
description="Choose whether updates are installed automatically or manually."
name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
]}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="autoDownloadUpdates"
title="Automatically download updates"
description="Download Yaak updates in the background so they are ready to install."
disabled={!settings.autoupdate}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="checkNotifications"
title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications."
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
disabled
checked={false}
onChange={() => {}}
/>
</SettingsSection>
</CargoFeature>
<SettingsSection
title={
<>
Workspace{" "}
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
{workspace.name}
</span>
</>
}
>
{appInfo.appDataDir}
</KeyValueRow>
<KeyValueRow
label="Logs Directory"
rightSlot={
<IconButton
title={revealInFinderText}
icon="folder_open"
size="2xs"
onClick={() => revealItemInDir(appInfo.appLogDir)}
<ModelSettingRowNumber
model={workspace}
modelKey={SETTING_REQUEST_TIMEOUT.modelKey}
title={SETTING_REQUEST_TIMEOUT.title}
description={SETTING_REQUEST_TIMEOUT.description}
placeholder={`${SETTING_REQUEST_TIMEOUT.defaultValue}`}
required
validate={(value) => Number.parseInt(value, 10) >= 0}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_VALIDATE_CERTIFICATES.modelKey}
title={SETTING_VALIDATE_CERTIFICATES.title}
description={SETTING_VALIDATE_CERTIFICATES.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_FOLLOW_REDIRECTS.modelKey}
title={SETTING_FOLLOW_REDIRECTS.title}
description={SETTING_FOLLOW_REDIRECTS.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_SEND_COOKIES.modelKey}
title={SETTING_SEND_COOKIES.title}
description={SETTING_SEND_COOKIES.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_STORE_COOKIES.modelKey}
title={SETTING_STORE_COOKIES.title}
description={SETTING_STORE_COOKIES.description}
/>
</SettingsSection>
<SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version.">
<SettingValue value={appInfo.version} />
</SettingRow>
<SettingRow
title="Data Directory"
description="Where Yaak stores application data."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appDataDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appDataDir),
},
]}
/>
}
>
{appInfo.appLogDir}
</KeyValueRow>
</KeyValueRows>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
);
}
@@ -3,7 +3,7 @@ import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { clamp, Heading, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
@@ -13,7 +13,16 @@ import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link";
import { Select } from "../core/Select";
import {
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__";
@@ -38,154 +47,172 @@ export function SettingsInterface() {
}
return (
<VStack space={3} className="mb-4">
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<Select
name="switchWorkspaceBehavior"
label="Open workspace behavior"
size="sm"
help="When opening a workspace, should it open in the current window or a new window?"
value={
settings.openWorkspaceNewWindow === true
? "new"
: settings.openWorkspaceNewWindow === false
? "current"
: "ask"
}
onChange={async (v) => {
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
options={[
{ label: "Always ask", value: "ask" },
{ label: "Open in current window", value: "current" },
{ label: "Open in new window", value: "new" },
]}
/>
<HStack space={2} alignItems="end">
{fonts.data && (
<Select
size="sm"
name="uiFont"
label="Interface font"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...(fonts.data.uiFonts.map((f) => ({
label: f,
value: f,
})) ?? []),
// Some people like monospace fonts for the UI
...(fonts.data.editorFonts.map((f) => ({
label: f,
value: f,
})) ?? []),
]}
<SettingsList className="space-y-8">
<SettingsSection title="Workspaces">
<SettingRowSelect
title="Open workspace behavior"
description="Choose what happens when opening another workspace."
name="switchWorkspaceBehavior"
value={
settings.openWorkspaceNewWindow === true
? "new"
: settings.openWorkspaceNewWindow === false
? "current"
: "ask"
}
onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { interfaceFont });
if (v === "current") await patchModel(settings, { openWorkspaceNewWindow: false });
else if (v === "new") await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
/>
)}
<Select
hideLabel
size="sm"
name="interfaceFontSize"
label="Interface Font Size"
defaultValue="14"
value={`${settings.interfaceFontSize}`}
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</HStack>
<HStack space={2} alignItems="end">
{fonts.data && (
<Select
size="sm"
name="editorFont"
label="Editor font"
value={settings.editorFont ?? NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...(fonts.data.editorFonts.map((f) => ({
label: f,
value: f,
})) ?? []),
{ label: "Always ask", value: "ask" },
{ label: "Open in current window", value: "current" },
{ label: "Open in new window", value: "new" },
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<Select
hideLabel
size="sm"
name="editorFontSize"
label="Editor Font Size"
defaultValue="12"
value={`${settings.editorFontSize}`}
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
}
/>
</HStack>
<Select
leftSlot={<Icon icon="keyboard" color="secondary" />}
size="sm"
name="editorKeymap"
label="Editor keymap"
value={`${settings.editorKeymap}`}
options={keymaps}
onChange={(v) => patchModel(settings, { editorKeymap: v })}
/>
<Checkbox
checked={settings.editorSoftWrap}
title="Wrap editor lines"
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
/>
<Checkbox
checked={settings.coloredMethods}
title="Colorize request methods"
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
/>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
</SettingsSection>
<NativeTitlebarSetting settings={settings} />
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="!w-72"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { interfaceFont });
}}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="!w-20"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
{type() !== "macos" && (
<Checkbox
checked={settings.hideWindowControls}
title="Hide window controls"
help="Hide the close/maximize/minimize controls on Windows or Linux"
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
/>
)}
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="!w-72"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="!w-20"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<ModelSettingRowSelect
model={settings}
modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="coloredMethods"
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
</SettingsList>
</VStack>
);
}
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return (
<div className="flex gap-1 overflow-hidden h-2xs">
<SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox
hideLabel
size="md"
checked={nativeTitlebar}
title="Native title bar"
help="Use the operating system's standard title bar and window controls"
onChange={setNativeTitlebar}
/>
{settings.useNativeTitlebar !== nativeTitlebar && (
<Button
color="primary"
size="2xs"
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart");
@@ -194,7 +221,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
Apply and Restart
</Button>
)}
</div>
</SettingRow>
);
}
@@ -205,37 +232,40 @@ function LicenseSettings({ settings }: { settings: Settings }) {
}
return (
<Checkbox
checked={settings.hideLicenseBadge}
title="Hide personal use badge"
onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) {
const confirmed = await showConfirm({
id: "hide-license-badge",
title: "Confirm Personal Use",
confirmText: "Confirm",
description: (
<VStack space={3}>
<p>Hey there 👋🏼</p>
<p>
Yaak is free for personal projects and learning.{" "}
<strong>If youre using Yaak at work, a license is required.</strong>
</p>
<p>
Licenses help keep Yaak independent and sustainable.{" "}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
</p>
</VStack>
),
requireTyping: "Personal Use",
color: "info",
});
if (!confirmed) {
return; // Cancel
<SettingsSection title="License">
<SettingRowBoolean
checked={settings.hideLicenseBadge}
title="Hide personal use badge"
description="Hide the personal-use badge from the interface."
onChange={async (hideLicenseBadge) => {
if (hideLicenseBadge) {
const confirmed = await showConfirm({
id: "hide-license-badge",
title: "Confirm Personal Use",
confirmText: "Confirm",
description: (
<VStack space={3}>
<p>Hey there 👋🏼</p>
<p>
Yaak is free for personal projects and learning.{" "}
<strong>If youre using Yaak at work, a license is required.</strong>
</p>
<p>
Licenses help keep Yaak independent and sustainable.{" "}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
</p>
</VStack>
),
requireTyping: "Personal Use",
color: "info",
});
if (!confirmed) {
return;
}
}
}
await patchModel(settings, { hideLicenseBadge });
}}
/>
await patchModel(settings, { hideLicenseBadge });
}}
/>
</SettingsSection>
);
}
@@ -1,13 +1,28 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { Checkbox } from "../core/Checkbox";
import { PlainInput } from "../core/PlainInput";
import { Select } from "../core/Select";
import { Separator } from "../core/Separator";
import {
SettingRowBoolean,
SettingRowSelect,
SettingRowText,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
export function SettingsProxy() {
const settings = useAtomValue(settingsAtom);
const proxy = enabledProxyOrDefault(settings.proxy);
const patchProxy = async (patch: Partial<EnabledProxySetting>) => {
await patchModel(settings, {
proxy: {
...proxy,
...patch,
auth: Object.hasOwn(patch, "auth") ? (patch.auth ?? null) : proxy.auth,
},
});
};
return (
<VStack space={1.5} className="mb-4">
@@ -18,188 +33,146 @@ export function SettingsProxy() {
traffic, or routing through specific infrastructure.
</p>
</div>
<Select
name="proxy"
label="Proxy"
hideLabel
size="sm"
value={settings.proxy?.type ?? "automatic"}
onChange={async (v) => {
if (v === "automatic") {
await patchModel(settings, { proxy: undefined });
} else if (v === "enabled") {
await patchModel(settings, {
proxy: {
disabled: false,
type: "enabled",
http: "",
https: "",
auth: { user: "", password: "" },
bypass: "",
},
});
} else {
await patchModel(settings, { proxy: { type: "disabled" } });
}
}}
options={[
{ label: "Automatic proxy detection", value: "automatic" },
{ label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" },
]}
/>
{settings.proxy?.type === "enabled" && (
<VStack space={1.5}>
<Checkbox
className="my-3"
checked={!settings.proxy.disabled}
title="Enable proxy"
help="Use this to temporarily disable the proxy without losing the configuration"
onChange={async (enabled) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = !enabled;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/>
<HStack space={1.5}>
<PlainInput
size="sm"
label={
<>
Proxy for <InlineCode>http://</InlineCode> traffic
</>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
title="Proxy"
description="Choose how Yaak should discover or use proxy settings."
name="proxy"
value={settings.proxy?.type ?? "automatic"}
onChange={async (v) => {
if (v === "automatic") {
await patchModel(settings, { proxy: undefined });
} else if (v === "enabled") {
await patchModel(settings, { proxy });
} else {
await patchModel(settings, { proxy: { type: "disabled" } });
}
placeholder="localhost:9090"
defaultValue={settings.proxy?.http}
onChange={async (http) => {
const { proxy } = settings;
const https = proxy?.type === "enabled" ? proxy.https : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
await patchModel(settings, {
proxy: {
type: "enabled",
http,
https,
auth,
disabled,
bypass,
},
});
}}
/>
<PlainInput
size="sm"
label={
<>
Proxy for <InlineCode>https://</InlineCode> traffic
</>
}
placeholder="localhost:9090"
defaultValue={settings.proxy?.https}
onChange={async (https) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = proxy?.type === "enabled" ? proxy.auth : null;
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
/>
</HStack>
<Separator className="my-6" />
<Checkbox
checked={settings.proxy.auth != null}
title="Enable authentication"
onChange={async (enabled) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const auth = enabled ? { user: "", password: "" } : null;
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
options={[
{ label: "Automatic proxy detection", value: "automatic" },
{ label: "Custom proxy configuration", value: "enabled" },
{ label: "No proxy", value: "disabled" },
]}
selectClassName="!w-64"
/>
</SettingsSection>
{settings.proxy.auth != null && (
<HStack space={1.5}>
<PlainInput
required
size="sm"
label="User"
placeholder="myUser"
defaultValue={settings.proxy.auth.user}
onChange={async (user) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
{settings.proxy?.type === "enabled" && (
<>
<SettingsSection title="Custom Proxy">
<SettingRowBoolean
checked={!settings.proxy.disabled}
title="Enable proxy"
description="Temporarily disable the proxy without losing the configuration."
onChange={(enabled) => patchProxy({ disabled: !enabled })}
/>
<PlainInput
size="sm"
label="Password"
type="password"
placeholder="s3cretPassw0rd"
defaultValue={settings.proxy.auth.password}
onChange={async (password) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const bypass = proxy?.type === "enabled" ? proxy.bypass : "";
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
<SettingRowText
name="proxyHttp"
title={
<>
Proxy for <InlineCode>http://</InlineCode> traffic
</>
}
description="Proxy host used for unencrypted HTTP traffic."
value={settings.proxy.http}
placeholder="localhost:9090"
onChange={(http) => patchProxy({ http })}
/>
</HStack>
)}
{settings.proxy.type === "enabled" && (
<>
<Separator className="my-6" />
<PlainInput
label="Proxy Bypass"
help="Comma-separated list to bypass the proxy."
defaultValue={settings.proxy.bypass}
<SettingRowText
name="proxyHttps"
title={
<>
Proxy for <InlineCode>https://</InlineCode> traffic
</>
}
description="Proxy host used for HTTPS traffic."
value={settings.proxy.https}
placeholder="localhost:9090"
onChange={(https) => patchProxy({ https })}
/>
<SettingRowText
name="proxyBypass"
title="Proxy Bypass"
description="Comma-separated list of hosts that should bypass the proxy."
value={settings.proxy.bypass}
placeholder="127.0.0.1, *.example.com, localhost:3000"
onChange={async (bypass) => {
const { proxy } = settings;
const http = proxy?.type === "enabled" ? proxy.http : "";
const https = proxy?.type === "enabled" ? proxy.https : "";
const disabled = proxy?.type === "enabled" ? proxy.disabled : false;
const user = proxy?.type === "enabled" ? (proxy.auth?.user ?? "") : "";
const password = proxy?.type === "enabled" ? (proxy.auth?.password ?? "") : "";
const auth = { user, password };
await patchModel(settings, {
proxy: { type: "enabled", http, https, auth, disabled, bypass },
});
}}
inputWidthClassName="!w-96"
onChange={(bypass) => patchProxy({ bypass })}
/>
</>
)}
</VStack>
)}
</SettingsSection>
<SettingsSection title="Authentication">
<SettingRowBoolean
checked={settings.proxy.auth != null}
title="Enable authentication"
description="Send proxy credentials with proxied requests."
onChange={(enabled) =>
patchProxy({ auth: enabled ? { user: "", password: "" } : null })
}
/>
{settings.proxy.auth != null && (
<>
<SettingRowText
required
name="proxyUser"
title="User"
description="Username for proxy authentication."
value={settings.proxy.auth.user}
placeholder="myUser"
onChange={(user) =>
patchProxy({
auth: {
user,
password:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.password ?? "")
: "",
},
})
}
/>
<SettingRowText
name="proxyPassword"
title="Password"
description="Password for proxy authentication."
value={settings.proxy.auth.password}
placeholder="s3cretPassw0rd"
type="password"
onChange={(password) =>
patchProxy({
auth: {
user:
settings.proxy?.type === "enabled"
? (settings.proxy.auth?.user ?? "")
: "",
password,
},
})
}
/>
</>
)}
</SettingsSection>
</>
)}
</SettingsList>
</VStack>
);
}
type EnabledProxySetting = Extract<ProxySetting, { type: "enabled" }>;
function enabledProxyOrDefault(proxy: ProxySetting | null): EnabledProxySetting {
if (proxy?.type === "enabled") return proxy;
return {
disabled: false,
type: "enabled",
http: "",
https: "",
auth: { user: "", password: "" },
bypass: "",
};
}
@@ -9,7 +9,12 @@ import type { ButtonProps } from "../core/Button";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import type { SelectProps } from "../core/Select";
import { Select } from "../core/Select";
import {
ModelSettingRowSelect,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
@@ -67,7 +72,7 @@ export function SettingsTheme() {
}));
return (
<VStack space={3} className="mb-4">
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
@@ -77,96 +82,92 @@ export function SettingsTheme() {
</Link>
</p>
</div>
<Select
name="appearance"
label="Appearance"
labelPosition="top"
size="sm"
value={settings.appearance}
onChange={(appearance) => patchModel(settings, { appearance })}
options={[
{ label: "Automatic", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
<HStack space={2}>
{(settings.appearance === "system" || settings.appearance === "light") && (
<Select
hideLabel
leftSlot={<Icon icon="sun" color="secondary" />}
name="lightTheme"
label="Light Theme"
size="sm"
className="flex-1"
value={activeTheme.data.light.id}
options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })}
<SettingsList className="space-y-8">
<SettingsSection title="Theme">
<ModelSettingRowSelect
model={settings}
modelKey="appearance"
title="Appearance"
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
options={[
{ label: "Automatic", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
)}
{(settings.appearance === "system" || settings.appearance === "dark") && (
<Select
hideLabel
name="darkTheme"
className="flex-1"
label="Dark Theme"
leftSlot={<Icon icon="moon" color="secondary" />}
size="sm"
value={activeTheme.data.dark.id}
options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })}
/>
)}
</HStack>
{(settings.appearance === "system" || settings.appearance === "light") && (
<SettingRowSelect
name="lightTheme"
title="Light theme"
description="Theme used when Yaak is in light mode."
value={activeTheme.data.light.id}
options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })}
/>
)}
{(settings.appearance === "system" || settings.appearance === "dark") && (
<SettingRowSelect
name="darkTheme"
title="Dark theme"
description="Theme used when Yaak is in dark mode."
value={activeTheme.data.dark.id}
options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })}
/>
)}
</SettingsSection>
<VStack
space={3}
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
<strong>{activeTheme.data.active.label}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
</HStack>
<Suspense>
<Editor
defaultValue={[
"let foo = { // Demo code editor",
' foo: ("bar" || "baz" ?? \'qux\'),',
" baz: [1, 10.2, null, false, true],",
"};",
].join("\n")}
heightMode="auto"
language="javascript"
stateKey={null}
/>
</Suspense>
</VStack>
<SettingsSection title="Preview">
<VStack
space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
<strong>{activeTheme.data.active.label}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
</HStack>
<Suspense>
<Editor
defaultValue={[
"let foo = { // Demo code editor",
' foo: ("bar" || "baz" ?? \'qux\'),',
" baz: [1, 10.2, null, false, true],",
"};",
].join("\n")}
heightMode="auto"
language="javascript"
stateKey={null}
/>
</Suspense>
</VStack>
</SettingsSection>
</SettingsList>
</VStack>
);
}
+159 -4
View File
@@ -1,6 +1,8 @@
import type { Extension } from "@codemirror/state";
import { Compartment } from "@codemirror/state";
import { debounce } from "@yaakapp-internal/lib";
import { gitMutations } from "@yaakapp-internal/git";
import type { GitStatus } from "@yaakapp-internal/git";
import type {
AnyModel,
Folder,
@@ -23,13 +25,18 @@ import {
} from "@yaakapp-internal/models";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import { atomFamily, selectAtom } from "jotai/utils";
import { atomFamily } from "jotai-family";
import { selectAtom } from "jotai/utils";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { moveToWorkspace } from "../commands/moveToWorkspace";
import { openFolderSettings } from "../commands/openFolderSettings";
import { activeFolderIdAtom } from "../hooks/useActiveFolderId";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import {
activeWorkspaceAtom,
activeWorkspaceIdAtom,
activeWorkspaceMetaAtom,
} from "../hooks/useActiveWorkspace";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems";
import { getFolderActions } from "../hooks/useFolderActions";
@@ -42,7 +49,13 @@ import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions";
import { deepEqualAtom } from "../lib/atoms";
import { showConfirm } from "../lib/confirm";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import {
gitWorktreeStatusByModelIdAtom,
gitWorktreeStatusFamily,
} from "../lib/gitWorktreeStatus";
import { jotaiStore } from "../lib/jotai";
import { resolvedModelName } from "../lib/resolvedModelName";
import { isSidebarFocused } from "../lib/scopes";
@@ -68,6 +81,9 @@ import type { InputHandle } from "./core/Input";
import { Input } from "./core/Input";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
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 key = ["sidebar_collapsed", treeId ?? "n/a"];
@@ -375,6 +391,8 @@ function Sidebar({ className }: { className?: string }) {
}
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 requestItems = items.filter(
(i) =>
@@ -458,8 +476,10 @@ function Sidebar({ className }: { className?: string }) {
...initialItems,
{
type: "separator",
hidden: initialItems.filter((v) => !v.hidden).length === 0,
hidden: initialItems.filter((v) => !v.hidden).length === 0 || gitItems.length === 0,
},
...gitItems,
{ type: "separator", hidden: gitItems.length === 0 },
{
label: "Rename",
leftSlot: <Icon icon="pencil" />,
@@ -661,6 +681,73 @@ function Sidebar({ className }: { className?: string }) {
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) => {
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
});
@@ -790,6 +877,64 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
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) {
const responses = jotaiStore.get(httpResponsesAtom);
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
@@ -836,6 +981,7 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
treeId: string;
item: SidebarModel;
}) {
const gitStatus = useAtomValue(sidebarGitStatusFamily(item.id));
const response = useAtomValue(
useMemo(
() =>
@@ -854,7 +1000,16 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
return (
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
<div className="truncate">{resolvedModelName(item)}</div>
<div
className={classNames(
"truncate",
gitStatus === "modified" && "text-info",
gitStatus === "untracked" && "text-success",
gitStatus === "removed" && "text-danger",
)}
>
{resolvedModelName(item)}
</div>
{response != null && (
<div className="ml-auto">
{response.state !== "closed" ? (
@@ -4,20 +4,79 @@ import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile";
export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
onCreateNewWorkspace: () => void;
value: { filePath: string | null; initGit?: boolean };
}
export function SyncToFilesystemSetting({
layout = "form",
onChange,
onCreateNewWorkspace,
value,
}: SyncToFilesystemSettingProps) {
const [syncDir, setSyncDir] = useState<string | null>(null);
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
};
if (layout === "settings") {
return (
<VStack className="w-full" space={0}>
{syncDir && (
<Banner color="notice" className="mb-3 flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(syncDir);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SettingRowDirectory
title="Local directory sync"
description="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={handleFilePathChange}
/>
{value.filePath && typeof value.initGit === "boolean" && (
<SettingRowBoolean
checked={value.initGit}
title="Initialize Git Repo"
description="Create a Git repository in the selected sync directory."
onChange={(initGit) => onChange({ ...value, initGit })}
/>
)}
</VStack>
);
}
return (
<VStack className="w-full my-2" space={3}>
{syncDir && (
@@ -47,18 +106,7 @@ export function SyncToFilesystemSetting({
noun="Directory"
help="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={async ({ filePath }) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
}
}
setSyncDir(null);
onChange({ ...value, filePath });
}}
onChange={async ({ filePath }) => handleFilePathChange(filePath)}
/>
{value.filePath && typeof value.initGit === "boolean" && (
@@ -34,6 +34,7 @@ import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
@@ -48,6 +49,7 @@ const TAB_MESSAGE = "message";
const TAB_PARAMS = "params";
const TAB_HEADERS = "headers";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs";
@@ -69,6 +71,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const authTab = useAuthTab(TAB_AUTH, activeRequest);
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
const inheritedHeaders = useInheritedHeaders(activeRequest);
const numSettingsOverrides = countOverriddenSettings(activeRequest);
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
useRequestEditorEvent(
@@ -109,12 +112,17 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
},
...headersTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
},
];
}, [authTab, headersTab, urlParameterPairs.length]);
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -266,6 +274,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`json.${activeRequest.id}`}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
+7 -1
View File
@@ -6,6 +6,7 @@ import { useAtomValue } from "jotai";
import * as m from "motion/react-m";
import { useMemo, useState } from "react";
import {
getActiveCookieJar,
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
} from "../hooks/useActiveCookieJar";
@@ -33,6 +34,7 @@ import { jotaiStore } from "../lib/jotai";
import { CreateDropdown } from "./CreateDropdown";
import { Button } from "./core/Button";
import { HotkeyList } from "./core/HotkeyList";
import { CookieDialog } from "./CookieDialog";
import { FeedbackLink } from "./core/Link";
import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout";
@@ -128,7 +130,7 @@ export function Workspace() {
);
return (
<div className="grid w-full h-full grid-rows-[auto_1fr]">
<div className="grid w-full h-full grid-rows-[auto_minmax(0,1fr)]">
{header}
<SidebarLayout
width={width ?? 250}
@@ -218,4 +220,8 @@ function useGlobalWorkspaceHooks() {
useHotKey("model.duplicate", () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
);
useHotKey("cookies_editor.show", () => CookieDialog.show(getActiveCookieJar()?.id ?? null), {
enable: () => getActiveCookieJar() != null,
});
}
@@ -20,16 +20,24 @@ import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { SettingRow } from "./core/SettingRow";
import { EncryptionHelp } from "./EncryptionHelp";
interface Props {
layout?: "form" | "settings";
size?: ButtonProps["size"];
expanded?: boolean;
onDone?: () => void;
onEnabledEncryption?: () => void;
}
export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {
export function WorkspaceEncryptionSetting({
layout = "form",
size,
expanded,
onDone,
onEnabledEncryption,
}: Props) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
@@ -66,7 +74,7 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
key.error != null ||
(workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)
) {
return (
const enterKey = (
<EnterWorkspaceKey
workspaceMeta={workspaceMeta}
error={key.error}
@@ -79,6 +87,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
}}
/>
);
return enterKey;
}
// Show the key if it exists
@@ -90,7 +100,8 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
encryptionKey={key.key}
/>
);
return (
const content = (
<VStack space={2} className="w-full">
{justEnabledEncryption && (
<Banner color="success" className="flex flex-col gap-2">
@@ -111,9 +122,43 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
)}
</VStack>
);
return content;
}
// Show button to enable encryption
if (layout === "settings") {
return (
<>
{error && (
<Banner color="danger" className="mb-3">
{error}
</Banner>
)}
<SettingRow
title="Workspace encryption"
description="Encrypt workspace secrets and sensitive values at rest."
>
<Button
color="secondary"
size={size}
onClick={async () => {
setError(null);
try {
await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true);
} catch (err) {
setError(`Failed to enable encryption: ${String(err)}`);
}
}}
>
Enable Encryption
</Button>
</SettingRow>
</>
);
}
return (
<div className="mb-auto flex flex-col-reverse">
<Button
@@ -5,16 +5,19 @@ import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { router } from "../lib/router";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { PlainInput } from "./core/PlainInput";
import { SettingsList, SettingsSection } from "./core/SettingRow";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { DnsOverridesEditor } from "./DnsOverridesEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { ModelSettingsEditor } from "./ModelSettingsEditor";
import { SyncToFilesystemSetting } from "./SyncToFilesystemSetting";
import { WorkspaceEncryptionSetting } from "./WorkspaceEncryptionSetting";
@@ -25,17 +28,17 @@ interface Props {
}
const TAB_AUTH = "auth";
const TAB_DATA = "data";
const TAB_DNS = "dns";
const TAB_HEADERS = "headers";
const TAB_GENERAL = "general";
const TAB_SETTINGS = "settings";
export type WorkspaceSettingsTab =
| typeof TAB_AUTH
| typeof TAB_DNS
| typeof TAB_HEADERS
| typeof TAB_GENERAL
| typeof TAB_DATA;
| typeof TAB_SETTINGS;
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
@@ -71,8 +74,8 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
tabs={[
{ value: TAB_GENERAL, label: "Workspace" },
{
value: TAB_DATA,
label: "Storage",
value: TAB_SETTINGS,
label: "Settings",
},
...headersTab,
...authTab,
@@ -100,6 +103,20 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
stateKey={`headers.${workspace.id}`}
/>
</TabContent>
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<SettingsList className="space-y-8 pb-3">
<SettingsSection title={null}>
<SyncToFilesystemSetting
layout="settings"
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList>
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full">
<PlainInput
@@ -152,19 +169,21 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
</HStack>
</div>
</TabContent>
<TabContent value={TAB_DATA} className="overflow-y-auto h-full px-4">
<VStack space={4} alignItems="start" className="pb-3 h-full">
<SyncToFilesystemSetting
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<WorkspaceEncryptionSetting size="xs" />
</VStack>
</TabContent>
<TabContent value={TAB_DNS} className="overflow-y-auto h-full px-4">
<DnsOverridesEditor workspace={workspace} />
</TabContent>
</Tabs>
);
}
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
showDialog({
id: "workspace-settings",
size: "lg",
className: "h-[calc(100vh-5rem)] !max-h-[50rem]",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
),
});
};
@@ -13,6 +13,7 @@ export interface CheckboxProps {
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
size?: "sm" | "md";
}
export function Checkbox({
@@ -25,6 +26,7 @@ export function Checkbox({
hideLabel,
fullWidth,
help,
size = "sm",
}: CheckboxProps) {
return (
<HStack
@@ -37,7 +39,9 @@ export function Checkbox({
<input
aria-hidden
className={classNames(
"appearance-none w-4 h-4 flex-shrink-0 border border-border",
"appearance-none flex-shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded outline-none ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted",
@@ -50,7 +54,7 @@ export function Checkbox({
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon
size="sm"
size={size}
className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/>
@@ -240,7 +240,13 @@ export function EventDetailHeader({
</HStack>
<HStack space={2} className="items-center">
{actions?.map((action) => (
<Button key={action.key} variant="border" size="xs" onClick={action.onClick}>
<Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
@@ -7,7 +7,7 @@ interface Props {
export function HttpResponseDurationTag({ response }: Props) {
const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);
const timeout = useRef<NodeJS.Timeout>(undefined);
const timeout = useRef<ReturnType<typeof setInterval>>(undefined);
// Calculate the duration of the response for use when the response hasn't finished yet
useEffect(() => {
@@ -1,16 +1,24 @@
import classNames from "classnames";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
interface Props {
children:
| ReactElement<HTMLAttributes<HTMLTableColElement>>
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
selectable?: boolean;
}
export function KeyValueRows({ children }: Props) {
export function KeyValueRows({ children, selectable }: Props) {
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
return (
<table className="text-editor font-mono min-w-0 w-full mb-auto">
<table
className={classNames(
"text-editor font-mono min-w-0 w-full mb-auto",
selectable &&
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
)}
>
<tbody className="divide-y divide-surface-highlight">
{childArray.map((child, i) => (
// oxlint-disable-next-line react/no-array-index-key
@@ -26,8 +34,11 @@ interface KeyValueRowProps {
children: ReactNode;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
align?: "top" | "middle";
labelClassName?: string;
labelColor?: "secondary" | "primary" | "info";
enableCopy?: boolean;
copyText?: string;
}
export function KeyValueRow({
@@ -35,14 +46,34 @@ export function KeyValueRow({
children,
rightSlot,
leftSlot,
align = "top",
labelColor = "secondary",
labelClassName,
enableCopy,
copyText,
}: KeyValueRowProps) {
const textToCopy =
copyText ??
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
const resolvedRightSlot =
rightSlot ??
(enableCopy && textToCopy != null ? (
<CopyIconButton
text={textToCopy}
className="text-text-subtle"
size="2xs"
title={`Copy ${label}`}
iconSize="sm"
/>
) : null);
return (
<>
<td
className={classNames(
"select-none py-0.5 pr-2 h-full align-top max-w-[10rem]",
"select-none py-0.5 pr-2 h-full max-w-[10rem]",
align === "top" && "align-top",
align === "middle" && "align-middle",
labelClassName,
labelColor === "primary" && "text-primary",
labelColor === "secondary" && "text-text-subtle",
@@ -51,11 +82,21 @@ export function KeyValueRow({
>
<span className="select-text cursor-text">{label}</span>
</td>
<td className="select-none py-0.5 break-all align-top max-w-[15rem]">
<td
className={classNames(
"select-none py-0.5 break-all max-w-[15rem]",
align === "top" && "align-top",
align === "middle" && "align-middle",
)}
>
<div className="select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
{leftSlot ?? <span aria-hidden />}
{children}
{rightSlot ? <div className="ml-1.5">{rightSlot}</div> : <span aria-hidden />}
{resolvedRightSlot ? (
<div className="ml-1.5">{resolvedRightSlot}</div>
) : (
<span aria-hidden />
)}
</div>
</td>
</>
@@ -43,6 +43,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
className,
containerClassName,
defaultValue,
disabled,
forceUpdateKey: forceUpdateKeyFromAbove,
help,
hideLabel,
@@ -163,7 +164,8 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
"relative w-full rounded-md text",
"border",
"overflow-hidden",
focused ? "border-border-focus" : "border-border-subtle",
focused && !disabled ? "border-border-focus" : "border-border-subtle",
disabled && "border-dotted",
hasChanged && "has-[:invalid]:border-danger", // For built-in HTML validation
size === "md" && "min-h-md",
size === "sm" && "min-h-sm",
@@ -198,12 +200,13 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
defaultValue={defaultValue ?? undefined}
disabled={disabled}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full")}
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus}
onBlur={handleBlur}
required={required}
+9 -1
View File
@@ -109,7 +109,15 @@ export function Select<T extends string>({
) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode
<RadioDropdown value={value} onChange={handleChange} items={options}>
<RadioDropdown
value={value}
onChange={handleChange}
items={options.map((o) =>
o.type === "separator" || o.value !== defaultValue
? o
: { ...o, label: <>{o.label} (default)</> },
)}
>
<Button
className="w-full text-sm font-mono"
justify="start"
@@ -24,7 +24,7 @@ export function Separator({
)}
<div
className={classNames(
"h-0 border-t opacity-60",
"opacity-60",
color == null && "border-border",
color === "primary" && "border-primary",
color === "secondary" && "border-secondary",
@@ -34,8 +34,8 @@ export function Separator({
color === "danger" && "border-danger",
color === "info" && "border-info",
dashed && "border-dashed",
orientation === "horizontal" && "w-full h-[1px]",
orientation === "vertical" && "h-full w-[1px]",
orientation === "horizontal" && "w-full h-0 border-t",
orientation === "vertical" && "h-full w-0 border-l",
)}
/>
</div>
@@ -0,0 +1,514 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import type { ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Checkbox } from "./Checkbox";
import { IconButton, type IconButtonProps } from "./IconButton";
import { PlainInput } from "./PlainInput";
import type { RadioDropdownItem } from "./RadioDropdown";
import { Select } from "./Select";
import { SelectFile } from "../SelectFile";
type ModelKeyOfValue<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
export function SettingValue({
actions,
className,
copyText,
enableCopy = true,
value,
}: {
actions?: SettingValueAction[];
className?: string;
copyText?: string;
enableCopy?: boolean;
value: ReactNode;
}) {
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
const textToCopy = copyText ?? textValue;
return (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
type SettingValueAction = {
icon: IconButtonProps["icon"];
onClick: () => void;
title: string;
};
export function SettingRowBoolean({
checked,
checkboxSize = "md",
onChange,
title,
...props
}: {
checked: boolean;
checkboxSize?: "sm" | "md";
onChange: (checked: boolean) => void;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowBoolean>[0], "checked" | "onChange">) {
return (
<SettingRowBoolean
checked={model[modelKey] as boolean}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowNumber({
inputClassName,
inputWidthClassName = "!w-48",
name,
onChange,
placeholder,
required,
title,
type = "number",
validate,
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: number) => void;
placeholder?: string;
required?: boolean;
type?: "number";
validate?: (value: string) => boolean;
value: number;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowNumber>[0], "name" | "onChange" | "value">) {
return (
<SettingRowNumber
name={String(modelKey)}
value={model[modelKey] as number}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowText({
inputClassName,
inputWidthClassName = "!w-80",
name,
onChange,
placeholder,
required,
title,
type = "text",
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: "text" | "password";
value: string;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowText>[0], "name" | "onChange" | "value">) {
return (
<SettingRowText
name={String(modelKey)}
value={model[modelKey] as string}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingRowFile({
buttonClassName,
controlClassName = "min-w-0 max-w-[min(32rem,45vw)]",
directory,
filePath,
nameOverride,
noun,
onChange,
size = "xs",
title,
...props
}: {
buttonClassName?: string;
directory?: boolean;
filePath: string | null;
nameOverride?: string | null;
noun?: string;
onChange: (filePath: string | null) => void | Promise<void>;
size?: Parameters<typeof SelectFile>[0]["size"];
} & SettingRowBaseProps) {
return (
<SettingRow title={title} controlClassName={controlClassName} {...props}>
<SelectFile
directory={directory}
inline
hideLabel
label={typeof title === "string" ? title : noun}
size={size}
noun={noun}
nameOverride={nameOverride}
filePath={filePath}
className={buttonClassName}
onChange={({ filePath }) => onChange(filePath)}
/>
</SettingRow>
);
}
export function SettingRowDirectory({
noun = "Directory",
...props
}: Omit<Parameters<typeof SettingRowFile>[0], "directory">) {
return <SettingRowFile directory noun={noun} {...props} />;
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "!w-48",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "!w-48",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function ModelSettingSelectControl<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingSelectControl<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingSelectControl
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function ModelSettingRowSelect<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
V extends M[K] & string,
>({
model,
modelKey,
...props
}: {
model: M;
modelKey: K;
} & Omit<Parameters<typeof SettingRowSelect<V>>[0], "name" | "onChange" | "value">) {
return (
<SettingRowSelect
name={String(modelKey)}
value={model[modelKey] as V}
onChange={(value) => patchModel(model, { [modelKey]: value } as Partial<M>)}
{...props}
/>
);
}
export function SettingOverrideRow({
children,
className,
controlClassName,
description,
disabled,
onResetOverride,
overridden,
resetTitle = "Reset override",
title,
}: {
children: ReactNode;
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
onResetOverride: () => void;
overridden: boolean;
resetTitle?: string;
title: ReactNode;
}) {
return (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
+1 -1
View File
@@ -31,7 +31,7 @@ export function Tooltip({ children, className, content, tabIndex, size = "md" }:
const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTimeout = useRef<NodeJS.Timeout>(undefined);
const showTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleOpenImmediate = () => {
if (triggerRef.current == null || tooltipRef.current == null) return;
@@ -0,0 +1,131 @@
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,12 +8,14 @@ import type {
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync";
import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
@@ -21,7 +23,7 @@ import { DiffViewer } from "../core/Editor/DiffViewer";
import { Input } from "../core/Input";
import { Separator } from "../core/Separator";
import { EmptyStateText } from "../EmptyStateText";
import { gitCallbacks } from "./callbacks";
import { useGitCallbacks } from "./callbacks";
import { handlePushResult } from "./git-util";
interface Props {
@@ -38,9 +40,10 @@ interface CommitTreeNode {
}
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const [{ status }, { commit, commitAndPush, add, unstage }] = useGit(
const callbacks = useGitCallbacks(syncDir);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
syncDir,
gitCallbacks(syncDir),
callbacks,
);
const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null);
@@ -165,6 +168,24 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
[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) {
return null;
}
@@ -259,7 +280,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
secondSlot={({ style }) => (
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
{selectedEntry ? (
<DiffPanel entry={selectedEntry} />
<DiffPanel entry={selectedEntry} onDiscardChanges={handleDiscardChanges} />
) : (
<EmptyStateText>Select a change to view diff</EmptyStateText>
)}
@@ -466,16 +487,35 @@ function isNodeRelevant(node: CommitTreeNode): boolean {
return node.children.some((c) => isNodeRelevant(c));
}
function DiffPanel({ entry }: { entry: GitStatusEntry }) {
function DiffPanel({
entry,
onDiscardChanges,
}: {
entry: GitStatusEntry;
onDiscardChanges: (entry: GitStatusEntry) => void | Promise<void>;
}) {
const prevYaml = modelToYaml(entry.prev);
const nextYaml = modelToYaml(entry.next);
return (
<div className="h-full flex flex-col">
<div className="text-sm text-text-subtle mb-2 px-1">
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
<div className="text-text-subtle mb-2 px-1 grid items-center gap-2 grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 truncate">
{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>
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
<DiffViewer
original={prevYaml ?? ""}
modified={nextYaml ?? ""}
className="flex-1 min-h-0"
/>
</div>
);
}
+480 -453
View File
@@ -1,9 +1,9 @@
import { useGit } from "@yaakapp-internal/git";
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { HTMLAttributes } from "react";
import { forwardRef } from "react";
import { forwardRef, useCallback, useMemo } from "react";
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
import { useKeyValue } from "../../hooks/useKeyValue";
@@ -12,17 +12,20 @@ import { sync } from "../../init/sync";
import { showConfirm, showConfirmDelete } from "../../lib/confirm";
import { fireAndForget } from "../../lib/fireAndForget";
import { showDialog } from "../../lib/dialog";
import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus";
import { showPrompt } from "../../lib/prompt";
import { showErrorToast, showToast } from "../../lib/toast";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
import { gitCallbacks } from "./callbacks";
import { useGitCallbacks } from "./callbacks";
import { GitCommitDialog } from "./GitCommitDialog";
import { GitRemotesDialog } from "./GitRemotesDialog";
import { handlePullResult, handlePushResult } from "./git-util";
import { HistoryDialog } from "./HistoryDialog";
const EMPTY_BRANCHES: string[] = [];
export function GitDropdown() {
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
if (workspaceMeta == null) return null;
@@ -36,469 +39,493 @@ export function GitDropdown() {
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useAtomValue(activeWorkspaceAtom);
const worktreeStatus = useAtomValue(gitWorktreeStatusAtom);
const [refreshKey, regenerateKey] = useRandomKey();
const [
{ status, log },
{
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
mergeBranch,
push,
pull,
checkout,
resetChanges,
init,
},
] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);
const branchInfo = useGitBranchInfo(syncDir, refreshKey);
const callbacks = useGitCallbacks(syncDir);
const {
createBranch,
deleteBranch,
deleteRemoteBranch,
renameBranch,
mergeBranch,
push,
pull,
checkout,
resetChanges,
init,
} = useGitMutations(syncDir, callbacks);
const localBranches = status.data?.localBranches ?? [];
const remoteBranches = status.data?.remoteBranches ?? [];
const remoteOnlyBranches = remoteBranches.filter(
(b) => !localBranches.includes(b.replace(/^origin\//, "")),
const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES;
const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES;
const remoteOnlyBranches = useMemo(
() => remoteBranches.filter((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) {
return null;
}
const noRepo = status.error?.includes("not found");
const noRepo = branchInfo.error?.includes("not found");
if (noRepo) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;
return <SetupGitDropdown workspaceId={workspace.id} initRepo={initRepo} />;
}
// Still loading
if (status.data == null) {
if (branchInfo.data == 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 (
<Dropdown fullWidth items={items} onOpen={regenerateKey}>
<GitMenuButton>
@@ -569,7 +596,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
color: "success",
label: "Open Workspace Settings",
leftSlot: <Icon icon="settings" />,
onSelect: () => openWorkspaceSettings("data"),
onSelect: () => openWorkspaceSettings("settings"),
},
{ type: "separator" },
{
@@ -10,7 +10,7 @@ import {
TableHeaderCell,
TableRow,
} from "@yaakapp-internal/ui";
import { gitCallbacks } from "./callbacks";
import { useGitCallbacks } from "./callbacks";
import { addGitRemote } from "./showAddRemoteDialog";
interface Props {
@@ -19,7 +19,8 @@ interface Props {
}
export function GitRemotesDialog({ dir }: Props) {
const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));
const callbacks = useGitCallbacks(dir);
const [{ remotes }, { rmRemote }] = useGit(dir, callbacks);
return (
<Table scrollable>
@@ -1,4 +1,4 @@
import type { GitCommit } from "@yaakapp-internal/git";
import { useGitLog } from "@yaakapp-internal/git";
import { formatDistanceToNowStrict } from "date-fns";
import {
Table,
@@ -10,11 +10,9 @@ import {
TruncatedWideTableCell,
} from "@yaakapp-internal/ui";
interface Props {
log: GitCommit[];
}
export function HistoryDialog({ dir }: { dir: string }) {
const log = useGitLog(dir);
export function HistoryDialog({ log }: Props) {
return (
<div className="pl-5 pr-1 pb-1">
<Table scrollable className="px-1">
@@ -26,8 +24,8 @@ export function HistoryDialog({ log }: Props) {
</TableRow>
</TableHead>
<TableBody>
{log.map((l) => (
<TableRow key={(l.author.name ?? "") + (l.message ?? "n/a") + l.when}>
{(log.data ?? []).map((l) => (
<TableRow key={l.oid}>
<TruncatedWideTableCell>
{l.message || <em className="text-text-subtle">No message</em>}
</TruncatedWideTableCell>
@@ -1,4 +1,5 @@
import type { GitCallbacks } from "@yaakapp-internal/git";
import { useMemo } from "react";
import { sync } from "../../init/sync";
import { promptCredentials } from "./credentials";
import { promptDivergedStrategy } from "./diverged";
@@ -24,3 +25,7 @@ export function gitCallbacks(dir: string): GitCallbacks {
forceSync: () => sync({ force: true }),
};
}
export function useGitCallbacks(dir: string): GitCallbacks {
return useMemo(() => gitCallbacks(dir), [dir]);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
{authentication.find((a) => a.name === inheritedAuth.authenticationType)
?.shortLabel ?? "UNKNOWN"}
<IconTooltip
icon="magic_wand"
icon="zap_off"
iconSize="xs"
content="Authentication was inherited from an ancestor"
/>
+4
View File
@@ -14,6 +14,7 @@ export type HotkeyAction =
| "app.zoom_out"
| "app.zoom_reset"
| "command_palette.toggle"
| "cookies_editor.show"
| "editor.autocomplete"
| "environment_editor.toggle"
| "hotkeys.showHelp"
@@ -43,6 +44,7 @@ const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
"app.zoom_out": ["Meta+Minus"],
"app.zoom_reset": ["Meta+0"],
"command_palette.toggle": ["Meta+k"],
"cookies_editor.show": ["Meta+Shift+k"],
"editor.autocomplete": ["Control+Space"],
"environment_editor.toggle": ["Meta+Shift+e"],
"request.rename": ["Control+Shift+r"],
@@ -73,6 +75,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
"app.zoom_out": ["Control+Minus"],
"app.zoom_reset": ["Control+0"],
"command_palette.toggle": ["Control+k"],
"cookies_editor.show": ["Control+Shift+k"],
"editor.autocomplete": ["Control+Space"],
"environment_editor.toggle": ["Control+Shift+e"],
"request.rename": ["F2"],
@@ -128,6 +131,7 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
"app.zoom_out": "Zoom Out",
"app.zoom_reset": "Zoom to Actual Size",
"command_palette.toggle": "Toggle Command Palette",
"cookies_editor.show": "Show Cookies",
"editor.autocomplete": "Trigger Autocomplete",
"environment_editor.toggle": "Edit Environments",
"hotkeys.showHelp": "Show Keyboard Shortcuts",
+38
View File
@@ -0,0 +1,38 @@
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);
}
+6 -7
View File
@@ -1,4 +1,4 @@
import { debounce } from "@yaakapp-internal/lib";
import { debounce, eagerDebounceAsync } from "@yaakapp-internal/lib";
import type { AnyModel, ModelPayload } from "@yaakapp-internal/models";
import { watchWorkspaceFiles } from "@yaakapp-internal/sync";
import { syncWorkspace } from "../commands/commands";
@@ -25,9 +25,8 @@ export async function sync({ force }: { force?: boolean } = {}) {
});
}
const debouncedSync = debounce(async () => {
await sync();
}, 1000);
const syncAfterFileChange = debounce(sync, 1000);
const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
/**
* Subscribe to model change events. Since we check the workspace ID on sync, we can
@@ -35,7 +34,7 @@ const debouncedSync = debounce(async () => {
*/
function initModelListeners() {
listenToTauriEvent<ModelPayload>("model_write", (p) => {
if (isModelRelevant(p.payload.model)) debouncedSync();
if (isModelRelevant(p.payload.model)) syncAfterModelWrite();
});
}
@@ -50,11 +49,11 @@ function initFileChangeListeners() {
await unsub?.(); // Unsub to previous
const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);
if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) return;
debouncedSync(); // Perform an initial sync when switching workspace
syncAfterFileChange(); // Perform an initial sync when switching workspace
unsub = watchWorkspaceFiles(
workspaceMeta.workspaceId,
workspaceMeta.settingSyncDir,
debouncedSync,
syncAfterFileChange,
);
});
}
+1 -2
View File
@@ -2,8 +2,7 @@ import type { SyncModel } from "@yaakapp-internal/git";
import { stringify } from "yaml";
/**
* Convert a SyncModel to a clean YAML string for diffing.
* Removes noisy fields like updatedAt that change on every edit.
* Convert a SyncModel to a YAML string for diffing.
*/
export function modelToYaml(model: SyncModel | null): string {
if (!model) return "";
+22
View File
@@ -0,0 +1,22 @@
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,
);
+81
View File
@@ -0,0 +1,81 @@
import type { AnyModel, Workspace } from "@yaakapp-internal/models";
type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type ModelForType<T extends ModelType> = Extract<AnyModel, { model: T }>;
type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
models: readonly ModelTypeWithSetting<K>[];
title: string;
};
export type RequestSettingKey = keyof WorkspaceRequestSettings;
function defineRequestSetting<const K extends RequestSettingKey>(
setting: RequestSettingDefinition<K>,
) {
return setting;
}
export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
defaultValue: 0,
description: "Maximum request duration in milliseconds. Set to 0 to disable.",
modelKey: "settingRequestTimeout",
models: ["workspace", "folder", "http_request"],
title: "Request Timeout",
});
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true,
description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates",
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
title: "Validate TLS certificates",
});
export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
defaultValue: true,
description: "Follow HTTP redirects automatically.",
modelKey: "settingFollowRedirects",
models: ["workspace", "folder", "http_request"],
title: "Follow redirects",
});
export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true,
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies",
});
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
});
export function modelSupportsSetting<K extends RequestSettingKey>(
model: Pick<AnyModel, "model">,
setting: RequestSettingDefinition<K>,
) {
return setting.models.some((modelType) => modelType === model.model);
}
+2
View File
@@ -5,6 +5,7 @@ import { changeModelStoreWorkspace, initModelStore } from "@yaakapp-internal/mod
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { initGit } from "./init/git";
import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners";
import { jotaiStore } from "./lib/jotai";
@@ -31,6 +32,7 @@ window.addEventListener("keydown", (e) => {
});
// Initialize a bunch of watchers
initGit();
initSync();
initModelStore(jotaiStore);
initGlobalListeners();
+7 -6
View File
@@ -31,14 +31,14 @@
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
"@tauri-apps/plugin-log": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.3",
"@tauri-apps/plugin-shell": "^2.3.5",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"cm6-graphql": "^0.2.1",
@@ -52,6 +52,7 @@
"hexy": "^0.3.5",
"history": "^5.3.0",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"js-md5": "^0.8.3",
"lucide-react": "^0.525.0",
"mime": "^4.0.4",
+5 -2
View File
@@ -3,7 +3,7 @@ import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { createRequire } from "node:module";
import path from "node:path";
import { defineConfig, normalizePath } from "vite";
import { defineConfig, normalizePath } from "vite-plus";
import { viteStaticCopy } from "vite-plugin-static-copy";
import svgr from "vite-plugin-svgr";
import topLevelAwait from "vite-plugin-top-level-await";
@@ -42,12 +42,15 @@ export default defineConfig(async () => {
sourcemap: true,
outDir: "../../dist/apps/yaak-client",
emptyOutDir: true,
rollupOptions: {
rolldownOptions: {
output: {
// Make chunk names readable
chunkFileNames: "assets/chunk-[name]-[hash].js",
entryFileNames: "assets/entry-[name]-[hash].js",
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,
},
},
},
+1 -1
View File
@@ -2,7 +2,7 @@ import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import { selectedIdsFamily, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils";
import { atomFamily } from "jotai-family";
import { useCallback } from "react";
import { httpExchangesAtom } from "../lib/store";
+2 -1
View File
@@ -10,7 +10,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/model-store": "^1.0.0",
"@yaakapp-internal/proxy-lib": "^1.0.0",
@@ -18,6 +18,7 @@
"@yaakapp-internal/ui": "^1.0.0",
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"motion": "^12.4.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
+1 -7
View File
@@ -473,7 +473,7 @@ async fn build_plugin_reply(
let names = cookie_jar
.cookies
.into_iter()
.filter_map(|c| parse_cookie_name_value(&c.raw_cookie).map(|(name, _)| name))
.map(|c| c.name)
.collect();
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
@@ -531,12 +531,6 @@ async fn render_json_value_for_cli<T: TemplateCallback>(
render_json_value_raw(value, vars, cb, opt).await
}
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> {
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
clipboard.set_text(text.to_string()).map_err(|e| e.to_string())
@@ -7,34 +7,6 @@ use tempfile::TempDir;
use yaak_models::models::HttpRequest;
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]
fn top_level_send_folder_sends_http_requests_and_prints_summary() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
+1 -5
View File
@@ -25,11 +25,7 @@ pub struct ActionMetadata {
}
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.
+2 -4
View File
@@ -14,10 +14,8 @@ pub struct ProxyQueryManager {
impl ProxyQueryManager {
pub fn new(db_path: &Path) -> Self {
let manager = SqliteConnectionManager::file(db_path);
let pool = Pool::builder()
.max_size(5)
.build(manager)
.expect("Failed to create proxy DB pool");
let pool =
Pool::builder().max_size(5).build(manager).expect("Failed to create proxy DB pool");
run_migrations(&pool, &MIGRATIONS).expect("Failed to run proxy DB migrations");
Self { pool }
}
+65 -68
View File
@@ -2,18 +2,18 @@ pub mod actions;
pub mod db;
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::path::Path;
use std::sync::Mutex;
use log::warn;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_database::{ModelChangeEvent, UpdateSource};
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
use crate::db::ProxyQueryManager;
use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
// -- Context --
@@ -25,11 +25,7 @@ pub struct ProxyCtx {
impl ProxyCtx {
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 }
}
}
@@ -88,17 +84,15 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
match invocation {
ActionInvocation::Global { action } => match action {
GlobalAction::ProxyStart => {
let mut handle = ctx
.handle
.lock()
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
let mut handle =
ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
if handle.is_some() {
return Ok(true); // already running
}
let mut proxy_handle = yaak_proxy::start_proxy(9090)
.map_err(|e| RpcError { message: e })?;
let mut proxy_handle =
yaak_proxy::start_proxy(9090).map_err(|e| RpcError { message: e })?;
if let Some(event_rx) = proxy_handle.take_event_rx() {
let db = ctx.db.clone();
@@ -107,49 +101,43 @@ fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool,
}
*handle = Some(proxy_handle);
ctx.events.emit("proxy_state_changed", &ProxyStatePayload {
state: ProxyState::Running,
});
ctx.events
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Running });
Ok(true)
}
GlobalAction::ProxyStop => {
let mut handle = ctx
.handle
.lock()
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
let mut handle =
ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
handle.take();
ctx.events.emit("proxy_state_changed", &ProxyStatePayload {
state: ProxyState::Stopped,
});
ctx.events
.emit("proxy_state_changed", &ProxyStatePayload { state: ProxyState::Stopped });
Ok(true)
}
},
}
}
fn get_proxy_state(ctx: &ProxyCtx, _req: GetProxyStateRequest) -> Result<GetProxyStateResponse, RpcError> {
let handle = ctx
.handle
.lock()
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
let state = if handle.is_some() {
ProxyState::Running
} else {
ProxyState::Stopped
};
fn get_proxy_state(
ctx: &ProxyCtx,
_req: GetProxyStateRequest,
) -> Result<GetProxyStateResponse, RpcError> {
let handle = ctx.handle.lock().map_err(|_| RpcError { message: "lock poisoned".into() })?;
let state = if handle.is_some() { ProxyState::Running } else { ProxyState::Stopped };
Ok(GetProxyStateResponse { state })
}
fn list_actions(_ctx: &ProxyCtx, _req: ListActionsRequest) -> Result<ListActionsResponse, RpcError> {
Ok(ListActionsResponse {
actions: crate::actions::all_global_actions(),
})
fn list_actions(
_ctx: &ProxyCtx,
_req: ListActionsRequest,
) -> Result<ListActionsResponse, RpcError> {
Ok(ListActionsResponse { actions: crate::actions::all_global_actions() })
}
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResponse, RpcError> {
ctx.db.with_conn(|db| {
Ok(ListModelsResponse {
http_exchanges: db.find_all::<HttpExchange>()
http_exchanges: db
.find_all::<HttpExchange>()
.map_err(|e| RpcError { message: e.to_string() })?,
})
})
@@ -157,28 +145,35 @@ fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResp
// -- Event loop --
fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManager, events: RpcEventEmitter) {
fn run_event_loop(
rx: std::sync::mpsc::Receiver<ProxyEvent>,
db: ProxyQueryManager,
events: RpcEventEmitter,
) {
let mut in_flight: HashMap<u64, CapturedRequest> = HashMap::new();
while let Ok(event) = rx.recv() {
match event {
ProxyEvent::RequestStart { id, method, url, http_version } => {
in_flight.insert(id, CapturedRequest {
in_flight.insert(
id,
method,
url,
http_version,
status: None,
elapsed_ms: None,
remote_http_version: None,
request_headers: vec![],
request_body: None,
response_headers: vec![],
response_body: None,
response_body_size: 0,
state: RequestState::Sending,
error: None,
});
CapturedRequest {
id,
method,
url,
http_version,
status: None,
elapsed_ms: None,
remote_http_version: None,
request_headers: vec![],
request_body: None,
response_headers: vec![],
response_body: None,
response_body_size: 0,
state: RequestState::Sending,
error: None,
},
);
}
ProxyEvent::RequestHeader { id, name, value } => {
if let Some(r) = in_flight.get_mut(&id) {
@@ -230,28 +225,30 @@ fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedReq
let entry = HttpExchange {
url: r.url.clone(),
method: r.method.clone(),
req_headers: r.request_headers.iter()
req_headers: r
.request_headers
.iter()
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
.collect(),
req_body: r.request_body.clone(),
res_status: r.status.map(|s| s as i32),
res_headers: r.response_headers.iter()
res_headers: r
.response_headers
.iter()
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
.collect(),
res_body: r.response_body.clone(),
error: r.error.clone(),
..Default::default()
};
db.with_conn(|ctx| {
match ctx.upsert(&entry, &UpdateSource::Background) {
Ok((saved, created)) => {
events.emit("model_write", &ModelPayload {
model: saved,
change: ModelChangeEvent::Upsert { created },
});
}
Err(e) => warn!("Failed to write proxy entry: {e}"),
db.with_conn(|ctx| match ctx.upsert(&entry, &UpdateSource::Background) {
Ok((saved, created)) => {
events.emit(
"model_write",
&ModelPayload { model: saved, change: ModelChangeEvent::Upsert { created } },
);
}
Err(e) => warn!("Failed to write proxy entry: {e}"),
});
}
+4 -1
View File
@@ -3,7 +3,10 @@ use rusqlite::Row;
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_database::{ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id, upsert_date};
use yaak_database::{
ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id,
upsert_date,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
+8 -7
View File
@@ -17,7 +17,7 @@ updater = []
license = ["yaak-license"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
tauri-build = { version = "2.6.1", features = [] }
[target.'cfg(target_os = "linux")'.dependencies]
openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work
@@ -30,6 +30,7 @@ eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client
http = { version = "1.2.0", default-features = false }
log = { workspace = true }
md5 = "0.8.0"
notify = "8.0.0"
pretty_graphql = "0.2"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
@@ -49,15 +50,15 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["raw_value"] }
tauri = { workspace = true, features = ["devtools", "protocol-asset"] }
tauri-plugin-clipboard-manager = "2.3.2"
tauri-plugin-deep-link = "2.4.5"
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = "2.4.4"
tauri-plugin-log = { version = "2.7.1", features = ["colored"] }
tauri-plugin-opener = "2.5.2"
tauri-plugin-fs = "2.5.1"
tauri-plugin-log = { version = "2.8.0", features = ["colored"] }
tauri-plugin-opener = "2.5.4"
tauri-plugin-os = "2.3.2"
tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
tauri-plugin-updater = "2.9.0"
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
tauri-plugin-updater = "2.10.1"
tauri-plugin-window-state = "2.4.1"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
+2
View File
@@ -12,6 +12,8 @@ export type UpdateResponseAction = "install" | "skip";
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 YaakNotificationAction = { label: string, url: string, };
+60 -6
View File
@@ -3,14 +3,18 @@
//! This module provides the Tauri commands for git functionality.
use crate::error::Result;
use crate::git_watcher::{GitWatchResult, watch_git_worktree_status};
use std::path::{Path, PathBuf};
use tauri::command;
use tauri::ipc::Channel;
use tauri::{AppHandle, Runtime, command};
use yaak_git::{
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
GitStatusSummary, GitWorktreeStatus, PullResult, PushResult, git_add, git_add_credential,
git_add_remote, git_branch_info, git_checkout_branch, git_clone, git_commit, git_create_branch,
git_delete_branch, git_delete_remote_branch, git_fetch_all, git_file_diff_for_commit, git_init,
git_log, git_log_for_file, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge,
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
@@ -54,11 +58,44 @@ pub async fn cmd_git_status(dir: &Path) -> Result<GitStatusSummary> {
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]
pub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {
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]
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
Ok(git_init(dir)?)
@@ -124,6 +161,23 @@ pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
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]
pub async fn cmd_git_add_credential(
remote_url: &str,
@@ -0,0 +1,172 @@
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}");
}
}
}
+26 -10
View File
@@ -67,6 +67,7 @@ mod commands;
mod encoding;
mod error;
mod git_ext;
mod git_watcher;
mod grpc;
mod history;
mod http_request;
@@ -121,9 +122,7 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> {
}
// 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" => {
let width = webview_window.outer_size().unwrap().width;
let height = width * 9 / 16;
@@ -296,7 +295,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -331,7 +330,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
&uri,
&proto_files,
&metadata,
workspace.setting_validate_certificates,
resolved_settings.validate_certificates.value,
client_certificate,
)
.await
@@ -354,7 +353,7 @@ async fn cmd_grpc_go<R: Runtime>(
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -424,7 +423,7 @@ async fn cmd_grpc_go<R: Runtime>(
uri.as_str(),
&proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),
&metadata,
workspace.setting_validate_certificates,
resolved_settings.validate_certificates.value,
client_cert.clone(),
)
.await;
@@ -1506,7 +1505,6 @@ async fn cmd_reload_plugins<R: Runtime>(
Ok(errors)
}
#[tauri::command]
async fn cmd_plugin_info<R: Runtime>(
id: &str,
@@ -1579,7 +1577,14 @@ async fn cmd_new_child_window(
inner_size: (f64, f64),
) -> YaakResult<()> {
let use_native_titlebar = parent_window.app_handle().db().get_settings().use_native_titlebar;
let win = yaak_window::window::create_child_window(&parent_window, url, label, title, inner_size, use_native_titlebar)?;
let win = yaak_window::window::create_child_window(
&parent_window,
url,
label,
title,
inner_size,
use_native_titlebar,
)?;
setup_window_menu(&win)?;
Ok(())
}
@@ -1831,8 +1836,13 @@ pub fn run() {
git_ext::cmd_git_delete_remote_branch,
git_ext::cmd_git_merge_branch,
git_ext::cmd_git_rename_branch,
git_ext::cmd_git_branch_info,
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_for_file,
git_ext::cmd_git_file_diff_for_commit,
git_ext::cmd_git_initialize,
git_ext::cmd_git_clone,
git_ext::cmd_git_commit,
@@ -1844,6 +1854,8 @@ pub fn run() {
git_ext::cmd_git_add,
git_ext::cmd_git_unstage,
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_remotes,
git_ext::cmd_git_add_remote,
@@ -1870,7 +1882,11 @@ pub fn run() {
match event {
RunEvent::Ready => {
let use_native_titlebar = app_handle.db().get_settings().use_native_titlebar;
if let Ok(win) = yaak_window::window::create_main_window(app_handle, "/", use_native_titlebar) {
if let Ok(win) = yaak_window::window::create_main_window(
app_handle,
"/",
use_native_titlebar,
) {
let _ = setup_window_menu(&win);
}
let h = app_handle.clone();
@@ -3,13 +3,11 @@ use crate::http_request::send_http_request_with_context;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use crate::render::{render_grpc_request, render_http_request, render_json_value};
use yaak_window::window::{CreateWindowConfig, create_window};
use crate::{
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
workspace_from_window,
};
use chrono::Utc;
use cookie::Cookie;
use log::error;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime};
@@ -36,6 +34,7 @@ use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_window::window::{CreateWindowConfig, create_window};
pub(crate) async fn handle_plugin_event<R: Runtime>(
app_handle: &AppHandle<R>,
@@ -409,11 +408,7 @@ async fn handle_host_plugin_request<R: Runtime>(
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
let names = match cookie_jar_from_window(&window) {
None => Vec::new(),
Some(j) => j
.cookies
.into_iter()
.filter_map(|c| Cookie::parse(c.raw_cookie).ok().map(|c| c.name().to_string()))
.collect(),
Some(j) => j.cookies.into_iter().map(|c| c.name).collect(),
};
Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
names,
+31 -16
View File
@@ -234,7 +234,7 @@ async fn start_integrated_update<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<UpdateResponseAction> {
let download_path = ensure_download_path(window, update)?;
let download_path = ensure_download_dir(window)?.join(download_file_name(update));
debug!("Download path: {}", download_path.display());
let downloaded = download_path.exists();
let ack_wait = Duration::from_secs(3);
@@ -345,7 +345,7 @@ pub async fn download_update_idempotent<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<PathBuf> {
let dl_path = ensure_download_path(window, update)?;
let dl_path = ensure_download_dir(window)?.join(download_file_name(update));
if dl_path.exists() {
info!("{} already downloaded to {}", update.version, dl_path.display());
@@ -385,21 +385,36 @@ pub async fn install_update_maybe_download<R: Runtime>(
let dl_path = download_update_idempotent(window, update).await?;
let update_bytes = std::fs::read(&dl_path)?;
update.install(update_bytes.as_slice())?;
delete_download_dir(window);
Ok(())
}
pub fn ensure_download_path<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<PathBuf> {
// Ensure dir exists
let base_dir = window.path().app_cache_dir()?.join("updates");
std::fs::create_dir_all(&base_dir)?;
// Generate name based on signature
let sig_digest = md5::compute(&update.signature);
let name = format!("yaak-{}-{:x}", update.version, sig_digest);
let dl_path = base_dir.join(name);
Ok(dl_path)
pub fn download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
Ok(window.path().app_cache_dir()?.join("updates"))
}
pub fn ensure_download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
let base_dir = download_dir(window)?;
std::fs::create_dir_all(&base_dir)?;
Ok(base_dir)
}
pub fn download_file_name(update: &Update) -> String {
let sig_digest = md5::compute(&update.signature);
format!("yaak-{}-{:x}", update.version, sig_digest)
}
pub fn delete_download_dir<R: Runtime>(window: &WebviewWindow<R>) {
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),
}
}
+31 -6
View File
@@ -134,7 +134,8 @@ pub async fn cmd_ws_connect<R: Runtime>(
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;
let resolved_settings =
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) =
resolve_websocket_request(&window, &unrendered_request)?;
@@ -247,11 +248,18 @@ pub async fn cmd_ws_connect<R: Runtime>(
}
}
// Add cookies to WS HTTP Upgrade
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);
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
if let (true, Some(store)) = (resolved_settings.send_cookies.value, cookie_store.as_ref()) {
// Convert WS URL -> HTTP URL because our cookie store matches based on
// Path/HttpOnly/Secure attributes even though WS upgrades are HTTP requests
let http_url = convert_ws_url_to_http(&url);
@@ -289,7 +297,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
url.as_str(),
headers,
receive_tx,
workspace.setting_validate_certificates,
resolved_settings.validate_certificates.value,
client_cert,
)
.await
@@ -328,6 +336,23 @@ pub async fn cmd_ws_connect<R: Runtime>(
})
.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(
&WebsocketConnection {
state: WebsocketConnectionState::Connected,
+1 -1
View File
@@ -10,7 +10,7 @@ name = "tauri_app_proxy_lib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
tauri-build = { version = "2.6.1", features = [] }
[dependencies]
log = { workspace = true }
+1 -1
View File
@@ -1,6 +1,6 @@
use log::{error, info, warn};
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
use tauri::Runtime;
use tauri::{Emitter, Manager, RunEvent, State, WebviewWindow};
use yaak_proxy_lib::ProxyCtx;
use yaak_rpc::{RpcEventEmitter, RpcRouter};
use yaak_window::window::CreateWindowConfig;
+4 -7
View File
@@ -109,19 +109,16 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64,
// we've modified it. This avoids the height growing on repeated calls.
use std::sync::OnceLock;
static DEFAULT_TITLEBAR_HEIGHT: OnceLock<f64> = OnceLock::new();
let default_height =
*DEFAULT_TITLEBAR_HEIGHT.get_or_init(|| NSView::frame(title_bar_container_view).size.height);
let default_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
// 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
// case, add TITLEBAR_EXTRA_HEIGHT extra pixels to push the buttons down.
let desired = button_height + y;
let title_bar_frame_height = if desired > default_height {
desired
} else {
default_height + TITLEBAR_EXTRA_HEIGHT
};
let title_bar_frame_height =
if desired > default_height { desired } else { default_height + TITLEBAR_EXTRA_HEIGHT };
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
@@ -65,8 +65,7 @@ impl<'a> DbContext<'a> {
.cond_where(Expr::col(col).eq(value))
.build_rusqlite(SqliteQueryBuilder);
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>>
@@ -126,9 +125,8 @@ impl<'a> DbContext<'a> {
let other_values = model.clone().insert_values(source)?;
let mut column_vec = vec![id_iden.clone()];
let mut value_vec = vec![
if id_val.is_empty() { M::generate_id().into() } else { id_val.into() },
];
let mut value_vec =
vec![if id_val.is_empty() { M::generate_id().into() } else { id_val.into() }];
for (col, val) in other_values {
value_vec.push(val.into());
+1 -2
View File
@@ -55,8 +55,7 @@ pub fn run_migrations(pool: &Pool<SqliteConnectionManager>, dir: &Dir<'_>) -> Re
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);
let conn = pool.get()?;
+4 -4
View File
@@ -10,10 +10,10 @@ pub fn generate_id() -> String {
pub fn generate_id_of_length(n: usize) -> String {
let alphabet: [char; 57] = [
'2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
'j', 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A',
'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T',
'U', 'V', 'W', 'X', 'Y', 'Z',
'2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C',
'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
'X', 'Y', 'Z',
];
nanoid!(n, &alphabet)
+6 -15
View File
@@ -3,7 +3,8 @@ use std::collections::HashMap;
use std::sync::mpsc;
/// Type-erased handler function: takes context + JSON payload, returns JSON or error.
type HandlerFn<Ctx> = Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + Send + Sync>;
type HandlerFn<Ctx> =
Box<dyn Fn(&Ctx, serde_json::Value) -> Result<serde_json::Value, RpcError> + Send + Sync>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
@@ -57,9 +58,7 @@ pub struct RpcRouter<Ctx> {
impl<Ctx> RpcRouter<Ctx> {
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
}
Self { handlers: HashMap::new() }
}
/// Register a handler for a command name.
@@ -77,23 +76,15 @@ impl<Ctx> RpcRouter<Ctx> {
) -> Result<serde_json::Value, RpcError> {
match self.handlers.get(cmd) {
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`.
pub fn handle(&self, req: RpcRequest, ctx: &Ctx) -> RpcResponse {
match self.dispatch(&req.cmd, req.payload, ctx) {
Ok(payload) => RpcResponse::Success {
id: req.id,
payload,
},
Err(e) => RpcResponse::Error {
id: req.id,
error: e.message,
},
Ok(payload) => RpcResponse::Success { id: req.id, payload },
Err(e) => RpcResponse::Error { id: req.id, error: e.message },
}
}
+9 -1
View File
@@ -7,7 +7,11 @@ export type CloneResult = { "type": "success" } | { "type": "cancelled" } | { "t
export type GitAuthor = { name: string | null, email: string | null, };
export type GitCommit = { author: GitAuthor, when: string, message: 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 = { oid: string, author: GitAuthor, when: string, message: string | null, };
export type GitFileDiff = { original: string, modified: string, };
export type GitRemote = { name: string, url: string | null, };
@@ -17,6 +21,10 @@ 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 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 PushResult = { "type": "success", message: string, } | { "type": "up_to_date" } | { "type": "needs_credentials", url: string, error: string | null, };
+156 -33
View File
@@ -1,45 +1,168 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type Environment = {
model: "environment";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
name: string;
public: boolean;
parentModel: string;
parentId: string | null;
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>;
color: string | null;
sortPriority: number;
};
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type Folder = {
model: "folder";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
sortPriority: number;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
};
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,
/**
* Server URL (http for plaintext or https for secure)
*/
url: string, };
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;
/**
* Server URL (http for plaintext or https for secure)
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
};
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,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
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;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
};
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type HttpUrlParameter = {
enabled?: boolean;
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string;
value: string;
id?: string;
};
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
export type SyncModel =
| ({ type: "workspace" } & Workspace)
| ({ type: "environment" } & Environment)
| ({ type: "folder" } & Folder)
| ({ type: "http_request" } & HttpRequest)
| ({ type: "grpc_request" } & GrpcRequest)
| ({ type: "websocket_request" } & WebsocketRequest);
export type WebsocketRequest = {
model: "websocket_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
};
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;
};
+145 -9
View File
@@ -1,14 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { Channel, invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { createFastMutation } from "@yaakapp/yaak-client/hooks/useFastMutation";
import { queryClient } from "@yaakapp/yaak-client/lib/queryClient";
import { useMemo } from "react";
import {
BranchDeleteResult,
CloneResult,
GitBranchInfo,
GitCommit,
GitFileDiff,
GitRemote,
GitStatusSummary,
GitWorktreeStatus,
PullResult,
PushResult,
} from "./bindings/gen_git";
@@ -26,6 +30,10 @@ export type DivergedStrategy = "force_reset" | "merge" | "cancel";
export type UncommittedChangesStrategy = "reset" | "cancel";
interface GitWatchResult {
unlistenEvent: string;
}
export interface GitCallbacks {
addRemote: () => Promise<GitRemote | null>;
promptCredentials: (
@@ -38,13 +46,98 @@ export interface GitCallbacks {
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ["git"] });
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
const fetchAll = useQuery<void, string>({
function gitWorktreeStatusQueryKey(dir?: string, refreshKey?: string) {
return refreshKey == null
? (["git", "worktree_status", dir] as const)
: (["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],
queryFn: () => invoke("cmd_git_fetch_all", { dir }),
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 [
{
remotes: useQuery<GitRemote[], string>({
@@ -52,11 +145,7 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
queryFn: () => getRemotes(dir),
placeholderData: (prev) => prev,
}),
log: useQuery<GitCommit[], string>({
queryKey: ["git", "log", dir, refreshKey],
queryFn: () => invoke("cmd_git_log", { dir }),
placeholderData: (prev) => prev,
}),
log: useGitLog(dir, refreshKey),
status: useQuery<GitStatusSummary, string>({
refetchOnMount: true,
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
@@ -68,6 +157,10 @@ export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string
] as const;
}
export function useGitMutations(dir: string, callbacks: GitCallbacks) {
return useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
}
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
const push = async () => {
const remotes = await getRemotes(dir);
@@ -250,6 +343,20 @@ export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
mutationFn: () => invoke("cmd_git_reset_changes", { dir }),
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;
};
@@ -257,6 +364,35 @@ async function getRemotes(dir: string) {
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.
*/
+8 -2
View File
@@ -14,6 +14,7 @@ mod push;
mod remotes;
mod repository;
mod reset;
mod restore;
mod status;
mod unstage;
mod util;
@@ -29,10 +30,15 @@ pub use commit::git_commit;
pub use credential::git_add_credential;
pub use fetch::git_fetch_all;
pub use init::git_init;
pub use log::{GitCommit, git_log};
pub use log::{GitCommit, GitFileDiff, git_file_diff_for_commit, git_log, git_log_for_file};
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
pub use push::{PushResult, git_push};
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 status::{GitStatusSummary, git_status};
pub use restore::{git_restore, git_restore_file_from_commit};
pub use status::{
GitBranchInfo, GitStatusSummary, GitWorktreeStatus, git_branch_info, git_status,
git_worktree_status,
};
pub use unstage::git_unstage;
+72
View File
@@ -8,6 +8,7 @@ use ts_rs::TS;
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
pub struct GitCommit {
pub oid: String,
pub author: GitAuthor,
pub when: DateTime<Utc>,
pub message: Option<String>,
@@ -21,7 +22,23 @@ pub struct GitAuthor {
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>> {
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)?;
// Return empty if empty repo or no head (new repo)
@@ -46,8 +63,16 @@ pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
.filter_map(|oid| {
let oid = filter_try!(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();
Some(GitCommit {
oid: oid.to_string(),
author: GitAuthor {
name: author.name().map(|s| s.to_string()),
email: author.email().map(|s| s.to_string()),
@@ -61,6 +86,53 @@ pub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {
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)]
fn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).unwrap()
+22 -1
View File
@@ -1,5 +1,12 @@
use crate::error::Error::{GitRepoNotFound, GitUnknown};
use std::path::Path;
use crate::error::{Error, Result};
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> {
match git2::Repository::discover(dir) {
@@ -8,3 +15,17 @@ pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
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)?)
}
+76
View File
@@ -0,0 +1,76 @@
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())))
}
}
+161 -73
View File
@@ -22,6 +22,20 @@ pub struct GitStatusSummary {
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)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_git.ts")]
@@ -33,6 +47,23 @@ pub struct GitStatusEntry {
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)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_git.ts")]
@@ -46,31 +77,43 @@ pub enum GitStatus {
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> {
let repo = open_repo(dir)?;
let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {
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 branch_info = git_branch_info_for_repo(&repo, dir)?;
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false)
@@ -83,51 +126,8 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
let mut entries: Vec<GitStatusEntry> = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
let rela_path = entry.path().unwrap().to_string();
let status = entry.status();
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
let Some((status, staged)) = git_status_from_raw(entry.status()) else {
continue;
};
// Get previous content from Git, if it's in there
@@ -158,9 +158,27 @@ 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 local_branches = local_branch_names(&repo)?;
let remote_branches = remote_branch_names(&repo)?;
let local_branches = local_branch_names(repo)?;
let remote_branches = remote_branch_names(repo)?;
// Compute ahead/behind relative to remote tracking branch
let (ahead, behind) = (|| -> Option<(usize, usize)> {
@@ -174,15 +192,85 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
})()
.unwrap_or((0, 0));
Ok(GitStatusSummary {
entries,
origins,
Ok(GitBranchInfo {
path: dir.to_string_lossy().to_string(),
head_ref,
head_ref_shorthand,
origins,
local_branches,
remote_branches,
ahead: ahead 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)
}
+33 -29
View File
@@ -7,7 +7,7 @@ use log::debug;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use url::Url;
use yaak_models::models::{Cookie, CookieDomain, CookieExpires};
use yaak_models::models::{Cookie, CookieDomain, CookieExpires, CookieSameSite};
/// A thread-safe cookie store that can be shared across requests
#[derive(Debug, Clone)]
@@ -45,10 +45,7 @@ impl CookieStore {
let matching_cookies: Vec<_> = cookies
.iter()
.filter(|cookie| self.cookie_matches(cookie, url, &now))
.filter_map(|cookie| {
// Parse the raw cookie to get name=value
parse_cookie_name_value(&cookie.raw_cookie)
})
.map(|cookie| (cookie.name.clone(), cookie.value.clone()))
.collect();
if matching_cookies.is_empty() {
@@ -72,13 +69,7 @@ impl CookieStore {
if let Some(cookie) = parse_set_cookie(header_value, url) {
// Remove any existing cookie with the same name and domain
cookies.retain(|existing| !cookies_match(existing, &cookie));
debug!(
"Storing cookie: {} for domain {:?}",
parse_cookie_name_value(&cookie.raw_cookie)
.map(|(n, _)| n)
.unwrap_or_else(|| "unknown".to_string()),
cookie.domain
);
debug!("Storing cookie: {} for domain {:?}", cookie.name, cookie.domain);
cookies.push(cookie);
}
}
@@ -117,10 +108,9 @@ impl CookieStore {
}
// Check path
let (cookie_path, _) = &cookie.path;
let url_path = url.path();
path_matches(url_path, cookie_path)
path_matches(url_path, &cookie.path)
}
}
@@ -133,8 +123,7 @@ pub fn get_cookie_value_from_jar(
let domain = domain.and_then(normalize_cookie_domain_filter);
cookies.into_iter().find_map(|cookie| {
let (cookie_name, value) = parse_cookie_name_value(&cookie.raw_cookie)?;
if cookie_name != name {
if cookie.name != name {
return None;
}
@@ -144,11 +133,12 @@ pub fn get_cookie_value_from_jar(
}
}
Some(value)
Some(cookie.value)
})
}
/// Parse name=value from a cookie string (raw_cookie format)
#[cfg(test)]
fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
// The raw_cookie typically looks like "name=value" or "name=value; attr1; attr2=..."
let first_part = raw_cookie.split(';').next()?;
@@ -177,8 +167,6 @@ fn cookie_domain_matches_filter(cookie_domain: &CookieDomain, domain: &str) -> b
fn parse_set_cookie(header_value: &str, request_url: &Url) -> Option<Cookie> {
let parsed = cookie::Cookie::parse(header_value).ok()?;
let raw_cookie = format!("{}={}", parsed.name(), parsed.value());
// Determine domain
let domain = if let Some(domain_attr) = parsed.domain() {
// Domain attribute present - this is a suffix match
@@ -216,14 +204,28 @@ fn parse_set_cookie(header_value: &str, request_url: &Url) -> Option<Cookie> {
// Determine path
let path = if let Some(path_attr) = parsed.path() {
(path_attr.to_string(), true)
path_attr.to_string()
} else {
// Default path is the directory of the request URI
let default_path = default_cookie_path(request_url.path());
(default_path, false)
default_cookie_path(request_url.path())
};
Some(Cookie { raw_cookie, domain, expires, path })
let same_site = parsed.same_site().map(|same_site| match same_site {
cookie::SameSite::Strict => CookieSameSite::Strict,
cookie::SameSite::Lax => CookieSameSite::Lax,
cookie::SameSite::None => CookieSameSite::None,
});
Some(Cookie {
name: parsed.name().to_string(),
value: parsed.value().to_string(),
domain,
expires,
path,
secure: parsed.secure().unwrap_or(false),
http_only: parsed.http_only().unwrap_or(false),
same_site,
})
}
/// Get the default cookie path from a request path (RFC 6265 Section 5.1.4)
@@ -261,10 +263,7 @@ fn path_matches(request_path: &str, cookie_path: &str) -> bool {
/// Check if two cookies match (same name and domain)
fn cookies_match(a: &Cookie, b: &Cookie) -> bool {
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 {
if a.name != b.name {
return false;
}
@@ -317,11 +316,16 @@ mod tests {
use super::*;
fn cookie(raw_cookie: &str, domain: CookieDomain) -> Cookie {
let (name, value) = parse_cookie_name_value(raw_cookie).unwrap();
Cookie {
raw_cookie: raw_cookie.to_string(),
name,
value,
domain,
expires: CookieExpires::SessionEnd,
path: ("/".to_string(), false),
path: "/".to_string(),
secure: false,
http_only: false,
same_site: None,
}
}
+13 -12
View File
@@ -24,7 +24,13 @@ pub enum RedirectBehavior {
#[derive(Debug, Clone)]
pub enum HttpResponseEvent {
Setting(String, String),
Setting {
name: String,
value: String,
source_model: Option<String>,
source_id: Option<String>,
source_name: Option<String>,
},
Info(String),
Redirect {
url: String,
@@ -67,7 +73,9 @@ pub enum HttpResponseEvent {
impl Display for HttpResponseEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
HttpResponseEvent::Setting { name, value, .. } => {
write!(f, "* Setting {}={}", name, value)
}
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
HttpResponseEvent::Redirect {
url,
@@ -146,7 +154,9 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
fn from(event: HttpResponseEvent) -> Self {
use yaak_models::models::HttpResponseEventData as D;
match event {
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
HttpResponseEvent::Setting { name, value, source_model, source_id, source_name } => {
D::Setting { name, value, source_model, source_id, source_name }
}
HttpResponseEvent::Info(message) => D::Info { message },
HttpResponseEvent::Redirect {
url,
@@ -483,15 +493,6 @@ impl HttpSender for ReqwestSender {
// Send the request
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 {
method: sendable_req.method().to_string(),
scheme: sendable_req.url().scheme().to_string(),
+182 -24
View File
@@ -12,22 +12,58 @@ pub struct HttpTransaction<S: HttpSender> {
sender: S,
max_redirects: usize,
cookie_store: Option<CookieStore>,
send_cookies: bool,
store_cookies: bool,
}
impl<S: HttpSender> HttpTransaction<S> {
/// Create a new transaction with default settings
pub fn new(sender: S) -> Self {
Self { sender, max_redirects: 10, cookie_store: None }
Self {
sender,
max_redirects: 10,
cookie_store: None,
send_cookies: false,
store_cookies: false,
}
}
/// Create a new transaction with custom max redirects
pub fn with_max_redirects(sender: S, max_redirects: usize) -> Self {
Self { sender, max_redirects, cookie_store: None }
Self {
sender,
max_redirects,
cookie_store: None,
send_cookies: false,
store_cookies: false,
}
}
/// Create a new transaction with a cookie store
pub fn with_cookie_store(sender: S, cookie_store: CookieStore) -> Self {
Self { sender, max_redirects: 10, cookie_store: Some(cookie_store) }
Self {
sender,
max_redirects: 10,
cookie_store: Some(cookie_store),
send_cookies: true,
store_cookies: true,
}
}
/// Create a new transaction with a cookie store and explicit send/store behavior
pub fn with_cookie_behavior(
sender: S,
cookie_store: CookieStore,
send_cookies: bool,
store_cookies: bool,
) -> Self {
Self {
sender,
max_redirects: 10,
cookie_store: Some(cookie_store),
send_cookies,
store_cookies,
}
}
/// Create a new transaction with custom max redirects and a cookie store
@@ -36,7 +72,13 @@ impl<S: HttpSender> HttpTransaction<S> {
max_redirects: usize,
cookie_store: Option<CookieStore>,
) -> Self {
Self { sender, max_redirects, cookie_store }
Self {
sender,
max_redirects,
send_cookies: cookie_store.is_some(),
store_cookies: cookie_store.is_some(),
cookie_store,
}
}
/// Execute the request with cancellation support.
@@ -66,9 +108,11 @@ impl<S: HttpSender> HttpTransaction<S> {
}
// Inject cookies into headers if we have a cookie store
let headers_with_cookies = if let Some(cookie_store) = &self.cookie_store {
let headers_with_cookies = if self.send_cookies {
let mut headers = current_headers.clone();
if let Ok(url) = Url::parse(&current_url) {
if let (Some(cookie_store), Ok(url)) =
(&self.cookie_store, Url::parse(&current_url))
{
if let Some(cookie_header) = cookie_store.get_cookie_header(&url) {
debug!("Injecting Cookie header: {}", cookie_header);
// Check if there's already a Cookie header and merge if so
@@ -100,12 +144,6 @@ impl<S: HttpSender> HttpTransaction<S> {
options: request.options.clone(),
};
// Send the request
send_event(HttpResponseEvent::Setting(
"redirects".to_string(),
request.options.follow_redirects.to_string(),
));
// Execute with cancellation support
let response = tokio::select! {
result = self.sender.send(req, event_tx.clone()) => result?,
@@ -115,8 +153,10 @@ impl<S: HttpSender> HttpTransaction<S> {
};
// Parse Set-Cookie headers and store cookies
if let Some(cookie_store) = &self.cookie_store {
if let Ok(url) = Url::parse(&current_url) {
if self.store_cookies {
if let (Some(cookie_store), Ok(url)) =
(&self.cookie_store, Url::parse(&current_url))
{
let set_cookie_headers: Vec<String> = response
.headers
.iter()
@@ -579,10 +619,14 @@ mod tests {
// Create a cookie store with a test cookie
let cookie = Cookie {
raw_cookie: "session=abc123".to_string(),
name: "session".to_string(),
value: "abc123".to_string(),
domain: CookieDomain::HostOnly("example.com".to_string()),
expires: CookieExpires::SessionEnd,
path: ("/".to_string(), false),
path: "/".to_string(),
secure: false,
http_only: false,
same_site: None,
};
let cookie_store = CookieStore::from_cookies(vec![cookie]);
@@ -602,6 +646,67 @@ mod tests {
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]
async fn test_set_cookie_parsing() {
// Create a cookie store
@@ -655,7 +760,62 @@ mod tests {
// Verify the cookie was stored
let cookies = cookie_store.get_all_cookies();
assert_eq!(cookies.len(), 1);
assert!(cookies[0].raw_cookie.contains("session=xyz789"));
assert_eq!(cookies[0].name, "session");
assert_eq!(cookies[0].value, "xyz789");
}
#[tokio::test]
async fn test_set_cookie_storage_can_be_disabled() {
let cookie_store = CookieStore::new();
struct SetCookieSender;
#[async_trait]
impl HttpSender for SetCookieSender {
async fn send(
&self,
_request: SendableHttpRequest,
_event_tx: mpsc::Sender<HttpResponseEvent>,
) -> Result<HttpResponse> {
let headers =
vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
Box::pin(std::io::Cursor::new(vec![]));
Ok(HttpResponse::new(
200,
None,
headers,
Vec::new(),
None,
"https://example.com".to_string(),
None,
Some("HTTP/1.1".to_string()),
body_stream,
ContentEncoding::Identity,
))
}
}
let transaction = HttpTransaction::with_cookie_behavior(
SetCookieSender,
cookie_store.clone(),
true,
false,
);
let request = SendableHttpRequest {
url: "https://example.com/login".to_string(),
method: "POST".to_string(),
headers: vec![],
..Default::default()
};
let (_tx, rx) = tokio::sync::watch::channel(false);
let (event_tx, _event_rx) = mpsc::channel(100);
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
assert!(result.is_ok());
assert!(cookie_store.get_all_cookies().is_empty());
}
#[tokio::test]
@@ -719,17 +879,15 @@ mod tests {
let cookies = cookie_store.get_all_cookies();
assert_eq!(cookies.len(), 3, "All three Set-Cookie headers should be parsed and stored");
let cookie_values: Vec<&str> = cookies.iter().map(|c| c.raw_cookie.as_str()).collect();
let cookie_values: Vec<_> =
cookies.iter().map(|c| format!("{}={}", c.name, c.value)).collect();
assert!(
cookie_values.iter().any(|c| c.contains("session=abc123")),
cookie_values.iter().any(|c| c == "session=abc123"),
"session cookie should be stored"
);
assert!(cookie_values.iter().any(|c| c == "user_id=42"), "user_id cookie should be stored");
assert!(
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")),
cookie_values.iter().any(|c| c == "preferences=dark"),
"preferences cookie should be stored"
);
}
+4 -1
View File
@@ -304,7 +304,10 @@ async fn build_binary_body(
}))
}
fn build_text_body(body: &BTreeMap<String, serde_json::Value>, body_type: &str) -> Option<SendableBodyWithMeta> {
fn build_text_body(
body: &BTreeMap<String, serde_json::Value>,
body_type: &str,
) -> Option<SendableBodyWithMeta> {
let text = get_str_map(body, "text");
if text.is_empty() {
return None;
+454 -61
View File
@@ -1,121 +1,514 @@
// 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";
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type AnyModel =
| CookieJar
| Environment
| Folder
| GraphQlIntrospection
| GrpcConnection
| GrpcEvent
| GrpcRequest
| HttpRequest
| HttpResponse
| HttpResponseEvent
| KeyValue
| Plugin
| Settings
| SyncState
| WebsocketConnection
| WebsocketEvent
| WebsocketRequest
| Workspace
| WorkspaceMeta;
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
export type ClientCertificate = {
host: string;
port: number | null;
crtFile: string | null;
keyFile: string | null;
pfxFile: string | null;
passphrase: string | null;
enabled?: boolean;
};
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
export type Cookie = {
name: string;
value: string;
domain: CookieDomain;
expires: CookieExpires;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: CookieSameSite | null;
};
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieExpires = { AtUtc: string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type CookieJar = {
model: "cookie_jar";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
cookies: Array<Cookie>;
name: string;
};
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type CookieSameSite = "Strict" | "Lax" | "None";
export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type EncryptedKey = { encryptedKey: string };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type Environment = {
model: "environment";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
name: string;
public: boolean;
parentModel: string;
parentId: string | null;
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>;
color: string | null;
sortPriority: number;
};
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type Folder = {
model: "folder";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
sortPriority: number;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
};
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
export type GraphQlIntrospection = {
model: "graphql_introspection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
content: string | null;
};
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
export type GrpcConnection = {
model: "grpc_connection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
elapsed: number;
error: string | null;
method: string;
service: string;
status: number;
state: GrpcConnectionState;
trailers: { [key in string]?: string };
url: string;
};
export type GrpcConnectionState = "initialized" | "connected" | "closed";
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
export type GrpcEvent = {
model: "grpc_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
connectionId: string;
content: string;
error: string | null;
eventType: GrpcEventType;
metadata: { [key in string]?: string };
status: number | null;
};
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcEventType =
| "info"
| "error"
| "client_message"
| "server_message"
| "connection_start"
| "connection_end";
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
/**
* Server URL (http for plaintext or https for secure)
*/
url: string, };
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;
/**
* Server URL (http for plaintext or https for secure)
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
};
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,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
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;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
};
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponse = {
model: "http_response";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
bodyPath: string | null;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
elapsedHeaders: number;
elapsedDns: number;
error: string | null;
headers: Array<HttpResponseHeader>;
remoteAddr: string | null;
requestContentLength: number | null;
requestHeaders: Array<HttpResponseHeader>;
status: number;
statusReason: string | null;
state: HttpResponseState;
url: string;
version: string | null;
};
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
export type HttpResponseEvent = {
model: "http_response_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
responseId: string;
event: HttpResponseEventData;
};
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData =
| {
type: "setting";
name: string;
value: string;
source_model?: string;
source_id?: string;
source_name?: string;
}
| { type: "info"; message: string }
| {
type: "redirect";
url: string;
status: number;
behavior: string;
dropped_body: boolean;
dropped_headers: Array<string>;
}
| {
type: "send_url";
method: string;
scheme: string;
username: string;
password: string;
host: string;
port: number;
path: string;
query: string;
fragment: string;
}
| { type: "receive_url"; version: string; status: string }
| { type: "header_up"; name: string; value: string }
| { type: "header_down"; name: string; value: string }
| { type: "chunk_sent"; bytes: number }
| { type: "chunk_received"; bytes: number }
| {
type: "dns_resolved";
hostname: string;
addresses: Array<string>;
duration: bigint;
overridden: boolean;
};
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseHeader = { name: string; value: string };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type HttpUrlParameter = {
enabled?: boolean;
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string;
value: string;
id?: string;
};
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type ParentAuthentication = { authentication: Record<string, any>, authenticationType: string | null, };
export type KeyValue = {
model: "key_value";
id: string;
createdAt: string;
updatedAt: string;
key: string;
namespace: string;
value: string;
};
export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
export type ModelPayload = {
model: AnyModel;
updateSource: UpdateSource;
change: ModelChangeEvent;
};
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type ParentAuthentication = {
authentication: Record<string, any>;
authenticationType: string | null;
};
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
export type ParentHeaders = { headers: Array<HttpRequestHeader> };
export type Plugin = {
model: "plugin";
id: string;
createdAt: string;
updatedAt: string;
checkedAt: string | null;
directory: string;
enabled: boolean;
url: string | null;
source: PluginSource;
};
export type PluginKeyValue = {
model: "plugin_key_value";
createdAt: string;
updatedAt: string;
pluginName: string;
key: string;
value: string;
};
export type PluginSource = "bundled" | "filesystem" | "registry";
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
export type ProxySetting =
| {
type: "enabled";
http: string;
https: string;
auth: ProxySettingAuth | null;
bypass: string;
disabled: boolean;
}
| { type: "disabled" };
export type ProxySettingAuth = { user: string, password: string, };
export type ProxySettingAuth = { user: string; password: string };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
export type Settings = {
model: "settings";
id: string;
createdAt: string;
updatedAt: string;
appearance: string;
clientCertificates: Array<ClientCertificate>;
coloredMethods: boolean;
editorFont: string | null;
editorFontSize: number;
editorKeymap: EditorKeymap;
editorSoftWrap: boolean;
hideWindowControls: boolean;
useNativeTitlebar: boolean;
interfaceFont: string | null;
interfaceFontSize: number;
interfaceScale: number;
openWorkspaceNewWindow: boolean | null;
proxy: ProxySetting | null;
themeDark: string;
themeLight: string;
updateChannel: string;
hideLicenseBadge: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
hotkeys: { [key in string]?: Array<string> };
};
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
export type SyncState = {
model: "sync_state";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
flushedAt: string;
modelId: string;
checksum: string;
relPath: string;
syncDir: string;
};
export type UpdateSource = { "type": "background" } | { "type": "import" } | { "type": "plugin" } | { "type": "sync" } | { "type": "window", label: string, };
export type UpdateSource =
| { type: "background" }
| { type: "import" }
| { type: "plugin" }
| { type: "sync" }
| { type: "window"; label: string };
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
export type WebsocketConnection = {
model: "websocket_connection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
elapsed: number;
error: string | null;
headers: Array<HttpResponseHeader>;
state: WebsocketConnectionState;
status: number;
url: string;
};
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
export type WebsocketEvent = {
model: "websocket_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
connectionId: string;
isServer: boolean;
message: Array<number>;
messageType: WebsocketEventType;
};
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketMessageType = "text" | "binary";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type WebsocketRequest = {
model: "websocket_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
};
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
export type Workspace = {
model: "workspace";
id: string;
createdAt: string;
updatedAt: string;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
encryptionKeyChallenge: string | null;
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
};
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
export type WorkspaceMeta = {
model: "workspace_meta";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
encryptionKey: EncryptedKey | null;
settingSyncDir: string | null;
};
@@ -0,0 +1,17 @@
ALTER TABLE workspaces ADD COLUMN setting_send_cookies BOOLEAN DEFAULT TRUE NOT NULL;
ALTER TABLE workspaces ADD COLUMN setting_store_cookies BOOLEAN DEFAULT TRUE NOT NULL;
ALTER TABLE folders ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE folders ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE folders ADD COLUMN setting_request_timeout TEXT DEFAULT '{"enabled":false,"value":0}' NOT NULL;
ALTER TABLE folders ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE folders ADD COLUMN setting_follow_redirects TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE http_requests ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE http_requests ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE http_requests ADD COLUMN setting_request_timeout TEXT DEFAULT '{"enabled":false,"value":0}' NOT NULL;
ALTER TABLE http_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE http_requests ADD COLUMN setting_follow_redirects TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE websocket_requests ADD COLUMN setting_send_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE websocket_requests ADD COLUMN setting_store_cookies TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE websocket_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
ALTER TABLE grpc_requests ADD COLUMN setting_validate_certificates TEXT DEFAULT '{"enabled":false,"value":true}' NOT NULL;
+309 -5
View File
@@ -1,7 +1,9 @@
use crate::error::Result;
use crate::models::HttpRequestIden::{
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
Method, Name, SortPriority, UpdatedAt, Url, UrlParameters, WorkspaceId,
Method, Name, SettingFollowRedirects, SettingRequestTimeout, SettingSendCookies,
SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt, Url, UrlParameters,
WorkspaceId,
};
use crate::util::generate_prefixed_id;
use chrono::{NaiveDateTime, Utc};
@@ -16,8 +18,8 @@ use std::collections::HashMap;
use std::fmt::{Debug, Display};
use std::str::FromStr;
use ts_rs::TS;
use yaak_database::{Result as DbResult, UpdateSource};
pub use yaak_database::{UpsertModelInfo, upsert_date};
use yaak_database::{UpdateSource, Result as DbResult};
#[macro_export]
macro_rules! impl_model {
@@ -90,6 +92,84 @@ pub struct DnsOverride {
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)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
@@ -322,6 +402,10 @@ pub struct Workspace {
pub setting_request_timeout: i32,
#[serde(default)]
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 {
@@ -363,6 +447,8 @@ impl UpsertModelInfo for Workspace {
(SettingRequestTimeout, self.setting_request_timeout.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
(SettingSendCookies, self.setting_send_cookies.into()),
(SettingStoreCookies, self.setting_store_cookies.into()),
])
}
@@ -380,6 +466,8 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingValidateCertificates,
WorkspaceIden::SettingDnsOverrides,
WorkspaceIden::SettingSendCookies,
WorkspaceIden::SettingStoreCookies,
]
}
@@ -405,6 +493,8 @@ impl UpsertModelInfo for Workspace {
setting_request_timeout: row.get("setting_request_timeout")?,
setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
setting_send_cookies: row.get("setting_send_cookies")?,
setting_store_cookies: row.get("setting_store_cookies")?,
})
}
}
@@ -509,11 +599,127 @@ pub enum CookieExpires {
#[derive(Debug, Clone, Serialize, Deserialize, 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 raw_cookie: String,
pub name: String,
pub value: String,
pub domain: CookieDomain,
pub expires: CookieExpires,
pub path: (String, bool),
pub path: String,
pub secure: bool,
pub http_only: bool,
pub same_site: Option<CookieSameSite>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CookieFields {
name: String,
value: String,
domain: CookieDomain,
expires: CookieExpires,
path: String,
#[serde(default)]
secure: bool,
#[serde(default)]
http_only: bool,
#[serde(default)]
same_site: Option<CookieSameSite>,
}
#[derive(Deserialize)]
struct LegacyCookie {
raw_cookie: String,
domain: CookieDomain,
expires: CookieExpires,
path: (String, bool),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum CookieCompat {
New(CookieFields),
Legacy(LegacyCookie),
}
impl<'de> Deserialize<'de> for Cookie {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(match CookieCompat::deserialize(deserializer)? {
CookieCompat::New(cookie) => Self {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
expires: cookie.expires,
path: cookie.path,
secure: cookie.secure,
http_only: cookie.http_only,
same_site: cookie.same_site,
},
CookieCompat::Legacy(cookie) => {
let (name, value, secure, http_only, same_site) =
parse_legacy_cookie_parts(&cookie.raw_cookie);
Self {
name,
value,
domain: cookie.domain,
expires: cookie.expires,
path: cookie.path.0,
secure,
http_only,
same_site,
}
}
})
}
}
fn parse_legacy_cookie_parts(
raw_cookie: &str,
) -> (String, String, bool, bool, Option<CookieSameSite>) {
let mut parts = raw_cookie.split(';').map(str::trim);
let (name, value) = parts
.next()
.and_then(|part| {
let mut nv = part.splitn(2, '=');
Some((nv.next()?.trim().to_string(), nv.next().unwrap_or("").trim().to_string()))
})
.unwrap_or_default();
let mut secure = false;
let mut http_only = false;
let mut same_site = None;
for part in parts {
let mut attr = part.splitn(2, '=');
let key = attr.next().unwrap_or("").trim().to_lowercase();
let value = attr.next().unwrap_or("").trim().to_lowercase();
match key.as_str() {
"secure" => secure = true,
"httponly" => http_only = true,
"samesite" => {
same_site = match value.as_str() {
"strict" => Some(CookieSameSite::Strict),
"lax" => Some(CookieSameSite::Lax),
"none" => Some(CookieSameSite::None),
_ => same_site,
};
}
_ => {}
}
}
(name, value, secure, http_only, same_site)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
@@ -751,6 +957,11 @@ pub struct Folder {
pub headers: Vec<HttpRequestHeader>,
pub name: String,
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 {
@@ -790,6 +1001,14 @@ impl UpsertModelInfo for Folder {
(Description, self.description.into()),
(Name, self.name.trim().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()),
])
}
@@ -803,6 +1022,11 @@ impl UpsertModelInfo for Folder {
FolderIden::Description,
FolderIden::FolderId,
FolderIden::SortPriority,
FolderIden::SettingSendCookies,
FolderIden::SettingStoreCookies,
FolderIden::SettingValidateCertificates,
FolderIden::SettingFollowRedirects,
FolderIden::SettingRequestTimeout,
]
}
@@ -812,6 +1036,11 @@ impl UpsertModelInfo for Folder {
{
let headers: String = row.get("headers")?;
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 {
id: row.get("id")?,
model: row.get("model")?,
@@ -825,6 +1054,14 @@ impl UpsertModelInfo for Folder {
headers: serde_json::from_str(&headers).unwrap_or_default(),
authentication_type: row.get("authentication_type")?,
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(),
})
}
}
@@ -885,6 +1122,11 @@ pub struct HttpRequest {
pub url: String,
/// URL parameters used for both path placeholders (`:id`) and query string entries.
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 {
@@ -928,6 +1170,14 @@ impl UpsertModelInfo for HttpRequest {
(AuthenticationType, self.authentication_type.into()),
(Headers, serde_json::to_string(&self.headers)?.into()),
(SortPriority, self.sort_priority.into()),
(SettingSendCookies, serde_json::to_string(&self.setting_send_cookies)?.into()),
(SettingStoreCookies, serde_json::to_string(&self.setting_store_cookies)?.into()),
(
SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(),
),
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
])
}
@@ -947,6 +1197,11 @@ impl UpsertModelInfo for HttpRequest {
Url,
UrlParameters,
SortPriority,
SettingSendCookies,
SettingStoreCookies,
SettingValidateCertificates,
SettingFollowRedirects,
SettingRequestTimeout,
]
}
@@ -955,6 +1210,11 @@ impl UpsertModelInfo for HttpRequest {
let body: String = row.get("body")?;
let authentication: String = row.get("authentication")?;
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 {
id: row.get("id")?,
model: row.get("model")?,
@@ -973,6 +1233,14 @@ impl UpsertModelInfo for HttpRequest {
sort_priority: row.get("sort_priority")?,
url: row.get("url")?,
url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),
setting_send_cookies: serde_json::from_str(&setting_send_cookies).unwrap_or_default(),
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(),
setting_follow_redirects: serde_json::from_str(&setting_follow_redirects)
.unwrap_or_default(),
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
.unwrap_or_default(),
})
}
}
@@ -1127,6 +1395,9 @@ pub struct WebsocketRequest {
pub url: String,
/// URL parameters used for both path placeholders (`:id`) and query string entries.
pub url_parameters: Vec<HttpUrlParameter>,
pub setting_send_cookies: InheritedBoolSetting,
pub setting_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting,
}
impl UpsertModelInfo for WebsocketRequest {
@@ -1169,6 +1440,12 @@ impl UpsertModelInfo for WebsocketRequest {
(SortPriority, self.sort_priority.into()),
(Url, self.url.into()),
(UrlParameters, serde_json::to_string(&self.url_parameters)?.into()),
(SettingSendCookies, serde_json::to_string(&self.setting_send_cookies)?.into()),
(SettingStoreCookies, serde_json::to_string(&self.setting_store_cookies)?.into()),
(
SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(),
),
])
}
@@ -1186,6 +1463,9 @@ impl UpsertModelInfo for WebsocketRequest {
WebsocketRequestIden::SortPriority,
WebsocketRequestIden::Url,
WebsocketRequestIden::UrlParameters,
WebsocketRequestIden::SettingSendCookies,
WebsocketRequestIden::SettingStoreCookies,
WebsocketRequestIden::SettingValidateCertificates,
]
}
@@ -1196,6 +1476,9 @@ impl UpsertModelInfo for WebsocketRequest {
let url_parameters: String = row.get("url_parameters")?;
let authentication: String = row.get("authentication")?;
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")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -1212,6 +1495,10 @@ impl UpsertModelInfo for WebsocketRequest {
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
folder_id: row.get("folder_id")?,
name: row.get("name")?,
setting_send_cookies: serde_json::from_str(&setting_send_cookies).unwrap_or_default(),
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(),
})
}
}
@@ -1493,6 +1780,15 @@ pub enum HttpResponseEventData {
Setting {
name: 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 {
message: String,
@@ -1742,6 +2038,7 @@ pub struct GrpcRequest {
pub sort_priority: f64,
/// Server URL (http for plaintext or https for secure)
pub url: String,
pub setting_validate_certificates: InheritedBoolSetting,
}
impl UpsertModelInfo for GrpcRequest {
@@ -1785,6 +2082,10 @@ impl UpsertModelInfo for GrpcRequest {
(AuthenticationType, self.authentication_type.into()),
(Authentication, serde_json::to_string(&self.authentication)?.into()),
(Metadata, serde_json::to_string(&self.metadata)?.into()),
(
SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(),
),
])
}
@@ -1803,6 +2104,7 @@ impl UpsertModelInfo for GrpcRequest {
GrpcRequestIden::AuthenticationType,
GrpcRequestIden::Authentication,
GrpcRequestIden::Metadata,
GrpcRequestIden::SettingValidateCertificates,
]
}
@@ -1812,6 +2114,7 @@ impl UpsertModelInfo for GrpcRequest {
{
let authentication: String = row.get("authentication")?;
let metadata: String = row.get("metadata")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -1829,6 +2132,8 @@ impl UpsertModelInfo for GrpcRequest {
url: row.get("url")?,
sort_priority: row.get("sort_priority")?,
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(),
})
}
}
@@ -2526,4 +2831,3 @@ impl AnyModel {
}
}
}
+60 -3
View File
@@ -1,9 +1,10 @@
use crate::connection_or_tx::ConnectionOrTx;
use crate::client_db::ClientDb;
use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Result;
use crate::models::{
Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest,
HttpRequestHeader, HttpRequestIden, WebsocketRequest, WebsocketRequestIden,
AnyModel, Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden,
HttpRequest, HttpRequestHeader, HttpRequestIden, ResolvedHttpRequestSettings, ResolvedSetting,
WebsocketRequest, WebsocketRequestIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
@@ -141,4 +142,60 @@ impl<'a> ClientDb<'a> {
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,7 +1,10 @@
use super::dedupe_headers;
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader};
use crate::models::{
AnyModel, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader,
ResolvedHttpRequestSettings, ResolvedSetting,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -104,4 +107,29 @@ impl<'a> ClientDb<'a> {
Ok(dedupe_headers(metadata))
}
pub fn resolve_settings_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
) -> Result<ResolvedHttpRequestSettings> {
let parent = if let Some(folder_id) = grpc_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
self.resolve_settings_for_folder(&folder)?
} else {
let workspace = self.get_workspace(&grpc_request.workspace_id)?;
self.resolve_settings_for_workspace(&workspace)
};
Ok(ResolvedHttpRequestSettings {
validate_certificates: if grpc_request.setting_validate_certificates.enabled {
ResolvedSetting::from_model(
grpc_request.setting_validate_certificates.value,
AnyModel::GrpcRequest(grpc_request.clone()),
)
} else {
parent.validate_certificates
},
..parent
})
}
}
@@ -1,7 +1,10 @@
use super::dedupe_headers;
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
use crate::models::{
AnyModel, Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
ResolvedHttpRequestSettings, ResolvedSetting,
};
use crate::util::UpdateSource;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -91,6 +94,62 @@ impl<'a> ClientDb<'a> {
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(
&self,
folder_id: &str,
@@ -16,7 +16,10 @@ impl<'a> ClientDb<'a> {
.add(Expr::col(PluginKeyValueIden::Key).eq(key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn().resolve().query_row(sql.as_str(), &*params.as_params(), |row| row.try_into()).ok()
self.conn()
.resolve()
.query_row(sql.as_str(), &*params.as_params(), |row| row.try_into())
.ok()
}
pub fn set_plugin_key_value(
@@ -2,7 +2,8 @@ use super::dedupe_headers;
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
Folder, FolderIden, HttpRequestHeader, WebsocketRequest, WebsocketRequestIden,
AnyModel, Folder, FolderIden, HttpRequestHeader, ResolvedHttpRequestSettings, ResolvedSetting,
WebsocketRequest, WebsocketRequestIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
@@ -116,4 +117,45 @@ impl<'a> ClientDb<'a> {
Ok(dedupe_headers(headers))
}
pub fn resolve_settings_for_websocket_request(
&self,
websocket_request: &WebsocketRequest,
) -> Result<ResolvedHttpRequestSettings> {
let parent = if let Some(folder_id) = websocket_request.folder_id.clone() {
let folder = self.get_folder(&folder_id)?;
self.resolve_settings_for_folder(&folder)?
} else {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
self.resolve_settings_for_workspace(&workspace)
};
Ok(ResolvedHttpRequestSettings {
validate_certificates: if websocket_request.setting_validate_certificates.enabled {
ResolvedSetting::from_model(
websocket_request.setting_validate_certificates.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.validate_certificates
},
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
})
}
}
+30 -2
View File
@@ -1,8 +1,8 @@
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
WebsocketRequestIden, Workspace, WorkspaceIden,
AnyModel, EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,
ResolvedHttpRequestSettings, ResolvedSetting, WebsocketRequestIden, Workspace, WorkspaceIden,
};
use crate::util::UpdateSource;
use serde_json::Value;
@@ -84,6 +84,34 @@ impl<'a> ClientDb<'a> {
headers.extend(workspace.headers.clone());
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.
+3 -1
View File
@@ -10,7 +10,9 @@ use std::collections::BTreeMap;
use ts_rs::TS;
use yaak_core::WorkspaceContext;
pub use yaak_database::{ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id};
pub use yaak_database::{
ModelChangeEvent, generate_id, generate_id_of_length, generate_prefixed_id,
};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
+430 -56
View File
@@ -1,108 +1,482 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type AnyModel =
| CookieJar
| Environment
| Folder
| GraphQlIntrospection
| GrpcConnection
| GrpcEvent
| GrpcRequest
| HttpRequest
| HttpResponse
| HttpResponseEvent
| KeyValue
| Plugin
| Settings
| SyncState
| WebsocketConnection
| WebsocketEvent
| WebsocketRequest
| Workspace
| WorkspaceMeta;
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
export type ClientCertificate = {
host: string;
port: number | null;
crtFile: string | null;
keyFile: string | null;
pfxFile: string | null;
passphrase: string | null;
enabled?: boolean;
};
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
export type Cookie = {
name: string;
value: string;
domain: CookieDomain;
expires: CookieExpires;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: CookieSameSite | null;
};
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieExpires = { AtUtc: string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type CookieJar = {
model: "cookie_jar";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
cookies: Array<Cookie>;
name: string;
};
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type CookieSameSite = "Strict" | "Lax" | "None";
export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type EncryptedKey = { encryptedKey: string };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type Environment = {
model: "environment";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
name: string;
public: boolean;
parentModel: string;
parentId: string | null;
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>;
color: string | null;
sortPriority: number;
};
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type Folder = {
model: "folder";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
sortPriority: number;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
};
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
export type GraphQlIntrospection = {
model: "graphql_introspection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
content: string | null;
};
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
export type GrpcConnection = {
model: "grpc_connection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
elapsed: number;
error: string | null;
method: string;
service: string;
status: number;
state: GrpcConnectionState;
trailers: { [key in string]?: string };
url: string;
};
export type GrpcConnectionState = "initialized" | "connected" | "closed";
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
export type GrpcEvent = {
model: "grpc_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
connectionId: string;
content: string;
error: string | null;
eventType: GrpcEventType;
metadata: { [key in string]?: string };
status: number | null;
};
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcEventType =
| "info"
| "error"
| "client_message"
| "server_message"
| "connection_start"
| "connection_end";
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
/**
* Server URL (http for plaintext or https for secure)
*/
url: string, };
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;
/**
* Server URL (http for plaintext or https for secure)
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
};
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,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
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;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
};
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponse = {
model: "http_response";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
bodyPath: string | null;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
elapsedHeaders: number;
elapsedDns: number;
error: string | null;
headers: Array<HttpResponseHeader>;
remoteAddr: string | null;
requestContentLength: number | null;
requestHeaders: Array<HttpResponseHeader>;
status: number;
statusReason: string | null;
state: HttpResponseState;
url: string;
version: string | null;
};
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
export type HttpResponseEvent = {
model: "http_response_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
responseId: string;
event: HttpResponseEventData;
};
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData =
| {
type: "setting";
name: string;
value: string;
source_model?: string;
source_id?: string;
source_name?: string;
}
| { type: "info"; message: string }
| {
type: "redirect";
url: string;
status: number;
behavior: string;
dropped_body: boolean;
dropped_headers: Array<string>;
}
| {
type: "send_url";
method: string;
scheme: string;
username: string;
password: string;
host: string;
port: number;
path: string;
query: string;
fragment: string;
}
| { type: "receive_url"; version: string; status: string }
| { type: "header_up"; name: string; value: string }
| { type: "header_down"; name: string; value: string }
| { type: "chunk_sent"; bytes: number }
| { type: "chunk_received"; bytes: number }
| {
type: "dns_resolved";
hostname: string;
addresses: Array<string>;
duration: bigint;
overridden: boolean;
};
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseHeader = { name: string; value: string };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type HttpUrlParameter = {
enabled?: boolean;
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string;
value: string;
id?: string;
};
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type InheritedIntSetting = { enabled?: boolean; value: number };
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 ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
export type ProxySetting =
| {
type: "enabled";
http: string;
https: string;
auth: ProxySettingAuth | null;
bypass: string;
disabled: boolean;
}
| { type: "disabled" };
export type ProxySettingAuth = { user: string, password: string, };
export type ProxySettingAuth = { user: string; password: string };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
export type Settings = {
model: "settings";
id: string;
createdAt: string;
updatedAt: string;
appearance: string;
clientCertificates: Array<ClientCertificate>;
coloredMethods: boolean;
editorFont: string | null;
editorFontSize: number;
editorKeymap: EditorKeymap;
editorSoftWrap: boolean;
hideWindowControls: boolean;
useNativeTitlebar: boolean;
interfaceFont: string | null;
interfaceFontSize: number;
interfaceScale: number;
openWorkspaceNewWindow: boolean | null;
proxy: ProxySetting | null;
themeDark: string;
themeLight: string;
updateChannel: string;
hideLicenseBadge: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
hotkeys: { [key in string]?: Array<string> };
};
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
export type SyncState = {
model: "sync_state";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
flushedAt: string;
modelId: string;
checksum: string;
relPath: string;
syncDir: string;
};
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
export type WebsocketConnection = {
model: "websocket_connection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
elapsed: number;
error: string | null;
headers: Array<HttpResponseHeader>;
state: WebsocketConnectionState;
status: number;
url: string;
};
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
export type WebsocketEvent = {
model: "websocket_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
connectionId: string;
isServer: boolean;
message: Array<number>;
messageType: WebsocketEventType;
};
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type 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 WebsocketRequest = {
model: "websocket_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
};
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };
export type Workspace = {
model: "workspace";
id: string;
createdAt: string;
updatedAt: string;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
encryptionKeyChallenge: string | null;
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
};
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
export type WorkspaceMeta = {
model: "workspace_meta";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
encryptionKey: EncryptedKey | null;
settingSyncDir: string | null;
};
+3 -4
View File
@@ -79,10 +79,9 @@ where
let len = data.len();
self.bytes_count += len as u64;
self.chunks.push(data.clone());
let _ = self.event_tx.send(ProxyEvent::ResponseBodyChunk {
id: self.request_id,
bytes: len,
});
let _ = self
.event_tx
.send(ProxyEvent::ResponseBodyChunk { id: self.request_id, bytes: len });
}
Poll::Ready(Some(Ok(frame)))
}
+13 -26
View File
@@ -18,23 +18,14 @@ impl CertificateAuthority {
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages.push(KeyUsagePurpose::KeyCertSign);
params.key_usages.push(KeyUsagePurpose::CrlSign);
params
.distinguished_name
.push(rcgen::DnType::CommonName, "Debug Proxy CA");
params
.distinguished_name
.push(rcgen::DnType::OrganizationName, "Debug Proxy");
params.distinguished_name.push(rcgen::DnType::CommonName, "Debug Proxy CA");
params.distinguished_name.push(rcgen::DnType::OrganizationName, "Debug Proxy");
let key = KeyPair::generate()?;
let ca_cert = params.self_signed(&key)?;
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 {
@@ -53,9 +44,7 @@ impl CertificateAuthority {
}
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_cert = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key)?;
@@ -63,20 +52,18 @@ impl CertificateAuthority {
let cert_der = leaf_cert.der().clone();
let key_der = leaf_key.serialize_der();
let mut config = ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(
vec![cert_der, self.ca_cert_der.clone()],
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
)?;
let mut config =
ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(
vec![cert_der, self.ca_cert_der.clone()],
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der)),
)?;
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
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)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
use std::sync::mpsc as std_mpsc;
use std::sync::Arc;
use std::sync::mpsc as std_mpsc;
use hyper::server::conn::http1;
use hyper::service::service_fn;
+11 -3
View File
@@ -4,9 +4,9 @@ mod connection;
mod request;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::mpsc as std_mpsc;
use std::sync::Arc;
use cert::CertificateAuthority;
use tokio::net::TcpListener;
@@ -27,7 +27,11 @@ pub enum ProxyEvent {
http_version: String,
},
/// A request header sent to the upstream server.
RequestHeader { id: u64, name: String, value: String },
RequestHeader {
id: u64,
name: String,
value: String,
},
/// The full request body (buffered before forwarding).
RequestBody { id: u64, body: Vec<u8> },
/// Response headers received from upstream.
@@ -38,7 +42,11 @@ pub enum ProxyEvent {
elapsed_ms: u64,
},
/// A response header received from the upstream server.
ResponseHeader { id: u64, name: String, value: String },
ResponseHeader {
id: u64,
name: String,
value: String,
},
/// A chunk of the response body was received (emitted per-frame).
ResponseBodyChunk { id: u64, bytes: usize },
/// The response body stream has completed.

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