mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Add Bruno imports and a file-tree importer API (#706)
This commit is contained in:
1 parent
46464cfacf
commit
aa10c44a45
109 files changed
+4684
-879
No files matched your search
+2
-1
@@ -3,6 +3,7 @@
|
|||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
"**/bindings/**",
|
"**/bindings/**",
|
||||||
"crates/yaak-templates/pkg/**",
|
"crates/yaak-templates/pkg/**",
|
||||||
"apps/yaak-client/routeTree.gen.ts"
|
"apps/yaak-client/routeTree.gen.ts",
|
||||||
|
"plugins/importer-bruno/tests/fixtures/upstream/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { gitClone } from "@yaakapp-internal/git";
|
import { gitClone } from "@yaakapp-internal/git";
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { promptCredentials } from "./git/credentials";
|
import { promptCredentials } from "./git/credentials";
|
||||||
@@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
const [subdirectory, setSubdirectory] = useState<string>("");
|
const [subdirectory, setSubdirectory] = useState<string>("");
|
||||||
const [isCloning, setIsCloning] = useState(false);
|
const [isCloning, setIsCloning] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
const repoName = extractRepoName(url);
|
const repoName = extractRepoName(url);
|
||||||
const sep = getPathSeparator(baseDirectory);
|
const sep = getPathSeparator(baseDirectory);
|
||||||
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
|
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
|
||||||
{error && (
|
{error && (
|
||||||
<Banner color="danger" className="w-full">
|
<Banner color="danger" className="w-full">
|
||||||
{error}
|
{error}
|
||||||
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<DialogFooter
|
||||||
type="submit"
|
actions={[
|
||||||
color="primary"
|
{
|
||||||
className="w-full mt-3"
|
label: isCloning ? "Cloning..." : "Clone Repository",
|
||||||
disabled={!url || !directory || isCloning}
|
color: "primary",
|
||||||
isLoading={isCloning}
|
form: formId,
|
||||||
>
|
disabled: !url || !directory || isCloning,
|
||||||
{isCloning ? "Cloning..." : "Clone Repository"}
|
isLoading: isCloning,
|
||||||
</Button>
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createWorkspaceModel } from "@yaakapp-internal/models";
|
import { createWorkspaceModel } from "@yaakapp-internal/models";
|
||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { useToggle } from "../hooks/useToggle";
|
import { useToggle } from "../hooks/useToggle";
|
||||||
import { ColorIndicator } from "./ColorIndicator";
|
import { ColorIndicator } from "./ColorIndicator";
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
||||||
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { Label } from "./core/Label";
|
import { Label } from "./core/Label";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
|
||||||
@@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
|
|||||||
const [name, setName] = useState<string>("");
|
const [name, setName] = useState<string>("");
|
||||||
const [color, setColor] = useState<string | null>(null);
|
const [color, setColor] = useState<string | null>(null);
|
||||||
const [sharable, toggleSharable] = useToggle(false);
|
const [sharable, toggleSharable] = useToggle(false);
|
||||||
|
const formId = useId();
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="pb-3 flex flex-col gap-3"
|
id={formId}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = await createWorkspaceModel({
|
const id = await createWorkspaceModel({
|
||||||
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
|
|||||||
</Label>
|
</Label>
|
||||||
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" color="secondary" className="mt-3">
|
<DialogFooter
|
||||||
{color != null && <ColorIndicator color={color} />}
|
inline
|
||||||
Create Environment
|
actions={[
|
||||||
</Button>
|
{
|
||||||
|
label: "Create Environment",
|
||||||
|
color: "secondary",
|
||||||
|
form: formId,
|
||||||
|
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,13 @@ import { gitMutations } from "@yaakapp-internal/git";
|
|||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||||
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
|
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
|
||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { Label } from "./core/Label";
|
import { Label } from "./core/Label";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { EncryptionHelp } from "./EncryptionHelp";
|
import { EncryptionHelp } from "./EncryptionHelp";
|
||||||
@@ -26,12 +26,13 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
|||||||
initGit?: boolean;
|
initGit?: boolean;
|
||||||
}>({ filePath: null, initGit: false });
|
}>({ filePath: null, initGit: false });
|
||||||
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
|
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
|
||||||
|
const formId = useId();
|
||||||
return (
|
return (
|
||||||
<VStack
|
<VStack
|
||||||
as="form"
|
as="form"
|
||||||
|
id={formId}
|
||||||
space={3}
|
space={3}
|
||||||
alignItems="start"
|
alignItems="start"
|
||||||
className="pb-3"
|
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const workspaceId = await createGlobalModel({ model: "workspace", name });
|
const workspaceId = await createGlobalModel({ model: "workspace", name });
|
||||||
@@ -89,9 +90,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
|||||||
title="Enable Encryption"
|
title="Enable Encryption"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" color="primary" className="w-full mt-3">
|
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
|
||||||
Create Workspace
|
|
||||||
</Button>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Workspace } from "@yaakapp-internal/models";
|
import type { Workspace } from "@yaakapp-internal/models";
|
||||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
@@ -8,9 +8,9 @@ import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
@@ -87,8 +87,8 @@ function ExportDataDialogContent({
|
|||||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
||||||
const noneSelected = numSelected === 0;
|
const noneSelected = numSelected === 0;
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
|
<>
|
||||||
<VStack space={3} className="overflow-auto px-5 pb-6">
|
<VStack space={3} className="pb-4">
|
||||||
<CommercialUseBanner source="data-export" title="Exporting work data?" />
|
<CommercialUseBanner source="data-export" title="Exporting work data?" />
|
||||||
|
|
||||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||||
@@ -141,29 +141,22 @@ function ExportDataDialogContent({
|
|||||||
/>
|
/>
|
||||||
</DetailsBanner>
|
</DetailsBanner>
|
||||||
</VStack>
|
</VStack>
|
||||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
|
<DialogFooter
|
||||||
<div>
|
leftSlot={
|
||||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
|
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
|
||||||
Create Run Button
|
Create Run Button
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
<HStack space={2} justifyContent="end">
|
actions={[
|
||||||
<Button size="sm" className="focus" variant="border" onClick={onHide}>
|
{ label: "Cancel", onClick: onHide },
|
||||||
Cancel
|
{
|
||||||
</Button>
|
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
|
||||||
<Button
|
color: "primary",
|
||||||
size="sm"
|
disabled: noneSelected,
|
||||||
type="submit"
|
onClick: () => handleExport(),
|
||||||
className="focus"
|
},
|
||||||
color="primary"
|
]}
|
||||||
disabled={noneSelected}
|
/>
|
||||||
onClick={() => handleExport()}
|
</>
|
||||||
>
|
|
||||||
Export{" "}
|
|
||||||
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -239,6 +239,7 @@ export function GrpcEditor({
|
|||||||
showDialog({
|
showDialog({
|
||||||
title: "Configure Schema",
|
title: "Configure Schema",
|
||||||
size: "md",
|
size: "md",
|
||||||
|
className: "h-[30rem]",
|
||||||
id: "grpc-configure-schema",
|
id: "grpc-configure-schema",
|
||||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||||
import { useGrpc } from "../hooks/useGrpc";
|
import { useGrpc } from "../hooks/useGrpc";
|
||||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { Button } from "./core/Button";
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { PathList } from "./core/PathList";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
|
|||||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
|
function GrpcProtoSelectionDialogWithRequest({
|
||||||
|
request,
|
||||||
|
onDone,
|
||||||
|
}: Props & { request: GrpcRequest }) {
|
||||||
const protoFilesKv = useGrpcProtoFiles(request.id);
|
const protoFilesKv = useGrpcProtoFiles(request.id);
|
||||||
const protoFiles = protoFilesKv.value ?? [];
|
const protoFiles = protoFilesKv.value ?? [];
|
||||||
const grpc = useGrpc(request, null, protoFiles);
|
const grpc = useGrpc(request, null, protoFiles);
|
||||||
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack className="flex-col-reverse mb-3" space={3}>
|
<>
|
||||||
{/* Buttons on top so they get focus first */}
|
<VStack space={5} className="pb-4">
|
||||||
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
variant="border"
|
|
||||||
onClick={async () => {
|
|
||||||
const selected = await platform.dialog.open({
|
|
||||||
title: "Select Proto Files",
|
|
||||||
multiple: true,
|
|
||||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
|
||||||
});
|
|
||||||
if (selected == null) return;
|
|
||||||
|
|
||||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
|
||||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
|
||||||
await grpc.reflect.refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Proto Files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color="primary"
|
|
||||||
onClick={async () => {
|
|
||||||
const selected = await platform.dialog.open({
|
|
||||||
title: "Select Proto Directory",
|
|
||||||
directory: true,
|
|
||||||
});
|
|
||||||
if (selected == null) return;
|
|
||||||
|
|
||||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
|
||||||
await grpc.reflect.refetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Import Folders
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
isLoading={grpc.reflect.isFetching}
|
|
||||||
disabled={grpc.reflect.isFetching}
|
|
||||||
variant="border"
|
|
||||||
color="secondary"
|
|
||||||
onClick={() => grpc.reflect.refetch()}
|
|
||||||
>
|
|
||||||
Refresh Schema
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
<VStack space={5}>
|
|
||||||
{reflectError && (
|
{reflectError && (
|
||||||
<Banner color="warning">
|
<Banner color="warning">
|
||||||
<h1 className="font-bold">
|
<h1 className="font-bold">
|
||||||
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{protoFiles.length > 0 && (
|
<PathList
|
||||||
<table className="w-full divide-y divide-surface-highlight">
|
items={protoFiles.map((path) => ({
|
||||||
<thead>
|
path,
|
||||||
<tr>
|
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
|
||||||
<th className="text-text-subtlest" colSpan={3}>
|
}))}
|
||||||
Added File Paths
|
onRemove={async (path) => {
|
||||||
</th>
|
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
|
||||||
</tr>
|
await grpc.reflect.refetch();
|
||||||
</thead>
|
}}
|
||||||
<tbody className="divide-y divide-surface-highlight">
|
addLabel="Add proto files or an import folder"
|
||||||
{protoFiles.map((f, i) => {
|
addItems={[
|
||||||
const parts = f.split("/");
|
{
|
||||||
// oxlint-disable-next-line no-array-index-key -- none
|
label: "Proto files",
|
||||||
return (
|
icon: "file_code",
|
||||||
<tr key={f + i} className="group">
|
onSelect: async () => {
|
||||||
<td>
|
const selected = await platform.dialog.open({
|
||||||
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
|
title: "Select Proto Files",
|
||||||
</td>
|
multiple: true,
|
||||||
<td className="pl-1 font-mono text-sm" title={f}>
|
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||||
{parts.length > 3 && ".../"}
|
});
|
||||||
{parts.slice(-3).join("/")}
|
if (selected == null) return;
|
||||||
</td>
|
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
||||||
<td className="w-0 py-0.5">
|
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||||
<IconButton
|
await grpc.reflect.refetch();
|
||||||
title="Remove file"
|
},
|
||||||
variant="border"
|
},
|
||||||
size="xs"
|
{
|
||||||
icon="trash"
|
label: "Import folder",
|
||||||
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
|
icon: "folder_code",
|
||||||
onClick={async () => {
|
onSelect: async () => {
|
||||||
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
|
const selected = await platform.dialog.open({
|
||||||
}}
|
title: "Select Proto Directory",
|
||||||
/>
|
directory: true,
|
||||||
</td>
|
});
|
||||||
</tr>
|
if (selected == null) return;
|
||||||
);
|
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
||||||
})}
|
await grpc.reflect.refetch();
|
||||||
</tbody>
|
},
|
||||||
</table>
|
},
|
||||||
)}
|
]}
|
||||||
|
/>
|
||||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
{reflectionUnimplemented && protoFiles.length === 0 && (
|
||||||
<Banner>
|
<Banner>
|
||||||
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
||||||
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</VStack>
|
<DialogFooter
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Refresh Schema",
|
||||||
|
isLoading: grpc.reflect.isFetching,
|
||||||
|
disabled: grpc.reflect.isFetching,
|
||||||
|
onClick: () => grpc.reflect.refetch(),
|
||||||
|
},
|
||||||
|
{ label: "Done", color: "primary", onClick: onDone },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,33 +1,36 @@
|
|||||||
import {
|
import {
|
||||||
type Folder,
|
|
||||||
type ImportDestination,
|
type ImportDestination,
|
||||||
type ImportPlan,
|
type ImportPlan,
|
||||||
type ImportPlanItem,
|
type ImportPlanItem,
|
||||||
type ImportSource,
|
type ImportSource,
|
||||||
type Workspace,
|
type Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { errorMessage } from "../lib/errorMessage";
|
||||||
import { pluralize } from "../lib/pluralize";
|
import { pluralize } from "../lib/pluralize";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import {
|
||||||
import { Checkbox } from "./core/Checkbox";
|
ImportSourceList,
|
||||||
|
type ImportSourceDetection,
|
||||||
|
type ImportSourcePath,
|
||||||
|
InlineButton,
|
||||||
|
toSourcePath,
|
||||||
|
} from "./ImportSourceList";
|
||||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
||||||
import { CheckboxTree } from "./core/CheckboxTree";
|
import { CheckboxTree } from "./core/CheckboxTree";
|
||||||
|
import { Chip } from "./core/Chip";
|
||||||
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
import { IconTooltip } from "./core/IconTooltip";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
|
||||||
import { Select } from "./core/Select";
|
import { Select } from "./core/Select";
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
import { SegmentedControl } from "./core/SegmentedControl";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentWorkspace: Workspace | null;
|
currentWorkspace: Workspace | null;
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
selectedFolder: Folder | null;
|
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
|
||||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
detectSource: (source: ImportSourcePath) => Promise<string>;
|
||||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
|
||||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
||||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
||||||
commit: (plan: ImportPlan) => Promise<void>;
|
commit: (plan: ImportPlan) => Promise<void>;
|
||||||
@@ -35,25 +38,6 @@ interface Props {
|
|||||||
onError: (err: unknown) => void;
|
onError: (err: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
|
|
||||||
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
|
|
||||||
*/
|
|
||||||
function isFilePath(value: string): boolean {
|
|
||||||
return (
|
|
||||||
value.startsWith("/") ||
|
|
||||||
value.startsWith("./") ||
|
|
||||||
value.startsWith("../") ||
|
|
||||||
value.startsWith("~/") ||
|
|
||||||
value.startsWith("\\\\") ||
|
|
||||||
/^[a-zA-Z]:[\\/]/.test(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileName(path: string): string {
|
|
||||||
return path.split(/[/\\]/).at(-1) || path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the current workspace's linked sources before rendering the dialog, so the inner
|
* Loads the current workspace's linked sources before rendering the dialog, so the inner
|
||||||
* component can construct its initial state (prefilled path, destination) in one pass instead of
|
* component can construct its initial state (prefilled path, destination) in one pass instead of
|
||||||
@@ -82,30 +66,19 @@ export function ImportDataDialog(props: Props) {
|
|||||||
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function latestSource(sources: ImportSource[]): ImportSource | null {
|
|
||||||
return sources.reduce<ImportSource | null>(
|
|
||||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadedImportDataDialog({
|
function LoadedImportDataDialog({
|
||||||
currentWorkspace,
|
currentWorkspace,
|
||||||
workspaces,
|
workspaces,
|
||||||
selectedFolder,
|
planSources,
|
||||||
planFile,
|
detectSource,
|
||||||
planUrl,
|
|
||||||
listSources,
|
|
||||||
findSourcesForOrigin,
|
findSourcesForOrigin,
|
||||||
commit,
|
commit,
|
||||||
cancel,
|
cancel,
|
||||||
onError,
|
onError,
|
||||||
initialSources,
|
initialSources,
|
||||||
}: Props & { initialSources: ImportSource[] }) {
|
}: Props & { initialSources: ImportSource[] }) {
|
||||||
// A workspace with a linked source is probably being re-imported, so start from that source
|
|
||||||
const prefill = latestSource(initialSources);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [sourceError, setSourceError] = useState<unknown>(null);
|
||||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
||||||
// null means no explicit choice yet, so the default below applies
|
// null means no explicit choice yet, so the default below applies
|
||||||
@@ -113,65 +86,82 @@ function LoadedImportDataDialog({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
||||||
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
|
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
|
||||||
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
|
const initialPaths = useMemo(
|
||||||
prefill != null ? initialSources : [],
|
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
|
||||||
|
[initialSources],
|
||||||
);
|
);
|
||||||
const [originSources, setOriginSources] = useState<ImportSource[]>(
|
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
|
||||||
prefill != null ? [prefill] : [],
|
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
|
||||||
|
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
|
||||||
|
const detect = useCallback(
|
||||||
|
(added: ImportSourcePath[]) => {
|
||||||
|
setDetections((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
|
||||||
|
}));
|
||||||
|
for (const source of added) {
|
||||||
|
const settle = (detection: ImportSourceDetection) =>
|
||||||
|
setDetections((prev) =>
|
||||||
|
source.path in prev ? { ...prev, [source.path]: detection } : prev,
|
||||||
|
);
|
||||||
|
detectSource(source).then(
|
||||||
|
(importer) => settle({ status: "ok", importer }),
|
||||||
|
(err) => settle({ status: "error", message: errorMessage(err) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[detectSource],
|
||||||
);
|
);
|
||||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
|
||||||
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
|
useEffect(() => {
|
||||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
detect(initialPaths);
|
||||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
}, [detect, initialPaths]);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const trimmedSource = source?.trim() ?? "";
|
|
||||||
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
|
|
||||||
|
|
||||||
const selectSource = (value: string) => {
|
const addSources = useCallback(
|
||||||
setSource(value);
|
(added: ImportSourcePath[]) => {
|
||||||
// Remount the input so it shows the path of the newly-picked file
|
setSourceError(null);
|
||||||
setForceUpdateKey((k) => k + 1);
|
const fresh = added.filter(({ path }) => {
|
||||||
|
if (knownPaths.current.has(path)) return false;
|
||||||
|
knownPaths.current.add(path);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (fresh.length === 0) return;
|
||||||
|
setSources((existing) => [...existing, ...fresh]);
|
||||||
|
detect(fresh);
|
||||||
|
},
|
||||||
|
[detect],
|
||||||
|
);
|
||||||
|
const removeSource = (path: string) => {
|
||||||
|
setSourceError(null);
|
||||||
|
knownPaths.current.delete(path);
|
||||||
|
setSources((current) => current.filter((source) => source.path !== path));
|
||||||
|
setDetections(({ [path]: _removed, ...rest }) => rest);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return platform.window.onDragDrop((event) => {
|
let cancelled = false;
|
||||||
if (event.type === "over") {
|
if (sources.length === 0) {
|
||||||
const p = event.position;
|
|
||||||
const r = ref.current?.getBoundingClientRect();
|
|
||||||
if (r == null) return;
|
|
||||||
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
|
||||||
} else if (event.type === "drop" && isHovering) {
|
|
||||||
const p = event.paths[0];
|
|
||||||
if (p) selectSource(p);
|
|
||||||
setIsHovering(false);
|
|
||||||
} else {
|
|
||||||
setIsHovering(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [isHovering, setSource]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (trimmedSource === "") {
|
|
||||||
setOriginSources([]);
|
setOriginSources([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
Promise.all(
|
||||||
const timeout = setTimeout(() => {
|
sources.map(({ path, kind }) =>
|
||||||
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
|
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
|
||||||
.then((sources) => {
|
),
|
||||||
if (!cancelled) setOriginSources(sources);
|
)
|
||||||
})
|
.then((found) => {
|
||||||
.catch(() => setOriginSources([]));
|
if (!cancelled) setOriginSources(found.flat());
|
||||||
}, 300);
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setOriginSources([]);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
};
|
||||||
}, [trimmedSource, filePath, findSourcesForOrigin]);
|
}, [sources, findSourcesForOrigin]);
|
||||||
|
|
||||||
// The one workspace this file is linked to, if there is exactly one.
|
// The one workspace these sources are linked to, if there is exactly one.
|
||||||
const linkedWorkspace = useMemo(() => {
|
const linkedWorkspace = useMemo(() => {
|
||||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
||||||
if (ids.length !== 1) return null;
|
if (ids.length !== 1) return null;
|
||||||
@@ -193,32 +183,6 @@ function LoadedImportDataDialog({
|
|||||||
? otherWorkspaceId
|
? otherWorkspaceId
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (destinationWorkspaceId == null) {
|
|
||||||
setLinkedSources([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
listSources(destinationWorkspaceId)
|
|
||||||
.then((sources) => {
|
|
||||||
if (!cancelled) setLinkedSources(sources);
|
|
||||||
})
|
|
||||||
.catch(() => setLinkedSources([]));
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [destinationWorkspaceId, listSources]);
|
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
|
||||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
|
||||||
if (selected == null) return;
|
|
||||||
selectSource(selected);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
|
|
||||||
// is also the destination.
|
|
||||||
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
|
|
||||||
|
|
||||||
const destination = (): ImportDestination => {
|
const destination = (): ImportDestination => {
|
||||||
if (destinationWorkspaceId == null) {
|
if (destinationWorkspaceId == null) {
|
||||||
return { type: "new_workspace" };
|
return { type: "new_workspace" };
|
||||||
@@ -226,21 +190,18 @@ function LoadedImportDataDialog({
|
|||||||
return {
|
return {
|
||||||
type: "existing_workspace",
|
type: "existing_workspace",
|
||||||
workspaceId: destinationWorkspaceId,
|
workspaceId: destinationWorkspaceId,
|
||||||
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreview = async () => {
|
const handlePreview = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setSourceError(null);
|
||||||
try {
|
try {
|
||||||
const nextPlan =
|
const nextPlan = await planSources(sources, destination());
|
||||||
filePath != null
|
|
||||||
? await planFile(filePath, destination())
|
|
||||||
: await planUrl(trimmedSource, destination());
|
|
||||||
setPlan(nextPlan);
|
setPlan(nextPlan);
|
||||||
setItems(nextPlan.items);
|
setItems(nextPlan.items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onError(err);
|
setSourceError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -258,8 +219,6 @@ function LoadedImportDataDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
|
||||||
|
|
||||||
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
||||||
// say which of those are destructive. Checking anything also brings back the folders it needs
|
// say which of those are destructive. Checking anything also brings back the folders it needs
|
||||||
// to live in.
|
// to live in.
|
||||||
@@ -313,196 +272,215 @@ function LoadedImportDataDialog({
|
|||||||
if (plan.destination.type === "new_workspace") {
|
if (plan.destination.type === "new_workspace") {
|
||||||
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
||||||
if (names.length === 0) return ["New workspace", "Untitled"];
|
if (names.length === 0) return ["New workspace", "Untitled"];
|
||||||
return [pluralize("New workspace", names.length), names.join(", ")];
|
return [
|
||||||
|
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
|
||||||
|
names.join(", "),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
const { workspaceId, folderId } = plan.destination;
|
const { workspaceId } = plan.destination;
|
||||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||||
return [
|
return ["Destination", name];
|
||||||
"Destination",
|
|
||||||
folderId != null && folderId === selectedFolder?.id
|
|
||||||
? `${name} / ${selectedFolder.name}`
|
|
||||||
: name,
|
|
||||||
];
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||||
// it — so its checkbox only aggregates the subtree.
|
// it — so its checkbox only aggregates the subtree.
|
||||||
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
|
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
|
||||||
const planned = plan.resources.workspaces[0];
|
|
||||||
const planDestination = plan.destination;
|
const planDestination = plan.destination;
|
||||||
const existing =
|
const existing =
|
||||||
planDestination.type === "existing_workspace"
|
planDestination.type === "existing_workspace"
|
||||||
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
||||||
: null;
|
: null;
|
||||||
return {
|
const destinations = existing ? [existing] : plan.resources.workspaces;
|
||||||
key: existing?.id ?? planned?.id ?? "workspace",
|
if (destinations.length === 1) {
|
||||||
|
const workspace = destinations[0]!;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: workspace.id,
|
||||||
|
data: {
|
||||||
|
kind: "destination",
|
||||||
|
label: workspace.name,
|
||||||
|
isNew: planDestination.type === "new_workspace",
|
||||||
|
},
|
||||||
|
children: buildItemTree(items),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const workspaceById = new Map(
|
||||||
|
[
|
||||||
|
...plan.resources.environments,
|
||||||
|
...plan.resources.folders,
|
||||||
|
...plan.resources.httpRequests,
|
||||||
|
...plan.resources.grpcRequests,
|
||||||
|
...plan.resources.websocketRequests,
|
||||||
|
].map((resource) => [resource.id, resource.workspaceId]),
|
||||||
|
);
|
||||||
|
return destinations.map((workspace) => ({
|
||||||
|
key: workspace.id,
|
||||||
data: {
|
data: {
|
||||||
kind: "destination",
|
kind: "destination",
|
||||||
label: existing?.name ?? planned?.name ?? "New workspace",
|
label: workspace.name,
|
||||||
isNew: planDestination.type === "new_workspace",
|
isNew: planDestination.type === "new_workspace",
|
||||||
},
|
},
|
||||||
children: itemTree,
|
children: buildItemTree(
|
||||||
};
|
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
|
||||||
|
),
|
||||||
|
}));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={4} className="pb-4">
|
<>
|
||||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
<VStack space={4} className="pb-4">
|
||||||
<PreviewRow label="Detected format" value={plan.importer} />
|
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||||
<PreviewRow label={destinationLabel} value={destinationValue} />
|
<PreviewRow label="Detected format" value={plan.importer} />
|
||||||
</div>
|
<PreviewRow label={destinationLabel} value={destinationValue} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{plan.warnings.map((warning) => (
|
{plan.warnings
|
||||||
<Banner
|
.filter(
|
||||||
key={`${warning.title}:${warning.detail}`}
|
(warning, index, warnings) =>
|
||||||
color={warning.level === "warning" ? "warning" : "info"}
|
warnings.findIndex(
|
||||||
className="flex items-start gap-2.5"
|
(other) =>
|
||||||
>
|
other.title === warning.title &&
|
||||||
<Icon
|
other.detail === warning.detail &&
|
||||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
other.level === warning.level,
|
||||||
size="sm"
|
) === index,
|
||||||
className="mt-0.5"
|
)
|
||||||
/>
|
.map((warning) => (
|
||||||
<div className="min-w-0">
|
<Banner
|
||||||
<div className="text-sm font-medium">{warning.title}</div>
|
key={`${warning.level}:${warning.title}:${warning.detail}`}
|
||||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
color={warning.level === "warning" ? "warning" : "info"}
|
||||||
|
className="flex items-start gap-2.5"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||||
|
size="sm"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{warning.title}</div>
|
||||||
|
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||||
|
</div>
|
||||||
|
</Banner>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{workspaceRoots.map((root) => (
|
||||||
|
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
|
||||||
|
<CheckboxTree
|
||||||
|
node={root}
|
||||||
|
checked={nodeCheckedStatus}
|
||||||
|
onCheck={toggleNode}
|
||||||
|
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||||
|
isCollapsedByDefault={(n) =>
|
||||||
|
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
|
||||||
|
(n.data.kind === "item" && n.data.item.action === "ignored")
|
||||||
|
}
|
||||||
|
isRelevant={(n) =>
|
||||||
|
n.data.kind === "destination" ||
|
||||||
|
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||||
|
}
|
||||||
|
renderRow={(n) => (
|
||||||
|
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Banner>
|
))}
|
||||||
))}
|
</VStack>
|
||||||
|
<DialogFooter
|
||||||
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
|
leftSlot={
|
||||||
<CheckboxTree
|
footerNote !== "" ? (
|
||||||
node={workspaceRoot}
|
<div className="text-xs text-text-subtle">{footerNote}</div>
|
||||||
checked={nodeCheckedStatus}
|
) : undefined
|
||||||
onCheck={toggleNode}
|
}
|
||||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
actions={[
|
||||||
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
|
{
|
||||||
isRelevant={(n) =>
|
label: "Back",
|
||||||
n.data.kind === "destination" ||
|
disabled: isLoading,
|
||||||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
onClick: () => {
|
||||||
}
|
setPlan(null);
|
||||||
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
|
setItems([]);
|
||||||
/>
|
},
|
||||||
</div>
|
},
|
||||||
|
{
|
||||||
<HStack space={2} alignItems="center" className="mt-3">
|
label: isLoading
|
||||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
? "Importing"
|
||||||
<Button
|
: changeCount > 0
|
||||||
className="ml-auto"
|
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||||
color="secondary"
|
: "Done",
|
||||||
variant="border"
|
color: "primary",
|
||||||
disabled={isLoading}
|
isLoading,
|
||||||
onClick={() => {
|
onClick: handleCommit,
|
||||||
setPlan(null);
|
},
|
||||||
setItems([]);
|
]}
|
||||||
}}
|
/>
|
||||||
>
|
</>
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
|
|
||||||
{isLoading
|
|
||||||
? "Importing"
|
|
||||||
: changeCount > 0
|
|
||||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
|
||||||
: "Done"}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastImported =
|
const destinationPicker = (
|
||||||
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
|
<HStack
|
||||||
linkedSources.reduce<ImportSource | null>(
|
space={2}
|
||||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
alignItems="center"
|
||||||
null,
|
className={classNames(workspaces.length === 0 && "hidden")}
|
||||||
);
|
>
|
||||||
|
<Select
|
||||||
return (
|
name="import-destination-kind"
|
||||||
<VStack ref={ref} space={4} className="pb-4">
|
label="Import into"
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
labelPosition="left"
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSelectFile}
|
|
||||||
className={classNames(
|
|
||||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
|
||||||
"flex flex-col items-center gap-1 text-center",
|
|
||||||
isHovering
|
|
||||||
? "border-notice bg-surface-highlight"
|
|
||||||
: "border-border hover:border-text-subtle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
|
||||||
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
|
||||||
<div className="h-6 w-full flex items-center justify-center">
|
|
||||||
{filePath == null ? (
|
|
||||||
<div className="text-text">
|
|
||||||
<strong className="font-semibold">Choose a file</strong> or drag it here
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
|
|
||||||
{fileName(filePath)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-subtlest">
|
|
||||||
Supports OpenAPI, Swagger, Postman, Insomnia, curl, and Yaak exports
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<PlainInput
|
|
||||||
label="Or enter a file path or URL"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder="https://example.com/openapi.json"
|
value={destinationKind}
|
||||||
defaultValue={source ?? ""}
|
onChange={setDestinationChoice}
|
||||||
forceUpdateKey={String(forceUpdateKey)}
|
options={[
|
||||||
onChange={setSource}
|
{ value: "new", label: "New Workspace" },
|
||||||
|
...(currentWorkspace != null
|
||||||
|
? [{ value: "current" as const, label: "Current Workspace" }]
|
||||||
|
: []),
|
||||||
|
{ value: "other", label: "Other Workspace" },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
{destinationKind === "other" && (
|
||||||
<VStack space={2} className={classNames(workspaces.length === 0 && "hidden")}>
|
|
||||||
<Select
|
<Select
|
||||||
name="import-destination-kind"
|
name="import-destination-workspace"
|
||||||
label="Import location"
|
label="Workspace"
|
||||||
|
hideLabel
|
||||||
size="sm"
|
size="sm"
|
||||||
value={destinationKind}
|
value={otherWorkspaceId ?? ""}
|
||||||
onChange={setDestinationChoice}
|
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
||||||
|
filterable
|
||||||
options={[
|
options={[
|
||||||
{ value: "new", label: "New Workspace" },
|
{ value: "", label: "Select a workspace" },
|
||||||
...(currentWorkspace != null
|
...workspaces
|
||||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
.filter((w) => w.id !== currentWorkspace?.id)
|
||||||
: []),
|
.map((w) => ({ value: w.id, label: w.name })),
|
||||||
{ value: "other", label: "Other Workspace" },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{destinationKind === "other" && (
|
)}
|
||||||
<Select
|
</HStack>
|
||||||
name="import-destination-workspace"
|
);
|
||||||
label="Workspace"
|
|
||||||
hideLabel
|
return (
|
||||||
size="sm"
|
<>
|
||||||
value={otherWorkspaceId ?? ""}
|
<VStack space={4} className="h-full pb-4">
|
||||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||||
filterable
|
|
||||||
options={[
|
<ImportSourceList
|
||||||
{ value: "", label: "Select a workspace" },
|
sources={sources}
|
||||||
...workspaces
|
detections={detections}
|
||||||
.filter((w) => w.id !== currentWorkspace?.id)
|
disabled={isLoading}
|
||||||
.map((w) => ({ value: w.id, label: w.name })),
|
onAdd={addSources}
|
||||||
]}
|
onRemove={removeSource}
|
||||||
/>
|
onError={setSourceError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sourceError != null && (
|
||||||
|
<Banner color="warning">
|
||||||
|
<FormattedError>{errorMessage(sourceError)}</FormattedError>
|
||||||
|
</Banner>
|
||||||
)}
|
)}
|
||||||
{lastImported != null && destinationWorkspaceId != null ? (
|
|
||||||
|
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
|
||||||
<div className="text-xs text-text-subtle">
|
<div className="text-xs text-text-subtle">
|
||||||
Last imported from {lastImported.originLabel} ·{" "}
|
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
|
||||||
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
|
<InlineButton
|
||||||
</div>
|
|
||||||
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
|
|
||||||
<div className="text-xs text-text-subtle">
|
|
||||||
This file was last imported into{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline hocus:text-text"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
if (linkedWorkspace.id === currentWorkspace?.id) {
|
||||||
setDestinationChoice("current");
|
setDestinationChoice("current");
|
||||||
@@ -513,36 +491,28 @@ function LoadedImportDataDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{linkedWorkspace.name}
|
{linkedWorkspace.name}
|
||||||
</button>
|
</InlineButton>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
{canTargetSelectedFolder && (
|
|
||||||
<Checkbox
|
|
||||||
checked={targetSelectedFolder}
|
|
||||||
title={`Place root resources in selected folder “${selectedFolder.name}”`}
|
|
||||||
onChange={setTargetSelectedFolder}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
<DialogFooter
|
||||||
<HStack space={2} justifyContent="end">
|
leftSlot={destinationPicker}
|
||||||
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
|
actions={[
|
||||||
Cancel
|
{ label: "Cancel", disabled: isLoading, onClick: cancel },
|
||||||
</Button>
|
{
|
||||||
<Button
|
label: isLoading ? "Analyzing" : "Preview Import",
|
||||||
color="primary"
|
color: "primary",
|
||||||
disabled={
|
disabled:
|
||||||
trimmedSource === "" ||
|
sources.length === 0 ||
|
||||||
isLoading ||
|
!allDetected ||
|
||||||
(destinationKind === "other" && otherWorkspaceId == null)
|
isLoading ||
|
||||||
}
|
(destinationKind === "other" && otherWorkspaceId == null),
|
||||||
isLoading={isLoading}
|
isLoading,
|
||||||
onClick={handlePreview}
|
onClick: handlePreview,
|
||||||
>
|
},
|
||||||
{isLoading ? "Analyzing" : "Preview Import"}
|
]}
|
||||||
</Button>
|
/>
|
||||||
</HStack>
|
</>
|
||||||
</VStack>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,10 +526,12 @@ function ImportTreeRow({
|
|||||||
if (row.kind !== "item") {
|
if (row.kind !== "item") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
|
{row.kind === "destination" && (
|
||||||
|
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
|
||||||
|
)}
|
||||||
<div className="truncate flex-1">{row.label}</div>
|
<div className="truncate flex-1">{row.label}</div>
|
||||||
{row.kind === "destination" && row.isNew && (
|
{row.kind === "destination" && row.isNew && (
|
||||||
<ActionChip label="new" help="Created by this import" className="text-success" />
|
<ActionChip label="new" help="Created by this import" color="success" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -569,11 +541,6 @@ function ImportTreeRow({
|
|||||||
const label = actionLabel(item);
|
const label = actionLabel(item);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{item.model === "folder" || item.model === "environment" ? (
|
|
||||||
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
|
|
||||||
) : (
|
|
||||||
<span aria-hidden className="w-4" />
|
|
||||||
)}
|
|
||||||
<div className="truncate flex-1">{item.name}</div>
|
<div className="truncate flex-1">{item.name}</div>
|
||||||
{item.action === "conflict" ? (
|
{item.action === "conflict" ? (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
@@ -593,17 +560,7 @@ function ImportTreeRow({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
label != null && (
|
label != null && (
|
||||||
<ActionChip
|
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
|
||||||
label={label}
|
|
||||||
help={actionHelp(item)}
|
|
||||||
className={classNames(
|
|
||||||
item.action === "create" && "text-success",
|
|
||||||
item.action === "update" && "text-info",
|
|
||||||
item.action === "delete" && "text-danger",
|
|
||||||
item.action === "keep_local" && item.selected && "text-warning",
|
|
||||||
item.action === "ignored" && "text-text-subtlest",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -613,26 +570,37 @@ function ImportTreeRow({
|
|||||||
function ActionChip({
|
function ActionChip({
|
||||||
label,
|
label,
|
||||||
help,
|
help,
|
||||||
className,
|
color,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
help: string | null;
|
help: string | null;
|
||||||
className?: string;
|
color: ComponentProps<typeof Chip>["color"];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<InlineCode
|
<Chip color={color}>
|
||||||
className={classNames(
|
|
||||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
|
||||||
"inline-flex items-center justify-center gap-1.5",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
||||||
</InlineCode>
|
</Chip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
|
||||||
|
switch (item.action) {
|
||||||
|
case "create":
|
||||||
|
return "success";
|
||||||
|
case "update":
|
||||||
|
return "info";
|
||||||
|
case "delete":
|
||||||
|
return "danger";
|
||||||
|
case "keep_local":
|
||||||
|
return item.selected ? "warning" : "default";
|
||||||
|
case "unchanged":
|
||||||
|
case "conflict":
|
||||||
|
case "ignored":
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function actionLabel(item: ImportPlanItem): string | null {
|
function actionLabel(item: ImportPlanItem): string | null {
|
||||||
switch (item.action) {
|
switch (item.action) {
|
||||||
case "create":
|
case "create":
|
||||||
@@ -701,7 +669,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
|
|||||||
*/
|
*/
|
||||||
type TreeRow =
|
type TreeRow =
|
||||||
| { kind: "destination"; label: string; isNew: boolean }
|
| { kind: "destination"; label: string; isNew: boolean }
|
||||||
| { kind: "group"; label: string; icon: IconProps["icon"] }
|
| { kind: "group"; label: string }
|
||||||
| { kind: "item"; item: ImportPlanItem };
|
| { kind: "item"; item: ImportPlanItem };
|
||||||
|
|
||||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||||
@@ -743,7 +711,7 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "group:environments",
|
key: "group:environments",
|
||||||
data: { kind: "group", label: "Variables", icon: "variable" },
|
data: { kind: "group", label: "Variables" },
|
||||||
children: environments.map((e) => toNode(e, new Set())),
|
children: environments.map((e) => toNode(e, new Set())),
|
||||||
},
|
},
|
||||||
...nodes,
|
...nodes,
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "./core/Button";
|
||||||
|
import { IconButton } from "./core/IconButton";
|
||||||
|
import { PathList, splitPath } from "./core/PathList";
|
||||||
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
|
||||||
|
export interface ImportSourcePath {
|
||||||
|
path: string;
|
||||||
|
kind?: "file" | "folder" | "url";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportSourceDetection =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "ok"; importer: string }
|
||||||
|
| { status: "error"; message: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sources: ImportSourcePath[];
|
||||||
|
detections: Record<string, ImportSourceDetection>;
|
||||||
|
disabled?: boolean;
|
||||||
|
onAdd: (sources: ImportSourcePath[]) => void;
|
||||||
|
onRemove: (path: string) => void;
|
||||||
|
onError: (err: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
|
||||||
|
export function isImportFilePath(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.startsWith("/") ||
|
||||||
|
value.startsWith("./") ||
|
||||||
|
value.startsWith("../") ||
|
||||||
|
value.startsWith("~/") ||
|
||||||
|
value.startsWith("\\\\") ||
|
||||||
|
/^[a-zA-Z]:[\\/]/.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
|
||||||
|
export function toSourcePath(path: string): ImportSourcePath {
|
||||||
|
return { path, kind: isImportFilePath(path) ? undefined : "url" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportSourceList({
|
||||||
|
sources,
|
||||||
|
detections,
|
||||||
|
disabled,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onError,
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const [pathInput, setPathInput] = useState("");
|
||||||
|
const [showPathInput, setShowPathInput] = useState(false);
|
||||||
|
const trimmedPath = pathInput.trim();
|
||||||
|
|
||||||
|
const closePathInput = () => {
|
||||||
|
setPathInput("");
|
||||||
|
setShowPathInput(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPath = () => {
|
||||||
|
if (!trimmedPath || disabled) return;
|
||||||
|
onAdd([toSourcePath(trimmedPath)]);
|
||||||
|
closePathInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled) return;
|
||||||
|
return platform.window.onDragDrop((event) => {
|
||||||
|
if (event.type === "leave") {
|
||||||
|
setIsHovering(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
const { x, y } = event.position;
|
||||||
|
const isOver =
|
||||||
|
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||||
|
if (event.type === "drop") {
|
||||||
|
if (isOver) onAdd(event.paths.map((path) => ({ path })));
|
||||||
|
setIsHovering(false);
|
||||||
|
} else {
|
||||||
|
setIsHovering(isOver);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [disabled, onAdd]);
|
||||||
|
|
||||||
|
const pick = async (directory: boolean) => {
|
||||||
|
try {
|
||||||
|
const selected = await platform.dialog.open({
|
||||||
|
title: directory ? "Select Import Folder" : "Select Import Files",
|
||||||
|
multiple: true,
|
||||||
|
directory,
|
||||||
|
});
|
||||||
|
if (selected == null) return;
|
||||||
|
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
|
||||||
|
} catch (err) {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePathKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
addPath();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
closePathInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pathInputEl = (
|
||||||
|
<PlainInput
|
||||||
|
label="URL or file path"
|
||||||
|
hideLabel
|
||||||
|
size="sm"
|
||||||
|
autoFocus
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Paste a URL or a file path"
|
||||||
|
onChange={setPathInput}
|
||||||
|
onKeyDownCapture={handlePathKeyDown}
|
||||||
|
rightSlot={
|
||||||
|
<div className="flex items-center gap-1 my-1 mr-1">
|
||||||
|
{trimmedPath !== "" && (
|
||||||
|
<Button size="xs" variant="border" onClick={addPath}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(
|
||||||
|
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
|
||||||
|
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8!" />
|
||||||
|
{isHovering ? (
|
||||||
|
<span>Drop to add</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Choose{" "}
|
||||||
|
<InlineButton disabled={disabled} onClick={() => pick(false)}>
|
||||||
|
files
|
||||||
|
</InlineButton>
|
||||||
|
, a{" "}
|
||||||
|
<InlineButton disabled={disabled} onClick={() => pick(true)}>
|
||||||
|
folder
|
||||||
|
</InlineButton>
|
||||||
|
, or a{" "}
|
||||||
|
<InlineButton
|
||||||
|
disabled={disabled || showPathInput}
|
||||||
|
onClick={() => setShowPathInput(true)}
|
||||||
|
>
|
||||||
|
URL
|
||||||
|
</InlineButton>
|
||||||
|
, or drag them here
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-text-subtlest">
|
||||||
|
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showPathInput && pathInputEl}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PathList
|
||||||
|
ref={ref}
|
||||||
|
hovering={isHovering}
|
||||||
|
disabled={disabled}
|
||||||
|
onRemove={onRemove}
|
||||||
|
addLabel="Add files, a folder, or a URL"
|
||||||
|
addHint="or drop them here"
|
||||||
|
addItems={[
|
||||||
|
{ label: "Files", icon: "file", onSelect: () => pick(false) },
|
||||||
|
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
|
||||||
|
{
|
||||||
|
label: "URL or file path",
|
||||||
|
icon: "globe",
|
||||||
|
disabled: showPathInput,
|
||||||
|
onSelect: () => setShowPathInput(true),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
items={sources.map((source) => {
|
||||||
|
const detection = detections[source.path];
|
||||||
|
return {
|
||||||
|
path: source.path,
|
||||||
|
icon: sourceIcon(source),
|
||||||
|
failed: detection?.status === "error",
|
||||||
|
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{showPathInput && pathInputEl}
|
||||||
|
</PathList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetectionSubtitle({
|
||||||
|
path,
|
||||||
|
detection,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
detection: ImportSourceDetection | undefined;
|
||||||
|
}) {
|
||||||
|
const { parent } = splitPath(path);
|
||||||
|
if (detection?.status === "loading") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingIcon size="xs" />
|
||||||
|
Detecting format
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (detection?.status === "error") return detection.message;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{detection?.status === "ok" && <span>{detection.importer}</span>}
|
||||||
|
{detection?.status === "ok" && parent !== "" && <span>·</span>}
|
||||||
|
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceIcon({ kind }: ImportSourcePath) {
|
||||||
|
switch (kind) {
|
||||||
|
case "url":
|
||||||
|
return "globe";
|
||||||
|
case "folder":
|
||||||
|
return "folder";
|
||||||
|
case "file":
|
||||||
|
return "file";
|
||||||
|
case undefined:
|
||||||
|
return "import";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineButton({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-text underline underline-offset-2 hocus:text-primary disabled:opacity-disabled"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export function Onboarding() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
icon="folder_input"
|
icon="folder_input"
|
||||||
title="Migrate from another tool"
|
title="Migrate from another tool"
|
||||||
description="Postman, Insomnia, OpenAPI, or curl"
|
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
|
||||||
busy={busy === "import"}
|
busy={busy === "import"}
|
||||||
disabled={busy != null}
|
disabled={busy != null}
|
||||||
onClick={choose("import", () => importData.mutateAsync())}
|
onClick={choose("import", () => importData.mutateAsync())}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Workspace } from "@yaakapp-internal/models";
|
import type { Workspace } from "@yaakapp-internal/models";
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
import { switchWorkspace } from "../commands/switchWorkspace";
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
|
import { DialogFooter } from "./core/Dialog";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
@@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
|
|||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const [remember, setRemember] = useState<boolean>(false);
|
const [remember, setRemember] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const open = async (inNewWindow: boolean) => {
|
||||||
|
hide();
|
||||||
|
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
|
||||||
|
if (remember) {
|
||||||
|
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={3}>
|
<VStack space={3}>
|
||||||
<p>
|
<p>
|
||||||
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
|
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
|
||||||
</p>
|
</p>
|
||||||
<HStack space={2} justifyContent="start" className="flex-row-reverse">
|
|
||||||
<Button
|
|
||||||
className="focus"
|
|
||||||
color="primary"
|
|
||||||
onClick={async () => {
|
|
||||||
hide();
|
|
||||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
|
|
||||||
if (remember) {
|
|
||||||
await patchModel(settings, { openWorkspaceNewWindow: false });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
This Window
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="focus"
|
|
||||||
color="secondary"
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
onClick={async () => {
|
|
||||||
hide();
|
|
||||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
|
|
||||||
if (remember) {
|
|
||||||
await patchModel(settings, { openWorkspaceNewWindow: true });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
New Window
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
{settings && (
|
{settings && (
|
||||||
<HStack justifyContent="end">
|
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
|
||||||
</HStack>
|
|
||||||
)}
|
)}
|
||||||
|
<DialogFooter
|
||||||
|
inline
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "New Window",
|
||||||
|
rightSlot: <Icon icon="external_link" />,
|
||||||
|
onClick: () => open(true),
|
||||||
|
},
|
||||||
|
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
|
|||||||
import { parseTemplate } from "@yaakapp-internal/templates";
|
import { parseTemplate } from "@yaakapp-internal/templates";
|
||||||
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
|
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
||||||
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
|
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
|
||||||
@@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog";
|
|||||||
import { convertTemplateToInsecure } from "../lib/encryption";
|
import { convertTemplateToInsecure } from "../lib/encryption";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||||
import { Button } from "./core/Button";
|
import { DialogFooter } from "./core/Dialog";
|
||||||
import { collectArgumentValues } from "./core/Editor/twig/util";
|
import { collectArgumentValues } from "./core/Editor/twig/util";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
@@ -91,6 +91,7 @@ function InitializedTemplateFunctionDialog({
|
|||||||
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
|
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
|
||||||
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
|
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
|
||||||
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
|
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
const tokens: Tokens = useMemo(() => {
|
const tokens: Tokens = useMemo(() => {
|
||||||
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
|
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
|
||||||
@@ -160,13 +161,14 @@ function InitializedTemplateFunctionDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
|
id={formId}
|
||||||
|
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleDone();
|
handleDone();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="overflow-y-auto h-full px-6">
|
<div className="overflow-y-auto h-full">
|
||||||
{name === "secure" ? (
|
{name === "secure" ? (
|
||||||
<PlainInput
|
<PlainInput
|
||||||
required
|
required
|
||||||
@@ -188,75 +190,71 @@ function InitializedTemplateFunctionDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
|
{previewType !== "none" ? (
|
||||||
{previewType !== "none" ? (
|
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
|
||||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
|
<HStack space={0.5}>
|
||||||
<HStack space={0.5}>
|
<HStack className="text-sm text-text-subtle" space={1.5}>
|
||||||
<HStack className="text-sm text-text-subtle" space={1.5}>
|
Rendered Preview
|
||||||
Rendered Preview
|
{rendered.isLoading && <LoadingIcon size="xs" />}
|
||||||
{rendered.isLoading && <LoadingIcon size="xs" />}
|
</HStack>
|
||||||
</HStack>
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
iconSize="sm"
|
||||||
|
icon={showSecretsInPreview ? "lock" : "lock_open"}
|
||||||
|
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
|
||||||
|
onClick={toggleShowSecretsInPreview}
|
||||||
|
className={classNames(
|
||||||
|
"ml-auto text-text-subtlest",
|
||||||
|
!dataContainsSecrets && "invisible",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
<div className="relative w-full max-h-40">
|
||||||
|
<InlineCode
|
||||||
|
className={classNames(
|
||||||
|
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
|
||||||
|
tooLarge && "italic text-danger",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rendered.error || tagText.error ? (
|
||||||
|
<em className="text-danger">
|
||||||
|
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
|
||||||
|
</em>
|
||||||
|
) : dataContainsSecrets && !showSecretsInPreview ? (
|
||||||
|
<span className="italic text-text-subtle">
|
||||||
|
------ sensitive values hidden ------
|
||||||
|
</span>
|
||||||
|
) : tooLarge ? (
|
||||||
|
"too large to preview"
|
||||||
|
) : (
|
||||||
|
rendered.data || <> </>
|
||||||
|
)}
|
||||||
|
</InlineCode>
|
||||||
|
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="xs"
|
size="xs"
|
||||||
iconSize="sm"
|
icon="refresh"
|
||||||
icon={showSecretsInPreview ? "lock" : "lock_open"}
|
className="text-text-subtle"
|
||||||
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
|
title="Refresh preview"
|
||||||
onClick={toggleShowSecretsInPreview}
|
spin={rendered.isPending}
|
||||||
className={classNames(
|
onClick={() => {
|
||||||
"ml-auto text-text-subtlest",
|
setRenderKey(new Date().toISOString());
|
||||||
!dataContainsSecrets && "invisible",
|
}}
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</HStack>
|
|
||||||
<div className="relative w-full max-h-40">
|
|
||||||
<InlineCode
|
|
||||||
className={classNames(
|
|
||||||
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
|
|
||||||
tooLarge && "italic text-danger",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{rendered.error || tagText.error ? (
|
|
||||||
<em className="text-danger">
|
|
||||||
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
|
|
||||||
</em>
|
|
||||||
) : dataContainsSecrets && !showSecretsInPreview ? (
|
|
||||||
<span className="italic text-text-subtle">
|
|
||||||
------ sensitive values hidden ------
|
|
||||||
</span>
|
|
||||||
) : tooLarge ? (
|
|
||||||
"too large to preview"
|
|
||||||
) : (
|
|
||||||
rendered.data || <> </>
|
|
||||||
)}
|
|
||||||
</InlineCode>
|
|
||||||
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
icon="refresh"
|
|
||||||
className="text-text-subtle"
|
|
||||||
title="Refresh preview"
|
|
||||||
spin={rendered.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
setRenderKey(new Date().toISOString());
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
|
|
||||||
{templateFunction.data.name === "secure" && (
|
|
||||||
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
|
|
||||||
Reveal Encryption Key
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" color="primary">
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<DialogFooter
|
||||||
|
actions={[
|
||||||
|
...(templateFunction.data.name === "secure"
|
||||||
|
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
|
||||||
|
: []),
|
||||||
|
{ label: "Save", color: "primary", form: formId },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,7 +270,6 @@ TemplateFunctionDialog.show = (
|
|||||||
id: `template-function-${Math.random()}`, // Allow multiple at once
|
id: `template-function-${Math.random()}`, // Allow multiple at once
|
||||||
size: "md",
|
size: "md",
|
||||||
className: "h-240",
|
className: "h-240",
|
||||||
noPadding: true,
|
|
||||||
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
||||||
description: fn.description,
|
description: fn.description,
|
||||||
render: ({ hide }) => {
|
render: ({ hide }) => {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
color?: Color | "default";
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small tinted label, like a Gmail or Trello label */
|
||||||
|
export function Chip({ color = "default", className, children }: Props) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
color === "default" && "text-text-subtle",
|
||||||
|
color === "primary" && "text-primary",
|
||||||
|
color === "secondary" && "text-secondary",
|
||||||
|
color === "info" && "text-info",
|
||||||
|
color === "success" && "text-success",
|
||||||
|
color === "notice" && "text-notice",
|
||||||
|
color === "warning" && "text-warning",
|
||||||
|
color === "danger" && "text-danger",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
import { CopyIconButton } from "../CopyIconButton";
|
||||||
import { Button } from "./Button";
|
import { DialogFooter } from "./Dialog";
|
||||||
import { PlainInput } from "./PlainInput";
|
import { PlainInput } from "./PlainInput";
|
||||||
|
|
||||||
export interface ConfirmProps {
|
export interface ConfirmProps {
|
||||||
@@ -22,6 +21,7 @@ export function Confirm({
|
|||||||
color = "primary",
|
color = "primary",
|
||||||
}: ConfirmProps) {
|
}: ConfirmProps) {
|
||||||
const [confirm, setConfirm] = useState<string>("");
|
const [confirm, setConfirm] = useState<string>("");
|
||||||
|
const formId = useId();
|
||||||
const handleHide = () => {
|
const handleHide = () => {
|
||||||
onResult(false);
|
onResult(false);
|
||||||
onHide();
|
onHide();
|
||||||
@@ -38,7 +38,7 @@ export function Confirm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex flex-col" onSubmit={handleSuccess}>
|
<form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
|
||||||
{requireTyping && (
|
{requireTyping && (
|
||||||
<PlainInput
|
<PlainInput
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -61,14 +61,19 @@ export function Confirm({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
|
<DialogFooter
|
||||||
<Button autoFocus={!requireTyping} type="submit" color={color} disabled={!didConfirm}>
|
inline
|
||||||
{confirmText ?? "Confirm"}
|
actions={[
|
||||||
</Button>
|
{ label: "Cancel", onClick: handleHide },
|
||||||
<Button onClick={handleHide} variant="border">
|
{
|
||||||
Cancel
|
label: confirmText ?? "Confirm",
|
||||||
</Button>
|
color,
|
||||||
</HStack>
|
form: formId,
|
||||||
|
disabled: !didConfirm,
|
||||||
|
autoFocus: !requireTyping,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { DialogSize } from "@yaakapp-internal/plugins";
|
import type { DialogSize } from "@yaakapp-internal/plugins";
|
||||||
import { Heading, Overlay } from "@yaakapp-internal/ui";
|
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import * as m from "motion/react-m";
|
import * as m from "motion/react-m";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { createContext, useContext, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Button, type ButtonProps } from "./Button";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
export interface DialogProps {
|
export interface DialogProps {
|
||||||
@@ -38,6 +40,7 @@ export function Dialog({
|
|||||||
vAlign = "center",
|
vAlign = "center",
|
||||||
}: DialogProps) {
|
}: DialogProps) {
|
||||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||||
|
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||||
const descriptionId = useMemo(
|
const descriptionId = useMemo(
|
||||||
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
||||||
[description],
|
[description],
|
||||||
@@ -71,7 +74,7 @@ export function Dialog({
|
|||||||
animate={{ top: 0, scale: 1 }}
|
animate={{ top: 0, scale: 1 }}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
|
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
|
||||||
"grid-cols-1", // must be here for inline code blocks to correctly break words
|
"grid-cols-1", // must be here for inline code blocks to correctly break words
|
||||||
"relative bg-surface pointer-events-auto",
|
"relative bg-surface pointer-events-auto",
|
||||||
"rounded-lg",
|
"rounded-lg",
|
||||||
@@ -108,9 +111,11 @@ export function Dialog({
|
|||||||
!noScroll && "overflow-y-auto overflow-x-hidden",
|
!noScroll && "overflow-y-auto overflow-x-hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ref={setFooterEl} className="contents" />
|
||||||
|
|
||||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||||
{!disableClose && !hideX && (
|
{!disableClose && !hideX && (
|
||||||
<div className="ml-auto absolute right-1 top-1">
|
<div className="ml-auto absolute right-1 top-1">
|
||||||
@@ -129,3 +134,67 @@ export function Dialog({
|
|||||||
</Overlay>
|
</Overlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
export interface DialogFooterAction {
|
||||||
|
label: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
/** Submit the form with this id, which can be anywhere in the dialog body */
|
||||||
|
form?: string;
|
||||||
|
color?: ButtonProps["color"];
|
||||||
|
variant?: ButtonProps["variant"];
|
||||||
|
disabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
leftSlot?: ReactNode;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
|
||||||
|
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
|
||||||
|
* dialogs whose body never scrolls.
|
||||||
|
*/
|
||||||
|
export function DialogFooter({
|
||||||
|
actions,
|
||||||
|
leftSlot,
|
||||||
|
inline,
|
||||||
|
}: {
|
||||||
|
actions: DialogFooterAction[];
|
||||||
|
leftSlot?: ReactNode;
|
||||||
|
inline?: boolean;
|
||||||
|
}) {
|
||||||
|
const slot = useContext(DialogFooterSlot);
|
||||||
|
const footer = (
|
||||||
|
<footer
|
||||||
|
className={classNames(
|
||||||
|
"px-6 flex items-center gap-3",
|
||||||
|
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
|
||||||
|
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
|
||||||
|
{actions.map((action, i) => (
|
||||||
|
<Button
|
||||||
|
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
|
||||||
|
key={i}
|
||||||
|
type={action.form != null ? "submit" : "button"}
|
||||||
|
form={action.form}
|
||||||
|
color={action.color ?? "secondary"}
|
||||||
|
variant={action.variant ?? (action.color == null ? "border" : "solid")}
|
||||||
|
disabled={action.disabled}
|
||||||
|
isLoading={action.isLoading}
|
||||||
|
autoFocus={action.autoFocus}
|
||||||
|
leftSlot={action.leftSlot}
|
||||||
|
rightSlot={action.rightSlot}
|
||||||
|
onClick={action.onClick}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
return slot == null ? footer : createPortal(footer, slot);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { forwardRef, type ReactNode } from "react";
|
||||||
|
import { Dropdown, type DropdownItem } from "./Dropdown";
|
||||||
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
|
export interface PathListItem {
|
||||||
|
path: string;
|
||||||
|
icon: IconProps["icon"];
|
||||||
|
/** Replaces the parent-path line under the name */
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
failed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: PathListItem[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onRemove: (path: string) => void;
|
||||||
|
addItems: DropdownItem[];
|
||||||
|
addLabel: string;
|
||||||
|
addHint?: string;
|
||||||
|
hovering?: boolean;
|
||||||
|
/** Rows rendered between the items and the add row, such as an inline input */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */
|
||||||
|
export const PathList = forwardRef<HTMLDivElement, Props>(function PathList(
|
||||||
|
{ items, disabled, onRemove, addItems, addLabel, addHint, hovering, children },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={classNames(
|
||||||
|
"flex flex-col gap-1.5 rounded-lg -m-1.5 p-1.5",
|
||||||
|
hovering && "bg-surface-highlight",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const { name, parent } = splitPath(item.path);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.path}
|
||||||
|
className={classNames(
|
||||||
|
"flex items-center gap-3 rounded-md pl-3 pr-1.5 py-2",
|
||||||
|
item.failed ? "bg-danger/10" : "bg-surface-highlight/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={item.icon}
|
||||||
|
className={classNames("shrink-0", item.failed ? "text-danger" : "text-text-subtle")}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1" title={item.path}>
|
||||||
|
<div className="truncate text-sm font-semibold">{name}</div>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"flex items-center gap-1.5 truncate text-xs",
|
||||||
|
item.failed ? "text-danger" : "text-text-subtlest",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.subtitle ??
|
||||||
|
(parent !== "" && <span className="truncate font-mono">{parent}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="x"
|
||||||
|
title={`Remove ${name}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => onRemove(item.path)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{children}
|
||||||
|
<Dropdown items={addItems}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className={classNames(
|
||||||
|
"h-sm w-full flex items-center gap-2 rounded-md border border-dashed px-3 text-sm text-text-subtle",
|
||||||
|
"hocus:border-border-focus hocus:text-text disabled:opacity-disabled",
|
||||||
|
hovering ? "border-notice" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="plus" size="sm" className="text-text-subtlest" />
|
||||||
|
{hovering ? (
|
||||||
|
"Drop to add"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{addLabel}</span>
|
||||||
|
{addHint != null && (
|
||||||
|
<span className="ml-auto text-xs text-text-subtlest">{addHint}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The last segment as the name and everything before it, shortened, as the parent. */
|
||||||
|
export function splitPath(path: string): { name: string; parent: string } {
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
|
||||||
|
try {
|
||||||
|
const url = new URL(path);
|
||||||
|
const segments = url.pathname.split("/").filter(Boolean);
|
||||||
|
const name = segments.at(-1) ?? url.host;
|
||||||
|
const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : "";
|
||||||
|
return { name, parent: parent.replace(/\/$/, "") };
|
||||||
|
} catch {
|
||||||
|
return { name: path, parent: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parts = path.split(/[/\\]/).filter(Boolean);
|
||||||
|
const name = parts.at(-1) ?? path;
|
||||||
|
const dirs = parts.slice(0, -1);
|
||||||
|
const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/");
|
||||||
|
return { name, parent };
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
|
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { generateId } from "../../lib/generateId";
|
import { generateId } from "../../lib/generateId";
|
||||||
import { DynamicForm } from "../DynamicForm";
|
import { DynamicForm } from "../DynamicForm";
|
||||||
import { Button } from "./Button";
|
import { DialogFooter } from "./Dialog";
|
||||||
|
|
||||||
export interface PromptProps {
|
export interface PromptProps {
|
||||||
inputs: FormInput[];
|
inputs: FormInput[];
|
||||||
@@ -48,19 +47,15 @@ export function Prompt({
|
|||||||
const id = `prompt.form.${useRef(generateId()).current}`;
|
const id = `prompt.form.${useRef(generateId()).current}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form id={id} className="grid grid-cols-[minmax(0,1fr)] mb-2" onSubmit={handleSubmit}>
|
||||||
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
|
||||||
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
|
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
|
||||||
<HStack space={2} justifyContent="end">
|
<DialogFooter
|
||||||
<Button onClick={onCancel} variant="border" color="secondary">
|
inline
|
||||||
{cancelText || "Cancel"}
|
actions={[
|
||||||
</Button>
|
{ label: cancelText || "Cancel", onClick: onCancel },
|
||||||
<Button type="submit" color="primary">
|
{ label: confirmText || "Done", color: "primary", form: id },
|
||||||
{confirmText || "Done"}
|
]}
|
||||||
</Button>
|
/>
|
||||||
</HStack>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import { Select } from "../core/Select";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
branches: string[];
|
|
||||||
onCancel: () => void;
|
|
||||||
onSelect: (branch: string) => void;
|
|
||||||
selectText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
|
|
||||||
const [branch, setBranch] = useState<string>("__NONE__");
|
|
||||||
return (
|
|
||||||
<VStack
|
|
||||||
className="mb-4"
|
|
||||||
as="form"
|
|
||||||
space={4}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSelect(branch);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
name="branch"
|
|
||||||
hideLabel
|
|
||||||
label="Branch"
|
|
||||||
value={branch}
|
|
||||||
options={branches.map((b) => ({ label: b, value: b }))}
|
|
||||||
onChange={setBranch}
|
|
||||||
/>
|
|
||||||
<HStack space={2} justifyContent="end">
|
|
||||||
<Button onClick={onCancel} variant="border" color="secondary">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" color="primary">
|
|
||||||
{selectText}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GitStatusEntry } from "@yaakapp-internal/git";
|
import type { GitStatus, GitStatusEntry } from "@yaakapp-internal/git";
|
||||||
import { useGit } from "@yaakapp-internal/git";
|
import { useGit } from "@yaakapp-internal/git";
|
||||||
import type {
|
import type {
|
||||||
Environment,
|
Environment,
|
||||||
@@ -9,8 +9,7 @@ import type {
|
|||||||
Workspace,
|
Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import { type ComponentProps, useCallback, useMemo, useState } from "react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { modelToYaml } from "../../lib/diffYaml";
|
import { modelToYaml } from "../../lib/diffYaml";
|
||||||
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||||
@@ -19,6 +18,7 @@ import { showErrorToast } from "../../lib/toast";
|
|||||||
import { sync } from "../../init/sync";
|
import { sync } from "../../init/sync";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
|
import { Chip } from "../core/Chip";
|
||||||
import type { CheckboxProps } from "../core/Checkbox";
|
import type { CheckboxProps } from "../core/Checkbox";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
import type { CheckboxTreeNode } from "../core/CheckboxTree";
|
import type { CheckboxTreeNode } from "../core/CheckboxTree";
|
||||||
@@ -333,16 +333,7 @@ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
|
|||||||
)}
|
)}
|
||||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||||
{node.status.status !== "current" && (
|
{node.status.status !== "current" && (
|
||||||
<InlineCode
|
<Chip color={statusColor(node.status.status)}>{node.status.status}</Chip>
|
||||||
className={classNames(
|
|
||||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
|
||||||
node.status.status === "modified" && "text-info",
|
|
||||||
node.status.status === "untracked" && "text-success",
|
|
||||||
node.status.status === "removed" && "text-danger",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{node.status.status}
|
|
||||||
</InlineCode>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -376,16 +367,9 @@ function ExternalTreeNode({
|
|||||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
||||||
<Icon color="secondary" icon="file_code" />
|
<Icon color="secondary" icon="file_code" />
|
||||||
<div className="truncate">{displayPath}</div>
|
<div className="truncate">{displayPath}</div>
|
||||||
<InlineCode
|
<Chip className="ml-auto" color={statusColor(entry.status)}>
|
||||||
className={classNames(
|
|
||||||
"py-0 ml-auto bg-transparent w-24 text-center",
|
|
||||||
entry.status === "modified" && "text-info",
|
|
||||||
entry.status === "untracked" && "text-success",
|
|
||||||
entry.status === "removed" && "text-danger",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{entry.status}
|
{entry.status}
|
||||||
</InlineCode>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -479,3 +463,20 @@ function DiffPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusColor(status: GitStatus): ComponentProps<typeof Chip>["color"] {
|
||||||
|
switch (status) {
|
||||||
|
case "modified":
|
||||||
|
return "info";
|
||||||
|
case "untracked":
|
||||||
|
return "success";
|
||||||
|
case "removed":
|
||||||
|
return "danger";
|
||||||
|
case "conflict":
|
||||||
|
return "warning";
|
||||||
|
case "current":
|
||||||
|
case "renamed":
|
||||||
|
case "type_change":
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { DivergedStrategy } from "@yaakapp-internal/git";
|
import type { DivergedStrategy } from "@yaakapp-internal/git";
|
||||||
import { HStack, InlineCode } from "@yaakapp-internal/ui";
|
import { InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { showDialog } from "../../lib/dialog";
|
import { showDialog } from "../../lib/dialog";
|
||||||
import { Button } from "../core/Button";
|
import { DialogFooter } from "../core/Dialog";
|
||||||
import { RadioCards } from "../core/RadioCards";
|
import { RadioCards } from "../core/RadioCards";
|
||||||
|
|
||||||
type Resolution = "force_reset" | "merge";
|
type Resolution = "force_reset" | "merge";
|
||||||
@@ -34,7 +34,7 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 mb-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-text-subtle">
|
<p className="text-text-subtle">
|
||||||
Your local branch has diverged from{" "}
|
Your local branch has diverged from{" "}
|
||||||
<InlineCode>
|
<InlineCode>
|
||||||
@@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<HStack space={2} justifyContent="start" className="flex-row-reverse">
|
<DialogFooter
|
||||||
<Button
|
inline
|
||||||
color={selected === "force_reset" ? "danger" : "primary"}
|
actions={[
|
||||||
disabled={selected == null}
|
{ label: "Cancel", onClick: handleCancel },
|
||||||
onClick={handleSubmit}
|
{
|
||||||
>
|
label: selected != null ? resolutionLabel[selected] : "Select an option",
|
||||||
{selected != null ? resolutionLabel[selected] : "Select an option"}
|
color: selected === "force_reset" ? "danger" : "primary",
|
||||||
</Button>
|
disabled: selected == null,
|
||||||
<Button variant="border" onClick={handleCancel}>
|
onClick: handleSubmit,
|
||||||
Cancel
|
},
|
||||||
</Button>
|
]}
|
||||||
</HStack>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export function useExportData() {
|
|||||||
id: "export-data",
|
id: "export-data",
|
||||||
title: "Export Data",
|
title: "Export Data",
|
||||||
size: "md",
|
size: "md",
|
||||||
noPadding: true,
|
|
||||||
render: ({ hide }) => (
|
render: ({ hide }) => (
|
||||||
<ExportDataDialog
|
<ExportDataDialog
|
||||||
onHide={hide}
|
onHide={hide}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function errorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
||||||
|
import { errorMessage } from "./errorMessage";
|
||||||
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
|
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
|
||||||
|
|
||||||
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
|
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
|
||||||
@@ -45,7 +46,3 @@ export function tryBuildIntrospectionFromFile(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(e: unknown): string {
|
|
||||||
return e instanceof Error ? e.message : String(e);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||||
import { Button } from "../components/core/Button";
|
import { Button } from "../components/core/Button";
|
||||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
import type { ImportSourcePath } from "../components/ImportSourceList";
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { showAlert } from "./alert";
|
import { showAlert } from "./alert";
|
||||||
@@ -20,10 +20,17 @@ import { rpc } from "./rpc";
|
|||||||
|
|
||||||
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
||||||
// fetches) every time the dialog container re-renders.
|
// fetches) every time the dialog container re-renders.
|
||||||
const planFile = (filePath: string, destination: ImportDestination) =>
|
const planSources = (sources: ImportSourcePath[], destination: ImportDestination) =>
|
||||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
|
rpc<ImportPlan>("cmd_import_data", {
|
||||||
const planUrl = (url: string, destination: ImportDestination) =>
|
filePaths: sources.filter((source) => source.kind !== "url").map((source) => source.path),
|
||||||
rpc<ImportPlan>("cmd_import_url", { url, destination });
|
urls: sources.filter((source) => source.kind === "url").map((source) => source.path),
|
||||||
|
destination,
|
||||||
|
});
|
||||||
|
const detectSource = (source: ImportSourcePath) =>
|
||||||
|
rpc<string>(
|
||||||
|
"cmd_detect_import_source",
|
||||||
|
source.kind === "url" ? { url: source.path } : { filePath: source.path },
|
||||||
|
);
|
||||||
const listSources = (workspaceId: string) =>
|
const listSources = (workspaceId: string) =>
|
||||||
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
||||||
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
||||||
@@ -43,11 +50,11 @@ export const importData = createFastMutation({
|
|||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||||
const workspaces = jotaiStore.get(workspacesAtom);
|
const workspaces = jotaiStore.get(workspacesAtom);
|
||||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
|
||||||
showDialog({
|
showDialog({
|
||||||
id: "import",
|
id: "import",
|
||||||
title: "Import Data",
|
title: "Import Data",
|
||||||
size: "lg",
|
size: "lg",
|
||||||
|
className: "h-[36rem]",
|
||||||
disableClose: true,
|
disableClose: true,
|
||||||
render: ({ hide }) => {
|
render: ({ hide }) => {
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
@@ -68,9 +75,8 @@ export const importData = createFastMutation({
|
|||||||
<ImportDataDialog
|
<ImportDataDialog
|
||||||
currentWorkspace={currentWorkspace}
|
currentWorkspace={currentWorkspace}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
selectedFolder={selectedFolder}
|
planSources={planSources}
|
||||||
planFile={planFile}
|
detectSource={detectSource}
|
||||||
planUrl={planUrl}
|
|
||||||
listSources={listSources}
|
listSources={listSources}
|
||||||
findSourcesForOrigin={findSourcesForOrigin}
|
findSourcesForOrigin={findSourcesForOrigin}
|
||||||
commit={commit}
|
commit={commit}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ description: >
|
|||||||
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
|
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
|
||||||
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
|
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
|
||||||
requests for reuse or manual testing, configure auth on saved requests, import
|
requests for reuse or manual testing, configure auth on saved requests, import
|
||||||
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
|
an OpenAPI spec, a Postman, Insomnia, or Bruno collection, or a cURL command, or run
|
||||||
saved requests across environments. Prefer over one-off `curl` when requests
|
saved requests across environments. Prefer over one-off `curl` when requests
|
||||||
should be saved, reused, shared, or run as a set.
|
should be saved, reused, shared, or run as a set.
|
||||||
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
|
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
|
||||||
@@ -98,9 +98,9 @@ re-run it after acting.
|
|||||||
|
|
||||||
## Core workflows
|
## Core workflows
|
||||||
|
|
||||||
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
|
**Start from a spec when one exists.** `yaak import <path>` auto-detects OpenAPI,
|
||||||
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
|
Swagger, Postman, Insomnia, Bruno, cURL, and Yaak exports, and beats authoring requests
|
||||||
by hand every time.
|
by hand every time. Bruno collections can also be imported from a ZIP or directory.
|
||||||
|
|
||||||
**Make the host swappable.** Put the base URL in a base-environment variable,
|
**Make the host swappable.** Put the base URL in a base-environment variable,
|
||||||
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
|
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub enum Commands {
|
|||||||
/// Authentication commands
|
/// Authentication commands
|
||||||
Auth(AuthArgs),
|
Auth(AuthArgs),
|
||||||
|
|
||||||
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
|
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Bruno, Swagger, or cURL
|
||||||
Import(ImportArgs),
|
Import(ImportArgs),
|
||||||
|
|
||||||
/// Export Yaak workspace data
|
/// Export Yaak workspace data
|
||||||
@@ -202,7 +202,7 @@ pub struct SendArgs {
|
|||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct ImportArgs {
|
pub struct ImportArgs {
|
||||||
/// Path to the file to import
|
/// Path to the file, ZIP archive, or directory to import
|
||||||
pub file: PathBuf,
|
pub file: PathBuf,
|
||||||
|
|
||||||
/// Existing workspace ID to import into when supported by the importer
|
/// Existing workspace ID to import into when supported by the importer
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ use crate::cli::{ExportArgs, ImportArgs};
|
|||||||
use crate::context::CliContext;
|
use crate::context::CliContext;
|
||||||
use crate::utils::workspace::resolve_workspace_id;
|
use crate::utils::workspace::resolve_workspace_id;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::ErrorKind;
|
|
||||||
use yaak_models::export::{self, ExportDataParams};
|
|
||||||
use yaak::import;
|
use yaak::import;
|
||||||
|
use yaak_models::export::{self, ExportDataParams};
|
||||||
use yaak_models::util::{
|
use yaak_models::util::{
|
||||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
||||||
};
|
};
|
||||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
use yaak_plugins::events::{ImportRequest, ImportResources, PluginContext};
|
||||||
|
|
||||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||||
|
|
||||||
@@ -51,11 +50,12 @@ async fn import(
|
|||||||
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_contents = read_import_file(&args.file)?;
|
let input = ImportRequest::from_path(&args.file)
|
||||||
|
.map_err(|err| format!("Unable to read import source {}: {err}", args.file.display()))?;
|
||||||
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
|
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
|
||||||
let plugin_manager = ctx.plugin_manager();
|
let plugin_manager = ctx.plugin_manager();
|
||||||
let import_result = plugin_manager
|
let import_result = plugin_manager
|
||||||
.import_data(&plugin_context, &file_contents)
|
.import_input(&plugin_context, &input)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||||
let importer = import_result.importer;
|
let importer = import_result.importer;
|
||||||
@@ -212,19 +212,6 @@ fn resolve_export_workspace_ids(
|
|||||||
Ok(workspace_ids)
|
Ok(workspace_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
|
|
||||||
fs::read_to_string(path).map_err(|err| {
|
|
||||||
if err.kind() == ErrorKind::InvalidData {
|
|
||||||
format!(
|
|
||||||
"Import file must be UTF-8 text; binary files are not supported: {}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("Unable to read import file {}: {err}", path.display())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
|
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
|
||||||
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|
||||||
|| resources.environments.iter().any(|e| {
|
|| resources.environments.iter().any(|e| {
|
||||||
|
|||||||
@@ -1,46 +1,89 @@
|
|||||||
use crate::PluginContextExt;
|
use crate::PluginContextExt;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
use std::fs::read_to_string;
|
use std::path::Path;
|
||||||
use std::io::ErrorKind;
|
|
||||||
use tauri::{Manager, Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
use yaak::import::{self, PlanImportDataParams};
|
use yaak::import::{self, PlanImportDataParams};
|
||||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
|
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
|
||||||
|
use yaak_plugins::MAX_IMPORT_BYTES;
|
||||||
|
use yaak_plugins::events::ImportRequest;
|
||||||
|
|
||||||
pub(crate) async fn import_data<R: Runtime>(
|
pub(crate) async fn import_data<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
origin: Option<ImportOrigin>,
|
origin: Option<ImportOrigin>,
|
||||||
) -> Result<BatchUpsertResult> {
|
) -> Result<BatchUpsertResult> {
|
||||||
let contents = read_import_file(file_path)?;
|
let input = read_import_file(file_path)?;
|
||||||
let plan =
|
let plan =
|
||||||
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
|
plan_import_contents(window, &input, ImportDestination::NewWorkspace, origin).await?;
|
||||||
commit_import(window, plan)
|
commit_import(window, plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn plan_import_data<R: Runtime>(
|
pub(crate) async fn plan_import_data<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
file_path: &str,
|
file_paths: &[String],
|
||||||
|
urls: &[String],
|
||||||
destination: ImportDestination,
|
destination: ImportDestination,
|
||||||
) -> Result<ImportPlan> {
|
) -> Result<ImportPlan> {
|
||||||
let contents = read_import_file(file_path)?;
|
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
|
||||||
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
|
let plugin_context = window.plugin_context();
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
let mut inputs = Vec::new();
|
||||||
|
for path in file_paths {
|
||||||
|
let origin = file_origin(path);
|
||||||
|
if !seen.insert(origin.origin.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let input = read_import_file(path)?;
|
||||||
|
let response = plugin_manager
|
||||||
|
.import_input(&plugin_context, &input)
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::GenericError(format!("Unable to import {path}: {err}")))?;
|
||||||
|
inputs.push((response, origin));
|
||||||
|
}
|
||||||
|
for url in urls {
|
||||||
|
let url = normalize_import_url(url)?;
|
||||||
|
if !seen.insert(url.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let input = fetch_import_url(window, &url).await?;
|
||||||
|
let response = plugin_manager
|
||||||
|
.import_input(&plugin_context, &input)
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::GenericError(format!("Unable to import {url}: {err}")))?;
|
||||||
|
inputs.push((response, url_origin(&url)));
|
||||||
|
}
|
||||||
|
Ok(import::plan_import_batch_resources(&window.db_manager(), destination, inputs)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn plan_import_url<R: Runtime>(
|
/// Which importer claims a source, without planning it. Backs the per-source status in the
|
||||||
|
/// import dialog so unsupported files are flagged before preview.
|
||||||
|
pub(crate) async fn detect_import_source<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
url: &str,
|
file_path: Option<String>,
|
||||||
destination: ImportDestination,
|
url: Option<String>,
|
||||||
) -> Result<ImportPlan> {
|
) -> Result<String> {
|
||||||
let url = normalize_import_url(url)?;
|
let input = match (file_path, url) {
|
||||||
let contents = fetch_import_url(window, &url).await?;
|
(Some(path), _) => read_import_file(&path)?,
|
||||||
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
|
(None, Some(url)) => fetch_import_url(window, &normalize_import_url(&url)?).await?,
|
||||||
|
(None, None) => return Err(Error::GenericError("Nothing to detect".into())),
|
||||||
|
};
|
||||||
|
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
|
||||||
|
let response = plugin_manager.import_input(&window.plugin_context(), &input).await.map_err(
|
||||||
|
|err| match err {
|
||||||
|
yaak_plugins::error::Error::PluginErr(msg) if msg.starts_with("No importers found") => {
|
||||||
|
Error::GenericError("Not a supported format".into())
|
||||||
|
}
|
||||||
|
err => Error::GenericError(err.to_string()),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(response.importer)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn plan_import_contents<R: Runtime>(
|
async fn plan_import_contents<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
contents: &str,
|
input: &ImportRequest,
|
||||||
destination: ImportDestination,
|
destination: ImportDestination,
|
||||||
origin: Option<ImportOrigin>,
|
origin: Option<ImportOrigin>,
|
||||||
) -> Result<ImportPlan> {
|
) -> Result<ImportPlan> {
|
||||||
@@ -53,7 +96,7 @@ async fn plan_import_contents<R: Runtime>(
|
|||||||
plugin_manager: &plugin_manager,
|
plugin_manager: &plugin_manager,
|
||||||
plugin_context: &plugin_context,
|
plugin_context: &plugin_context,
|
||||||
destination,
|
destination,
|
||||||
contents,
|
input,
|
||||||
origin,
|
origin,
|
||||||
})
|
})
|
||||||
.await?)
|
.await?)
|
||||||
@@ -88,7 +131,10 @@ pub(crate) fn commit_import<R: Runtime>(
|
|||||||
/// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy,
|
/// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy,
|
||||||
/// client certificate, or certificate-validation settings. Requests are unauthenticated, so
|
/// client certificate, or certificate-validation settings. Requests are unauthenticated, so
|
||||||
/// specs behind auth must still be downloaded manually and imported as a file.
|
/// specs behind auth must still be downloaded manually and imported as a file.
|
||||||
async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> Result<String> {
|
async fn fetch_import_url<R: Runtime>(
|
||||||
|
window: &WebviewWindow<R>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<ImportRequest> {
|
||||||
let url = normalize_import_url(url)?;
|
let url = normalize_import_url(url)?;
|
||||||
let app_version = window.app_handle().package_info().version.to_string();
|
let app_version = window.app_handle().package_info().version.to_string();
|
||||||
let response = yaak_api_client(ApiClientKind::App, &app_version)?
|
let response = yaak_api_client(ApiClientKind::App, &app_version)?
|
||||||
@@ -104,10 +150,27 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
|
|||||||
return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}")));
|
return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
response
|
// Apply the same input-size limit to downloads before allocating the whole body.
|
||||||
.text()
|
let mut response = response;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
while let Some(chunk) = response
|
||||||
|
.chunk()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
|
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))?
|
||||||
|
{
|
||||||
|
if (bytes.len() + chunk.len()) as u64 > MAX_IMPORT_BYTES {
|
||||||
|
return Err(Error::GenericError("Import file exceeds the 64 MiB limit".into()));
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
let name = reqwest::Url::parse(&url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| {
|
||||||
|
url.path_segments().and_then(|mut segments| segments.next_back().map(str::to_owned))
|
||||||
|
})
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or_else(|| "download".into());
|
||||||
|
ImportRequest::from_bytes(&name, &bytes).map_err(|err| Error::GenericError(err.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
|
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
|
||||||
@@ -128,15 +191,9 @@ pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_import_file(file_path: &str) -> Result<String> {
|
fn read_import_file(file_path: &str) -> Result<ImportRequest> {
|
||||||
read_to_string(file_path).map_err(|err| {
|
ImportRequest::from_path(Path::new(file_path)).map_err(|err| {
|
||||||
if err.kind() == ErrorKind::InvalidData {
|
Error::GenericError(format!("Unable to read import source {file_path}: {err}"))
|
||||||
Error::GenericError(format!(
|
|
||||||
"Import file must be UTF-8 text; binary files are not supported: {file_path}"
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Error::GenericError(format!("Unable to read import file {file_path}: {err}"))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +204,7 @@ mod tests {
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn read_import_file_returns_error_for_binary_file() {
|
fn read_import_file_accepts_binary_file() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
"yaak-import-binary-{}.pftrace",
|
"yaak-import-binary-{}.pftrace",
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -157,10 +214,11 @@ mod tests {
|
|||||||
));
|
));
|
||||||
write(&path, [0xff, 0xfe, 0xfd]).expect("write binary fixture");
|
write(&path, [0xff, 0xfe, 0xfd]).expect("write binary fixture");
|
||||||
|
|
||||||
let err = read_import_file(path.to_str().expect("temp path is utf-8"))
|
let input = read_import_file(path.to_str().expect("temp path is utf-8"))
|
||||||
.expect_err("binary import should return an error");
|
.expect("binary import should preserve bytes");
|
||||||
|
|
||||||
assert!(err.to_string().contains("binary files are not supported"));
|
assert!(input.source.is_some());
|
||||||
|
assert!(input.content.is_empty());
|
||||||
|
|
||||||
remove_file(path).expect("remove binary fixture");
|
remove_file(path).expect("remove binary fixture");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::grpc::{build_metadata, metadata_to_map};
|
use crate::grpc::{build_metadata, metadata_to_map};
|
||||||
use crate::http_request::send_http_request;
|
use crate::http_request::send_http_request;
|
||||||
use crate::import::{commit_import, plan_import_data, plan_import_url};
|
use crate::import::{commit_import, plan_import_data};
|
||||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||||
use crate::notifications::YaakNotifier;
|
use crate::notifications::YaakNotifier;
|
||||||
use crate::render::{render_grpc_request, render_template};
|
use crate::render::{render_grpc_request, render_template};
|
||||||
@@ -1040,18 +1040,11 @@ async fn cmd_get_sse_events<R: Runtime>(
|
|||||||
|
|
||||||
async fn cmd_import_data<R: Runtime>(
|
async fn cmd_import_data<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
file_path: &str,
|
file_paths: &[String],
|
||||||
|
urls: &[String],
|
||||||
destination: ImportDestination,
|
destination: ImportDestination,
|
||||||
) -> YaakResult<ImportPlan> {
|
) -> YaakResult<ImportPlan> {
|
||||||
plan_import_data(&window, file_path, destination).await
|
plan_import_data(&window, file_paths, urls, destination).await
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_import_url<R: Runtime>(
|
|
||||||
window: WebviewWindow<R>,
|
|
||||||
url: &str,
|
|
||||||
destination: ImportDestination,
|
|
||||||
) -> YaakResult<ImportPlan> {
|
|
||||||
plan_import_url(&window, url, destination).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_commit_import<R: Runtime>(
|
async fn cmd_commit_import<R: Runtime>(
|
||||||
|
|||||||
@@ -517,11 +517,8 @@ async fn cmd_import_data<R: Runtime>(
|
|||||||
ctx: ClientCtx<R>,
|
ctx: ClientCtx<R>,
|
||||||
req: CmdImportDataReq,
|
req: CmdImportDataReq,
|
||||||
) -> Result<ImportPlan> {
|
) -> Result<ImportPlan> {
|
||||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
|
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_paths, &req.urls, req.destination)
|
||||||
}
|
.await?)
|
||||||
|
|
||||||
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
|
|
||||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_commit_import<R: Runtime>(
|
async fn cmd_commit_import<R: Runtime>(
|
||||||
@@ -539,6 +536,13 @@ async fn cmd_list_import_sources<R: Runtime>(
|
|||||||
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
|
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cmd_detect_import_source<R: Runtime>(
|
||||||
|
ctx: ClientCtx<R>,
|
||||||
|
req: CmdDetectImportSourceReq,
|
||||||
|
) -> Result<String> {
|
||||||
|
Ok(crate::import::detect_import_source(&ctx.window, req.file_path, req.url).await?)
|
||||||
|
}
|
||||||
|
|
||||||
async fn cmd_import_sources_for_origin<R: Runtime>(
|
async fn cmd_import_sources_for_origin<R: Runtime>(
|
||||||
ctx: ClientCtx<R>,
|
ctx: ClientCtx<R>,
|
||||||
req: CmdImportSourcesForOriginReq,
|
req: CmdImportSourcesForOriginReq,
|
||||||
|
|||||||
+4
-4
@@ -45,6 +45,8 @@ export type CmdDeleteAllHttpResponsesReq = { requestId: string, };
|
|||||||
|
|
||||||
export type CmdDeleteSendHistoryReq = { workspaceId: string, };
|
export type CmdDeleteSendHistoryReq = { workspaceId: string, };
|
||||||
|
|
||||||
|
export type CmdDetectImportSourceReq = { filePath?: string, url?: string, };
|
||||||
|
|
||||||
export type CmdDisableEncryptionReq = { workspaceId: string, };
|
export type CmdDisableEncryptionReq = { workspaceId: string, };
|
||||||
|
|
||||||
export type CmdDismissNotificationReq = { notificationId: string, };
|
export type CmdDismissNotificationReq = { notificationId: string, };
|
||||||
@@ -145,12 +147,10 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, };
|
|||||||
|
|
||||||
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
|
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
|
||||||
|
|
||||||
export type CmdImportDataReq = { filePath: string, destination: ImportDestination, };
|
export type CmdImportDataReq = { filePaths: Array<string>, urls: Array<string>, destination: ImportDestination, };
|
||||||
|
|
||||||
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
|
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
|
||||||
|
|
||||||
export type CmdImportUrlReq = { url: string, destination: ImportDestination, };
|
|
||||||
|
|
||||||
export type CmdListImportSourcesReq = { workspaceId: string, };
|
export type CmdListImportSourcesReq = { workspaceId: string, };
|
||||||
|
|
||||||
export type CmdMetadataReq = Record<string, never>;
|
export type CmdMetadataReq = Record<string, never>;
|
||||||
@@ -256,6 +256,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
|
|||||||
|
|
||||||
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
|
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
|
||||||
|
|
||||||
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_workLine truncated
|
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfoLine truncated
|
||||||
|
|
||||||
export type WatchResult = { unlistenEvent: string, };
|
export type WatchResult = { unlistenEvent: string, };
|
||||||
+12
@@ -54,6 +54,10 @@ export type ImportPlan = {
|
|||||||
*/
|
*/
|
||||||
items: Array<ImportPlanItem>;
|
items: Array<ImportPlanItem>;
|
||||||
origin?: ImportOrigin;
|
origin?: ImportOrigin;
|
||||||
|
/**
|
||||||
|
* Individual origins in a combined import, each retaining its own reimport history.
|
||||||
|
*/
|
||||||
|
sources: Array<ImportPlanSource>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImportPlanAction =
|
export type ImportPlanAction =
|
||||||
@@ -88,6 +92,14 @@ export type ImportPlanItem = {
|
|||||||
*/
|
*/
|
||||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||||
|
|
||||||
|
export type ImportPlanSource = {
|
||||||
|
importer: string;
|
||||||
|
origin: ImportOrigin;
|
||||||
|
workspaceId: string;
|
||||||
|
sourceKeys: { [key in string]?: string };
|
||||||
|
linkedSourceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -228,15 +228,8 @@ pub struct CmdGetHttpResponseEventsReq {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct CmdImportDataReq {
|
pub struct CmdImportDataReq {
|
||||||
pub file_path: String,
|
pub file_paths: Vec<String>,
|
||||||
pub destination: ImportDestination,
|
pub urls: Vec<String>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct CmdImportUrlReq {
|
|
||||||
pub url: String,
|
|
||||||
pub destination: ImportDestination,
|
pub destination: ImportDestination,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +257,16 @@ pub struct CmdImportSourcesForOriginReq {
|
|||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub struct CmdDetectImportSourceReq {
|
||||||
|
#[ts(optional)]
|
||||||
|
pub file_path: Option<String>,
|
||||||
|
#[ts(optional)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct CmdHttpRequestActionsReq {}
|
pub struct CmdHttpRequestActionsReq {}
|
||||||
@@ -939,10 +942,10 @@ macro_rules! with_commands {
|
|||||||
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
||||||
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
||||||
cmd_import_data(CmdImportDataReq) -> ImportPlan,
|
cmd_import_data(CmdImportDataReq) -> ImportPlan,
|
||||||
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
|
|
||||||
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
|
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
|
||||||
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
|
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
|
||||||
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
|
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
|
||||||
|
cmd_detect_import_source(CmdDetectImportSourceReq) -> String,
|
||||||
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
|
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
|
||||||
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
|
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
|
||||||
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
|
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
|
||||||
|
|||||||
Generated
+12
@@ -54,6 +54,10 @@ export type ImportPlan = {
|
|||||||
*/
|
*/
|
||||||
items: Array<ImportPlanItem>;
|
items: Array<ImportPlanItem>;
|
||||||
origin?: ImportOrigin;
|
origin?: ImportOrigin;
|
||||||
|
/**
|
||||||
|
* Individual origins in a combined import, each retaining its own reimport history.
|
||||||
|
*/
|
||||||
|
sources: Array<ImportPlanSource>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImportPlanAction =
|
export type ImportPlanAction =
|
||||||
@@ -88,6 +92,14 @@ export type ImportPlanItem = {
|
|||||||
*/
|
*/
|
||||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||||
|
|
||||||
|
export type ImportPlanSource = {
|
||||||
|
importer: string;
|
||||||
|
origin: ImportOrigin;
|
||||||
|
workspaceId: string;
|
||||||
|
sourceKeys: { [key in string]?: string };
|
||||||
|
linkedSourceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ pub enum ImportDestination {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_util.ts")]
|
#[ts(export, export_to = "gen_util.ts")]
|
||||||
pub struct ImportPlanWarning {
|
pub struct ImportPlanWarning {
|
||||||
@@ -232,6 +232,19 @@ pub struct ImportPlanItem {
|
|||||||
pub changed_fields: Vec<String>,
|
pub changed_fields: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_util.ts")]
|
||||||
|
pub struct ImportPlanSource {
|
||||||
|
pub importer: String,
|
||||||
|
pub origin: ImportOrigin,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub source_keys: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub linked_source_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_util.ts")]
|
#[ts(export, export_to = "gen_util.ts")]
|
||||||
@@ -251,6 +264,10 @@ pub struct ImportPlan {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub origin: Option<ImportOrigin>,
|
pub origin: Option<ImportOrigin>,
|
||||||
|
|
||||||
|
/// Individual origins in a combined import, each retaining its own reimport history.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sources: Vec<ImportPlanSource>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_workspace_export_resources(
|
pub fn get_workspace_export_resources(
|
||||||
|
|||||||
+10
-1
@@ -470,7 +470,16 @@ export type HttpRequestAction = { label: string, icon?: Icon, };
|
|||||||
|
|
||||||
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
|
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
|
||||||
|
|
||||||
export type ImportRequest = { content: string, };
|
/**
|
||||||
|
* Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
|
||||||
|
*/
|
||||||
|
export type ImportFileSource = { "type": "file", name: string, base64: string, } | { "type": "directory", path: string, };
|
||||||
|
|
||||||
|
export type ImportRequest = {
|
||||||
|
/**
|
||||||
|
* Kept for text-only importers and older runtimes.
|
||||||
|
*/
|
||||||
|
content: string, source?: ImportFileSource, };
|
||||||
|
|
||||||
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||||
|
|
||||||
|
|||||||
@@ -240,7 +240,20 @@ pub struct ReloadResponse {
|
|||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_events.ts")]
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
pub struct ImportRequest {
|
pub struct ImportRequest {
|
||||||
|
/// Kept for text-only importers and older runtimes.
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
#[ts(optional)]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source: Option<ImportFileSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
|
pub enum ImportFileSource {
|
||||||
|
File { name: String, base64: String },
|
||||||
|
Directory { path: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use crate::events::{ImportFileSource, ImportRequest};
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub const MAX_IMPORT_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
impl ImportRequest {
|
||||||
|
pub fn from_text(content: &str) -> Self {
|
||||||
|
Self { content: content.to_string(), source: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_bytes(name: &str, bytes: &[u8]) -> io::Result<Self> {
|
||||||
|
if bytes.len() as u64 > MAX_IMPORT_BYTES {
|
||||||
|
return Err(io::Error::other("Import file exceeds the 64 MiB limit"));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
content: std::str::from_utf8(bytes).unwrap_or_default().to_string(),
|
||||||
|
source: Some(ImportFileSource::File {
|
||||||
|
name: name.to_string(),
|
||||||
|
base64: STANDARD.encode(bytes),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_path(path: &Path) -> io::Result<Self> {
|
||||||
|
let path = fs::canonicalize(path)?;
|
||||||
|
let metadata = fs::metadata(&path)?;
|
||||||
|
if metadata.is_dir() {
|
||||||
|
return Ok(Self {
|
||||||
|
content: String::new(),
|
||||||
|
source: Some(ImportFileSource::Directory {
|
||||||
|
path: path
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| io::Error::other("Import path is not UTF-8"))?
|
||||||
|
.to_string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(io::Error::other("Import source must be a file or directory"));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
File::open(&path)?.take(MAX_IMPORT_BYTES + 1).read_to_end(&mut bytes)?;
|
||||||
|
Self::from_bytes(path.file_name().and_then(|v| v.to_str()).unwrap_or("input"), &bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_and_binary_inputs_preserve_bytes() {
|
||||||
|
let input = ImportRequest::from_bytes("api.yml", "café".as_bytes()).unwrap();
|
||||||
|
assert_eq!(input.content, "café");
|
||||||
|
let input = ImportRequest::from_bytes("data.zip", &[0x50, 0x4b, 0xff]).unwrap();
|
||||||
|
assert_eq!(input.content, "");
|
||||||
|
let Some(ImportFileSource::File { name, base64 }) = input.source else {
|
||||||
|
panic!("file input")
|
||||||
|
};
|
||||||
|
assert_eq!(name, "data.zip");
|
||||||
|
assert_eq!(STANDARD.decode(base64).unwrap(), vec![0x50, 0x4b, 0xff]);
|
||||||
|
assert!(ImportRequest::from_text("text").source.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_input_is_lazy_and_canonical() {
|
||||||
|
let input = ImportRequest::from_path(Path::new(".")).unwrap();
|
||||||
|
let Some(ImportFileSource::Directory { path }) = input.source else {
|
||||||
|
panic!("directory input")
|
||||||
|
};
|
||||||
|
assert_eq!(Path::new(&path), fs::canonicalize(".").unwrap());
|
||||||
|
assert!(input.content.is_empty());
|
||||||
|
assert!(ImportRequest::from_path(Path::new("does-not-exist-yaak-import")).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ pub mod api;
|
|||||||
mod checksum;
|
mod checksum;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
mod import_input;
|
||||||
|
pub use import_input::MAX_IMPORT_BYTES;
|
||||||
pub mod install;
|
pub mod install;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
pub mod native_template_functions;
|
pub mod native_template_functions;
|
||||||
|
|||||||
@@ -1091,18 +1091,32 @@ impl PluginManager {
|
|||||||
&self,
|
&self,
|
||||||
plugin_context: &PluginContext,
|
plugin_context: &PluginContext,
|
||||||
content: &str,
|
content: &str,
|
||||||
|
) -> Result<ImportResponse> {
|
||||||
|
self.import_input(plugin_context, &ImportRequest::from_text(content)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_input(
|
||||||
|
&self,
|
||||||
|
plugin_context: &PluginContext,
|
||||||
|
input: &ImportRequest,
|
||||||
) -> Result<ImportResponse> {
|
) -> Result<ImportResponse> {
|
||||||
let reply_events = self
|
let reply_events = self
|
||||||
.send_and_wait(
|
.send_and_wait(
|
||||||
plugin_context,
|
plugin_context,
|
||||||
&InternalEventPayload::ImportRequest(ImportRequest {
|
&InternalEventPayload::ImportRequest(input.clone()),
|
||||||
content: content.to_string(),
|
|
||||||
}),
|
|
||||||
Duration::from_secs(60),
|
Duration::from_secs(60),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// TODO: Don't just return the first valid response
|
// TODO: Don't just return the first valid response
|
||||||
|
let errors = reply_events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match &e.payload {
|
||||||
|
InternalEventPayload::ErrorResponse(error) => Some(error.error.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
let result = reply_events.into_iter().find_map(|e| match e {
|
let result = reply_events.into_iter().find_map(|e| match e {
|
||||||
InternalEvent {
|
InternalEvent {
|
||||||
plugin_name,
|
plugin_name,
|
||||||
@@ -1120,7 +1134,8 @@ impl PluginManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
None => Err(PluginErr("No importers found for file contents".to_string())),
|
None if !errors.is_empty() => Err(PluginErr(errors)),
|
||||||
|
None => Err(PluginErr("No importers found for this input".to_string())),
|
||||||
Some(resp) => Ok(resp),
|
Some(resp) => Ok(resp),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+626
-32
@@ -12,10 +12,10 @@ use yaak_models::models::{
|
|||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::{
|
use yaak_models::util::{
|
||||||
BatchUpsertResult, ImportConflictResolution, ImportDestination, ImportOrigin, ImportPlan,
|
BatchUpsertResult, ImportConflictResolution, ImportDestination, ImportOrigin, ImportPlan,
|
||||||
ImportPlanAction, ImportPlanItem, ImportPlanReason, ImportPlanWarning, ImportResourceType,
|
ImportPlanAction, ImportPlanItem, ImportPlanReason, ImportPlanSource, ImportPlanWarning,
|
||||||
UpdateSource,
|
ImportResourceType, UpdateSource,
|
||||||
};
|
};
|
||||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
use yaak_plugins::events::{ImportRequest, ImportResources, ImportResponse, PluginContext};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
pub struct PlanImportDataParams<'a> {
|
pub struct PlanImportDataParams<'a> {
|
||||||
@@ -23,14 +23,14 @@ pub struct PlanImportDataParams<'a> {
|
|||||||
pub plugin_manager: &'a PluginManager,
|
pub plugin_manager: &'a PluginManager,
|
||||||
pub plugin_context: &'a PluginContext,
|
pub plugin_context: &'a PluginContext,
|
||||||
pub destination: ImportDestination,
|
pub destination: ImportDestination,
|
||||||
pub contents: &'a str,
|
pub input: &'a ImportRequest,
|
||||||
pub origin: Option<ImportOrigin>,
|
pub origin: Option<ImportOrigin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
|
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
|
||||||
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
|
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
|
||||||
let import_result =
|
let import_result =
|
||||||
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
|
params.plugin_manager.import_input(params.plugin_context, params.input).await?;
|
||||||
|
|
||||||
plan_import_resources(
|
plan_import_resources(
|
||||||
params.query_manager,
|
params.query_manager,
|
||||||
@@ -42,6 +42,157 @@ pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<Import
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plan each input independently so importer keys and linked-source matching remain scoped to
|
||||||
|
/// that input, then combine the results for one preview and one atomic commit.
|
||||||
|
pub fn plan_import_batch_resources(
|
||||||
|
query_manager: &QueryManager,
|
||||||
|
destination: ImportDestination,
|
||||||
|
inputs: Vec<(ImportResponse, ImportOrigin)>,
|
||||||
|
) -> Result<ImportPlan> {
|
||||||
|
let mut plans = Vec::new();
|
||||||
|
for (response, origin) in inputs {
|
||||||
|
let has_workspace = !response.resources.workspaces.is_empty();
|
||||||
|
let plan = plan_import_resources(
|
||||||
|
query_manager,
|
||||||
|
response.importer,
|
||||||
|
destination.clone(),
|
||||||
|
response.resources,
|
||||||
|
response.source_keys,
|
||||||
|
Some(origin),
|
||||||
|
)?;
|
||||||
|
plans.push((plan, has_workspace));
|
||||||
|
}
|
||||||
|
if plans.len() == 1 {
|
||||||
|
return Ok(plans.remove(0).0);
|
||||||
|
}
|
||||||
|
if plans.is_empty() {
|
||||||
|
return Err(yaak_models::error::Error::GenericError(
|
||||||
|
"Choose at least one import source".into(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone environments/requests accompany the first actual collection, regardless of
|
||||||
|
// picker order. If every input is standalone, they share the first generated workspace.
|
||||||
|
let primary = plans.iter().position(|(_, has_workspace)| *has_workspace).unwrap_or(0);
|
||||||
|
let shared_workspace = plans[primary].0.resources.workspaces.first().cloned();
|
||||||
|
let mut taken_names =
|
||||||
|
query_manager.connect().list_workspaces()?.into_iter().map(|w| w.name).collect::<Vec<_>>();
|
||||||
|
let mut seen_items = BTreeSet::new();
|
||||||
|
let mut seen_linked_sources = BTreeSet::new();
|
||||||
|
let mut importers = Vec::new();
|
||||||
|
let mut combined = ImportPlan {
|
||||||
|
importer: String::new(),
|
||||||
|
destination,
|
||||||
|
resources: BatchUpsertResult::default(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
source_keys: BTreeMap::new(),
|
||||||
|
items: Vec::new(),
|
||||||
|
origin: None,
|
||||||
|
sources: Vec::new(),
|
||||||
|
};
|
||||||
|
for (index, (mut plan, has_workspace)) in plans.into_iter().enumerate() {
|
||||||
|
if let Some(workspace) = &shared_workspace
|
||||||
|
&& !has_workspace
|
||||||
|
&& index != primary
|
||||||
|
{
|
||||||
|
for old in plan.resources.workspaces.drain(..) {
|
||||||
|
plan.source_keys.remove(&old.id);
|
||||||
|
}
|
||||||
|
for v in &mut plan.resources.environments {
|
||||||
|
v.workspace_id = workspace.id.clone();
|
||||||
|
if v.parent_model == "workspace" {
|
||||||
|
v.parent_model = "environment".into();
|
||||||
|
v.name = format!("{} (Imported)", v.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v in &mut plan.resources.folders {
|
||||||
|
v.workspace_id = workspace.id.clone();
|
||||||
|
}
|
||||||
|
for v in &mut plan.resources.http_requests {
|
||||||
|
v.workspace_id = workspace.id.clone();
|
||||||
|
}
|
||||||
|
for v in &mut plan.resources.grpc_requests {
|
||||||
|
v.workspace_id = workspace.id.clone();
|
||||||
|
}
|
||||||
|
for v in &mut plan.resources.websocket_requests {
|
||||||
|
v.workspace_id = workspace.id.clone();
|
||||||
|
}
|
||||||
|
plan.items = create_only_items(&plan);
|
||||||
|
}
|
||||||
|
for workspace in &mut plan.resources.workspaces {
|
||||||
|
workspace.name = unique_name(&workspace.name, &taken_names);
|
||||||
|
taken_names.push(workspace.name.clone());
|
||||||
|
}
|
||||||
|
for item in &plan.items {
|
||||||
|
if !seen_items.insert(item.model_id.clone()) {
|
||||||
|
return Err(yaak_models::error::Error::GenericError(
|
||||||
|
format!("More than one import source changes {}. Import these sources separately to resolve the overlap.", item.name)
|
||||||
|
).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !importers.contains(&plan.importer) {
|
||||||
|
importers.push(plan.importer.clone());
|
||||||
|
}
|
||||||
|
let workspace_id = match &plan.destination {
|
||||||
|
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
|
||||||
|
ImportDestination::NewWorkspace => plan
|
||||||
|
.resources
|
||||||
|
.workspaces
|
||||||
|
.first()
|
||||||
|
.or(shared_workspace.as_ref())
|
||||||
|
.expect("planned workspace")
|
||||||
|
.id
|
||||||
|
.clone(),
|
||||||
|
};
|
||||||
|
if let Some(origin) = plan.origin {
|
||||||
|
let linked_source_id = match &plan.destination {
|
||||||
|
ImportDestination::NewWorkspace => None,
|
||||||
|
ImportDestination::ExistingWorkspace { .. } => match resolve_linked_source(
|
||||||
|
&query_manager.connect(),
|
||||||
|
&workspace_id,
|
||||||
|
&plan.importer,
|
||||||
|
&origin,
|
||||||
|
&plan.source_keys.values().cloned().collect(),
|
||||||
|
)? {
|
||||||
|
LinkedSource::Linked(source) => Some(source.id),
|
||||||
|
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Some(id) = &linked_source_id
|
||||||
|
&& !seen_linked_sources.insert(id.clone())
|
||||||
|
{
|
||||||
|
return Err(yaak_models::error::Error::GenericError(
|
||||||
|
"More than one input matches the same linked import. Keep only one version of that source.".into()
|
||||||
|
).into());
|
||||||
|
}
|
||||||
|
combined.sources.push(ImportPlanSource {
|
||||||
|
importer: plan.importer,
|
||||||
|
origin,
|
||||||
|
workspace_id,
|
||||||
|
source_keys: plan.source_keys.clone(),
|
||||||
|
linked_source_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
combined.resources.workspaces.extend(plan.resources.workspaces);
|
||||||
|
combined.resources.environments.extend(plan.resources.environments);
|
||||||
|
combined.resources.folders.extend(plan.resources.folders);
|
||||||
|
combined.resources.http_requests.extend(plan.resources.http_requests);
|
||||||
|
combined.resources.grpc_requests.extend(plan.resources.grpc_requests);
|
||||||
|
combined.resources.websocket_requests.extend(plan.resources.websocket_requests);
|
||||||
|
combined.items.extend(plan.items);
|
||||||
|
combined.source_keys.extend(plan.source_keys);
|
||||||
|
for warning in plan.warnings {
|
||||||
|
if !combined.warnings.contains(&warning) {
|
||||||
|
combined.warnings.push(warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
combined.importer = importers.join(", ");
|
||||||
|
validate_plan(&combined)?;
|
||||||
|
Ok(combined)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remap parsed importer resources into their selected destination.
|
/// Remap parsed importer resources into their selected destination.
|
||||||
///
|
///
|
||||||
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
|
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
|
||||||
@@ -294,32 +445,31 @@ pub fn plan_import_resources(
|
|||||||
warnings,
|
warnings,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
origin,
|
origin,
|
||||||
|
sources: Vec::new(),
|
||||||
};
|
};
|
||||||
merge_with_linked_source(query_manager, &mut plan, &original)?;
|
merge_with_linked_source(query_manager, &mut plan, &original)?;
|
||||||
warn_if_imported_elsewhere(&query_manager.connect(), &mut plan)?;
|
warn_if_imported_elsewhere(&query_manager.connect(), &mut plan)?;
|
||||||
Ok(plan)
|
Ok(plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A document that already produced a workspace can be merged back into it. Importing it anywhere
|
/// Warn only when this importer has already read the exact same origin elsewhere. Similar
|
||||||
/// else copies it instead, which is worth saying before the user finds two of everything.
|
/// resource names are not evidence of a previous import; overlap matching belongs to merging
|
||||||
|
/// into an explicitly selected workspace.
|
||||||
fn warn_if_imported_elsewhere(db: &ClientDb, plan: &mut ImportPlan) -> Result<()> {
|
fn warn_if_imported_elsewhere(db: &ClientDb, plan: &mut ImportPlan) -> Result<()> {
|
||||||
|
let Some(origin) = &plan.origin else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
let destination_id = match &plan.destination {
|
let destination_id = match &plan.destination {
|
||||||
ImportDestination::ExistingWorkspace { workspace_id, .. } => Some(workspace_id.as_str()),
|
ImportDestination::ExistingWorkspace { workspace_id, .. } => Some(workspace_id.as_str()),
|
||||||
ImportDestination::NewWorkspace => None,
|
ImportDestination::NewWorkspace => None,
|
||||||
};
|
};
|
||||||
let incoming = plan.source_keys.values().collect::<BTreeSet<_>>();
|
|
||||||
|
|
||||||
let mut names = BTreeSet::new();
|
let mut names = BTreeSet::new();
|
||||||
for workspace in db.list_workspaces()? {
|
for workspace in db.list_workspaces()? {
|
||||||
if Some(workspace.id.as_str()) == destination_id {
|
if Some(workspace.id.as_str()) == destination_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for source in db.list_import_sources(&workspace.id)? {
|
for source in db.list_import_sources(&workspace.id)? {
|
||||||
let overlaps = db
|
if source.importer == plan.importer && source.origin == origin.origin {
|
||||||
.list_import_source_resources(&source.id)?
|
|
||||||
.iter()
|
|
||||||
.any(|row| incoming.contains(&row.source_key));
|
|
||||||
if overlaps {
|
|
||||||
names.insert(workspace.name.clone());
|
names.insert(workspace.name.clone());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -508,10 +658,15 @@ fn record_import_source(
|
|||||||
items: &BTreeMap<String, ImportPlanItem>,
|
items: &BTreeMap<String, ImportPlanItem>,
|
||||||
upserted: &BatchUpsertResult,
|
upserted: &BatchUpsertResult,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
if !plan.sources.is_empty() {
|
||||||
|
for source in &plan.sources {
|
||||||
|
record_import_plan_source(db, plan, source, items, upserted)?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let Some(origin) = &plan.origin else {
|
let Some(origin) = &plan.origin else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let workspace_id = match &plan.destination {
|
let workspace_id = match &plan.destination {
|
||||||
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
|
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
|
||||||
ImportDestination::NewWorkspace => match upserted.workspaces.first() {
|
ImportDestination::NewWorkspace => match upserted.workspaces.first() {
|
||||||
@@ -519,20 +674,50 @@ fn record_import_source(
|
|||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
record_import_plan_source(
|
||||||
|
db,
|
||||||
|
plan,
|
||||||
|
&ImportPlanSource {
|
||||||
|
importer: plan.importer.clone(),
|
||||||
|
origin: origin.clone(),
|
||||||
|
workspace_id,
|
||||||
|
source_keys: plan.source_keys.clone(),
|
||||||
|
linked_source_id: None,
|
||||||
|
},
|
||||||
|
items,
|
||||||
|
upserted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let incoming_keys: BTreeSet<String> = plan.source_keys.values().cloned().collect();
|
fn record_import_plan_source(
|
||||||
let existing =
|
db: &WriteDb,
|
||||||
match resolve_linked_source(db, &workspace_id, &plan.importer, origin, &incoming_keys)? {
|
plan: &ImportPlan,
|
||||||
LinkedSource::Linked(source) => Some(source),
|
source: &ImportPlanSource,
|
||||||
|
items: &BTreeMap<String, ImportPlanItem>,
|
||||||
|
upserted: &BatchUpsertResult,
|
||||||
|
) -> Result<()> {
|
||||||
|
let incoming_keys: BTreeSet<String> = source.source_keys.values().cloned().collect();
|
||||||
|
let source_id = if plan.sources.is_empty() {
|
||||||
|
match resolve_linked_source(
|
||||||
|
db,
|
||||||
|
&source.workspace_id,
|
||||||
|
&source.importer,
|
||||||
|
&source.origin,
|
||||||
|
&incoming_keys,
|
||||||
|
)? {
|
||||||
|
LinkedSource::Linked(source) => Some(source.id),
|
||||||
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
|
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
|
||||||
};
|
}
|
||||||
|
} else {
|
||||||
|
source.linked_source_id.clone()
|
||||||
|
};
|
||||||
let import_source = db.upsert_import_source(
|
let import_source = db.upsert_import_source(
|
||||||
&ImportSource {
|
&ImportSource {
|
||||||
id: existing.map(|s| s.id).unwrap_or_default(),
|
id: source_id.unwrap_or_default(),
|
||||||
workspace_id,
|
workspace_id: source.workspace_id.clone(),
|
||||||
importer: plan.importer.clone(),
|
importer: source.importer.clone(),
|
||||||
origin: origin.origin.clone(),
|
origin: source.origin.origin.clone(),
|
||||||
origin_label: origin.label.clone(),
|
origin_label: source.origin.label.clone(),
|
||||||
last_imported_at: Utc::now().naive_utc(),
|
last_imported_at: Utc::now().naive_utc(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -560,7 +745,7 @@ fn record_import_source(
|
|||||||
resource: ImportResourceType,
|
resource: ImportResourceType,
|
||||||
incoming: &dyn Fn() -> Result<Value>|
|
incoming: &dyn Fn() -> Result<Value>|
|
||||||
-> Result<()> {
|
-> Result<()> {
|
||||||
let Some(source_key) = plan.source_keys.get(model_id) else {
|
let Some(source_key) = source.source_keys.get(model_id) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let item = items.get(model_id);
|
let item = items.get(model_id);
|
||||||
@@ -919,7 +1104,7 @@ fn merge_with_linked_source(
|
|||||||
|
|
||||||
let incoming = comparable(serde_json::to_value(&any)?);
|
let incoming = comparable(serde_json::to_value(&any)?);
|
||||||
let current = comparable(current_models.get(&planned_id).cloned().unwrap_or_default());
|
let current = comparable(current_models.get(&planned_id).cloned().unwrap_or_default());
|
||||||
let (source_changed, local_changed) = match recorded_hash(row) {
|
let (source_changed, local_changed) = match recorded_hash(row, &incoming) {
|
||||||
Some(hash) => {
|
Some(hash) => {
|
||||||
(hash_comparable(&incoming) != hash, hash_comparable(¤t) != hash)
|
(hash_comparable(&incoming) != hash, hash_comparable(¤t) != hash)
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1272,12 @@ enum KeyStatus<'a> {
|
|||||||
fn comparable(value: Value) -> Value {
|
fn comparable(value: Value) -> Value {
|
||||||
let mut value = strip_ids(value);
|
let mut value = strip_ids(value);
|
||||||
if let Some(object) = value.as_object_mut() {
|
if let Some(object) = value.as_object_mut() {
|
||||||
|
// Folder environment names are derived by upsert_environment, not source content.
|
||||||
|
if object.get("model").and_then(Value::as_str) == Some("environment")
|
||||||
|
&& object.get("parentModel").and_then(Value::as_str) == Some("folder")
|
||||||
|
{
|
||||||
|
object.remove("name");
|
||||||
|
}
|
||||||
for field in [
|
for field in [
|
||||||
"model",
|
"model",
|
||||||
"workspaceId",
|
"workspaceId",
|
||||||
@@ -1118,7 +1309,11 @@ fn strip_ids(value: Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTENT_HASH_VERSION: &str = "v1:";
|
fn content_hash_version(value: &Value) -> &'static str {
|
||||||
|
// Only folder environments changed comparison semantics. Older hashes for these fall back
|
||||||
|
// to comparing the two sides until the next import records a hash without the derived name.
|
||||||
|
if value.get("parentModel").and_then(Value::as_str) == Some("folder") { "v2:" } else { "v1:" }
|
||||||
|
}
|
||||||
|
|
||||||
/// Identifies a resource's content well enough to tell "changed since the last import" from
|
/// Identifies a resource's content well enough to tell "changed since the last import" from
|
||||||
/// "unchanged", without keeping a copy of every imported resource around.
|
/// "unchanged", without keeping a copy of every imported resource around.
|
||||||
@@ -1128,7 +1323,7 @@ fn content_hash(value: Value) -> String {
|
|||||||
|
|
||||||
fn hash_comparable(value: &Value) -> String {
|
fn hash_comparable(value: &Value) -> String {
|
||||||
let canonical = serde_json::to_string(&sorted_keys(value.clone())).unwrap_or_default();
|
let canonical = serde_json::to_string(&sorted_keys(value.clone())).unwrap_or_default();
|
||||||
format!("{CONTENT_HASH_VERSION}{:x}", Sha256::digest(canonical.as_bytes()))
|
format!("{}{:x}", content_hash_version(value), Sha256::digest(canonical.as_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The fields two comparable forms disagree on, so a preview row can explain itself.
|
/// The fields two comparable forms disagree on, so a preview row can explain itself.
|
||||||
@@ -1147,9 +1342,9 @@ fn changed_fields(incoming: &Value, current: &Value) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A hash written by a version this build doesn't understand says nothing about the resource.
|
/// A hash written by a version this build doesn't understand says nothing about the resource.
|
||||||
fn recorded_hash(row: &ImportSourceResource) -> Option<&str> {
|
fn recorded_hash<'a>(row: &'a ImportSourceResource, incoming: &Value) -> Option<&'a str> {
|
||||||
let hash = row.content_hash.as_deref()?;
|
let hash = row.content_hash.as_deref()?;
|
||||||
hash.starts_with(CONTENT_HASH_VERSION).then_some(hash)
|
hash.starts_with(content_hash_version(incoming)).then_some(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sorted_keys(value: Value) -> Value {
|
fn sorted_keys(value: Value) -> Value {
|
||||||
@@ -1312,7 +1507,15 @@ fn unique_name(name: &str, taken: &[String]) -> String {
|
|||||||
if name.is_empty() || !taken.iter().any(|t| t == name) {
|
if name.is_empty() || !taken.iter().any(|t| t == name) {
|
||||||
return name.to_string();
|
return name.to_string();
|
||||||
}
|
}
|
||||||
let mut n = 2;
|
// A suffix this function generated continues the sequence instead of nesting. A name that
|
||||||
|
// merely ends in a number, like "Release (2024)", has no taken base and keeps its name.
|
||||||
|
let (name, mut n) = match name.rsplit_once(" (") {
|
||||||
|
Some((base, rest)) => match rest.strip_suffix(')').and_then(|n| n.parse::<u32>().ok()) {
|
||||||
|
Some(n) if taken.iter().any(|t| t == base) => (base, n + 1),
|
||||||
|
_ => (name, 2),
|
||||||
|
},
|
||||||
|
None => (name, 2),
|
||||||
|
};
|
||||||
loop {
|
loop {
|
||||||
let candidate = format!("{name} ({n})");
|
let candidate = format!("{name} ({n})");
|
||||||
if !taken.iter().any(|t| *t == candidate) {
|
if !taken.iter().any(|t| *t == candidate) {
|
||||||
@@ -1607,6 +1810,235 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn batch_input(
|
||||||
|
importer: &str,
|
||||||
|
path: &str,
|
||||||
|
resources: ImportResources,
|
||||||
|
) -> (ImportResponse, ImportOrigin) {
|
||||||
|
(
|
||||||
|
ImportResponse { importer: importer.into(), resources, source_keys: None },
|
||||||
|
ImportOrigin { origin: path.into(), label: path.into() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn standalone_environment(name: &str) -> ImportResources {
|
||||||
|
ImportResources {
|
||||||
|
environments: vec![Environment {
|
||||||
|
id: "ev_export".into(),
|
||||||
|
model: "environment".into(),
|
||||||
|
workspace_id: "CURRENT_WORKSPACE".into(),
|
||||||
|
name: name.into(),
|
||||||
|
parent_model: "environment".into(),
|
||||||
|
variables: vec![EnvironmentVariable {
|
||||||
|
enabled: true,
|
||||||
|
name: "host".into(),
|
||||||
|
value: "example.com".into(),
|
||||||
|
id: None,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collection_and_environments() -> Vec<(ImportResponse, ImportOrigin)> {
|
||||||
|
vec![
|
||||||
|
batch_input("Postman Environment", "/local.json", standalone_environment("Local")),
|
||||||
|
batch_input("Postman", "/collection.json", imported_resources()),
|
||||||
|
batch_input(
|
||||||
|
"Postman Environment",
|
||||||
|
"/production.json",
|
||||||
|
standalone_environment("Production"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_collection_and_environments_share_one_workspace_in_either_order() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
|
||||||
|
let mut inputs = collection_and_environments();
|
||||||
|
if reverse {
|
||||||
|
inputs.reverse();
|
||||||
|
}
|
||||||
|
let plan =
|
||||||
|
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, inputs)
|
||||||
|
.unwrap();
|
||||||
|
assert!(manager.connect().list_workspaces().unwrap().is_empty());
|
||||||
|
assert_eq!(plan.resources.workspaces.len(), 1);
|
||||||
|
let workspace_id = plan.resources.workspaces[0].id.clone();
|
||||||
|
assert_eq!(plan.resources.workspaces[0].name, "Imported");
|
||||||
|
assert_eq!(plan.resources.environments.len(), 3);
|
||||||
|
assert!(plan.resources.environments.iter().all(|e| e.workspace_id == workspace_id));
|
||||||
|
assert_eq!(plan.sources.len(), 3);
|
||||||
|
// The plan crosses IPC and is returned with the user's item selections.
|
||||||
|
let plan = serde_json::from_value(serde_json::to_value(plan).unwrap()).unwrap();
|
||||||
|
let committed = commit_import_plan(&manager, plan).unwrap();
|
||||||
|
assert_eq!(committed.workspaces.len(), 1);
|
||||||
|
assert_eq!(committed.environments.len(), 3);
|
||||||
|
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_reimport_preserves_ids_and_scopes_deletions_to_each_source() {
|
||||||
|
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
|
||||||
|
let first = plan_import_batch_resources(
|
||||||
|
&manager,
|
||||||
|
ImportDestination::NewWorkspace,
|
||||||
|
collection_and_environments(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let workspace_id = first.resources.workspaces[0].id.clone();
|
||||||
|
let ids =
|
||||||
|
first.resources.environments.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
|
||||||
|
commit_import_plan(&manager, first).unwrap();
|
||||||
|
let destination = ImportDestination::ExistingWorkspace {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
folder_id: None,
|
||||||
|
};
|
||||||
|
let unchanged = plan_import_batch_resources(
|
||||||
|
&manager,
|
||||||
|
destination.clone(),
|
||||||
|
collection_and_environments(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(unchanged.items.iter().all(|i| i.action == ImportPlanAction::Unchanged));
|
||||||
|
assert_eq!(
|
||||||
|
unchanged.resources.environments.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>(),
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
commit_import_plan(&manager, unchanged).unwrap();
|
||||||
|
let mut inputs = collection_and_environments();
|
||||||
|
inputs[1].0.resources.http_requests.remove(0);
|
||||||
|
let changed = plan_import_batch_resources(&manager, destination.clone(), inputs).unwrap();
|
||||||
|
let deletes = changed
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.action == ImportPlanAction::Delete)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(deletes.len(), 1);
|
||||||
|
assert_eq!(deletes[0].name, "Root Request");
|
||||||
|
commit_import_plan(&manager, changed).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
manager
|
||||||
|
.connect()
|
||||||
|
.list_environments(&workspace_id)
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.id.clone())
|
||||||
|
.collect::<BTreeSet<_>>(),
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 3);
|
||||||
|
// A companion file can still be reimported on its own after the batch.
|
||||||
|
let single = plan_import_batch_resources(
|
||||||
|
&manager,
|
||||||
|
destination,
|
||||||
|
vec![collection_and_environments().remove(0)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(single.items.len(), 1);
|
||||||
|
assert_eq!(single.items[0].action, ImportPlanAction::Unchanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_same_named_environments_keep_distinct_source_links() {
|
||||||
|
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
|
||||||
|
let inputs = || {
|
||||||
|
vec![
|
||||||
|
batch_input("Postman Environment", "/one.json", standalone_environment("Local")),
|
||||||
|
batch_input("Postman Environment", "/two.json", standalone_environment("Local")),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let first =
|
||||||
|
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, inputs())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.resources.workspaces.len(), 1);
|
||||||
|
let workspace_id = first.resources.workspaces[0].id.clone();
|
||||||
|
commit_import_plan(&manager, first).unwrap();
|
||||||
|
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 2);
|
||||||
|
let next = plan_import_batch_resources(
|
||||||
|
&manager,
|
||||||
|
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||||
|
inputs(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(next.items.len(), 2);
|
||||||
|
assert!(next.items.iter().all(|i| i.action == ImportPlanAction::Unchanged));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_separate_collections_keep_their_workspaces_and_unique_names() {
|
||||||
|
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
|
||||||
|
let plan = plan_import_batch_resources(
|
||||||
|
&manager,
|
||||||
|
ImportDestination::NewWorkspace,
|
||||||
|
vec![
|
||||||
|
batch_input("Bruno", "/one", imported_resources()),
|
||||||
|
batch_input("Bruno", "/two", imported_resources()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
plan.resources.workspaces.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["Imported", "Imported (2)"]
|
||||||
|
);
|
||||||
|
let committed = commit_import_plan(&manager, plan).unwrap();
|
||||||
|
for workspace in committed.workspaces {
|
||||||
|
assert_eq!(manager.connect().list_http_requests(&workspace.id).unwrap().len(), 2);
|
||||||
|
assert_eq!(manager.connect().list_import_sources(&workspace.id).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_name_continues_an_existing_suffix() {
|
||||||
|
let taken = vec!["Imported".to_string(), "Imported (2)".to_string()];
|
||||||
|
assert_eq!(unique_name("Imported", &taken), "Imported (3)");
|
||||||
|
assert_eq!(unique_name("Imported (2)", &taken), "Imported (3)");
|
||||||
|
assert_eq!(unique_name("Other (2)", &taken), "Other (2)");
|
||||||
|
let taken = vec!["Release (2024)".to_string()];
|
||||||
|
assert_eq!(unique_name("Release (2024)", &taken), "Release (2024) (2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_rejects_two_versions_of_the_same_linked_collection() {
|
||||||
|
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
|
||||||
|
let input = || batch_input("Postman", "/collection.json", imported_resources());
|
||||||
|
let first =
|
||||||
|
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, vec![input()])
|
||||||
|
.unwrap();
|
||||||
|
let workspace_id = first.resources.workspaces[0].id.clone();
|
||||||
|
commit_import_plan(&manager, first).unwrap();
|
||||||
|
let result = plan_import_batch_resources(
|
||||||
|
&manager,
|
||||||
|
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||||
|
vec![input(), input()],
|
||||||
|
);
|
||||||
|
assert!(result.unwrap_err().to_string().contains("More than one import source"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn batch_commit_rolls_back_all_sources_if_linking_a_later_source_fails() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("models.sqlite");
|
||||||
|
let (manager, _blobs, _rx) =
|
||||||
|
yaak_models::init_standalone(&db_path, &dir.path().join("blobs.sqlite")).unwrap();
|
||||||
|
let plan = plan_import_batch_resources(
|
||||||
|
&manager,
|
||||||
|
ImportDestination::NewWorkspace,
|
||||||
|
collection_and_environments(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let workspace_id = plan.resources.workspaces[0].id.clone();
|
||||||
|
let connection = rusqlite::Connection::open(&db_path).unwrap();
|
||||||
|
connection.execute_batch("CREATE TRIGGER fail_source BEFORE INSERT ON import_sources WHEN NEW.origin = '/production.json' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;").unwrap();
|
||||||
|
drop(connection);
|
||||||
|
assert!(commit_import_plan(&manager, plan).is_err());
|
||||||
|
assert!(manager.connect().list_workspaces().unwrap().is_empty());
|
||||||
|
assert!(manager.connect().list_import_sources(&workspace_id).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn existing_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
|
fn existing_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
|
||||||
let (query_manager, _blob_manager, _rx) =
|
let (query_manager, _blob_manager, _rx) =
|
||||||
@@ -1795,6 +2227,66 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_import_warning_requires_the_same_importer_and_origin() {
|
||||||
|
for origin in [
|
||||||
|
"/tmp/api.yaml",
|
||||||
|
"/tmp/bruno-collection",
|
||||||
|
"https://example.com/api.yaml",
|
||||||
|
] {
|
||||||
|
let (query_manager, _blob_manager, _rx) =
|
||||||
|
yaak_models::init_in_memory().expect("initialize database");
|
||||||
|
let original =
|
||||||
|
ImportOrigin { origin: origin.to_string(), label: "Original".to_string() };
|
||||||
|
let first = plan_import_resources(
|
||||||
|
&query_manager,
|
||||||
|
"Bruno".to_string(),
|
||||||
|
ImportDestination::NewWorkspace,
|
||||||
|
imported_resources(),
|
||||||
|
Some(importer_keys()),
|
||||||
|
Some(original.clone()),
|
||||||
|
)
|
||||||
|
.expect("plan first import");
|
||||||
|
commit_import_plan(&query_manager, first).expect("commit first import");
|
||||||
|
|
||||||
|
// Every case has the same resource keys, but only an exact origin/importer match
|
||||||
|
// proves this source was imported before. Display labels do not establish identity.
|
||||||
|
for (importer, incoming_origin, should_warn) in [
|
||||||
|
(
|
||||||
|
"Bruno",
|
||||||
|
Some(ImportOrigin { label: "Different label".to_string(), ..original.clone() }),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Bruno",
|
||||||
|
Some(ImportOrigin { origin: format!("{origin}-other"), ..original.clone() }),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
("OpenAPI", Some(original.clone()), false),
|
||||||
|
("Bruno", None, false),
|
||||||
|
] {
|
||||||
|
let plan = plan_import_resources(
|
||||||
|
&query_manager,
|
||||||
|
importer.to_string(),
|
||||||
|
ImportDestination::NewWorkspace,
|
||||||
|
imported_resources(),
|
||||||
|
Some(importer_keys()),
|
||||||
|
incoming_origin.clone(),
|
||||||
|
)
|
||||||
|
.expect("plan another workspace");
|
||||||
|
assert_eq!(
|
||||||
|
plan.warnings.iter().any(|w| w.title.starts_with("Already imported")),
|
||||||
|
should_warn,
|
||||||
|
"importer {importer}, origin {incoming_origin:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plan.items.iter().all(|i| i.action == ImportPlanAction::Create),
|
||||||
|
"a new workspace never merges into an older import"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn environment_collisions_are_explicit_and_do_not_overwrite() {
|
fn environment_collisions_are_explicit_and_do_not_overwrite() {
|
||||||
let (query_manager, _blob_manager, _rx) =
|
let (query_manager, _blob_manager, _rx) =
|
||||||
@@ -2839,6 +3331,108 @@ mod tests {
|
|||||||
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
|
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folder_environment_names_do_not_cause_reimport_updates() {
|
||||||
|
for legacy_hash in [false, true] {
|
||||||
|
let (query_manager, _blob_manager, _rx) =
|
||||||
|
yaak_models::init_in_memory().expect("initialize database");
|
||||||
|
let mut resources = imported_resources();
|
||||||
|
resources.environments.push(Environment {
|
||||||
|
id: "ev_source_folder".to_string(),
|
||||||
|
name: "Folder Environment".to_string(),
|
||||||
|
parent_model: "folder".to_string(),
|
||||||
|
parent_id: Some("fl_source".to_string()),
|
||||||
|
..resources.environments[0].clone()
|
||||||
|
});
|
||||||
|
let committed = first_import_with(&query_manager, resources.clone());
|
||||||
|
let workspace_id = &committed.workspaces[0].id;
|
||||||
|
let environment = committed
|
||||||
|
.environments
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.parent_model == "folder")
|
||||||
|
.expect("folder environment");
|
||||||
|
assert_eq!(environment.name, "Imported Folder Environment");
|
||||||
|
|
||||||
|
if legacy_hash {
|
||||||
|
// Reproduce the pre-fix hash, which included the name generated on save.
|
||||||
|
let mut value = comparable(serde_json::to_value(environment).unwrap());
|
||||||
|
value["name"] = json!(environment.name);
|
||||||
|
let canonical = serde_json::to_string(&sorted_keys(value)).unwrap();
|
||||||
|
let hash = format!("v1:{:x}", Sha256::digest(canonical.as_bytes()));
|
||||||
|
let row = source_rows(&query_manager, workspace_id)
|
||||||
|
.into_iter()
|
||||||
|
.find(|r| r.model_id.as_deref() == Some(environment.id.as_str()))
|
||||||
|
.unwrap();
|
||||||
|
query_manager
|
||||||
|
.with_tx(|db| {
|
||||||
|
db.upsert_import_source_resource(&ImportSourceResource {
|
||||||
|
content_hash: Some(hash),
|
||||||
|
..row
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.expect("write legacy hash");
|
||||||
|
|
||||||
|
let mut changed = resources.clone();
|
||||||
|
changed.environments[1].variables[0].value = "changed".to_string();
|
||||||
|
let plan = replan(&query_manager, workspace_id, changed);
|
||||||
|
assert_eq!(
|
||||||
|
item_by_name(&plan, "Folder Environment").action,
|
||||||
|
ImportPlanAction::Conflict,
|
||||||
|
"an old hash cannot attribute a real difference"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan = replan(&query_manager, workspace_id, resources.clone());
|
||||||
|
let item = item_by_name(&plan, "Folder Environment");
|
||||||
|
assert_eq!(item.action, ImportPlanAction::Unchanged);
|
||||||
|
assert!(item.changed_fields.is_empty());
|
||||||
|
commit_import_plan(&query_manager, plan).expect("record name-independent hash");
|
||||||
|
|
||||||
|
let mut changed = resources.clone();
|
||||||
|
changed.environments[1].variables[0].value = "changed".to_string();
|
||||||
|
let plan = replan(&query_manager, workspace_id, changed.clone());
|
||||||
|
let item = item_by_name(&plan, "Folder Environment");
|
||||||
|
assert_eq!(item.action, ImportPlanAction::Update);
|
||||||
|
assert_eq!(item.changed_fields, vec!["variables"]);
|
||||||
|
|
||||||
|
query_manager
|
||||||
|
.with_tx(|db| {
|
||||||
|
let mut local = db.get_environment(&environment.id)?;
|
||||||
|
local.variables[0].value = "local".to_string();
|
||||||
|
db.upsert_environment(&local, &UpdateSource::Background)
|
||||||
|
})
|
||||||
|
.expect("edit folder variables locally");
|
||||||
|
let plan = replan(&query_manager, workspace_id, resources);
|
||||||
|
assert_eq!(
|
||||||
|
item_by_name(&plan, "Folder Environment").action,
|
||||||
|
ImportPlanAction::KeepLocal
|
||||||
|
);
|
||||||
|
let plan = replan(&query_manager, workspace_id, changed);
|
||||||
|
assert_eq!(
|
||||||
|
item_by_name(&plan, "Folder Environment").action,
|
||||||
|
ImportPlanAction::Conflict
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_and_named_environment_names_remain_import_content() {
|
||||||
|
for parent_model in ["workspace", "environment"] {
|
||||||
|
let original = Environment {
|
||||||
|
model: "environment".to_string(),
|
||||||
|
parent_model: parent_model.to_string(),
|
||||||
|
name: "Original".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let renamed = Environment { name: "Renamed".to_string(), ..original.clone() };
|
||||||
|
let original = comparable(serde_json::to_value(original).unwrap());
|
||||||
|
let renamed = comparable(serde_json::to_value(renamed).unwrap());
|
||||||
|
assert_eq!(changed_fields(&renamed, &original), vec!["name"]);
|
||||||
|
assert_ne!(hash_comparable(&original), hash_comparable(&renamed));
|
||||||
|
assert!(hash_comparable(&original).starts_with("v1:"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filling_in_editor_row_ids_is_not_an_edit() {
|
fn filling_in_editor_row_ids_is_not_an_edit() {
|
||||||
let (query_manager, _blob_manager, _rx) =
|
let (query_manager, _blob_manager, _rx) =
|
||||||
|
|||||||
Generated
+103
-8
@@ -33,6 +33,7 @@
|
|||||||
"plugins/auth-oauth1",
|
"plugins/auth-oauth1",
|
||||||
"plugins/filter-jsonpath",
|
"plugins/filter-jsonpath",
|
||||||
"plugins/filter-xpath",
|
"plugins/filter-xpath",
|
||||||
|
"plugins/importer-bruno",
|
||||||
"plugins/importer-curl",
|
"plugins/importer-curl",
|
||||||
"plugins/importer-insomnia",
|
"plugins/importer-insomnia",
|
||||||
"plugins/importer-openapi",
|
"plugins/importer-openapi",
|
||||||
@@ -4574,11 +4575,19 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nanoid": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nanoid/-/nanoid-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.6",
|
"version": "25.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.6.tgz",
|
||||||
"integrity": "sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==",
|
"integrity": "sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": ">=7.24.0 <7.24.7"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
@@ -4667,12 +4676,28 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/yauzl": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@ungap/structured-clone": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||||
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
|
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@usebruno/common": {
|
||||||
|
"version": "0.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@usebruno/common/-/common-0.25.0.tgz",
|
||||||
|
"integrity": "sha512-aTQtMuWTU3dL6brl5zwnmaoPeKStl6EK/frzwtVP4gmbqo+8Kjf7xafgYumrgOnL/lO1q34HqE00XCTEwXSmtw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||||
@@ -5084,6 +5109,10 @@
|
|||||||
"resolved": "plugins-external/httpsnippet",
|
"resolved": "plugins-external/httpsnippet",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@yaak/importer-bruno": {
|
||||||
|
"resolved": "plugins/importer-bruno",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@yaak/importer-curl": {
|
"node_modules/@yaak/importer-curl": {
|
||||||
"resolved": "plugins/importer-curl",
|
"resolved": "plugins/importer-curl",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -5904,6 +5933,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/arcsecond": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/arcsecond/-/arcsecond-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-J/fHdyadnsIencRsM6oUSsraCKG+Ni9Udcgr/eusxjTzX3SEQtCUQSpP0YtImFPfIK6DdT1nqwN0ng4FqNmwgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
@@ -9702,6 +9737,12 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -11367,6 +11408,15 @@
|
|||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ohm-js": {
|
||||||
|
"version": "16.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ohm-js/-/ohm-js-16.6.0.tgz",
|
||||||
|
"integrity": "sha512-X9P4koSGa7swgVQ0gt71UCYtkAQGOjciJPJAz74kDxWt8nXbH5HrDOQG6qBDH7SR40ktNv4x61BwpTDE9q4lRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -11719,7 +11769,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
@@ -13660,7 +13709,6 @@
|
|||||||
"version": "7.24.6",
|
"version": "7.24.6",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicorn-magic": {
|
"node_modules/unicorn-magic": {
|
||||||
@@ -14769,9 +14817,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
@@ -14787,7 +14835,6 @@
|
|||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
|
||||||
"integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
|
"integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pend": "~1.2.0"
|
"pend": "~1.2.0"
|
||||||
@@ -14913,11 +14960,13 @@
|
|||||||
"packages/plugin-runtime": {
|
"packages/plugin-runtime": {
|
||||||
"name": "@yaakapp-internal/plugin-runtime",
|
"name": "@yaakapp-internal/plugin-runtime",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0",
|
||||||
|
"yauzl": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
|
"@types/yauzl": "^3.4.0",
|
||||||
"esbuild": "^0.28.0"
|
"esbuild": "^0.28.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -15116,6 +15165,52 @@
|
|||||||
"xpath": "^0.0.34"
|
"xpath": "^0.0.34"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"plugins/importer-bruno": {
|
||||||
|
"name": "@yaak/importer-bruno",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@usebruno/filestore": "^0.12.0",
|
||||||
|
"yaml": "^2.8.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins/importer-bruno/node_modules/@usebruno/filestore": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@usebruno/filestore/-/filestore-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-MgVuM6H+qXxipPJ+T3SaIdWoTn5OTq5QaMQHVQZ3GUByj78GPgWiflqfQXGJTLF8AAQFNNgMkCOU/36hv1Lw6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/nanoid": "^2.1.0",
|
||||||
|
"@usebruno/common": "0.25.0",
|
||||||
|
"@usebruno/lang": "0.39.0",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"lodash": "4.18.1",
|
||||||
|
"yaml": "^2.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins/importer-bruno/node_modules/@usebruno/lang": {
|
||||||
|
"version": "0.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@usebruno/lang/-/lang-0.39.0.tgz",
|
||||||
|
"integrity": "sha512-D/TWoJYhXvC4CjI7OsZCm8zVRel7so9BiktMXmdpKbvF2b1bin33aO71hn9K38pypsBicl5Y8CK8RJ0QXo1qBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"arcsecond": "^5.0.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"lodash": "4.18.1",
|
||||||
|
"ohm-js": "^16.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins/importer-bruno/node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"plugins/importer-curl": {
|
"plugins/importer-curl": {
|
||||||
"name": "@yaak/importer-curl",
|
"name": "@yaak/importer-curl",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"plugins/auth-oauth1",
|
"plugins/auth-oauth1",
|
||||||
"plugins/filter-jsonpath",
|
"plugins/filter-jsonpath",
|
||||||
"plugins/filter-xpath",
|
"plugins/filter-xpath",
|
||||||
|
"plugins/importer-bruno",
|
||||||
"plugins/importer-curl",
|
"plugins/importer-curl",
|
||||||
"plugins/importer-insomnia",
|
"plugins/importer-insomnia",
|
||||||
"plugins/importer-openapi",
|
"plugins/importer-openapi",
|
||||||
|
|||||||
@@ -288,10 +288,10 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
|||||||
"Importing from a file needs a filesystem, which a browser tab has no",
|
"Importing from a file needs a filesystem, which a browser tab has no",
|
||||||
"localFiles",
|
"localFiles",
|
||||||
],
|
],
|
||||||
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
|
|
||||||
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
|
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
|
||||||
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
||||||
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
|
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
|
||||||
|
cmd_detect_import_source: ["Importing isn't available in the browser yet", null],
|
||||||
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
|
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
|
||||||
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
||||||
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
|
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Importers
|
||||||
|
|
||||||
|
Implement **`onImportSource(ctx, { source })`**. The source is one of two things:
|
||||||
|
|
||||||
|
- `{ type: "text", name, text }`: a single UTF-8 document, or pasted text (named `input`).
|
||||||
|
- `{ type: "directory", files }`: a directory or a ZIP archive, read as one file tree.
|
||||||
|
|
||||||
|
Return `null` or `undefined` when the source isn't your format. Only one importer's result is used per source.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { PluginDefinition } from "@yaakapp/api";
|
||||||
|
|
||||||
|
export const plugin: PluginDefinition = {
|
||||||
|
importer: {
|
||||||
|
name: "Example",
|
||||||
|
async onImportSource(ctx, { source }) {
|
||||||
|
if (source.type === "text") return convertDocument(source.text);
|
||||||
|
for (const entry of await source.files.readDir()) {
|
||||||
|
if (entry.name === "example.json") {
|
||||||
|
return convertDocument(await source.files.readTextFile(entry.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The older `onImport(ctx, { text })` hook still works but is deprecated. It only receives single text documents and is ignored when `onImportSource` is defined.
|
||||||
|
|
||||||
|
## The file tree
|
||||||
|
|
||||||
|
- `files.kind` is `"directory"` or `"zip"`; `files.name` is the selected basename.
|
||||||
|
- `readDir(path = "")` lists immediate children. `readFile(path)` returns bytes. `readTextFile(path)` decodes UTF-8 strictly.
|
||||||
|
- Paths use `/` and are relative to the root. Absolute paths, `..`, and backslashes are rejected. Symbolic links are skipped in listings and rejected on read. Nothing is extracted to disk.
|
||||||
|
- ZIPs are detected by signature, not extension. A ZIP made by compressing a folder is rooted at that folder, so plugins see the same tree as for the directory. Directory reads are not a snapshot: a symlink swapped in under a validated path can redirect a read outside the root, so treat directory imports as trusted input.
|
||||||
|
- Limits: 64 MiB per source file or download, 32 MiB per file read, 10,000 entries, 256 MiB declared ZIP expansion. Encrypted ZIPs are unsupported.
|
||||||
|
- The tree is read-only and only valid until the hook settles.
|
||||||
|
|
||||||
|
Plugins run with the normal Node.js permissions of the plugin runtime; the tree scopes convenience, not security.
|
||||||
+10
-1
@@ -470,7 +470,16 @@ export type HttpRequestAction = { label: string, icon?: Icon, };
|
|||||||
|
|
||||||
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
|
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
|
||||||
|
|
||||||
export type ImportRequest = { content: string, };
|
/**
|
||||||
|
* Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
|
||||||
|
*/
|
||||||
|
export type ImportFileSource = { "type": "file", name: string, base64: string, } | { "type": "directory", path: string, };
|
||||||
|
|
||||||
|
export type ImportRequest = {
|
||||||
|
/**
|
||||||
|
* Kept for text-only importers and older runtimes.
|
||||||
|
*/
|
||||||
|
content: string, source?: ImportFileSource, };
|
||||||
|
|
||||||
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,10 @@ export * from "./bindings/gen_events";
|
|||||||
|
|
||||||
// Some extras for utility
|
// Some extras for utility
|
||||||
|
|
||||||
export type { PartialImportResources } from "./plugins/ImporterPlugin";
|
export type {
|
||||||
|
PartialImportResources,
|
||||||
|
ImportFiles,
|
||||||
|
ImportFileEntry,
|
||||||
|
ImportSource,
|
||||||
|
ImportSourceHandler,
|
||||||
|
} from "./plugins/ImporterPlugin";
|
||||||
@@ -21,11 +21,45 @@ export type ImportPluginResponse =
|
|||||||
resources: PartialImportResources;
|
resources: PartialImportResources;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A read-only view of a selected directory or ZIP archive. Paths are relative and use
|
||||||
|
* forward slashes. Directory path checks are best-effort under concurrent filesystem
|
||||||
|
* changes, not a security boundary. Valid only while onImportSource runs. */
|
||||||
|
export interface ImportFiles {
|
||||||
|
readonly name: string;
|
||||||
|
readonly kind: "zip" | "directory";
|
||||||
|
/** List immediate children. The root directory is "" (the default). */
|
||||||
|
readDir(path?: string): Promise<ImportFileEntry[]>;
|
||||||
|
readFile(path: string): Promise<Uint8Array>;
|
||||||
|
/** Decode UTF-8 strictly; binary or invalid UTF-8 content throws. */
|
||||||
|
readTextFile(path: string): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportFileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the user chose to import: one text document, or a directory or ZIP read as a tree. */
|
||||||
|
export type ImportSource =
|
||||||
|
| { type: "text"; name: string; text: string }
|
||||||
|
| { type: "directory"; files: ImportFiles };
|
||||||
|
|
||||||
|
type ImportTextHandler = (
|
||||||
|
ctx: Context,
|
||||||
|
args: { text: string },
|
||||||
|
) => MaybePromise<ImportPluginResponse | undefined>;
|
||||||
|
|
||||||
|
export type ImportSourceHandler = (
|
||||||
|
ctx: Context,
|
||||||
|
args: { source: ImportSource },
|
||||||
|
) => MaybePromise<ImportPluginResponse | undefined>;
|
||||||
|
|
||||||
export type ImporterPlugin = {
|
export type ImporterPlugin = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
onImport(
|
/** Return null when the source isn't this importer's format. Takes precedence over onImport. */
|
||||||
ctx: Context,
|
onImportSource?: ImportSourceHandler;
|
||||||
args: { text: string },
|
/** @deprecated Use onImportSource. Only receives single text documents. */
|
||||||
): MaybePromise<ImportPluginResponse | null | undefined>;
|
onImport?: ImportTextHandler;
|
||||||
};
|
};
|
||||||
@@ -6,11 +6,13 @@
|
|||||||
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0",
|
||||||
|
"yauzl": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
|
"@types/yauzl": "^3.4.0",
|
||||||
"esbuild": "^0.28.0"
|
"esbuild": "^0.28.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,7 @@ import type {
|
|||||||
} from "@yaakapp-internal/plugins";
|
} from "@yaakapp-internal/plugins";
|
||||||
import { applyDynamicFormInput } from "./common";
|
import { applyDynamicFormInput } from "./common";
|
||||||
import { EventChannel } from "./EventChannel";
|
import { EventChannel } from "./EventChannel";
|
||||||
|
import { runImporter } from "./importFiles";
|
||||||
import { migrateTemplateFunctionSelectOptions } from "./migrations";
|
import { migrateTemplateFunctionSelectOptions } from "./migrations";
|
||||||
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||||
|
|
||||||
@@ -157,19 +158,14 @@ export class PluginInstance {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (payload.type === "import_request" && this.#mod?.importer != null) {
|
||||||
payload.type === "import_request" &&
|
const reply = await runImporter(this.#mod.importer, ctx, payload);
|
||||||
typeof this.#mod?.importer?.onImport === "function"
|
|
||||||
) {
|
|
||||||
const reply = await this.#mod.importer.onImport(ctx, {
|
|
||||||
text: payload.content,
|
|
||||||
});
|
|
||||||
if (reply != null) {
|
if (reply != null) {
|
||||||
const replyPayload: InternalEventPayload = {
|
const replyPayload: InternalEventPayload = {
|
||||||
type: "import_response",
|
type: "import_response",
|
||||||
importer: this.#mod.importer.name,
|
importer: this.#mod.importer.name,
|
||||||
resources: reply.resources as ImportResources,
|
resources: reply.resources as ImportResources,
|
||||||
sourceKeys: reply.sourceKeys ?? null,
|
sourceKeys: reply.sourceKeys,
|
||||||
};
|
};
|
||||||
this.#sendPayload(context, replyPayload, replyId);
|
this.#sendPayload(context, replyPayload, replyId);
|
||||||
return;
|
return;
|
||||||
@@ -649,10 +645,12 @@ export class PluginInstance {
|
|||||||
complete: info.complete,
|
complete: info.complete,
|
||||||
},
|
},
|
||||||
async (offset, length) => {
|
async (offset, length) => {
|
||||||
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
|
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(context, {
|
||||||
context,
|
type: "read_http_response_body_chunk_request",
|
||||||
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
|
responseId,
|
||||||
);
|
offset,
|
||||||
|
length,
|
||||||
|
});
|
||||||
return decodeBase64Chunk(chunk.data);
|
return decodeBase64Chunk(chunk.data);
|
||||||
},
|
},
|
||||||
{ refresh: bodyInfo },
|
{ refresh: bodyInfo },
|
||||||
@@ -853,7 +851,10 @@ export class PluginInstance {
|
|||||||
// carries the only copy of its body. A saved one is read back from
|
// carries the only copy of its body. A saved one is read back from
|
||||||
// the host like any other. Callers get the same thing either way.
|
// the host like any other. Callers get the same thing either way.
|
||||||
if (body == null) {
|
if (body == null) {
|
||||||
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
|
return {
|
||||||
|
httpResponse: forPlugin(httpResponse),
|
||||||
|
body: await storedBody(httpResponse.id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = decodeBase64Chunk(body);
|
const bytes = decodeBase64Chunk(body);
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,224 @@
|
|||||||
|
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Context, ImportRequest, ImportSource } from "@yaakapp/api";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import { createImportFiles, runImporter } from "./importFiles";
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
const input = (name: string, data: Uint8Array): ImportRequest => ({
|
||||||
|
content: "",
|
||||||
|
source: { type: "file", name, base64: Buffer.from(data).toString("base64") },
|
||||||
|
});
|
||||||
|
async function zip(name = "import-files.zip") {
|
||||||
|
return input(name, await readFile(new URL(`./fixtures/${name}`, import.meta.url)));
|
||||||
|
}
|
||||||
|
const ctx = {} as Context;
|
||||||
|
|
||||||
|
describe("import file tree", () => {
|
||||||
|
test("exposes legacy text and single binary files through the same read API", async () => {
|
||||||
|
const session = await createImportFiles({ content: "café" });
|
||||||
|
expect(session.text).toBe("café");
|
||||||
|
expect(await session.files.readDir()).toEqual([{ name: "input", path: "input", type: "file" }]);
|
||||||
|
expect(await session.files.readTextFile("input")).toBe("café");
|
||||||
|
session.close();
|
||||||
|
await expect(session.files.readFile("input")).rejects.toThrow("closed");
|
||||||
|
const binary = await createImportFiles(input("body.bin", Uint8Array.from([255, 0, 128])));
|
||||||
|
expect(binary.isText).toBe(false);
|
||||||
|
expect([...(await binary.files.readFile("body.bin"))]).toEqual([255, 0, 128]);
|
||||||
|
await expect(binary.files.readTextFile("body.bin")).rejects.toThrow();
|
||||||
|
binary.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists immediate ZIP children, including implicit/empty directories, and reads binary/UTF-8", async () => {
|
||||||
|
const session = await createImportFiles(await zip());
|
||||||
|
expect(session.files.kind).toBe("zip");
|
||||||
|
expect(session.text).toBe("");
|
||||||
|
expect(await session.files.readDir()).toEqual([
|
||||||
|
{ name: "binary.bin", path: "binary.bin", type: "file" },
|
||||||
|
{ name: "collection", path: "collection", type: "directory" },
|
||||||
|
]);
|
||||||
|
expect((await session.files.readDir("collection")).map((e) => e.name)).toEqual([
|
||||||
|
"café.yml",
|
||||||
|
"empty",
|
||||||
|
"nested",
|
||||||
|
]);
|
||||||
|
expect(await session.files.readDir("collection/empty")).toEqual([]);
|
||||||
|
expect(await session.files.readTextFile("collection/café.yml")).toBe("name: café\n");
|
||||||
|
expect([...(await session.files.readFile("binary.bin"))]).toEqual([0, 255, 128]);
|
||||||
|
await expect(session.files.readFile("collection")).rejects.toThrow("not found");
|
||||||
|
await expect(session.files.readDir("missing")).rejects.toThrow("not found");
|
||||||
|
session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a misleading ZIP suffix still dispatches as text", async () => {
|
||||||
|
const content = '{"openapi":"3.0.0","info":{"title":"Example","version":"1"},"paths":{}}';
|
||||||
|
const source = input("api.zip", Buffer.from(content));
|
||||||
|
const onImport = vi.fn().mockResolvedValue(null);
|
||||||
|
await runImporter({ name: "Text", onImport }, ctx, source);
|
||||||
|
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: content });
|
||||||
|
const onImportSource = vi.fn().mockResolvedValue(null);
|
||||||
|
await runImporter({ name: "Source", onImportSource }, ctx, source);
|
||||||
|
expect(onImportSource).toHaveBeenCalledExactlyOnceWith(ctx, {
|
||||||
|
source: { type: "text", name: "api.zip", text: content },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ZIP signatures take precedence over names, including empty archives", async () => {
|
||||||
|
const bytes = await readFile(new URL("./fixtures/import-files.zip", import.meta.url));
|
||||||
|
const empty = Buffer.alloc(22);
|
||||||
|
empty.writeUInt32LE(0x06054b50);
|
||||||
|
for (const data of [bytes, empty]) {
|
||||||
|
const source = input("collection.yaml", data);
|
||||||
|
const session = await createImportFiles(source);
|
||||||
|
try {
|
||||||
|
expect(session.files.kind).toBe("zip");
|
||||||
|
if (data === empty) expect(await session.files.readDir()).toEqual([]);
|
||||||
|
else expect(await session.files.readTextFile("collection/café.yml")).toBe("name: café\n");
|
||||||
|
} finally {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
const onImport = vi.fn().mockResolvedValue(null);
|
||||||
|
expect(await runImporter({ name: "Text", onImport }, ctx, source)).toBeNull();
|
||||||
|
expect(onImport).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a ZIP made by compressing a folder is rooted at that folder", async () => {
|
||||||
|
const session = await createImportFiles(await zip("wrapped.zip"));
|
||||||
|
try {
|
||||||
|
expect((await session.files.readDir()).map((e) => e.name)).toEqual(["nested", "root.txt"]);
|
||||||
|
expect(await session.files.readTextFile("nested/file.txt")).toBe("inner\n");
|
||||||
|
await expect(session.files.readDir("Demo")).rejects.toThrow("not found");
|
||||||
|
} finally {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed input with a ZIP signature still fails archive validation", async () => {
|
||||||
|
const source = input("broken.zip", Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
||||||
|
await expect(createImportFiles(source)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("directory view reads lazily and blocks links and escaping paths", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "yaak-import-"));
|
||||||
|
dirs.push(root);
|
||||||
|
await mkdir(path.join(root, "nested"));
|
||||||
|
await writeFile(path.join(root, "nested", "file"), "first");
|
||||||
|
await symlink(tmpdir(), path.join(root, "escape"));
|
||||||
|
const session = await createImportFiles({
|
||||||
|
content: "",
|
||||||
|
source: { type: "directory", path: root },
|
||||||
|
});
|
||||||
|
expect(await session.files.readDir()).toEqual([
|
||||||
|
{ name: "nested", path: "nested", type: "directory" },
|
||||||
|
]);
|
||||||
|
await writeFile(path.join(root, "nested", "file"), "updated");
|
||||||
|
expect(await session.files.readTextFile("nested/file")).toBe("updated");
|
||||||
|
await expect(session.files.readDir("escape")).rejects.toThrow("symbolic");
|
||||||
|
for (const p of [
|
||||||
|
"../outside",
|
||||||
|
"/absolute",
|
||||||
|
"C:/outside",
|
||||||
|
"nested/../../outside",
|
||||||
|
"nested\\file",
|
||||||
|
]) {
|
||||||
|
await expect(session.files.readFile(p)).rejects.toThrow();
|
||||||
|
}
|
||||||
|
session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["import-traversal.zip", "import-duplicate.zip", "import-symlink.zip"])(
|
||||||
|
"rejects unsafe ZIP %s",
|
||||||
|
async (name) => {
|
||||||
|
await expect(createImportFiles(await zip(name))).rejects.toThrow();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("ZIP entries are decompressed only on read, with a read-size limit", async () => {
|
||||||
|
const session = await createImportFiles(await zip("import-large.zip"));
|
||||||
|
expect((await session.files.readDir())[0]?.name).toBe("large.txt");
|
||||||
|
await expect(session.files.readFile("large.txt")).rejects.toThrow("32 MiB");
|
||||||
|
session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the deprecated text hook preserves its arguments and skips other input", async () => {
|
||||||
|
const onImport = vi.fn().mockResolvedValue(null);
|
||||||
|
const importer = { name: "Legacy", onImport };
|
||||||
|
await runImporter(importer, ctx, { content: "original" });
|
||||||
|
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: "original" });
|
||||||
|
onImport.mockClear();
|
||||||
|
await runImporter(importer, ctx, input("empty.txt", new Uint8Array()));
|
||||||
|
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: "" });
|
||||||
|
onImport.mockClear();
|
||||||
|
for (const source of [
|
||||||
|
await zip(),
|
||||||
|
input("binary", Uint8Array.from([255])),
|
||||||
|
{
|
||||||
|
content: "",
|
||||||
|
source: { type: "directory", path: "/does-not-exist" },
|
||||||
|
} satisfies ImportRequest,
|
||||||
|
]) {
|
||||||
|
expect(await runImporter(importer, ctx, source)).toBeNull();
|
||||||
|
}
|
||||||
|
expect(onImport).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the source hook gets text for documents and a tree for directories and ZIPs", async () => {
|
||||||
|
const onImport = vi.fn().mockResolvedValue(null);
|
||||||
|
const onImportSource = vi.fn().mockResolvedValue(null);
|
||||||
|
const importer = { name: "Both", onImport, onImportSource };
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "yaak-import-hooks-"));
|
||||||
|
dirs.push(root);
|
||||||
|
const cases: [ImportRequest, ImportSource["type"] | null, string?][] = [
|
||||||
|
[{ content: "pasted" }, "text", "pasted"],
|
||||||
|
[input("text.txt", Buffer.from("text")), "text", "text"],
|
||||||
|
[input("empty.txt", new Uint8Array()), "text", ""],
|
||||||
|
[input("binary", Uint8Array.from([255])), null],
|
||||||
|
[await zip(), "directory"],
|
||||||
|
[{ content: "", source: { type: "directory", path: root } }, "directory"],
|
||||||
|
];
|
||||||
|
for (const [request, type, text] of cases) {
|
||||||
|
onImportSource.mockClear();
|
||||||
|
expect(await runImporter(importer, ctx, request)).toBeNull();
|
||||||
|
if (type == null) {
|
||||||
|
expect(onImportSource).not.toHaveBeenCalled();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(onImportSource).toHaveBeenCalledOnce();
|
||||||
|
const { source } = onImportSource.mock.calls[0]![1] as { source: ImportSource };
|
||||||
|
expect(source.type).toBe(type);
|
||||||
|
if (source.type === "text") expect(source.text).toBe(text);
|
||||||
|
else await expect(source.files.readDir()).rejects.toThrow("closed");
|
||||||
|
}
|
||||||
|
onImportSource.mockResolvedValue(undefined);
|
||||||
|
await runImporter(importer, ctx, { content: "pasted" });
|
||||||
|
expect(onImport).not.toHaveBeenCalled();
|
||||||
|
expect(console.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes file access even if the importer throws", async () => {
|
||||||
|
let saved: import("@yaakapp/api").ImportFiles | undefined;
|
||||||
|
await expect(
|
||||||
|
runImporter(
|
||||||
|
{
|
||||||
|
name: "Fails",
|
||||||
|
onImportSource(_ctx, { source }) {
|
||||||
|
if (source.type === "directory") saved = source.files;
|
||||||
|
throw new Error("failure");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
await zip(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("failure");
|
||||||
|
await expect(saved!.readDir()).rejects.toThrow("closed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { constants } from "node:fs";
|
||||||
|
import { lstat, open, opendir, realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Context, ImportFileEntry, ImportFiles, ImportRequest } from "@yaakapp/api";
|
||||||
|
import type { ImporterPlugin, ImportSource } from "@yaakapp/api/lib/plugins/ImporterPlugin";
|
||||||
|
import { type Entry, fromBuffer, type ZipFile } from "yauzl";
|
||||||
|
|
||||||
|
const MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||||||
|
const MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
||||||
|
const MAX_ENTRIES = 10_000;
|
||||||
|
const MAX_EXPANDED_BYTES = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
function relativePath(value: string): string {
|
||||||
|
if (
|
||||||
|
value.includes("\\") ||
|
||||||
|
value.includes("\0") ||
|
||||||
|
value.startsWith("/") ||
|
||||||
|
/^[a-z]:/i.test(value)
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid import path: ${value}`);
|
||||||
|
}
|
||||||
|
const parts = value.split("/");
|
||||||
|
if (parts.includes("..")) throw new Error("Import paths cannot leave the selected source");
|
||||||
|
return parts.filter((p) => p !== "" && p !== ".").join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZip(bytes: Buffer): boolean {
|
||||||
|
// Names are hints only: a text specification can legitimately be named *.zip.
|
||||||
|
return (
|
||||||
|
bytes[0] === 0x50 &&
|
||||||
|
bytes[1] === 0x4b &&
|
||||||
|
((bytes[2] === 3 && bytes[3] === 4) ||
|
||||||
|
(bytes[2] === 5 && bytes[3] === 6) ||
|
||||||
|
(bytes[2] === 7 && bytes[3] === 8))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportFileSession {
|
||||||
|
files: ImportFiles;
|
||||||
|
kind: "file" | ImportFiles["kind"];
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
/** Whether text-only plugins can handle this input, including an empty text file. */
|
||||||
|
isText: boolean;
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construct one scoped view for one invocation. No ZIP entries are extracted to disk. */
|
||||||
|
export async function createImportFiles(input: ImportRequest): Promise<ImportFileSession> {
|
||||||
|
let closed = false;
|
||||||
|
let zip: ZipFile | undefined;
|
||||||
|
let zipError: Error | undefined;
|
||||||
|
const assertOpen = () => {
|
||||||
|
if (closed) throw new Error("Import source is closed");
|
||||||
|
if (zipError) throw zipError;
|
||||||
|
};
|
||||||
|
let kind: ImportFileSession["kind"] = "file";
|
||||||
|
let name = "input";
|
||||||
|
let text = input.content;
|
||||||
|
let isText = true;
|
||||||
|
let readDir: ImportFiles["readDir"];
|
||||||
|
let readFile: ImportFiles["readFile"];
|
||||||
|
|
||||||
|
if (input.source?.type === "directory") {
|
||||||
|
kind = "directory";
|
||||||
|
isText = false;
|
||||||
|
text = "";
|
||||||
|
const root = await realpath(input.source.path);
|
||||||
|
name = path.basename(root);
|
||||||
|
const resolve = async (value: string) => {
|
||||||
|
assertOpen();
|
||||||
|
const rel = relativePath(value);
|
||||||
|
let candidate = root;
|
||||||
|
// Reject links visible during validation, including intermediate components.
|
||||||
|
// These path checks are not atomic with open/opendir: concurrent filesystem
|
||||||
|
// changes can redirect a later operation. This is not a confinement boundary.
|
||||||
|
for (const component of rel.split("/").filter(Boolean)) {
|
||||||
|
candidate = path.join(candidate, component);
|
||||||
|
if ((await lstat(candidate)).isSymbolicLink())
|
||||||
|
throw new Error("Import sources do not follow symbolic links");
|
||||||
|
}
|
||||||
|
const canonical = await realpath(candidate);
|
||||||
|
const within = path.relative(root, canonical);
|
||||||
|
if (within === ".." || within.startsWith(`..${path.sep}`) || path.isAbsolute(within))
|
||||||
|
throw new Error("Import path is outside the selected source");
|
||||||
|
return canonical;
|
||||||
|
};
|
||||||
|
readDir = async (value = "") => {
|
||||||
|
const rel = relativePath(value);
|
||||||
|
const entries: ImportFileEntry[] = [];
|
||||||
|
for await (const entry of await opendir(await resolve(rel))) {
|
||||||
|
if (!entry.isFile() && !entry.isDirectory()) continue;
|
||||||
|
entries.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: rel ? `${rel}/${entry.name}` : entry.name,
|
||||||
|
type: entry.isDirectory() ? "directory" : "file",
|
||||||
|
});
|
||||||
|
if (entries.length > MAX_ENTRIES)
|
||||||
|
throw new Error("Import directory contains too many entries");
|
||||||
|
}
|
||||||
|
assertOpen();
|
||||||
|
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
};
|
||||||
|
readFile = async (value) => {
|
||||||
|
const handle = await open(
|
||||||
|
await resolve(value),
|
||||||
|
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const stat = await handle.stat();
|
||||||
|
if (!stat.isFile()) throw new Error("Import path is not a regular file");
|
||||||
|
if (stat.size > MAX_FILE_BYTES)
|
||||||
|
throw new Error("Import file exceeds the 32 MiB read limit");
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
||||||
|
const bytes = Buffer.from(chunk as Uint8Array);
|
||||||
|
total += bytes.length;
|
||||||
|
if (total > MAX_FILE_BYTES) throw new Error("Import file exceeds the 32 MiB read limit");
|
||||||
|
chunks.push(bytes);
|
||||||
|
}
|
||||||
|
assertOpen();
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const source = input.source;
|
||||||
|
if (source && source.base64.length > Math.ceil(MAX_SOURCE_BYTES / 3) * 4)
|
||||||
|
throw new Error("Import source exceeds the 64 MiB limit");
|
||||||
|
const bytes = source ? Buffer.from(source.base64, "base64") : Buffer.from(input.content);
|
||||||
|
name = source?.name || "input";
|
||||||
|
if (bytes.length > MAX_SOURCE_BYTES) throw new Error("Import source exceeds the 64 MiB limit");
|
||||||
|
const entries = new Map<string, { type: "file" | "directory"; entry?: Entry; data?: Buffer }>([
|
||||||
|
["", { type: "directory" }],
|
||||||
|
]);
|
||||||
|
if (source && isZip(bytes)) {
|
||||||
|
kind = "zip";
|
||||||
|
text = "";
|
||||||
|
isText = false;
|
||||||
|
zip = await new Promise<ZipFile>((resolve, reject) => {
|
||||||
|
fromBuffer(
|
||||||
|
bytes,
|
||||||
|
{ lazyEntries: true, autoClose: false, strictFileNames: false, validateEntrySizes: true },
|
||||||
|
(err, result) => (err ? reject(err) : resolve(result)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
zip.on("error", (err: Error) => {
|
||||||
|
zipError = err;
|
||||||
|
});
|
||||||
|
const archive = zip;
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let count = 0;
|
||||||
|
let expandedBytes = 0;
|
||||||
|
archive.once("error", reject);
|
||||||
|
archive.once("end", () => {
|
||||||
|
archive.removeListener("error", reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
archive.on("entry", (entry: Entry) => {
|
||||||
|
try {
|
||||||
|
if (++count > MAX_ENTRIES) throw new Error("ZIP contains too many entries");
|
||||||
|
expandedBytes += entry.uncompressedSize;
|
||||||
|
if (expandedBytes > MAX_EXPANDED_BYTES)
|
||||||
|
throw new Error("ZIP expanded size exceeds the 256 MiB limit");
|
||||||
|
if (entry.isEncrypted()) throw new Error("Encrypted ZIP entries are not supported");
|
||||||
|
const mode = (entry.externalFileAttributes >>> 16) & 0xf000;
|
||||||
|
if (mode === 0xa000) throw new Error("ZIP symbolic links are not supported");
|
||||||
|
const entryPath = relativePath(entry.fileName);
|
||||||
|
if (!entryPath) throw new Error("ZIP contains an empty entry path");
|
||||||
|
const type = entry.fileName.endsWith("/") ? "directory" : "file";
|
||||||
|
const previous = entries.get(entryPath);
|
||||||
|
if (previous && (type !== "directory" || previous.type !== "directory"))
|
||||||
|
throw new Error(`Duplicate ZIP path: ${entryPath}`);
|
||||||
|
entries.set(entryPath, { type, entry });
|
||||||
|
let parent = path.posix.dirname(entryPath);
|
||||||
|
while (parent !== ".") {
|
||||||
|
if (entries.get(parent)?.type === "file")
|
||||||
|
throw new Error(`ZIP file/directory conflict: ${parent}`);
|
||||||
|
if (!entries.has(parent)) entries.set(parent, { type: "directory" });
|
||||||
|
parent = path.posix.dirname(parent);
|
||||||
|
}
|
||||||
|
if (entries.size > MAX_ENTRIES) throw new Error("ZIP contains too many paths");
|
||||||
|
archive.readEntry();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
archive.readEntry();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
archive.close();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
unwrapSingleRoot(entries);
|
||||||
|
} else {
|
||||||
|
const entryPath = relativePath(name);
|
||||||
|
if (!entryPath || entryPath.includes("/"))
|
||||||
|
throw new Error("Single import files must have a basename");
|
||||||
|
entries.set(entryPath, { type: "file", data: bytes });
|
||||||
|
try {
|
||||||
|
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
text = "";
|
||||||
|
isText = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readDir = async (value = "") => {
|
||||||
|
assertOpen();
|
||||||
|
const rel = relativePath(value);
|
||||||
|
if (entries.get(rel)?.type !== "directory")
|
||||||
|
throw new Error(`Import directory not found: ${rel}`);
|
||||||
|
return [...entries]
|
||||||
|
.filter(
|
||||||
|
([p]) => p !== "" && (path.posix.dirname(p) === "." ? "" : path.posix.dirname(p)) === rel,
|
||||||
|
)
|
||||||
|
.map(([p, entry]) => ({ name: path.posix.basename(p), path: p, type: entry.type }))
|
||||||
|
.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
};
|
||||||
|
readFile = async (value) => {
|
||||||
|
assertOpen();
|
||||||
|
const found = entries.get(relativePath(value));
|
||||||
|
if (!found || found.type !== "file") throw new Error(`Import file not found: ${value}`);
|
||||||
|
if (found.data) {
|
||||||
|
if (found.data.length > MAX_FILE_BYTES)
|
||||||
|
throw new Error("Import file exceeds the 32 MiB read limit");
|
||||||
|
return Buffer.from(found.data);
|
||||||
|
}
|
||||||
|
const entry = found.entry!;
|
||||||
|
if (entry.uncompressedSize > MAX_FILE_BYTES)
|
||||||
|
throw new Error("Import file exceeds the 32 MiB read limit");
|
||||||
|
const stream = await new Promise<import("node:stream").Readable>((resolve, reject) => {
|
||||||
|
zip!.openReadStream(entry, (err, result) => (err ? reject(err) : resolve(result)));
|
||||||
|
});
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const bytes = Buffer.from(chunk as Uint8Array);
|
||||||
|
total += bytes.length;
|
||||||
|
if (total > MAX_FILE_BYTES) {
|
||||||
|
stream.destroy();
|
||||||
|
throw new Error("Import file exceeds the 32 MiB read limit");
|
||||||
|
}
|
||||||
|
chunks.push(bytes);
|
||||||
|
}
|
||||||
|
assertOpen();
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
isText,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
files: {
|
||||||
|
// A lone file is only ever handed to plugins as text, never as a tree
|
||||||
|
kind: kind === "file" ? "directory" : kind,
|
||||||
|
name,
|
||||||
|
readDir,
|
||||||
|
readFile,
|
||||||
|
async readTextFile(value) {
|
||||||
|
return new TextDecoder("utf-8", { fatal: true }).decode(await readFile(value));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
closed = true;
|
||||||
|
zip?.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compressing a folder puts everything under that folder inside the archive. Present the
|
||||||
|
* folder's contents as the root so plugins see the same tree they would for the directory.
|
||||||
|
*/
|
||||||
|
function unwrapSingleRoot<T extends { type: "file" | "directory" }>(entries: Map<string, T>) {
|
||||||
|
const tops = new Set<string>();
|
||||||
|
for (const entryPath of entries.keys()) {
|
||||||
|
if (entryPath === "") continue;
|
||||||
|
const top = entryPath.split("/")[0]!;
|
||||||
|
if (top === "__MACOSX" || top.startsWith(".")) continue;
|
||||||
|
tops.add(top);
|
||||||
|
}
|
||||||
|
if (tops.size !== 1) return;
|
||||||
|
const [root] = tops;
|
||||||
|
if (entries.get(root!)?.type !== "directory") return;
|
||||||
|
const prefix = `${root}/`;
|
||||||
|
const snapshot = Array.from(entries);
|
||||||
|
for (const [entryPath, value] of snapshot) {
|
||||||
|
if (entryPath === "") continue;
|
||||||
|
entries.delete(entryPath);
|
||||||
|
if (entryPath === root) continue;
|
||||||
|
if (entryPath.startsWith(prefix)) entries.set(entryPath.slice(prefix.length), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep legacy text plugins out of directories, archives, and arbitrary binary inputs. */
|
||||||
|
export async function runImporter(importer: ImporterPlugin, ctx: Context, input: ImportRequest) {
|
||||||
|
if (!importer.onImportSource && input.source?.type === "directory") return null;
|
||||||
|
// Avoid opening archives once for every legacy importer.
|
||||||
|
if (!importer.onImportSource && input.source?.type === "file") {
|
||||||
|
const bytes = Buffer.from(input.source.base64, "base64");
|
||||||
|
if (isZip(bytes)) return null;
|
||||||
|
}
|
||||||
|
const session = await createImportFiles(input);
|
||||||
|
try {
|
||||||
|
if (importer.onImportSource) {
|
||||||
|
const source: ImportSource | null =
|
||||||
|
session.kind === "file"
|
||||||
|
? session.isText
|
||||||
|
? { type: "text", name: session.name, text: session.text }
|
||||||
|
: null
|
||||||
|
: { type: "directory", files: session.files };
|
||||||
|
if (source == null) return null;
|
||||||
|
return await importer.onImportSource(ctx, { source });
|
||||||
|
}
|
||||||
|
if (session.isText && importer.onImport) {
|
||||||
|
return await importer.onImport(ctx, { text: session.text });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onC
|
|||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
justify?: "start" | "center";
|
justify?: "start" | "center";
|
||||||
type?: "button" | "submit";
|
type?: "button" | "submit";
|
||||||
|
/** Submit a form the button is not inside, by that form's id */
|
||||||
|
form?: string;
|
||||||
forDropdown?: boolean;
|
forDropdown?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Bruno importer
|
||||||
|
|
||||||
|
Import Bruno's **Single File (YAML)** or **Bruno Collection (ZIP)** exports, or select a collection directory. Both OpenCollection `1.0.0` YAML collections and legacy `.bru` collections are supported. Individual `.bru` request files also work. A directory/ZIP must contain one collection root (`opencollection.yml`, `opencollection.yaml`, or `bruno.json`); a wrapper directory in the ZIP is allowed. Use **Choose folder** for an on-disk collection, rather than selecting its configuration file alone.
|
||||||
|
|
||||||
|
The importer consumes the shared [file-tree API](../../packages/plugin-runtime-types/IMPORTERS.md). It skips `.git`, `node_modules`, `__MACOSX`, and hidden entries. It does not install dependencies, load dotenv files, or run collection scripts.
|
||||||
|
|
||||||
|
Supported:
|
||||||
|
|
||||||
|
- Nested folders, ordering, descriptions, collection/folder headers, and variables.
|
||||||
|
- Environments, disabled variables, typed values, and secret placeholders. Environment inheritance is flattened into each selectable environment because Yaak does not have nested selectable environments.
|
||||||
|
- HTTP URLs, query/path parameters, headers, raw bodies, URL-encoded forms, multipart files, and selected binary files.
|
||||||
|
- GraphQL query and variables in Yaak's sendable body shape.
|
||||||
|
- gRPC URL, service/method, metadata, and selected message; WebSocket URL, headers, and selected message.
|
||||||
|
- Explicit auth inheritance, no auth, basic, bearer, digest, NTLM, API key, AWS credentials, common OAuth 1.0 header signing, and OAuth 2.0 flows with header tokens.
|
||||||
|
- Plain `{{variable}}` references converted to Yaak templates. Expressions, dynamic variables, and `process.env` references stay unchanged.
|
||||||
|
|
||||||
|
Scripts, assertions, actions, request-scoped variables, examples, advanced settings, and unsupported authentication configurations are retained in resource descriptions for manual migration, not executed. Alternative bodies/messages use the selected variant (or the first when unspecified); retained source variants remain in descriptions. File paths are preserved without reading files; relative paths and gRPC proto files may need to be configured after import. External secret providers, certificates, proxies, OAuth token caches, and WebSocket binary messages are not activated by the importer.
|
||||||
|
|
||||||
|
OpenCollection does not export persistent resource IDs, so Yaak derives the import source keys using its existing fallback matching.
|
||||||
|
|
||||||
|
Implementation references:
|
||||||
|
|
||||||
|
- [OpenCollection types and schema](https://github.com/opencollection-dev/opencollection)
|
||||||
|
- [Bruno's bundled export converter](https://github.com/usebruno/bruno/blob/main/packages/bruno-converters/src/opencollection/bruno-to-opencollection.ts)
|
||||||
|
- [Bruno's Share exporter](https://github.com/usebruno/bruno/blob/main/packages/bruno-app/src/utils/exporters/opencollection.js)
|
||||||
|
|
||||||
|
Legacy `.bru` parsing uses Bruno's official `@usebruno/filestore` (backed by `@usebruno/lang`). Its YAML serializers normalize parsed collections to OpenCollection before they enter the same Yaak conversion path. YAML collections are assembled directly to preserve their fields.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@yaak/importer-bruno",
|
||||||
|
"displayName": "Bruno Importer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Import Bruno collections",
|
||||||
|
"scripts": {
|
||||||
|
"build": "yaakcli build",
|
||||||
|
"dev": "yaakcli dev",
|
||||||
|
"test": "vp test --run tests"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@usebruno/filestore": "^0.12.0",
|
||||||
|
"yaml": "^2.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { HttpRequest } from "@yaakapp/api";
|
||||||
|
import { object, template, text, type Obj } from "./common";
|
||||||
|
|
||||||
|
type Authentication = Pick<HttpRequest, "authentication" | "authenticationType">;
|
||||||
|
export function auth(value: unknown, inheritWhenAbsent = false): Authentication {
|
||||||
|
// Bruno omits auth for 'none'. A folder without request defaults inherits from its parent.
|
||||||
|
if (value === "inherit" || (inheritWhenAbsent && value == null))
|
||||||
|
return { authenticationType: null, authentication: {} };
|
||||||
|
const a = object(value);
|
||||||
|
const type = text(a.type);
|
||||||
|
const fields = (names: string[]) =>
|
||||||
|
Object.fromEntries(names.map((name) => [name, template(a[name])]));
|
||||||
|
switch (type) {
|
||||||
|
case "basic":
|
||||||
|
case "digest":
|
||||||
|
return { authenticationType: type, authentication: fields(["username", "password"]) };
|
||||||
|
case "bearer":
|
||||||
|
return { authenticationType: type, authentication: fields(["token"]) };
|
||||||
|
case "ntlm":
|
||||||
|
return {
|
||||||
|
authenticationType: "windows",
|
||||||
|
authentication: fields(["username", "password", "domain"]),
|
||||||
|
};
|
||||||
|
case "awsv4":
|
||||||
|
if (a.profileName) break;
|
||||||
|
return {
|
||||||
|
authenticationType: type,
|
||||||
|
authentication: fields([
|
||||||
|
"accessKeyId",
|
||||||
|
"secretAccessKey",
|
||||||
|
"sessionToken",
|
||||||
|
"region",
|
||||||
|
"service",
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
case "apikey":
|
||||||
|
return {
|
||||||
|
authenticationType: type,
|
||||||
|
authentication: {
|
||||||
|
...fields(["key", "value"]),
|
||||||
|
location: a.placement === "query" ? "query" : "header",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "oauth1": {
|
||||||
|
const privateKey = object(a.privateKey);
|
||||||
|
if (
|
||||||
|
(a.placement != null && a.placement !== "header") ||
|
||||||
|
a.includeBodyHash === true ||
|
||||||
|
privateKey.type === "file"
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
return {
|
||||||
|
authenticationType: type,
|
||||||
|
authentication: {
|
||||||
|
...fields([
|
||||||
|
"consumerKey",
|
||||||
|
"consumerSecret",
|
||||||
|
"verifier",
|
||||||
|
"timestamp",
|
||||||
|
"nonce",
|
||||||
|
"version",
|
||||||
|
"realm",
|
||||||
|
]),
|
||||||
|
signatureMethod: text(a.signatureMethod) || "HMAC-SHA1",
|
||||||
|
tokenKey: template(a.accessToken),
|
||||||
|
tokenSecret: template(a.accessTokenSecret),
|
||||||
|
callback: template(a.callbackUrl),
|
||||||
|
privateKey: template(typeof a.privateKey === "string" ? a.privateKey : privateKey.value),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "oauth2": {
|
||||||
|
const flow = text(a.flow);
|
||||||
|
const token = object(a.tokenConfig);
|
||||||
|
const placement = object(token.placement);
|
||||||
|
// Yaak currently places OAuth tokens in a header and uses the access token.
|
||||||
|
if (
|
||||||
|
placement.query != null ||
|
||||||
|
token.source === "id_token" ||
|
||||||
|
![
|
||||||
|
"authorization_code",
|
||||||
|
"client_credentials",
|
||||||
|
"resource_owner_password_credentials",
|
||||||
|
"implicit",
|
||||||
|
].includes(flow)
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
const credentials = object(a.credentials);
|
||||||
|
const owner = object(a.resourceOwner);
|
||||||
|
const pkce = object(a.pkce);
|
||||||
|
return {
|
||||||
|
authenticationType: type,
|
||||||
|
authentication: {
|
||||||
|
...fields(["authorizationUrl", "accessTokenUrl", "scope", "state"]),
|
||||||
|
grantType: flow === "resource_owner_password_credentials" ? "password" : flow,
|
||||||
|
clientId: template(credentials.clientId),
|
||||||
|
clientSecret: template(credentials.clientSecret),
|
||||||
|
credentials: credentials.placement === "basic_auth_header" ? "basic" : "body",
|
||||||
|
redirectUri: template(a.callbackUrl),
|
||||||
|
username: template(owner.username),
|
||||||
|
password: template(owner.password),
|
||||||
|
usePkce: a.pkce != null && pkce.disabled !== true,
|
||||||
|
pkceChallengeMethod: pkce.method === "plain" ? "plain" : "S256",
|
||||||
|
headerPrefix: placement.header == null ? "Bearer" : template(placement.header),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { authenticationType: "none", authentication: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manualAuth(value: unknown): Obj | undefined {
|
||||||
|
const a = object(value);
|
||||||
|
if (!Object.keys(a).length) return;
|
||||||
|
if (auth(value).authenticationType === "none") return a;
|
||||||
|
// OAuth settings beyond the core flow are retained for manual migration.
|
||||||
|
if (a.type === "oauth2" && (a.additionalParameters || a.refreshTokenUrl || a.settings)) return a;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { HttpRequest, HttpRequestHeader } from "@yaakapp/api";
|
||||||
|
import { headers, object, rows, selected, template, text } from "./common";
|
||||||
|
|
||||||
|
export function body(
|
||||||
|
value: unknown,
|
||||||
|
graphql: boolean,
|
||||||
|
rawHeaders: unknown,
|
||||||
|
inheritedHeaders: HttpRequestHeader[] = [],
|
||||||
|
): Pick<HttpRequest, "body" | "bodyType" | "headers"> {
|
||||||
|
const b = object(selected(value, "body"));
|
||||||
|
const requestHeaders = headers(rawHeaders);
|
||||||
|
const result = (bodyType: string | null, data: Record<string, unknown>, contentType = "") => {
|
||||||
|
if (
|
||||||
|
contentType &&
|
||||||
|
![...inheritedHeaders, ...requestHeaders].some((h) => h.name.toLowerCase() === "content-type")
|
||||||
|
) {
|
||||||
|
requestHeaders.push({ name: "Content-Type", value: contentType, enabled: true });
|
||||||
|
}
|
||||||
|
return { bodyType, body: data, headers: requestHeaders };
|
||||||
|
};
|
||||||
|
if (graphql) {
|
||||||
|
return result(
|
||||||
|
"graphql",
|
||||||
|
{
|
||||||
|
query: template(b.query),
|
||||||
|
variables: template(
|
||||||
|
typeof b.variables === "object" && b.variables != null
|
||||||
|
? JSON.stringify(b.variables, null, 2)
|
||||||
|
: b.variables,
|
||||||
|
),
|
||||||
|
...(typeof b.operationName === "string"
|
||||||
|
? { operationName: template(b.operationName) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const types: Record<string, [string, string]> = {
|
||||||
|
json: ["application/json", "application/json"],
|
||||||
|
xml: ["application/xml", "application/xml"],
|
||||||
|
text: ["other", "text/plain"],
|
||||||
|
sparql: ["other", "application/sparql-query"],
|
||||||
|
};
|
||||||
|
const type = text(b.type);
|
||||||
|
const rawType = Object.hasOwn(types, type) ? types[type] : undefined;
|
||||||
|
if (rawType) return result(rawType[0], { text: template(b.data) }, rawType[1]);
|
||||||
|
if (type === "form-urlencoded")
|
||||||
|
return result(
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
{ form: headers(b.data) },
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
);
|
||||||
|
if (type === "multipart-form") {
|
||||||
|
const form = rows(b.data).flatMap<Record<string, unknown>>((v) => {
|
||||||
|
const base = {
|
||||||
|
name: template(v.name),
|
||||||
|
enabled: v.disabled !== true,
|
||||||
|
...(v.contentType ? { contentType: template(v.contentType) } : {}),
|
||||||
|
};
|
||||||
|
return v.type === "file"
|
||||||
|
? (Array.isArray(v.value) ? v.value : [v.value]).map((file: unknown) => ({
|
||||||
|
...base,
|
||||||
|
file: template(file),
|
||||||
|
}))
|
||||||
|
: [{ ...base, value: template(v.value) }];
|
||||||
|
});
|
||||||
|
return result("multipart/form-data", { form }, "multipart/form-data");
|
||||||
|
}
|
||||||
|
if (type === "file") {
|
||||||
|
const file = rows(b.data).find((v) => v.selected === true);
|
||||||
|
return file
|
||||||
|
? result("binary", { filePath: template(file.filePath) }, text(file.contentType))
|
||||||
|
: result(null, {});
|
||||||
|
}
|
||||||
|
return result(null, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function urlAndParams(url: unknown, value: unknown) {
|
||||||
|
const params = rows(value);
|
||||||
|
const urlParameters = params.map((p) => ({
|
||||||
|
name: `${p.type === "path" ? ":" : ""}${template(p.name)}`,
|
||||||
|
value: template(p.value),
|
||||||
|
enabled: p.disabled !== true,
|
||||||
|
}));
|
||||||
|
// Bruno stores query parameters in both the URL and the editor rows. Yaak appends rows.
|
||||||
|
const names = new Set(params.filter((p) => p.type !== "path").map((p) => text(p.name)));
|
||||||
|
const raw = text(url);
|
||||||
|
const hash = raw.indexOf("#");
|
||||||
|
const fragment = hash < 0 ? "" : raw.slice(hash);
|
||||||
|
const base = hash < 0 ? raw : raw.slice(0, hash);
|
||||||
|
const q = base.indexOf("?");
|
||||||
|
if (q < 0 || names.size === 0) return { url: template(raw), urlParameters };
|
||||||
|
const query = base
|
||||||
|
.slice(q + 1)
|
||||||
|
.split("&")
|
||||||
|
.filter((pair) => {
|
||||||
|
const key = pair.split("=")[0] ?? "";
|
||||||
|
try {
|
||||||
|
return !names.has(decodeURIComponent(key.replaceAll("+", " ")));
|
||||||
|
} catch {
|
||||||
|
return !names.has(key);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join("&");
|
||||||
|
return {
|
||||||
|
url: template(`${base.slice(0, q)}${query ? `?${query}` : ""}${fragment}`),
|
||||||
|
urlParameters,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { EnvironmentVariable, HttpRequestHeader } from "@yaakapp/api";
|
||||||
|
import YAML from "yaml";
|
||||||
|
|
||||||
|
export type Obj = Record<string, unknown>;
|
||||||
|
export function object(value: unknown): Obj {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : {};
|
||||||
|
}
|
||||||
|
export function rows(value: unknown): Obj[] {
|
||||||
|
return Array.isArray(value) ? value.map(object) : [];
|
||||||
|
}
|
||||||
|
export function text(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function template(value: unknown): string {
|
||||||
|
return text(value).replace(/{{\s*([^{}]+?)\s*}}/g, (full, name: string) => {
|
||||||
|
// Bruno's expressions, process.env and dynamic variables have different semantics.
|
||||||
|
if (
|
||||||
|
name.startsWith("process.env.") ||
|
||||||
|
["true", "false", "null"].includes(name) ||
|
||||||
|
!/^[\p{L}_][\p{L}\p{N}_.-]*$/u.test(name)
|
||||||
|
)
|
||||||
|
return full;
|
||||||
|
return `\${[ ${name} ]}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selected(value: unknown, key: string): unknown {
|
||||||
|
if (!Array.isArray(value)) return value;
|
||||||
|
const variants = rows(value);
|
||||||
|
return (variants.find((v) => v.selected === true) ?? variants[0])?.[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function variables(value: unknown): EnvironmentVariable[] {
|
||||||
|
return rows(value).map((v) => {
|
||||||
|
const raw = selected(v.value, "value");
|
||||||
|
return {
|
||||||
|
name: text(v.name),
|
||||||
|
value: v.secret === true ? "" : template(typeof raw === "object" ? object(raw).data : raw),
|
||||||
|
enabled: v.disabled !== true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function headers(value: unknown): HttpRequestHeader[] {
|
||||||
|
return rows(value).map((h) => ({
|
||||||
|
name: template(h.name),
|
||||||
|
value: template(h.value),
|
||||||
|
enabled: h.disabled !== true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function description(item: Obj, manual: Obj = {}): string {
|
||||||
|
const info = object(item.info);
|
||||||
|
const content = (v: unknown) => (typeof v === "string" ? v : text(object(v).content));
|
||||||
|
const parts = [content(info.description), content(item.docs)].filter(Boolean);
|
||||||
|
const retained = Object.fromEntries(Object.entries(manual).filter(([, v]) => v != null));
|
||||||
|
if (Object.keys(retained).length) {
|
||||||
|
// Keep unsupported executable features as inert text for manual migration.
|
||||||
|
const yaml = YAML.stringify(retained).trimEnd();
|
||||||
|
const fence = "`".repeat(Math.max(3, ...[...yaml.matchAll(/`+/g)].map((m) => m[0].length + 1)));
|
||||||
|
parts.push(
|
||||||
|
`Bruno source fields for manual review (scripts are not executed):\n\n${fence}yaml\n${yaml}\n${fence}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parts.join("\n\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { PartialImportResources } from "@yaakapp/api";
|
||||||
|
import { rows, text, variables, type Obj } from "./common";
|
||||||
|
|
||||||
|
export function environments(
|
||||||
|
value: unknown,
|
||||||
|
workspaceId: string,
|
||||||
|
id: () => string,
|
||||||
|
): PartialImportResources["environments"] {
|
||||||
|
const source = rows(value);
|
||||||
|
const byName = new Map(source.map((e) => [text(e.name), e]));
|
||||||
|
const resolve = (env: Obj, seen = new Set<Obj>()): ReturnType<typeof variables> => {
|
||||||
|
if (seen.has(env)) throw new Error("Bruno environment inheritance contains a cycle");
|
||||||
|
if (seen.size > 100) throw new Error("Bruno environment inheritance is too deeply nested");
|
||||||
|
seen.add(env);
|
||||||
|
const own = variables(env.variables);
|
||||||
|
const parentName = text(env.extends);
|
||||||
|
if (!parentName) return own;
|
||||||
|
const parent = byName.get(parentName);
|
||||||
|
if (!parent)
|
||||||
|
throw new Error(
|
||||||
|
`Bruno environment "${text(env.name)}" extends missing environment "${parentName}"`,
|
||||||
|
);
|
||||||
|
const overrides = new Set(own.filter((v) => v.enabled).map((v) => v.name));
|
||||||
|
return [...resolve(parent, seen).filter((v) => v.enabled && !overrides.has(v.name)), ...own];
|
||||||
|
};
|
||||||
|
// Yaak has one level of selectable environments. Flatten Bruno's extends chain so
|
||||||
|
// inheritance survives the host clearing environment parent IDs during import.
|
||||||
|
return source.map((e, sortPriority) => ({
|
||||||
|
model: "environment",
|
||||||
|
id: id(),
|
||||||
|
workspaceId,
|
||||||
|
name: text(e.name) || "Environment",
|
||||||
|
parentModel: "environment",
|
||||||
|
parentId: null,
|
||||||
|
variables: resolve(e),
|
||||||
|
color: text(e.color) || null,
|
||||||
|
sortPriority,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
parseCollection,
|
||||||
|
parseEnvironment,
|
||||||
|
parseFolder,
|
||||||
|
parseRequest,
|
||||||
|
stringifyCollection,
|
||||||
|
stringifyEnvironment,
|
||||||
|
stringifyFolder,
|
||||||
|
stringifyRequest,
|
||||||
|
} from "@usebruno/filestore";
|
||||||
|
import type { ImportFileEntry, ImportFiles } from "@yaakapp/api";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import { object, rows, text, type Obj } from "./common";
|
||||||
|
|
||||||
|
const ignored = new Set([".git", "node_modules", "__MACOSX"]);
|
||||||
|
const yaml = (value: string) => object(YAML.parse(value, { maxAliasCount: 0 }));
|
||||||
|
|
||||||
|
/** Assemble a collection from the virtual file tree. No direct filesystem access in the plugin. */
|
||||||
|
export function isBruRequestFile(fileName: string): boolean {
|
||||||
|
return /\.bru$/i.test(fileName) && !["collection.bru", "folder.bru"].includes(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lone .bru request, wrapped as a one-request bundled collection */
|
||||||
|
export function singleBruRequest(contents: string, fileName: string): Obj {
|
||||||
|
return {
|
||||||
|
opencollection: "1.0.0",
|
||||||
|
bundled: true,
|
||||||
|
info: { name: fileName.replace(/\.bru$/i, "") },
|
||||||
|
items: [yaml(stringifyRequest(parseRequest(contents, { format: "bru" }), { format: "yml" }))],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const markerNames = ["bruno.json", "opencollection.yml", "opencollection.yaml"];
|
||||||
|
|
||||||
|
export async function readBrunoCollection(files: ImportFiles): Promise<Obj | null> {
|
||||||
|
const listings = new Map<string, ImportFileEntry[]>();
|
||||||
|
const list = async (dir: string) => {
|
||||||
|
let entries = listings.get(dir);
|
||||||
|
if (entries == null) {
|
||||||
|
entries = (await files.readDir(dir)).filter(
|
||||||
|
(e) => !ignored.has(e.name) && !e.name.startsWith("."),
|
||||||
|
);
|
||||||
|
listings.set(dir, entries);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
const has = async (dir: string, name: string) =>
|
||||||
|
(await list(dir)).some((e) => e.type === "file" && e.name === name);
|
||||||
|
const hasMarker = async (dir: string) => {
|
||||||
|
for (const name of markerNames) if (await has(dir, name)) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The selected directory must be the collection itself. Subfolders are only checked to give a
|
||||||
|
// better error than "not supported" when the user picked the folder above it.
|
||||||
|
const rootPath = "";
|
||||||
|
if (!(await hasMarker(rootPath))) {
|
||||||
|
const top = await list(rootPath);
|
||||||
|
const [only] = top;
|
||||||
|
if (top.length === 1 && only?.type === "file" && isBruRequestFile(only.name)) {
|
||||||
|
return singleBruRequest(await files.readTextFile(only.path), only.name);
|
||||||
|
}
|
||||||
|
for (const entry of top) {
|
||||||
|
if (entry.type === "directory" && (await hasMarker(entry.path))) {
|
||||||
|
throw new Error(
|
||||||
|
"This folder contains Bruno collections. Select the collection directory itself.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const at = (name: string) => name;
|
||||||
|
const yamlMarker = (await has(rootPath, "opencollection.yml"))
|
||||||
|
? at("opencollection.yml")
|
||||||
|
: (await has(rootPath, "opencollection.yaml"))
|
||||||
|
? at("opencollection.yaml")
|
||||||
|
: undefined;
|
||||||
|
const isYaml = yamlMarker != null;
|
||||||
|
const root = isYaml
|
||||||
|
? yaml(await files.readTextFile(yamlMarker))
|
||||||
|
: object(JSON.parse(await files.readTextFile(at("bruno.json"))));
|
||||||
|
if (isYaml && root.opencollection !== "1.0.0")
|
||||||
|
throw new Error(`Unsupported Bruno collection format version: ${text(root.opencollection)}`);
|
||||||
|
const load = async (p: string, parse: (s: string) => Obj) => {
|
||||||
|
try {
|
||||||
|
return parse(await files.readTextFile(p));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to import ${p}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const assemble = async (dir: string): Promise<Obj[]> => {
|
||||||
|
const items: Obj[] = [];
|
||||||
|
for (const entry of await list(dir)) {
|
||||||
|
if (dir === rootPath && entry.name === "environments") continue;
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
let configPath: string | undefined;
|
||||||
|
for (const name of isYaml ? ["folder.yml", "folder.yaml"] : ["folder.bru"]) {
|
||||||
|
if (await has(entry.path, name)) {
|
||||||
|
configPath = `${entry.path}/${name}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const folder = configPath
|
||||||
|
? await load(configPath, (s) =>
|
||||||
|
isYaml
|
||||||
|
? yaml(s)
|
||||||
|
: yaml(stringifyFolder(parseFolder(s, { format: "bru" }), { format: "yml" })),
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
const children = await assemble(entry.path);
|
||||||
|
items.push({
|
||||||
|
...folder,
|
||||||
|
info: { name: entry.name, ...object(folder.info), type: "folder" },
|
||||||
|
items: children,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
isYaml &&
|
||||||
|
/\.ya?ml$/i.test(entry.name) &&
|
||||||
|
!["opencollection.yml", "opencollection.yaml", "folder.yml", "folder.yaml"].includes(
|
||||||
|
entry.name,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const item = await load(entry.path, yaml);
|
||||||
|
if (object(item.info).type || item.http || item.graphql || item.grpc || item.websocket)
|
||||||
|
items.push(item);
|
||||||
|
} else if (
|
||||||
|
!isYaml &&
|
||||||
|
/\.bru$/i.test(entry.name) &&
|
||||||
|
!["collection.bru", "folder.bru"].includes(entry.name)
|
||||||
|
) {
|
||||||
|
items.push(
|
||||||
|
await load(entry.path, (s) =>
|
||||||
|
yaml(stringifyRequest(parseRequest(s, { format: "bru" }), { format: "yml" })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
const environments: Obj[] = [];
|
||||||
|
const hasEnvDir = (await list(rootPath)).some(
|
||||||
|
(e) => e.type === "directory" && e.name === "environments",
|
||||||
|
);
|
||||||
|
const envFiles = hasEnvDir
|
||||||
|
? (await list(at("environments"))).filter(
|
||||||
|
(e) => e.type === "file" && (isYaml ? /\.ya?ml$/i.test(e.name) : /\.bru$/i.test(e.name)),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
for (const p of envFiles.map((e) => e.path)) {
|
||||||
|
const env = await load(p, (s) =>
|
||||||
|
isYaml
|
||||||
|
? yaml(s)
|
||||||
|
: yaml(stringifyEnvironment(parseEnvironment(s, { format: "bru" }), { format: "yml" })),
|
||||||
|
);
|
||||||
|
environments.push({ ...env, name: path.posix.basename(p).replace(/\.(?:bru|ya?ml)$/i, "") });
|
||||||
|
}
|
||||||
|
const items = await assemble(rootPath);
|
||||||
|
if (isYaml)
|
||||||
|
return {
|
||||||
|
...root,
|
||||||
|
bundled: true,
|
||||||
|
items: [...rows(root.items), ...items],
|
||||||
|
config: {
|
||||||
|
...object(root.config),
|
||||||
|
environments: [...rows(object(root.config).environments), ...environments],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const collectionRoot = (await has(rootPath, "collection.bru"))
|
||||||
|
? parseCollection(await files.readTextFile(at("collection.bru")), { format: "bru" })
|
||||||
|
: undefined;
|
||||||
|
const normalized = yaml(
|
||||||
|
stringifyCollection(
|
||||||
|
collectionRoot ?? {},
|
||||||
|
{ ...root, name: text(root.name) || files.name },
|
||||||
|
{ format: "yml" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
bundled: true,
|
||||||
|
items,
|
||||||
|
config: { ...object(normalized.config), environments },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import type { HttpRequestHeader, PartialImportResources, PluginDefinition } from "@yaakapp/api";
|
||||||
|
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import { auth, manualAuth } from "./auth";
|
||||||
|
import { body, urlAndParams } from "./body";
|
||||||
|
import { environments } from "./environments";
|
||||||
|
import { isBruRequestFile, readBrunoCollection, singleBruRequest } from "./files";
|
||||||
|
import {
|
||||||
|
description,
|
||||||
|
headers,
|
||||||
|
object,
|
||||||
|
rows,
|
||||||
|
selected,
|
||||||
|
template,
|
||||||
|
text,
|
||||||
|
variables,
|
||||||
|
type Obj,
|
||||||
|
} from "./common";
|
||||||
|
|
||||||
|
export const plugin: PluginDefinition = {
|
||||||
|
importer: {
|
||||||
|
name: "Bruno",
|
||||||
|
description: "Import Bruno collections",
|
||||||
|
async onImportSource(_ctx, { source }) {
|
||||||
|
if (source.type === "text") {
|
||||||
|
return isBruRequestFile(source.name)
|
||||||
|
? convertBruno(YAML.stringify(singleBruRequest(source.text, source.name)))
|
||||||
|
: convertBruno(source.text);
|
||||||
|
}
|
||||||
|
const root = await readBrunoCollection(source.files);
|
||||||
|
return root ? convertBruno(YAML.stringify(root)) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function convertBruno(contents: string): ImportPluginResponse {
|
||||||
|
let root: Obj;
|
||||||
|
try {
|
||||||
|
// Bundled exports don't need aliases. Disallow cycles before walking the item tree.
|
||||||
|
root = object(YAML.parse(contents, { maxAliasCount: 0 }));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof root.opencollection !== "string") return null;
|
||||||
|
if (root.opencollection !== "1.0.0")
|
||||||
|
throw new Error(`Unsupported Bruno collection format version: ${root.opencollection}`);
|
||||||
|
if (root.bundled === false || (!Array.isArray(root.items) && root.bundled !== true)) {
|
||||||
|
throw new Error(
|
||||||
|
"Select the collection using Choose folder, or export a single YAML file from Bruno's Share menu. A collection configuration file alone does not contain the requests.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resources: PartialImportResources = {
|
||||||
|
workspaces: [],
|
||||||
|
environments: [],
|
||||||
|
folders: [],
|
||||||
|
httpRequests: [],
|
||||||
|
grpcRequests: [],
|
||||||
|
websocketRequests: [],
|
||||||
|
};
|
||||||
|
let nextId = 0;
|
||||||
|
const id = () => `GENERATE_ID::${++nextId}`;
|
||||||
|
const workspaceId = id();
|
||||||
|
const defaults = object(root.request);
|
||||||
|
const config = object(root.config);
|
||||||
|
const environmentSettings = rows(config.environments)
|
||||||
|
.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
externalSecrets: e.externalSecrets,
|
||||||
|
dotEnvFilePath: e.dotEnvFilePath,
|
||||||
|
clientCertificates: e.clientCertificates,
|
||||||
|
}))
|
||||||
|
.filter((e) => e.externalSecrets || e.dotEnvFilePath || e.clientCertificates);
|
||||||
|
resources.workspaces.push({
|
||||||
|
model: "workspace",
|
||||||
|
id: workspaceId,
|
||||||
|
name: text(object(root.info).name) || "Bruno Import",
|
||||||
|
headers: headers(defaults.headers),
|
||||||
|
...auth(defaults.auth),
|
||||||
|
description: description(root, {
|
||||||
|
scripts: defaults.scripts,
|
||||||
|
actions: defaults.actions,
|
||||||
|
settings: defaults.settings,
|
||||||
|
auth: manualAuth(defaults.auth),
|
||||||
|
...(environmentSettings.length ? { environmentSettings } : {}),
|
||||||
|
...Object.fromEntries(Object.entries(config).filter(([key]) => key !== "environments")),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
resources.environments.push({
|
||||||
|
model: "environment",
|
||||||
|
id: id(),
|
||||||
|
workspaceId,
|
||||||
|
name: "Base Environment",
|
||||||
|
parentModel: "workspace",
|
||||||
|
parentId: null,
|
||||||
|
variables: variables(defaults.variables),
|
||||||
|
});
|
||||||
|
resources.environments.push(...environments(config.environments, workspaceId, id));
|
||||||
|
|
||||||
|
const visit = (
|
||||||
|
items: unknown,
|
||||||
|
folderId: string | null,
|
||||||
|
depth = 0,
|
||||||
|
inheritedHeaders: HttpRequestHeader[] = headers(defaults.headers),
|
||||||
|
) => {
|
||||||
|
if (depth > 100) throw new Error("Bruno folders are nested too deeply");
|
||||||
|
for (const [index, item] of rows(items).entries()) {
|
||||||
|
const info = object(item.info);
|
||||||
|
const type = text(info.type);
|
||||||
|
const common = {
|
||||||
|
id: id(),
|
||||||
|
workspaceId,
|
||||||
|
folderId,
|
||||||
|
name: text(info.name) || "Untitled",
|
||||||
|
sortPriority: typeof info.seq === "number" && Number.isFinite(info.seq) ? info.seq : index,
|
||||||
|
};
|
||||||
|
if (type === "folder" || (type === "" && Array.isArray(item.items))) {
|
||||||
|
const request = object(item.request);
|
||||||
|
resources.folders.push({
|
||||||
|
...common,
|
||||||
|
model: "folder",
|
||||||
|
headers: headers(request.headers),
|
||||||
|
...auth(request.auth, item.request == null),
|
||||||
|
description: description(item, {
|
||||||
|
scripts: request.scripts,
|
||||||
|
actions: request.actions,
|
||||||
|
settings: request.settings,
|
||||||
|
auth: manualAuth(request.auth),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (Array.isArray(request.variables) && request.variables.length) {
|
||||||
|
resources.environments.push({
|
||||||
|
model: "environment",
|
||||||
|
id: id(),
|
||||||
|
workspaceId,
|
||||||
|
name: "Folder Environment",
|
||||||
|
parentModel: "folder",
|
||||||
|
parentId: common.id,
|
||||||
|
variables: variables(request.variables),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visit(item.items, common.id, depth + 1, [...inheritedHeaders, ...headers(request.headers)]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const r = object(item[type]);
|
||||||
|
const settings = object(item.settings);
|
||||||
|
const desc = description(item, {
|
||||||
|
runtime: item.runtime,
|
||||||
|
examples: item.examples,
|
||||||
|
settings: item.settings,
|
||||||
|
auth: manualAuth(r.auth),
|
||||||
|
...(Array.isArray(r.body) || object(r.body).type === "file"
|
||||||
|
? { bodyVariants: r.body }
|
||||||
|
: type === "http" &&
|
||||||
|
r.body != null &&
|
||||||
|
!["json", "xml", "text", "sparql", "form-urlencoded", "multipart-form"].includes(
|
||||||
|
text(object(r.body).type),
|
||||||
|
)
|
||||||
|
? { unsupportedBody: r.body }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
if (type === "http" || type === "graphql") {
|
||||||
|
resources.httpRequests.push({
|
||||||
|
...common,
|
||||||
|
model: "http_request",
|
||||||
|
description: desc,
|
||||||
|
...urlAndParams(r.url, r.params),
|
||||||
|
method: text(r.method) || (type === "graphql" ? "POST" : "GET"),
|
||||||
|
...auth(r.auth),
|
||||||
|
...body(r.body, type === "graphql", r.headers, inheritedHeaders),
|
||||||
|
...(typeof settings.followRedirects === "boolean"
|
||||||
|
? { settingFollowRedirects: { enabled: true, value: settings.followRedirects } }
|
||||||
|
: {}),
|
||||||
|
...(typeof settings.timeout === "number" &&
|
||||||
|
Number.isInteger(settings.timeout) &&
|
||||||
|
settings.timeout >= 0 &&
|
||||||
|
settings.timeout <= 2147483647
|
||||||
|
? { settingRequestTimeout: { enabled: true, value: settings.timeout } }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
} else if (type === "grpc") {
|
||||||
|
const [service, method] = text(r.method).replace(/^\//, "").split("/");
|
||||||
|
resources.grpcRequests.push({
|
||||||
|
...common,
|
||||||
|
model: "grpc_request",
|
||||||
|
url: template(r.url),
|
||||||
|
service: service || null,
|
||||||
|
method: method || null,
|
||||||
|
message: template(selected(r.message, "message")),
|
||||||
|
metadata: headers(r.metadata),
|
||||||
|
...auth(r.auth),
|
||||||
|
description: description(item, {
|
||||||
|
runtime: item.runtime,
|
||||||
|
protoFilePath: r.protoFilePath,
|
||||||
|
messageVariants: Array.isArray(r.message) ? r.message : undefined,
|
||||||
|
auth: manualAuth(r.auth),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (type === "websocket") {
|
||||||
|
const message = object(selected(r.message, "message"));
|
||||||
|
resources.websocketRequests.push({
|
||||||
|
...common,
|
||||||
|
model: "websocket_request",
|
||||||
|
url: template(r.url),
|
||||||
|
headers: headers(r.headers),
|
||||||
|
...auth(r.auth),
|
||||||
|
message: message.type === "binary" ? "" : template(message.data),
|
||||||
|
description: description(item, {
|
||||||
|
runtime: item.runtime,
|
||||||
|
settings: item.settings,
|
||||||
|
message: r.message,
|
||||||
|
auth: manualAuth(r.auth),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unknown items remain visible in the import instead of silently disappearing.
|
||||||
|
resources.folders.push({
|
||||||
|
...common,
|
||||||
|
model: "folder",
|
||||||
|
description: description(item, { unsupportedItem: item }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(root.items, null);
|
||||||
|
// OpenCollection has no persistent resource IDs. Let the host derive source keys.
|
||||||
|
return { resources };
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { Context, ImportFiles } from "@yaakapp/api";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { createImportFiles, runImporter } from "../../../packages/plugin-runtime/src/importFiles";
|
||||||
|
import { plugin } from "../src";
|
||||||
|
import { readBrunoCollection } from "../src/files";
|
||||||
|
|
||||||
|
const ctx = {} as Context;
|
||||||
|
|
||||||
|
describe.each(["opencollection", "legacy"])("Bruno %s file tree", (name) => {
|
||||||
|
test("imports the same resources from a ZIP or directory, including nested folders and environments", async () => {
|
||||||
|
const directory = fileURLToPath(new URL(`./fixtures/${name}/Demo`, import.meta.url));
|
||||||
|
const bytes = await readFile(new URL(`./fixtures/${name}.zip`, import.meta.url));
|
||||||
|
const zipped = await runImporter(plugin.importer!, ctx, {
|
||||||
|
content: "",
|
||||||
|
source: { type: "file", name: `${name}.zip`, base64: bytes.toString("base64") },
|
||||||
|
});
|
||||||
|
const unzipped = await runImporter(plugin.importer!, ctx, {
|
||||||
|
content: "",
|
||||||
|
source: { type: "directory", path: directory },
|
||||||
|
});
|
||||||
|
expect(zipped).toEqual(unzipped);
|
||||||
|
expect(zipped?.resources.workspaces[0]?.name).toBe(
|
||||||
|
name === "legacy" ? "Legacy Bruno" : "Directory YAML",
|
||||||
|
);
|
||||||
|
expect(zipped?.resources.httpRequests).toHaveLength(2);
|
||||||
|
expect(zipped?.resources.httpRequests.find((r) => r.name === "Get Users")).toMatchObject({
|
||||||
|
url: "${[ host ]}/users",
|
||||||
|
authenticationType: null,
|
||||||
|
});
|
||||||
|
expect(zipped?.resources.httpRequests.find((r) => r.name === "Query")).toMatchObject({
|
||||||
|
bodyType: "graphql",
|
||||||
|
body: { query: "query { users { id } }" },
|
||||||
|
});
|
||||||
|
expect(zipped?.resources.environments[0]?.variables).toContainEqual({
|
||||||
|
name: "host",
|
||||||
|
value: "https://example.com",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(zipped?.resources.environments[1]?.variables).toContainEqual({
|
||||||
|
name: "token",
|
||||||
|
value: "sample",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(zipped?.resources.folders.find((f) => f.name === "Users")).toMatchObject({
|
||||||
|
sortPriority: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still imports a bundled YAML file through the new runtime entry point", async () => {
|
||||||
|
const bytes = await readFile(new URL("./fixtures/collection.yaml", import.meta.url));
|
||||||
|
const result = await runImporter(plugin.importer!, ctx, {
|
||||||
|
content: bytes.toString(),
|
||||||
|
source: { type: "file", name: "collection.yaml", base64: bytes.toString("base64") },
|
||||||
|
});
|
||||||
|
expect(result?.resources.workspaces[0]?.name).toBe("Example API");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports pasted YAML through the file hook's virtual input file", async () => {
|
||||||
|
const content = await readFile(new URL("./fixtures/collection.yaml", import.meta.url), "utf8");
|
||||||
|
const result = await runImporter(plugin.importer!, ctx, { content });
|
||||||
|
expect(result?.resources.workspaces[0]?.name).toBe("Example API");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports an individual .bru request", async () => {
|
||||||
|
const bytes = await readFile(new URL("./fixtures/legacy/Demo/Users/get.bru", import.meta.url));
|
||||||
|
const result = await runImporter(plugin.importer!, ctx, {
|
||||||
|
content: bytes.toString(),
|
||||||
|
source: { type: "file", name: "get.bru", base64: bytes.toString("base64") },
|
||||||
|
});
|
||||||
|
expect(result?.resources.httpRequests[0]?.name).toBe("Get Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not claim an unrelated binary file", async () => {
|
||||||
|
expect(
|
||||||
|
await runImporter(plugin.importer!, ctx, {
|
||||||
|
content: "",
|
||||||
|
source: { type: "file", name: "body.bin", base64: "/w==" },
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a folder of collections before reading their contents", async () => {
|
||||||
|
const files: ImportFiles = {
|
||||||
|
name: "collections",
|
||||||
|
kind: "directory",
|
||||||
|
async readDir(dir = "") {
|
||||||
|
return dir === ""
|
||||||
|
? ["a", "b"].map((name) => ({ name, path: name, type: "directory" as const }))
|
||||||
|
: [{ name: "bruno.json", path: `${dir}/bruno.json`, type: "file" }];
|
||||||
|
},
|
||||||
|
async readFile() {
|
||||||
|
throw new Error("should not read");
|
||||||
|
},
|
||||||
|
async readTextFile() {
|
||||||
|
throw new Error("should not read");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await expect(readBrunoCollection(files)).rejects.toThrow("Select the collection directory");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports parse errors with the relative file path", async () => {
|
||||||
|
const session = await createImportFiles({
|
||||||
|
content: "meta { invalid",
|
||||||
|
source: {
|
||||||
|
type: "file",
|
||||||
|
name: "broken.bru",
|
||||||
|
base64: Buffer.from("meta { invalid").toString("base64"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(readBrunoCollection(session.files)).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Synthetic bundled export following Bruno's OpenCollection 1.0.0 exporter.
|
||||||
|
opencollection: 1.0.0
|
||||||
|
info:
|
||||||
|
name: Example API
|
||||||
|
request:
|
||||||
|
auth:
|
||||||
|
type: bearer
|
||||||
|
token: "{{token}}"
|
||||||
|
headers:
|
||||||
|
- name: X-Collection
|
||||||
|
value: "{{client}}"
|
||||||
|
variables:
|
||||||
|
- name: host
|
||||||
|
value: https://example.com
|
||||||
|
- name: client
|
||||||
|
value: yaak
|
||||||
|
config:
|
||||||
|
environments:
|
||||||
|
- name: Development
|
||||||
|
extends: Shared
|
||||||
|
variables:
|
||||||
|
- name: host
|
||||||
|
value: https://dev.example.com
|
||||||
|
- name: token
|
||||||
|
secret: true
|
||||||
|
- name: disabled
|
||||||
|
value: ignored
|
||||||
|
disabled: true
|
||||||
|
- name: Shared
|
||||||
|
color: "#123456"
|
||||||
|
variables:
|
||||||
|
- name: count
|
||||||
|
value:
|
||||||
|
type: number
|
||||||
|
data: "42"
|
||||||
|
items:
|
||||||
|
- info:
|
||||||
|
name: Users
|
||||||
|
type: folder
|
||||||
|
seq: 2
|
||||||
|
request:
|
||||||
|
auth: inherit
|
||||||
|
variables:
|
||||||
|
- name: userId
|
||||||
|
value: "123"
|
||||||
|
items:
|
||||||
|
- info:
|
||||||
|
name: Get User
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
description: Fetch one user
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: "{{host}}/users/:id?expand=profile&keep=1#details"
|
||||||
|
auth: inherit
|
||||||
|
params:
|
||||||
|
- name: id
|
||||||
|
value: "{{userId}}"
|
||||||
|
type: path
|
||||||
|
- name: expand
|
||||||
|
value: profile
|
||||||
|
type: query
|
||||||
|
- name: inactive
|
||||||
|
value: "true"
|
||||||
|
type: query
|
||||||
|
disabled: true
|
||||||
|
headers:
|
||||||
|
- name: X-Disabled
|
||||||
|
value: ignored
|
||||||
|
disabled: true
|
||||||
|
settings:
|
||||||
|
timeout: 2000
|
||||||
|
followRedirects: false
|
||||||
|
- info:
|
||||||
|
name: GraphQL
|
||||||
|
type: graphql
|
||||||
|
seq: 1
|
||||||
|
graphql:
|
||||||
|
method: POST
|
||||||
|
url: "{{host}}/graphql"
|
||||||
|
auth:
|
||||||
|
type: basic
|
||||||
|
username: "{{client}}"
|
||||||
|
password: example
|
||||||
|
body:
|
||||||
|
query: "query Get($id: ID!) { node(id: $id) { id } }"
|
||||||
|
variables: '{"id":"{{userId}}"}'
|
||||||
|
- info:
|
||||||
|
name: Public
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: "{{host}}/status"
|
||||||
|
- info:
|
||||||
|
name: Upload
|
||||||
|
type: http
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: "{{host}}/upload"
|
||||||
|
body:
|
||||||
|
type: multipart-form
|
||||||
|
data:
|
||||||
|
- name: label
|
||||||
|
type: text
|
||||||
|
value: "{{client}}"
|
||||||
|
- name: files
|
||||||
|
type: file
|
||||||
|
value: [./first.txt, ./second.txt]
|
||||||
|
contentType: text/plain
|
||||||
|
- info:
|
||||||
|
name: Hello RPC
|
||||||
|
type: grpc
|
||||||
|
grpc:
|
||||||
|
url: localhost:50051
|
||||||
|
method: /example.Greeter/SayHello
|
||||||
|
message: '{"name":"{{client}}"}'
|
||||||
|
metadata:
|
||||||
|
- name: authorization
|
||||||
|
value: "Bearer {{token}}"
|
||||||
|
- info:
|
||||||
|
name: Events
|
||||||
|
type: websocket
|
||||||
|
websocket:
|
||||||
|
url: wss://example.com/events
|
||||||
|
auth: inherit
|
||||||
|
message:
|
||||||
|
type: json
|
||||||
|
data: '{"subscribe":"{{client}}"}'
|
||||||
|
docs:
|
||||||
|
type: text/markdown
|
||||||
|
content: Synthetic migration fixture.
|
||||||
|
bundled: true
|
||||||
|
extensions:
|
||||||
|
bruno:
|
||||||
|
exportedUsing: Bruno/3.0.0
|
||||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
meta {
|
||||||
|
name: Users
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Users
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
get {
|
||||||
|
url: {{host}}/users
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
meta {
|
||||||
|
name: Query
|
||||||
|
type: graphql
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
url: {{host}}/graphql
|
||||||
|
body: graphql
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
body:graphql {
|
||||||
|
query { users { id } }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "version": "1", "name": "Legacy Bruno", "type": "collection" }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
vars:pre-request {
|
||||||
|
host: https://example.com
|
||||||
|
}
|
||||||
|
auth {
|
||||||
|
mode: bearer
|
||||||
|
}
|
||||||
|
auth:bearer {
|
||||||
|
token: {{token}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
vars {
|
||||||
|
token: sample
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
info:
|
||||||
|
name: Users
|
||||||
|
type: folder
|
||||||
|
seq: 3
|
||||||
|
request:
|
||||||
|
auth: inherit
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
info:
|
||||||
|
name: Get Users
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: "{{host}}/users"
|
||||||
|
auth: inherit
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
info:
|
||||||
|
name: Query
|
||||||
|
type: graphql
|
||||||
|
graphql:
|
||||||
|
url: "{{host}}/graphql"
|
||||||
|
body:
|
||||||
|
query: "query { users { id } }"
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
name: Local
|
||||||
|
variables:
|
||||||
|
- name: token
|
||||||
|
value: sample
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
opencollection: 1.0.0
|
||||||
|
info:
|
||||||
|
name: Directory YAML
|
||||||
|
request:
|
||||||
|
variables:
|
||||||
|
- name: host
|
||||||
|
value: https://example.com
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Anoop M D, Anusree P S and Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Upstream Bruno fixtures
|
||||||
|
|
||||||
|
These two complete fixture directories are copied byte-for-byte from
|
||||||
|
[usebruno/bruno](https://github.com/usebruno/bruno) at commit
|
||||||
|
[`4eb7e585f0fb318b2ef8d88b34635da633f3acf4`](https://github.com/usebruno/bruno/commit/4eb7e585f0fb318b2ef8d88b34635da633f3acf4).
|
||||||
|
|
||||||
|
- `bru-migration/`: [`tests/collection/migrate-to-yml/fixtures/collection`](https://github.com/usebruno/bruno/tree/4eb7e585f0fb318b2ef8d88b34635da633f3acf4/tests/collection/migrate-to-yml/fixtures/collection). Exercises collection/folder headers, environments, request variables, JSON bodies, and retained scripts/assertions.
|
||||||
|
- `yaml-docs/`: [`packages/bruno-cli/tests/commands/docs/fixtures/yml-collection`](https://github.com/usebruno/bruno/tree/4eb7e585f0fb318b2ef8d88b34635da633f3acf4/packages/bruno-cli/tests/commands/docs/fixtures/yml-collection). Exercises YAML collection metadata, ordering, and repeated request names in different folders.
|
||||||
|
|
||||||
|
The upstream MIT license is preserved in [LICENSE.md](./LICENSE.md). This directory
|
||||||
|
is excluded from formatting so upstream inputs remain unchanged. To refresh,
|
||||||
|
copy both complete directories from a new pinned revision and update these links;
|
||||||
|
do not edit the inputs to accommodate importer behavior.
|
||||||
|
|
||||||
|
The tests only import the fixtures. They do not send requests or execute the
|
||||||
|
embedded Bruno scripts. Existing synthetic fixtures cover additional edge cases.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
meta {
|
||||||
|
name: api
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
X-Folder-Header: api-folder
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
meta {
|
||||||
|
name: get-users
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{host}}/api/echo/json
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
Accept: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
// pre-request script in folder request
|
||||||
|
bru.setVar("requestVar", "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status: eq 200
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"name": "migration-test",
|
||||||
|
"type": "collection",
|
||||||
|
"collectionVersion": "v9.9.9",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
headers {
|
||||||
|
X-Collection-Header: migration-test
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
// collection pre-request script
|
||||||
|
bru.setVar("collectionVar", "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
# Migration Test Collection
|
||||||
|
This collection is used to test bru to yml migration.
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
vars {
|
||||||
|
host: http://localhost:8081
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
vars {
|
||||||
|
host: https://api.example.com
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: ping
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{host}}/ping
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status: eq 200
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
meta {
|
||||||
|
name: post-json
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{host}}/api/echo/json
|
||||||
|
body: json
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
headers {
|
||||||
|
Content-Type: application/json
|
||||||
|
}
|
||||||
|
|
||||||
|
body:json {
|
||||||
|
{
|
||||||
|
"message": "hello from migration test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status: eq 200
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
info:
|
||||||
|
name: folder_1
|
||||||
|
type: folder
|
||||||
|
seq: 1
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
info:
|
||||||
|
name: request_1
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: https://api.example.com/folder-1/one
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
info:
|
||||||
|
name: folder_2
|
||||||
|
type: folder
|
||||||
|
seq: 2
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
info:
|
||||||
|
name: request_1
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: https://api.example.com/folder-2/one
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
opencollection: "1.0.0"
|
||||||
|
info:
|
||||||
|
name: ymlcollection
|
||||||
|
version: "3.4.0"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
info:
|
||||||
|
name: request_1
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: https://api.example.com/one
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
info:
|
||||||
|
name: request_2
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: https://api.example.com/two
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import { convertBruno } from "../src";
|
||||||
|
import { auth } from "../src/auth";
|
||||||
|
import { body, urlAndParams } from "../src/body";
|
||||||
|
import { template, variables } from "../src/common";
|
||||||
|
|
||||||
|
function collection(items: unknown[] = [], extra: Record<string, unknown> = {}) {
|
||||||
|
return { opencollection: "1.0.0", info: { name: "Test" }, bundled: true, items, ...extra };
|
||||||
|
}
|
||||||
|
const fixture = readFileSync(new URL("./fixtures/collection.yaml", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
describe("Bruno OpenCollection importer", () => {
|
||||||
|
test("imports a bundled export with folders, scopes, disabled rows, and all four request types", () => {
|
||||||
|
const imported = convertBruno(fixture)!;
|
||||||
|
const {
|
||||||
|
workspaces: [workspace],
|
||||||
|
folders: [folder],
|
||||||
|
environments,
|
||||||
|
httpRequests,
|
||||||
|
grpcRequests: [grpc],
|
||||||
|
websocketRequests: [ws],
|
||||||
|
} = imported.resources;
|
||||||
|
expect(workspace).toMatchObject({
|
||||||
|
name: "Example API",
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: { token: "${[ token ]}" },
|
||||||
|
description: "Synthetic migration fixture.",
|
||||||
|
});
|
||||||
|
expect(folder).toMatchObject({ name: "Users", sortPriority: 2, authenticationType: null });
|
||||||
|
const [base, dev, shared, folderEnv] = environments;
|
||||||
|
expect(base).toMatchObject({
|
||||||
|
parentModel: "workspace",
|
||||||
|
parentId: null,
|
||||||
|
variables: [
|
||||||
|
{ name: "host", value: "https://example.com" },
|
||||||
|
{ name: "client", value: "yaak" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(dev).toMatchObject({
|
||||||
|
parentModel: "environment",
|
||||||
|
parentId: null,
|
||||||
|
variables: [
|
||||||
|
{ name: "count", value: "42" },
|
||||||
|
{ name: "host" },
|
||||||
|
{ name: "token", value: "" },
|
||||||
|
{ name: "disabled", enabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(shared).toMatchObject({ color: "#123456", variables: [{ name: "count", value: "42" }] });
|
||||||
|
expect(folderEnv).toMatchObject({ parentModel: "folder", parentId: folder!.id });
|
||||||
|
const [get, graphql, publicRequest, upload] = httpRequests;
|
||||||
|
expect(get).toMatchObject({
|
||||||
|
folderId: folder!.id,
|
||||||
|
authenticationType: null,
|
||||||
|
sortPriority: 3,
|
||||||
|
url: "${[ host ]}/users/:id?keep=1#details",
|
||||||
|
urlParameters: [
|
||||||
|
{ name: ":id", value: "${[ userId ]}", enabled: true },
|
||||||
|
{ name: "expand", value: "profile", enabled: true },
|
||||||
|
{ name: "inactive", value: "true", enabled: false },
|
||||||
|
],
|
||||||
|
headers: [{ name: "X-Disabled", value: "ignored", enabled: false }],
|
||||||
|
settingFollowRedirects: { enabled: true, value: false },
|
||||||
|
settingRequestTimeout: { enabled: true, value: 2000 },
|
||||||
|
});
|
||||||
|
expect(graphql).toMatchObject({
|
||||||
|
bodyType: "graphql",
|
||||||
|
method: "POST",
|
||||||
|
authenticationType: "basic",
|
||||||
|
authentication: { username: "${[ client ]}" },
|
||||||
|
body: {
|
||||||
|
query: "query Get($id: ID!) { node(id: $id) { id } }",
|
||||||
|
variables: '{"id":"${[ userId ]}"}',
|
||||||
|
},
|
||||||
|
headers: [{ name: "Content-Type", value: "application/json", enabled: true }],
|
||||||
|
});
|
||||||
|
expect(publicRequest).toMatchObject({ authenticationType: "none", folderId: null });
|
||||||
|
expect(upload?.body?.form).toEqual([
|
||||||
|
{ name: "label", value: "${[ client ]}", enabled: true },
|
||||||
|
{ name: "files", file: "./first.txt", contentType: "text/plain", enabled: true },
|
||||||
|
{ name: "files", file: "./second.txt", contentType: "text/plain", enabled: true },
|
||||||
|
]);
|
||||||
|
expect(grpc).toMatchObject({
|
||||||
|
service: "example.Greeter",
|
||||||
|
method: "SayHello",
|
||||||
|
message: '{"name":"${[ client ]}"}',
|
||||||
|
});
|
||||||
|
expect(ws).toMatchObject({
|
||||||
|
message: '{"subscribe":"${[ client ]}"}',
|
||||||
|
authenticationType: null,
|
||||||
|
});
|
||||||
|
const all = Object.values(imported.resources).flat();
|
||||||
|
expect(new Set(all.map((r) => r.id)).size).toBe(all.length);
|
||||||
|
expect(
|
||||||
|
all.filter((r) => r.model !== "workspace").every((r) => r.workspaceId === workspace!.id),
|
||||||
|
).toBe(true);
|
||||||
|
expect(imported.sourceKeys).toBeUndefined();
|
||||||
|
expect(convertBruno(fixture)).toEqual(imported);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts JSON serialization and inline collections without a bundled flag", () => {
|
||||||
|
expect(convertBruno(JSON.stringify(YAML.parse(fixture)))).toEqual(convertBruno(fixture));
|
||||||
|
expect(
|
||||||
|
convertBruno(JSON.stringify({ opencollection: "1.0.0", items: [] }))?.resources.workspaces,
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(convertBruno(JSON.stringify(collection()))?.resources.httpRequests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
"",
|
||||||
|
"not: [valid",
|
||||||
|
"null",
|
||||||
|
"[]",
|
||||||
|
"openapi: 3.1.0",
|
||||||
|
'{"info":{"name":"Postman"},"item":[]}',
|
||||||
|
"meta { name: Test }",
|
||||||
|
"opencollection: 1.0.0\nbundled: true\nitems: &items [*items]",
|
||||||
|
])("ignores unrelated, malformed, or cyclic input: %s", (input) => {
|
||||||
|
expect(convertBruno(input)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects directory config and unknown format versions with actionable errors", () => {
|
||||||
|
expect(() => convertBruno("opencollection: 1.0.0\ninfo:\n name: Directory")).toThrow(
|
||||||
|
"Share menu",
|
||||||
|
);
|
||||||
|
expect(() => convertBruno(JSON.stringify(collection([], { bundled: false })))).toThrow(
|
||||||
|
"single YAML file",
|
||||||
|
);
|
||||||
|
expect(() => convertBruno(JSON.stringify(collection([], { opencollection: "2.0.0" })))).toThrow(
|
||||||
|
"Unsupported Bruno collection format version",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects missing and cyclic environment parents", () => {
|
||||||
|
for (const environments of [
|
||||||
|
[{ name: "A", extends: "missing" }],
|
||||||
|
[{ name: "A", extends: "A" }],
|
||||||
|
[
|
||||||
|
{ name: "A", extends: "B" },
|
||||||
|
{ name: "B", extends: "A" },
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
expect(() =>
|
||||||
|
convertBruno(JSON.stringify(collection([], { config: { environments } }))),
|
||||||
|
).toThrow(/environment/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retains scripts, request variables, unsupported authentication, and unknown items for manual migration", () => {
|
||||||
|
const result = convertBruno(
|
||||||
|
JSON.stringify(
|
||||||
|
collection([
|
||||||
|
{
|
||||||
|
info: { name: "Scripted", type: "http" },
|
||||||
|
http: { url: "{{host}}", auth: { type: "wsse", username: "test" } },
|
||||||
|
runtime: {
|
||||||
|
scripts: [{ code: "bru.setVar('token', 'example');\n```" }],
|
||||||
|
variables: [{ name: "host", value: "https://example.com" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ info: { name: "App", type: "app" }, code: "example" },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
expect(result.resources.httpRequests[0]).toMatchObject({ authenticationType: "none" });
|
||||||
|
const desc = result.resources.httpRequests[0]!.description;
|
||||||
|
expect(desc).toContain("not executed");
|
||||||
|
expect(desc).toContain("bru.setVar");
|
||||||
|
expect(desc).toContain("variables:");
|
||||||
|
expect(desc).toContain("wsse");
|
||||||
|
expect(desc).toContain("````yaml");
|
||||||
|
expect(result.resources.folders[0]?.description).toContain("unsupportedItem:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not override a collection Content-Type with a generated body header", () => {
|
||||||
|
const result = convertBruno(
|
||||||
|
JSON.stringify(
|
||||||
|
collection(
|
||||||
|
[{ info: { name: "JSON", type: "http" }, http: { body: { type: "json", data: "{}" } } }],
|
||||||
|
{
|
||||||
|
request: { headers: [{ name: "content-type", value: "application/vnd.example+json" }] },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
expect(result.resources.httpRequests[0]?.headers).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("request conversion", () => {
|
||||||
|
test.each([
|
||||||
|
["json", "application/json", "application/json"],
|
||||||
|
["xml", "application/xml", "application/xml"],
|
||||||
|
["text", "other", "text/plain"],
|
||||||
|
["sparql", "other", "application/sparql-query"],
|
||||||
|
])("imports %s bodies with a default content type", (type, bodyType, contentType) => {
|
||||||
|
expect(body({ type, data: "{{value}}" }, false, [])).toEqual({
|
||||||
|
bodyType,
|
||||||
|
body: { text: "${[ value ]}" },
|
||||||
|
headers: [{ name: "Content-Type", value: contentType, enabled: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test("preserves explicit and disabled Content-Type headers", () => {
|
||||||
|
expect(
|
||||||
|
body({ type: "json", data: "{}" }, false, [
|
||||||
|
{ name: "CONTENT-type", value: "custom", disabled: true },
|
||||||
|
]).headers,
|
||||||
|
).toEqual([{ name: "CONTENT-type", value: "custom", enabled: false }]);
|
||||||
|
});
|
||||||
|
test("imports form fields and the selected file or body variant", () => {
|
||||||
|
expect(
|
||||||
|
body(
|
||||||
|
{ type: "form-urlencoded", data: [{ name: "field", value: "{{value}}", disabled: true }] },
|
||||||
|
false,
|
||||||
|
[],
|
||||||
|
).body,
|
||||||
|
).toEqual({ form: [{ name: "field", value: "${[ value ]}", enabled: false }] });
|
||||||
|
expect(
|
||||||
|
body(
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
data: [
|
||||||
|
{ filePath: "wrong", selected: false },
|
||||||
|
{ filePath: "./correct.bin", selected: true, contentType: "application/octet-stream" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toMatchObject({ bodyType: "binary", body: { filePath: "./correct.bin" } });
|
||||||
|
expect(
|
||||||
|
body(
|
||||||
|
[
|
||||||
|
{ title: "A", body: { type: "json", data: "a" } },
|
||||||
|
{ title: "B", selected: true, body: { type: "json", data: "b" } },
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
[],
|
||||||
|
).body,
|
||||||
|
).toEqual({ text: "b" });
|
||||||
|
expect(
|
||||||
|
body({ type: "file", data: [{ filePath: "disabled", selected: false }] }, false, []).bodyType,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
test("imports raw GraphQL variables without parsing expressions and preserves operationName", () => {
|
||||||
|
expect(
|
||||||
|
body(
|
||||||
|
{ query: "query Get { me }", variables: '{"id": {{id}}}', operationName: "Get" },
|
||||||
|
true,
|
||||||
|
[],
|
||||||
|
).body,
|
||||||
|
).toEqual({ query: "query Get { me }", variables: '{"id": ${[ id ]}}', operationName: "Get" });
|
||||||
|
expect(body({ query: "{ me }", variables: { id: "{{id}}" } }, true, []).body.variables).toBe(
|
||||||
|
'{\n "id": "${[ id ]}"\n}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("avoids duplicate encoded query keys without losing unrelated query entries or fragments", () => {
|
||||||
|
expect(
|
||||||
|
urlAndParams("{{host}}/a?some%20key=old&some+key=old&keep=%2F#part", [
|
||||||
|
{ type: "query", name: "some key", value: "new" },
|
||||||
|
]),
|
||||||
|
).toMatchObject({
|
||||||
|
url: "${[ host ]}/a?keep=%2F#part",
|
||||||
|
urlParameters: [{ name: "some key", value: "new" }],
|
||||||
|
});
|
||||||
|
expect(urlAndParams("{{host}}/a?x=1", []).url).toBe("${[ host ]}/a?x=1");
|
||||||
|
});
|
||||||
|
test("converts plain variables and preserves unsupported expressions", () => {
|
||||||
|
expect(
|
||||||
|
template(
|
||||||
|
"{{ host }} {{user.id}} {{process.env.TOKEN}} {{$randomUUID}} {{a + b}} {{true}} {{null}}",
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
"${[ host ]} ${[ user.id ]} {{process.env.TOKEN}} {{$randomUUID}} {{a + b}} {{true}} {{null}}",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
variables([
|
||||||
|
{ name: "secret", secret: true, value: "must not import" },
|
||||||
|
{
|
||||||
|
name: "variant",
|
||||||
|
value: [{ value: "a" }, { selected: true, value: { type: "object", data: '{"x":1}' } }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "secret", value: "", enabled: true },
|
||||||
|
{ name: "variant", value: '{"x":1}', enabled: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authentication", () => {
|
||||||
|
test("distinguishes request no-auth from explicit and folder inheritance", () => {
|
||||||
|
expect(auth(undefined).authenticationType).toBe("none");
|
||||||
|
expect(auth("inherit").authenticationType).toBeNull();
|
||||||
|
expect(auth(undefined, true).authenticationType).toBeNull();
|
||||||
|
});
|
||||||
|
test.each([
|
||||||
|
["basic", "basic"],
|
||||||
|
["digest", "digest"],
|
||||||
|
["ntlm", "windows"],
|
||||||
|
["bearer", "bearer"],
|
||||||
|
["awsv4", "awsv4"],
|
||||||
|
])("imports %s auth", (type, expected) => {
|
||||||
|
expect(auth({ type, username: "{{user}}", token: "{{token}}" }).authenticationType).toBe(
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("maps API key placement and OAuth client credentials/PKCE", () => {
|
||||||
|
expect(auth({ type: "apikey", key: "key", value: "{{token}}", placement: "query" })).toEqual({
|
||||||
|
authenticationType: "apikey",
|
||||||
|
authentication: { key: "key", value: "${[ token ]}", location: "query" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
auth({
|
||||||
|
type: "oauth2",
|
||||||
|
flow: "authorization_code",
|
||||||
|
credentials: { clientId: "{{client}}", placement: "basic_auth_header" },
|
||||||
|
callbackUrl: "http://localhost/callback",
|
||||||
|
pkce: { method: "S256" },
|
||||||
|
tokenConfig: { placement: { header: "Token" } },
|
||||||
|
}).authentication,
|
||||||
|
).toMatchObject({
|
||||||
|
grantType: "authorization_code",
|
||||||
|
clientId: "${[ client ]}",
|
||||||
|
credentials: "basic",
|
||||||
|
redirectUri: "http://localhost/callback",
|
||||||
|
usePkce: true,
|
||||||
|
pkceChallengeMethod: "S256",
|
||||||
|
headerPrefix: "Token",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
auth({
|
||||||
|
type: "oauth2",
|
||||||
|
flow: "resource_owner_password_credentials",
|
||||||
|
resourceOwner: { username: "a", password: "b" },
|
||||||
|
}).authentication,
|
||||||
|
).toMatchObject({ grantType: "password", username: "a", password: "b" });
|
||||||
|
expect(auth({ type: "oauth2", flow: "client_credentials" }).authentication).toMatchObject({
|
||||||
|
grantType: "client_credentials",
|
||||||
|
credentials: "body",
|
||||||
|
usePkce: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.each([
|
||||||
|
{ type: "awsv4", profileName: "local" },
|
||||||
|
{ type: "oauth2", flow: "client_credentials", tokenConfig: { placement: { query: "token" } } },
|
||||||
|
{ type: "oauth2", flow: "unknown" },
|
||||||
|
{ type: "wsse" },
|
||||||
|
])("does not silently inherit auth for unsupported configurations", (value) => {
|
||||||
|
expect(auth(value).authenticationType).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scope and unsupported-field regressions", () => {
|
||||||
|
test("flattens multi-level environments without disabled overrides hiding inherited values", () => {
|
||||||
|
const environments = [
|
||||||
|
{
|
||||||
|
name: "Child",
|
||||||
|
extends: "Middle",
|
||||||
|
variables: [
|
||||||
|
{ name: "a", value: "disabled", disabled: true },
|
||||||
|
{ name: "b", value: "child" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: "Middle", extends: "Root", variables: [{ name: "a", value: "middle" }] },
|
||||||
|
{
|
||||||
|
name: "Root",
|
||||||
|
variables: [
|
||||||
|
{ name: "a", value: "root" },
|
||||||
|
{ name: "b", value: "root" },
|
||||||
|
{ name: "off", value: "ignored", disabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = convertBruno(JSON.stringify(collection([], { config: { environments } })))!;
|
||||||
|
const child = result.resources.environments[1]!;
|
||||||
|
expect(child.parentId).toBeNull();
|
||||||
|
expect(child.variables).toEqual([
|
||||||
|
{ name: "a", value: "middle", enabled: true },
|
||||||
|
{ name: "a", value: "disabled", enabled: false },
|
||||||
|
{ name: "b", value: "child", enabled: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("distinguishes an empty folder from folder request defaults with no auth", () => {
|
||||||
|
const result = convertBruno(
|
||||||
|
JSON.stringify(
|
||||||
|
collection([
|
||||||
|
{ info: { type: "folder", name: "Empty" } },
|
||||||
|
{ info: { type: "folder", name: "No auth" }, request: { headers: [] } },
|
||||||
|
{ info: { type: "folder", name: "Inherited" }, request: { auth: "inherit" } },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
expect(result.resources.folders.map((f) => f.authenticationType)).toEqual([null, "none", null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retains external environment settings and unknown bodies, and does not turn binary WS data into text", () => {
|
||||||
|
const result = convertBruno(
|
||||||
|
JSON.stringify(
|
||||||
|
collection(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
info: { type: "http", name: "Unknown body" },
|
||||||
|
http: { body: { type: "future", data: "original" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { type: "websocket", name: "Binary" },
|
||||||
|
websocket: { message: { type: "binary", data: "AA==" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: "Vault",
|
||||||
|
externalSecrets: { type: "aws-secrets-manager" },
|
||||||
|
dotEnvFilePath: "./.env",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
expect(result.resources.workspaces[0]?.description).toContain("aws-secrets-manager");
|
||||||
|
expect(result.resources.workspaces[0]?.description).toContain("./.env");
|
||||||
|
expect(result.resources.httpRequests[0]?.description).toContain("original");
|
||||||
|
expect(result.resources.websocketRequests[0]?.message).toBe("");
|
||||||
|
expect(result.resources.websocketRequests[0]?.description).toContain("AA==");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps OAuth 1.0 credentials and keeps unsupported private-key files inactive", () => {
|
||||||
|
expect(
|
||||||
|
auth({
|
||||||
|
type: "oauth1",
|
||||||
|
consumerKey: "{{key}}",
|
||||||
|
accessToken: "token",
|
||||||
|
accessTokenSecret: "secret",
|
||||||
|
privateKey: { type: "text", value: "pem" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
authenticationType: "oauth1",
|
||||||
|
authentication: {
|
||||||
|
consumerKey: "${[ key ]}",
|
||||||
|
tokenKey: "token",
|
||||||
|
tokenSecret: "secret",
|
||||||
|
privateKey: "pem",
|
||||||
|
signatureMethod: "HMAC-SHA1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
auth({ type: "oauth1", privateKey: { type: "file", value: "./key.pem" } }).authenticationType,
|
||||||
|
).toBe("none");
|
||||||
|
expect(auth({ type: "oauth1", placement: "query" }).authenticationType).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user