mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-01 16:17:25 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
222ea9cb83 | ||
|
|
360c098b09 | ||
|
|
fce1039bac | ||
|
|
f18f93d613 | ||
|
|
661a384bed |
Generated
+1
@@ -11225,6 +11225,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
@@ -11,12 +10,20 @@ import {
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from "codemirror-json-schema";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { showAlert } from "../lib/alert";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
|
||||
protoFiles: string[];
|
||||
};
|
||||
|
||||
type MethodSchema =
|
||||
| { type: "none" }
|
||||
| { type: "schema"; schema: JsonSchema }
|
||||
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
|
||||
|
||||
export function GrpcEditor({
|
||||
services,
|
||||
reflectionError,
|
||||
@@ -42,21 +54,16 @@ export function GrpcEditor({
|
||||
setEditorView(h);
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method and update the editor
|
||||
useEffect(() => {
|
||||
if (
|
||||
editorView == null ||
|
||||
services === null ||
|
||||
request.service === null ||
|
||||
request.method === null
|
||||
) {
|
||||
return;
|
||||
// Find the schema for the selected service and method
|
||||
const methodSchema = useMemo<MethodSchema>(() => {
|
||||
if (services === null || request.service === null || request.method === null) {
|
||||
return { type: "none" };
|
||||
}
|
||||
|
||||
const s = services.find((s) => s.name === request.service);
|
||||
if (s == null) {
|
||||
console.log("Failed to find service", { service: request.service, services });
|
||||
showAlert({
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-service-error",
|
||||
title: "Couldn't Find Service",
|
||||
body: (
|
||||
@@ -64,14 +71,14 @@ export function GrpcEditor({
|
||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
log: ["Failed to find service", { service: request.service, services }],
|
||||
};
|
||||
}
|
||||
|
||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||
if (request.method != null && schema == null) {
|
||||
console.log("Failed to find method", { method: request.method, methods: s?.methods });
|
||||
showAlert({
|
||||
if (schema == null) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-schema-error",
|
||||
title: "Couldn't Find Method",
|
||||
body: (
|
||||
@@ -80,18 +87,15 @@ export function GrpcEditor({
|
||||
<InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema == null) {
|
||||
return;
|
||||
log: ["Failed to find method", { method: request.method, methods: s.methods }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
updateSchema(editorView, JSON.parse(schema));
|
||||
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
|
||||
} catch (err) {
|
||||
showAlert({
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-parse-schema-error",
|
||||
title: "Failed to Parse Schema",
|
||||
body: (
|
||||
@@ -103,9 +107,22 @@ export function GrpcEditor({
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
log: ["Failed to parse schema", err],
|
||||
};
|
||||
}
|
||||
}, [editorView, services, request.method, request.service]);
|
||||
}, [services, request.method, request.service]);
|
||||
|
||||
useEffect(() => {
|
||||
if (methodSchema.type !== "error") return;
|
||||
console.log(...methodSchema.log);
|
||||
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
|
||||
}, [methodSchema]);
|
||||
|
||||
// Update the editor whenever the schema changes
|
||||
useEffect(() => {
|
||||
if (editorView == null || methodSchema.type !== "schema") return;
|
||||
updateSchema(editorView, methodSchema.schema as JSONSchema7);
|
||||
}, [editorView, methodSchema]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
@@ -124,45 +141,145 @@ export function GrpcEditor({
|
||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
||||
|
||||
const handleGenerateExample = useCallback(async () => {
|
||||
if (methodSchema.type !== "schema") return;
|
||||
|
||||
if (request.message.trim() !== "") {
|
||||
const confirmed = await showConfirm({
|
||||
id: "grpc-generate-example",
|
||||
title: "Generate Example",
|
||||
description: "The current message will be replaced with an example.",
|
||||
confirmText: "Generate",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
|
||||
await patchModel(request, { message });
|
||||
|
||||
// Force the editor to pick up the new message
|
||||
wasUpdatedExternally(request.id);
|
||||
}, [methodSchema, request]);
|
||||
|
||||
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
|
||||
// reaches it without threading a refetch down from the connection layout.
|
||||
const handleReloadSchema = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
|
||||
[request.id],
|
||||
);
|
||||
|
||||
const handleShowReflectionError = useCallback(() => {
|
||||
showDialog({
|
||||
id: "grpc-reflection-error",
|
||||
title: "Reflection Failed",
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<>
|
||||
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
|
||||
<div className="w-full my-4">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await handleReloadSchema();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}, [handleReloadSchema, reflectionError]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
|
||||
<Button
|
||||
size="xs"
|
||||
color={
|
||||
reflectionLoading
|
||||
? "secondary"
|
||||
: reflectionUnavailable
|
||||
? "info"
|
||||
: reflectionError
|
||||
? "danger"
|
||||
: "secondary"
|
||||
}
|
||||
isLoading={reflectionLoading}
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "reflection-failed",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
}}
|
||||
// Matches the GraphQL editor: one always-visible control labelled by schema state,
|
||||
// with everything schema-related behind it.
|
||||
<div key="schema" className="opacity-100!">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
// Hidden for servers without reflection, which isn't an error
|
||||
hidden: !reflectionError,
|
||||
type: "content",
|
||||
label: (
|
||||
<Banner color="danger">
|
||||
<p className="mb-1">Reflection failed</p>
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={handleShowReflectionError}
|
||||
>
|
||||
View Error
|
||||
</Button>
|
||||
</Banner>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Generate Example Message",
|
||||
leftSlot: <Icon icon="magic_wand" />,
|
||||
disabled: methodSchema.type !== "schema",
|
||||
onSelect: handleGenerateExample,
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Reload Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleReloadSchema,
|
||||
},
|
||||
{
|
||||
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "grpc-configure-schema",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null && protoFiles.length === 0
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
title="Schema"
|
||||
forDropdown
|
||||
isLoading={reflectionLoading}
|
||||
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
|
||||
[
|
||||
handleGenerateExample,
|
||||
handleReloadSchema,
|
||||
handleShowReflectionError,
|
||||
methodSchema.type,
|
||||
protoFiles.length,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
reflectionUnavailable,
|
||||
services,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -194,13 +194,6 @@ export function GrpcRequestPane({
|
||||
type: "default",
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
itemsAfter={[
|
||||
{
|
||||
label: "Refresh",
|
||||
type: "default",
|
||||
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import {
|
||||
type Folder,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
modelTypeLabel,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
|
||||
interface Props {
|
||||
importFile: (filePath: string) => Promise<void>;
|
||||
importUrl: (url: string) => Promise<void>;
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
selectedFolder: Folder | null;
|
||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
cancel: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/** Sentinel for the "create a new workspace" option. Workspace IDs are prefixed `wk_`, so this
|
||||
* can never collide with a real one. */
|
||||
const NEW_WORKSPACE = "new_workspace";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -31,8 +51,20 @@ function fileName(path: string): string {
|
||||
return path.split(/[/\\]/).at(-1) || path;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
export function ImportDataDialog({
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
selectedFolder,
|
||||
planFile,
|
||||
planUrl,
|
||||
commit,
|
||||
cancel,
|
||||
onError,
|
||||
}: Props) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [destinationId, setDestinationId] = useState<string>(NEW_WORKSPACE);
|
||||
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
|
||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||
@@ -71,19 +103,117 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
selectSource(selected);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
|
||||
// is also the destination.
|
||||
const canTargetSelectedFolder =
|
||||
selectedFolder != null && destinationId === currentWorkspace?.id;
|
||||
|
||||
const destination = (): ImportDestination => {
|
||||
if (destinationId === NEW_WORKSPACE) {
|
||||
return { type: "new_workspace" };
|
||||
}
|
||||
return {
|
||||
type: "existing_workspace",
|
||||
workspaceId: destinationId,
|
||||
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (filePath != null) {
|
||||
await importFile(filePath);
|
||||
} else {
|
||||
await importUrl(trimmedSource);
|
||||
}
|
||||
const nextPlan =
|
||||
filePath != null
|
||||
? await planFile(filePath, destination())
|
||||
: await planUrl(trimmedSource, destination());
|
||||
setPlan(nextPlan);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (plan == null) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await commit(plan);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (plan != null) {
|
||||
const counts = [
|
||||
[plan.resources.workspaces[0], plan.resources.workspaces.length],
|
||||
[plan.resources.environments[0], plan.resources.environments.length],
|
||||
[plan.resources.folders[0], plan.resources.folders.length],
|
||||
[plan.resources.httpRequests[0], plan.resources.httpRequests.length],
|
||||
[plan.resources.grpcRequests[0], plan.resources.grpcRequests.length],
|
||||
[plan.resources.websocketRequests[0], plan.resources.websocketRequests.length],
|
||||
] as const;
|
||||
const destinationLabel = (() => {
|
||||
if (plan.destination.type === "new_workspace") return "New workspace";
|
||||
const { workspaceId, folderId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name;
|
||||
})();
|
||||
|
||||
return (
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label="Destination" value={destinationLabel} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Resources</div>
|
||||
<ul className="list-disc pl-6 text-sm text-text-subtle">
|
||||
{counts.map(([model, count]) =>
|
||||
model == null ? null : (
|
||||
<li key={model.model}>{pluralizeCount(modelTypeLabel(model), count)}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{plan.warnings.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Import details</div>
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
{plan.warnings.map((warning) => (
|
||||
<div
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
||||
>
|
||||
<Icon icon="info" color="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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
|
||||
{isLoading ? "Importing" : "Confirm Import"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack ref={ref} space={4} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
@@ -115,25 +245,66 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
/>
|
||||
|
||||
<VStack space={2}>
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
<Select
|
||||
name="import-destination"
|
||||
label="Import location"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
value={destinationId}
|
||||
onChange={setDestinationId}
|
||||
// The native macOS select drops separators
|
||||
filterable
|
||||
options={[
|
||||
{ value: NEW_WORKSPACE, label: "New Workspace" },
|
||||
...(workspaces.length > 0
|
||||
? [{ type: "separator" as const, label: "Existing Workspaces" }]
|
||||
: []),
|
||||
...workspaces.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.id === currentWorkspace?.id ? `${w.name} (current workspace)` : w.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{canTargetSelectedFolder && (
|
||||
<Checkbox
|
||||
checked={targetSelectedFolder}
|
||||
title={`Place root resources in selected folder “${selectedFolder.name}”`}
|
||||
onChange={setTargetSelectedFolder}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={trimmedSource === "" || isLoading}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
{isLoading ? "Analyzing" : "Preview Import"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
|
||||
<span className="text-text-subtle">{label}</span>
|
||||
<span className="text-right font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
disableBackdropClose?: boolean;
|
||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
||||
disableClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
@@ -27,7 +29,7 @@ export function Dialog({
|
||||
size = "full",
|
||||
open,
|
||||
onClose,
|
||||
disableBackdropClose,
|
||||
disableClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
@@ -42,7 +44,7 @@ export function Dialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
|
||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
||||
<div
|
||||
role="dialog"
|
||||
className={classNames(
|
||||
@@ -58,7 +60,7 @@ export function Dialog({
|
||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||
// dialogs and can be intercepted by children if needed.
|
||||
if (e.key === "Escape") {
|
||||
onClose?.();
|
||||
if (!disableClose) onClose?.();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -110,7 +112,7 @@ export function Dialog({
|
||||
</div>
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!hideX && (
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
<IconButton
|
||||
className="opacity-70 hover:opacity-100"
|
||||
|
||||
@@ -601,6 +601,8 @@ function getExtensions({
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: "off",
|
||||
autocorrect: "off",
|
||||
// Keeps macOS Writing Tools from offering to write code for us
|
||||
writingsuggestions: "false",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { SearchQuery } from "@codemirror/search";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
currentMatch,
|
||||
literalSearch,
|
||||
MAX_COUNT,
|
||||
MatchCounter,
|
||||
normalizeDoc,
|
||||
normalizeSearch,
|
||||
scanNormalized,
|
||||
scanQuery,
|
||||
} from "./searchMatchCount";
|
||||
|
||||
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
|
||||
|
||||
const stateOf = (doc: string) => EditorState.create({ doc });
|
||||
|
||||
/**
|
||||
* The matches the counter finds, having checked them against the search panel's own cursor.
|
||||
*
|
||||
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
|
||||
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
|
||||
*/
|
||||
function matchesOf(doc: string, config: QueryConfig) {
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery(config);
|
||||
const matches = new MatchCounter().matches(state, query);
|
||||
expect(matches).toEqual(scanQuery(state, query));
|
||||
return matches;
|
||||
}
|
||||
|
||||
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
|
||||
|
||||
describe("counting", () => {
|
||||
test("counts every match, whatever the case", () => {
|
||||
expect(countOf("one Two three two", { search: "two" })).toBe(2);
|
||||
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("skips matches overlapping an earlier one", () => {
|
||||
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
|
||||
expect(countOf("ababa", { search: "aba" })).toBe(1);
|
||||
});
|
||||
|
||||
test("treats a query as text, not as a pattern", () => {
|
||||
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
|
||||
});
|
||||
|
||||
test("unquotes escapes unless the query is literal", () => {
|
||||
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
|
||||
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("counts regexp and whole word queries through the cursor", () => {
|
||||
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
|
||||
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
|
||||
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
|
||||
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
|
||||
});
|
||||
|
||||
test("stops counting at the cap", () => {
|
||||
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
|
||||
});
|
||||
|
||||
test("reports where the matches are", () => {
|
||||
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds nothing to match with an empty needle", () => {
|
||||
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization", () => {
|
||||
test("finds what a character decomposes into", () => {
|
||||
// The é is one character holding an `e`, and the match covers the whole of it
|
||||
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
|
||||
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
|
||||
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
|
||||
expect(countOf("one two", { search: "one two" })).toBe(1);
|
||||
expect(countOf("full width", { search: "full" })).toBe(1);
|
||||
});
|
||||
|
||||
test("matches a decomposed query against composed text, and the reverse", () => {
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps offsets straight after an expansion", () => {
|
||||
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
|
||||
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
|
||||
});
|
||||
|
||||
test("normalizes the query whole, the document by character", () => {
|
||||
expect(normalizeSearch("CAFÉ", false)).toBe("café");
|
||||
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
|
||||
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
|
||||
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
|
||||
});
|
||||
|
||||
test("leaves a document that normalizes to itself untouched", () => {
|
||||
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
|
||||
expect(text).toBe("plain 日本 🎉 text");
|
||||
expect(expansions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current match", () => {
|
||||
const matches = [
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
{ from: 8, to: 10 },
|
||||
];
|
||||
|
||||
test("counts from one, and reports 0 off a match", () => {
|
||||
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
|
||||
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
|
||||
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
|
||||
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
test("moving the selection doesn't scan again", () => {
|
||||
const state = stateOf("a1 b2 c3");
|
||||
const query = new SearchQuery({ search: "\\d", regexp: true });
|
||||
const counter = new MatchCounter();
|
||||
const found = counter.matches(state, query);
|
||||
|
||||
// The document a selection-only transaction leaves behind is the one already scanned
|
||||
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
|
||||
expect(counter.matches(moved, query)).toBe(found);
|
||||
expect(currentMatch(found, moved.selection.main)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The mapping from normalized offsets back to document offsets is the part of this that can go
|
||||
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
|
||||
*/
|
||||
describe("against the cursor, on awkward text", () => {
|
||||
const ALPHABET = [
|
||||
..."abcABC .\\\n".split(""),
|
||||
"é",
|
||||
"é",
|
||||
"fi",
|
||||
"…",
|
||||
" ",
|
||||
"İ",
|
||||
"Σ",
|
||||
"ς",
|
||||
"日",
|
||||
"🎉",
|
||||
"Ⅻ",
|
||||
"f",
|
||||
"①",
|
||||
"́",
|
||||
];
|
||||
|
||||
/** Seeded, so a failure is the same failure next run */
|
||||
function random(seed: number) {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
for (const caseSensitive of [false, true]) {
|
||||
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
|
||||
const next = random(caseSensitive ? 20260831 : 7);
|
||||
|
||||
for (let round = 0; round < 400; round++) {
|
||||
const doc = Array.from(
|
||||
{ length: 2 + Math.floor(next() * 60) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
|
||||
// Half the queries are lifted out of the document, so matches are actually found
|
||||
const start = Math.floor(next() * doc.length);
|
||||
const search =
|
||||
next() < 0.5
|
||||
? doc.slice(start, start + 1 + Math.floor(next() * 3))
|
||||
: Array.from(
|
||||
{ length: 1 + Math.floor(next() * 2) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
if (search === "") continue;
|
||||
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery({ search, caseSensitive });
|
||||
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
|
||||
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,232 @@
|
||||
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { EditorState, Extension, Text } from "@codemirror/state";
|
||||
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
|
||||
/** Matches are counted no further than this, since an exact total stops being useful long before */
|
||||
export const MAX_COUNT = 9999;
|
||||
|
||||
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
|
||||
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
|
||||
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
|
||||
|
||||
export interface Match {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** A character whose normalized form is a different length, shifting every offset past it */
|
||||
interface Expansion {
|
||||
normFrom: number;
|
||||
normTo: number;
|
||||
docFrom: number;
|
||||
docTo: number;
|
||||
}
|
||||
|
||||
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
|
||||
export interface NormalizedDoc {
|
||||
text: string;
|
||||
expansions: Expansion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
|
||||
* case-sensitive — in a single pass rather than one call per code point.
|
||||
*
|
||||
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
|
||||
* makes counting matches in a large response slow. Doing it a character at a time still matters
|
||||
* for the result, since it keeps NFKD from reordering marks across characters, so the
|
||||
* granularity stays and only the repeated work goes: each distinct character is normalized once
|
||||
* and the answer reused, and ASCII runs never reach ICU at all.
|
||||
*/
|
||||
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
|
||||
const rewritten = new Map<string, string>();
|
||||
const expansions: Expansion[] = [];
|
||||
let shift = 0;
|
||||
|
||||
const normalized = text.replace(
|
||||
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
|
||||
(chunk: string, at: number) => {
|
||||
// An ASCII run only ever folds case, which can't change its length
|
||||
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
|
||||
|
||||
let out = rewritten.get(chunk);
|
||||
if (out === undefined) {
|
||||
out = chunk.normalize("NFKD");
|
||||
if (!caseSensitive) out = out.toLowerCase();
|
||||
rewritten.set(chunk, out);
|
||||
}
|
||||
|
||||
if (out.length !== chunk.length) {
|
||||
expansions.push({
|
||||
normFrom: at + shift,
|
||||
normTo: at + shift + out.length,
|
||||
docFrom: at,
|
||||
docTo: at + chunk.length,
|
||||
});
|
||||
shift += out.length - chunk.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
);
|
||||
|
||||
return { text: normalized, expansions };
|
||||
}
|
||||
|
||||
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
|
||||
export function normalizeSearch(search: string, caseSensitive: boolean): string {
|
||||
const normalized = search.normalize("NFKD");
|
||||
return caseSensitive ? normalized : normalized.toLowerCase();
|
||||
}
|
||||
|
||||
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
|
||||
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
|
||||
const matches: Match[] = [];
|
||||
if (needle === "") return matches;
|
||||
|
||||
const { text } = doc;
|
||||
let pos = text.indexOf(needle);
|
||||
while (pos >= 0) {
|
||||
let end = pos + needle.length;
|
||||
// However the query was cut, a match ends on a whole code point, as the cursor's do
|
||||
if (isLowSurrogate(text.charCodeAt(end))) end++;
|
||||
|
||||
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
|
||||
pos = text.indexOf(needle, resumeAfter(doc, end));
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** The same through the query's own cursor, which handles regexps and whole words */
|
||||
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
|
||||
const matches: Match[] = [];
|
||||
const cursor = query.getCursor(state);
|
||||
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
matches.push({ from: result.value.from, to: result.value.to });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
|
||||
|
||||
/** The last character expansion beginning at or before `offset`, if there is one */
|
||||
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
|
||||
let low = 0;
|
||||
let high = expansions.length - 1;
|
||||
let found: Expansion | null = null;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (expansions[mid]!.normFrom <= offset) {
|
||||
found = expansions[mid]!;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function docStart(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
// A match starting inside a character's expansion starts at the character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docFrom
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where scanning picks up after a match ending at `offset`.
|
||||
*
|
||||
* The cursor moves through the document a character at a time, so once a match ends inside a
|
||||
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
|
||||
* ever counts as one match of ".".
|
||||
*/
|
||||
function resumeAfter(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
|
||||
? expansion.normTo
|
||||
: offset;
|
||||
}
|
||||
|
||||
function docEnd(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
if (offset <= expansion.normFrom) return expansion.docFrom;
|
||||
// A match ending inside a character's expansion covers the whole character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docTo
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
|
||||
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
|
||||
let index = 0;
|
||||
for (const match of matches) {
|
||||
index++;
|
||||
if (match.from <= selection.from && match.to >= selection.to) return index;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The text a plain query looks for, or null when only the cursor can answer it */
|
||||
export function literalSearch(query: SearchQuery): string | null {
|
||||
if (query.regexp || query.wholeWord || query.test != null) return null;
|
||||
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
|
||||
return query.literal
|
||||
? query.search
|
||||
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
|
||||
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the matches for the search panel, keeping the normalized document and the matches it
|
||||
* last found, so neither moving the selection nor typing another character starts over.
|
||||
*/
|
||||
export class MatchCounter {
|
||||
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
|
||||
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
|
||||
|
||||
matches(state: EditorState, query: SearchQuery): Match[] {
|
||||
const last = this.last;
|
||||
if (last != null && last.doc === state.doc && last.query.eq(query)) {
|
||||
return last.matches;
|
||||
}
|
||||
|
||||
const matches = this.scan(state, query);
|
||||
this.last = { doc: state.doc, query, matches };
|
||||
return matches;
|
||||
}
|
||||
|
||||
private scan(state: EditorState, query: SearchQuery): Match[] {
|
||||
const search = literalSearch(query);
|
||||
if (search == null) return scanQuery(state, query);
|
||||
|
||||
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
|
||||
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
|
||||
}
|
||||
|
||||
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
|
||||
const cached = this.doc;
|
||||
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
|
||||
return cached.normalized;
|
||||
}
|
||||
|
||||
const normalized = normalizeDoc(doc.toString(), caseSensitive);
|
||||
this.doc = { doc, caseSensitive, normalized };
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A CodeMirror extension that displays the total number of search matches
|
||||
* inside the built-in search panel.
|
||||
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
private countEl: HTMLElement | null = null;
|
||||
private counter = new MatchCounter();
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.updateCount();
|
||||
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
|
||||
}
|
||||
|
||||
this.ensureCountEl();
|
||||
if (this.countEl == null) return;
|
||||
|
||||
if (!query.search) {
|
||||
if (this.countEl) {
|
||||
this.countEl.textContent = "0/0";
|
||||
}
|
||||
this.countEl.textContent = "0/0";
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = state.selection.main;
|
||||
let count = 0;
|
||||
let currentIndex = 0;
|
||||
const MAX_COUNT = 9999;
|
||||
const cursor = query.getCursor(state);
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
count++;
|
||||
const match = result.value;
|
||||
if (match.from <= selection.from && match.to >= selection.to) {
|
||||
currentIndex = count;
|
||||
}
|
||||
if (count > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
if (this.countEl) {
|
||||
if (count > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (count === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else if (currentIndex > 0) {
|
||||
this.countEl.textContent = `${currentIndex}/${count}`;
|
||||
} else {
|
||||
this.countEl.textContent = `0/${count}`;
|
||||
}
|
||||
const matches = this.counter.matches(state, query);
|
||||
if (matches.length > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (matches.length === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else {
|
||||
const current = currentMatch(matches, state.selection.main);
|
||||
this.countEl.textContent = `${current}/${matches.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,9 +119,13 @@ export function Select<T extends string>({
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="w-full text-sm font-mono"
|
||||
className={classNames(
|
||||
"w-full text-sm font-mono",
|
||||
disabled && "border-dotted",
|
||||
isInvalidSelection && "border-danger",
|
||||
)}
|
||||
justify="start"
|
||||
variant="border"
|
||||
variant="input"
|
||||
size={size}
|
||||
leftSlot={leftSlot}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
|
||||
showDialog({
|
||||
id: "git-diverged",
|
||||
title: "Branches Diverged",
|
||||
hideX: true,
|
||||
size: "sm",
|
||||
disableBackdropClose: true,
|
||||
onClose: () => resolve("cancel"),
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
DivergedDialog({
|
||||
remote,
|
||||
|
||||
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
|
||||
showDialog({
|
||||
id,
|
||||
title,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ export async function showConfirm({
|
||||
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
||||
showDialog({
|
||||
...extraProps,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import {
|
||||
type BatchUpsertResult,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
workspacesAtom,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
@@ -21,29 +29,43 @@ export const importData = createFastMutation({
|
||||
},
|
||||
mutationFn: async () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "sm",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||
try {
|
||||
await finishImport(await runImport());
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
const cancel = () => {
|
||||
hide();
|
||||
resolve();
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
hide();
|
||||
reject(err);
|
||||
};
|
||||
const commit = async (plan: ImportPlan) => {
|
||||
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
|
||||
hide();
|
||||
await finishImport(imported);
|
||||
resolve();
|
||||
};
|
||||
return (
|
||||
<ImportDataDialog
|
||||
importFile={(filePath) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={(filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination })
|
||||
}
|
||||
importUrl={(url) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||
planUrl={(url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination })
|
||||
}
|
||||
commit={commit}
|
||||
cancel={cancel}
|
||||
onError={fail}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import type { JsonSchema } from "./jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "./jsonSchemaExample";
|
||||
|
||||
describe("buildExampleFromSchema", () => {
|
||||
test("fills scalar fields with placeholders", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number", format: "int32" },
|
||||
active: { type: "boolean" },
|
||||
data: { type: "string", format: "byte" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
name: "",
|
||||
age: 0,
|
||||
active: false,
|
||||
data: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("encodes 64-bit integers as strings", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "int64" },
|
||||
count: { type: "string", format: "uint64" },
|
||||
offset: { type: "string", format: "sfixed64" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
|
||||
});
|
||||
|
||||
test("fills date-time with a parseable timestamp", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { createdAt: { type: "string", format: "date-time" } },
|
||||
};
|
||||
|
||||
const example = buildExampleFromSchema(schema) as { createdAt: string };
|
||||
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
|
||||
});
|
||||
|
||||
test("fills a duration with a value that parses", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { timeout: { type: "string", format: "duration" } },
|
||||
};
|
||||
|
||||
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
|
||||
});
|
||||
|
||||
test("expands nested messages through $defs", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { user: { $ref: "#/$defs/example.User" } },
|
||||
$defs: {
|
||||
"example.User": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: { $ref: "#/$defs/example.Address" },
|
||||
},
|
||||
},
|
||||
"example.Address": {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
user: { name: "", address: { city: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("gives repeated fields a single placeholder item", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
|
||||
unknown: { type: "array" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
tags: [""],
|
||||
users: [{ name: "" }],
|
||||
unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the first value of an enum", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
|
||||
empty: { type: "string", enum: [] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
|
||||
});
|
||||
|
||||
test("gives maps a single placeholder entry", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
labels: { type: "object", additionalProperties: { type: "string" } },
|
||||
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
labels: { key: "" },
|
||||
users: { key: { name: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops at the root self-reference", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
children: { type: "array", items: { $ref: "#" } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
|
||||
});
|
||||
|
||||
test("stops at a cycle between messages", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
$defs: {
|
||||
"example.Node": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
parent: { $ref: "#/$defs/example.Node" },
|
||||
leaf: { $ref: "#/$defs/example.Leaf" },
|
||||
},
|
||||
},
|
||||
"example.Leaf": {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
node: { name: "", parent: {}, leaf: { node: {} } },
|
||||
});
|
||||
});
|
||||
|
||||
test("expands the same message twice when it is not on the same path", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { $ref: "#/$defs/example.User" },
|
||||
to: { $ref: "#/$defs/example.User" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
|
||||
});
|
||||
|
||||
test("fills every branch of a flattened oneof", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
text: { type: "string" },
|
||||
image: { $ref: "#/$defs/example.Image" },
|
||||
},
|
||||
$defs: {
|
||||
"example.Image": { type: "object", properties: { url: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
id: "",
|
||||
text: "",
|
||||
image: { url: "" },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops expanding once the node budget runs out", () => {
|
||||
// Every level references the next one twice, so an unbounded walk would build 2^depth
|
||||
// nodes without ever repeating a ref on the same path.
|
||||
const depth = 16;
|
||||
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
|
||||
for (let i = 0; i < depth; i++) {
|
||||
$defs[`d${i}`] = {
|
||||
type: "object",
|
||||
properties: {
|
||||
a: { $ref: `#/$defs/d${i + 1}` },
|
||||
b: { $ref: `#/$defs/d${i + 1}` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const example = buildExampleFromSchema({
|
||||
type: "object",
|
||||
properties: { root: { $ref: "#/$defs/d0" } },
|
||||
$defs,
|
||||
});
|
||||
|
||||
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
|
||||
expect(countNodes(example)).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
test("handles messages without a known type", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
empty: {},
|
||||
struct: { type: "object" },
|
||||
missing: { $ref: "#/$defs/example.Nope" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
|
||||
});
|
||||
});
|
||||
|
||||
function countNodes(value: unknown): number {
|
||||
if (Array.isArray(value)) {
|
||||
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
|
||||
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
type?: string;
|
||||
format?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
additionalProperties?: JsonSchema;
|
||||
enum?: unknown[];
|
||||
$defs?: Record<string, JsonSchema>;
|
||||
$ref?: string;
|
||||
};
|
||||
|
||||
const DEFS_PREFIX = "#/$defs/";
|
||||
const ROOT_REF = "#";
|
||||
|
||||
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
|
||||
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
|
||||
|
||||
// Refs on sibling branches each expand their own subtree, so a schema that references the
|
||||
// same messages repeatedly can produce exponentially many nodes without ever cycling.
|
||||
const MAX_NODES = 5000;
|
||||
|
||||
type Budget = { remaining: number };
|
||||
|
||||
/** Build a sample message with placeholder values for every field in the schema */
|
||||
export function buildExampleFromSchema(schema: JsonSchema): unknown {
|
||||
// The root is already being built, so a `#` ref anywhere below it is a cycle
|
||||
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
|
||||
}
|
||||
|
||||
function buildValue(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.remaining -= 1;
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
if (refPath.has(schema.$ref)) {
|
||||
return {};
|
||||
}
|
||||
const resolved = resolveRef(schema.$ref, root);
|
||||
if (resolved == null) {
|
||||
return {};
|
||||
}
|
||||
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return schema.enum[0] ?? "";
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "object":
|
||||
return buildObject(schema, root, refPath, budget);
|
||||
case "array":
|
||||
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
|
||||
case "string":
|
||||
return buildString(schema.format);
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildObject(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema.properties != null && typeof schema.properties === "object") {
|
||||
const example: Record<string, unknown> = {};
|
||||
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
||||
example[name] = buildValue(propertySchema, root, refPath, budget);
|
||||
}
|
||||
return example;
|
||||
}
|
||||
|
||||
// Maps have no properties, only a value schema
|
||||
if (schema.additionalProperties != null) {
|
||||
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildString(format: string | undefined): string {
|
||||
if (format === "date-time") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
|
||||
if (format === "duration") {
|
||||
return "0s";
|
||||
}
|
||||
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
|
||||
return "0";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
|
||||
if (ref === ROOT_REF) {
|
||||
return root;
|
||||
}
|
||||
if (!ref.startsWith(DEFS_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
|
||||
}
|
||||
@@ -25,13 +25,8 @@ export async function showPromptForm({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
hideX: true,
|
||||
size: size ?? "sm",
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
onClose: () => {
|
||||
// Click backdrop, close, or escape
|
||||
resolve(null);
|
||||
},
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
Prompt({
|
||||
onCancel: () => {
|
||||
|
||||
@@ -5,8 +5,7 @@ use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
@@ -51,6 +50,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.import_data(&plugin_context, &file_contents)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
let importer = import_result.importer;
|
||||
let resources = import_result.resources;
|
||||
let workspace_id = args.workspace_id;
|
||||
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||
@@ -59,13 +59,13 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id,
|
||||
environment_id: None,
|
||||
cookie_jar_id: None,
|
||||
request_id: None,
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||
let plan = import::plan_import_resources(ctx.query_manager(), importer, destination, resources)
|
||||
.map_err(|e| format!("Failed to plan import: {e}"))?;
|
||||
let imported = import::commit_import_plan(ctx.query_manager(), plan)
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -81,14 +81,21 @@ fn import_reads_yaak_workspace_file() {
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(
|
||||
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||
"Imported Workspace"
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_http_request("req_import").expect("request imported").url,
|
||||
"https://example.com"
|
||||
);
|
||||
let workspaces = db.list_workspaces().expect("list imported workspaces");
|
||||
let workspace = workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.name == "Imported Workspace")
|
||||
.expect("workspace imported");
|
||||
assert_ne!(workspace.id, "wrk_import");
|
||||
|
||||
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
|
||||
let request = requests
|
||||
.iter()
|
||||
.find(|request| request.name == "Imported Request")
|
||||
.expect("request imported");
|
||||
assert_ne!(request.id, "req_import");
|
||||
assert_eq!(request.workspace_id, workspace.id);
|
||||
assert_eq!(request.url, "https://example.com");
|
||||
}
|
||||
|
||||
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||
|
||||
@@ -4,53 +4,63 @@ use crate::models_ext::QueryManagerExt;
|
||||
use std::fs::read_to_string;
|
||||
use std::io::ErrorKind;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak::import::{self, ImportDataParams};
|
||||
use yaak::import::{self, PlanImportDataParams};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
|
||||
pub(crate) async fn import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
import_contents(window, &contents).await
|
||||
let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?;
|
||||
commit_import(window, plan)
|
||||
}
|
||||
|
||||
pub(crate) async fn import_url<R: Runtime>(
|
||||
pub(crate) async fn plan_import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
plan_import_contents(window, &contents, destination).await
|
||||
}
|
||||
|
||||
pub(crate) async fn plan_import_url<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
url: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let contents = fetch_import_url(window, url).await?;
|
||||
import_contents(window, &contents).await
|
||||
plan_import_contents(window, &contents, destination).await
|
||||
}
|
||||
|
||||
async fn import_contents<R: Runtime>(
|
||||
async fn plan_import_contents<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
contents: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
let query_manager = window.db_manager();
|
||||
let plugin_context = window.plugin_context();
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id: window.workspace_id(),
|
||||
environment_id: window.environment_id(),
|
||||
cookie_jar_id: window.cookie_jar_id(),
|
||||
request_id: None,
|
||||
};
|
||||
|
||||
Ok(import::import_data(ImportDataParams {
|
||||
Ok(import::plan_import_data(PlanImportDataParams {
|
||||
query_manager: &query_manager,
|
||||
plugin_manager: &plugin_manager,
|
||||
plugin_context: &plugin_context,
|
||||
workspace_context,
|
||||
destination,
|
||||
contents,
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub(crate) fn commit_import<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
plan: ImportPlan,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
|
||||
}
|
||||
|
||||
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
|
||||
/// pipeline as a file on disk.
|
||||
///
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map};
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::import::{import_data, import_url};
|
||||
use crate::import::{commit_import, plan_import_data, plan_import_url};
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
@@ -40,7 +40,7 @@ use yaak_models::models::{
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
RenderPurpose, ShowToastRequest,
|
||||
@@ -1014,15 +1014,24 @@ async fn cmd_get_sse_events<R: Runtime>(
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_data(&window, file_path).await
|
||||
destination: ImportDestination,
|
||||
) -> YaakResult<ImportPlan> {
|
||||
plan_import_data(&window, file_path, 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>(
|
||||
window: WebviewWindow<R>,
|
||||
plan: ImportPlan,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_url(&window, url).await
|
||||
commit_import(&window, plan)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ use yaak_models::models::{
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
@@ -441,12 +441,16 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
|
||||
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).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>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
@@ -843,4 +847,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
|
||||
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
|
||||
+6
-4
File diff suppressed because one or more lines are too long
+12
@@ -2,3 +2,15 @@
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
|
||||
/**
|
||||
* Where a staged import will be committed.
|
||||
*
|
||||
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
* the exact destination that confirmation will use.
|
||||
*/
|
||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
|
||||
|
||||
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>, };
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
|
||||
@@ -23,7 +23,7 @@ use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
@@ -229,6 +229,7 @@ pub struct CmdGetHttpResponseEventsReq {
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportDataReq {
|
||||
pub file_path: String,
|
||||
pub destination: ImportDestination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
@@ -236,6 +237,14 @@ pub struct CmdImportDataReq {
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportUrlReq {
|
||||
pub url: String,
|
||||
pub destination: ImportDestination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdCommitImportReq {
|
||||
pub plan: ImportPlan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
@@ -909,8 +918,9 @@ macro_rules! with_commands {
|
||||
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
|
||||
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
||||
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
||||
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
|
||||
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
|
||||
cmd_import_data(CmdImportDataReq) -> ImportPlan,
|
||||
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
|
||||
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
|
||||
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
|
||||
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
|
||||
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
|
||||
|
||||
@@ -210,7 +210,7 @@ fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEn
|
||||
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
|
||||
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
|
||||
"google.protobuf.Duration" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
|
||||
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
|
||||
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
|
||||
|
||||
Generated
+12
@@ -2,3 +2,15 @@
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
|
||||
/**
|
||||
* Where a staged import will be committed.
|
||||
*
|
||||
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
* the exact destination that confirmation will use.
|
||||
*/
|
||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
|
||||
|
||||
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>, };
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
|
||||
@@ -85,6 +85,42 @@ pub struct BatchUpsertResult {
|
||||
pub websocket_requests: Vec<WebsocketRequest>,
|
||||
}
|
||||
|
||||
/// Where a staged import will be committed.
|
||||
///
|
||||
/// The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
/// the exact destination that confirmation will use.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportDestination {
|
||||
NewWorkspace,
|
||||
ExistingWorkspace {
|
||||
#[serde(rename = "workspaceId")]
|
||||
workspace_id: String,
|
||||
#[serde(rename = "folderId")]
|
||||
#[ts(optional)]
|
||||
folder_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub struct ImportPlanWarning {
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub struct ImportPlan {
|
||||
pub importer: String,
|
||||
pub destination: ImportDestination,
|
||||
pub resources: BatchUpsertResult,
|
||||
pub warnings: Vec<ImportPlanWarning>,
|
||||
}
|
||||
|
||||
pub fn get_workspace_export_resources(
|
||||
db: &ClientDb,
|
||||
yaak_version: &str,
|
||||
|
||||
+1
-1
@@ -474,7 +474,7 @@ export type ImportRequest = { content: string, };
|
||||
|
||||
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
|
||||
export type ImportResponse = { resources: ImportResources, };
|
||||
export type ImportResponse = { importer: string, resources: ImportResources, };
|
||||
|
||||
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
||||
|
||||
|
||||
@@ -247,6 +247,8 @@ pub struct ImportRequest {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct ImportResponse {
|
||||
/// Display name of the importer that recognized the input.
|
||||
pub importer: String,
|
||||
pub resources: ImportResources,
|
||||
}
|
||||
|
||||
|
||||
@@ -1104,8 +1104,19 @@ impl PluginManager {
|
||||
.await?;
|
||||
|
||||
// TODO: Don't just return the first valid response
|
||||
let result = reply_events.into_iter().find_map(|e| match e.payload {
|
||||
InternalEventPayload::ImportResponse(resp) => Some(resp),
|
||||
let result = reply_events.into_iter().find_map(|e| match e {
|
||||
InternalEvent {
|
||||
plugin_name,
|
||||
payload: InternalEventPayload::ImportResponse(mut resp),
|
||||
..
|
||||
} => {
|
||||
// Older plugin runtimes do not include the importer's display name. The plugin
|
||||
// package name is still enough to identify the detected format in that case.
|
||||
if resp.importer.is_empty() {
|
||||
resp.importer = plugin_name;
|
||||
}
|
||||
Some(resp)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@ yaak-templates = { workspace = true }
|
||||
yaak-tls = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
+744
-73
@@ -1,129 +1,800 @@
|
||||
use crate::Result;
|
||||
use log::info;
|
||||
use std::collections::BTreeMap;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{
|
||||
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
||||
DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo,
|
||||
WebsocketRequest, Workspace,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportPlan, ImportPlanWarning, UpdateSource,
|
||||
};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub struct ImportDataParams<'a> {
|
||||
pub struct PlanImportDataParams<'a> {
|
||||
pub query_manager: &'a QueryManager,
|
||||
pub plugin_manager: &'a PluginManager,
|
||||
pub plugin_context: &'a PluginContext,
|
||||
pub workspace_context: WorkspaceContext,
|
||||
pub destination: ImportDestination,
|
||||
pub contents: &'a str,
|
||||
}
|
||||
|
||||
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
|
||||
/// 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> {
|
||||
let import_result =
|
||||
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
|
||||
|
||||
import_resources(params.query_manager, params.workspace_context, import_result.resources)
|
||||
plan_import_resources(
|
||||
params.query_manager,
|
||||
import_result.importer,
|
||||
params.destination,
|
||||
import_result.resources,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn import_resources(
|
||||
/// Remap parsed importer resources into their selected destination.
|
||||
///
|
||||
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
|
||||
/// existing model and also makes the plan safe to inspect before it is committed.
|
||||
pub fn plan_import_resources(
|
||||
query_manager: &QueryManager,
|
||||
workspace_context: WorkspaceContext,
|
||||
importer: String,
|
||||
destination: ImportDestination,
|
||||
resources: ImportResources,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
|
||||
) -> Result<ImportPlan> {
|
||||
let mut warnings = Vec::new();
|
||||
validate_destination(query_manager, &destination)?;
|
||||
|
||||
let workspaces: Vec<Workspace> = resources
|
||||
.workspaces
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
|
||||
let mut folder_ids = BTreeMap::new();
|
||||
for folder in &resources.folders {
|
||||
folder_ids.insert(folder.id.clone(), Folder::generate_id());
|
||||
}
|
||||
|
||||
let environments: Vec<Environment> = resources
|
||||
.environments
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
|
||||
("folder", Some(parent_id)) => {
|
||||
v.parent_id =
|
||||
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
|
||||
}
|
||||
("", _) => {
|
||||
v.parent_model = "workspace".to_string();
|
||||
}
|
||||
_ => {
|
||||
v.parent_id = None;
|
||||
}
|
||||
};
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let mut workspace_ids = BTreeMap::new();
|
||||
let mut workspaces = Vec::new();
|
||||
let (default_workspace_id, target_folder_id) = match &destination {
|
||||
ImportDestination::NewWorkspace => {
|
||||
for source in &resources.workspaces {
|
||||
let mut workspace = source.clone();
|
||||
workspace.id = Workspace::generate_id();
|
||||
workspace_ids.insert(source.id.clone(), workspace.id.clone());
|
||||
workspaces.push(workspace);
|
||||
}
|
||||
|
||||
let folders: Vec<Folder> = resources
|
||||
if workspaces.is_empty() {
|
||||
let workspace = Workspace {
|
||||
id: Workspace::generate_id(),
|
||||
model: "workspace".to_string(),
|
||||
name: format!("{} Import", display_importer_name(&importer)),
|
||||
setting_follow_redirects: true,
|
||||
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
setting_validate_certificates: true,
|
||||
setting_send_cookies: true,
|
||||
setting_store_cookies: true,
|
||||
..Default::default()
|
||||
};
|
||||
workspaces.push(workspace);
|
||||
}
|
||||
|
||||
(workspaces[0].id.clone(), None)
|
||||
}
|
||||
ImportDestination::ExistingWorkspace { workspace_id, folder_id } => {
|
||||
for source in &resources.workspaces {
|
||||
workspace_ids.insert(source.id.clone(), workspace_id.clone());
|
||||
}
|
||||
if !resources.workspaces.is_empty() {
|
||||
let destination_workspace = query_manager.connect().get_workspace(workspace_id)?;
|
||||
let skipped_fields = resources
|
||||
.workspaces
|
||||
.iter()
|
||||
.flat_map(|source| {
|
||||
workspace_fields_not_imported(source, &destination_workspace)
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !skipped_fields.is_empty() {
|
||||
let source = if resources.workspaces.len() == 1 {
|
||||
resources.workspaces[0].name.clone()
|
||||
} else {
|
||||
format!("{} imported workspaces", resources.workspaces.len())
|
||||
};
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Workspace settings skipped".to_string(),
|
||||
detail: format!("{source} · {}", display_list(&skipped_fields)),
|
||||
});
|
||||
}
|
||||
}
|
||||
(workspace_id.clone(), folder_id.clone())
|
||||
}
|
||||
};
|
||||
|
||||
let resolve_workspace_id = |source_id: &str| {
|
||||
workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone())
|
||||
};
|
||||
|
||||
let resolve_folder_id = |source_id: Option<String>| match source_id {
|
||||
Some(source_id) if source_folder_ids.contains(&source_id) => {
|
||||
folder_ids.get(&source_id).cloned()
|
||||
}
|
||||
_ => target_folder_id.clone(),
|
||||
};
|
||||
|
||||
let folders = resources
|
||||
.folders
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
.map(|mut folder| {
|
||||
folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id);
|
||||
folder.workspace_id = resolve_workspace_id(&folder.workspace_id);
|
||||
folder.folder_id = resolve_folder_id(folder.folder_id);
|
||||
folder
|
||||
})
|
||||
.collect();
|
||||
|
||||
let http_requests: Vec<HttpRequest> = resources
|
||||
let http_requests = resources
|
||||
.http_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
.map(|mut request| {
|
||||
request.id = HttpRequest::generate_id();
|
||||
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
||||
request.folder_id = resolve_folder_id(request.folder_id);
|
||||
request
|
||||
})
|
||||
.collect();
|
||||
|
||||
let grpc_requests: Vec<GrpcRequest> = resources
|
||||
let grpc_requests = resources
|
||||
.grpc_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
.map(|mut request| {
|
||||
request.id = GrpcRequest::generate_id();
|
||||
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
||||
request.folder_id = resolve_folder_id(request.folder_id);
|
||||
request
|
||||
})
|
||||
.collect();
|
||||
|
||||
let websocket_requests: Vec<WebsocketRequest> = resources
|
||||
let websocket_requests = resources
|
||||
.websocket_requests
|
||||
.into_iter()
|
||||
.map(|mut v| {
|
||||
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||
v.workspace_id =
|
||||
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||
v
|
||||
.map(|mut request| {
|
||||
request.id = WebsocketRequest::generate_id();
|
||||
request.workspace_id = resolve_workspace_id(&request.workspace_id);
|
||||
request.folder_id = resolve_folder_id(request.folder_id);
|
||||
request
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!("Importing data");
|
||||
let importing_into_existing =
|
||||
matches!(destination, ImportDestination::ExistingWorkspace { .. });
|
||||
let mut separated_base_environments = Vec::new();
|
||||
let mut converted_duplicate_base_environment = false;
|
||||
let mut converted_duplicate_folder_environment = false;
|
||||
let mut base_environment_workspaces = BTreeSet::new();
|
||||
let mut folder_environment_ids = BTreeSet::new();
|
||||
let environments = resources
|
||||
.environments
|
||||
.into_iter()
|
||||
.map(|mut environment| {
|
||||
environment.id = Environment::generate_id();
|
||||
environment.workspace_id = resolve_workspace_id(&environment.workspace_id);
|
||||
|
||||
query_manager.with_tx(|tx| {
|
||||
tx.batch_upsert(
|
||||
match (environment.parent_model.as_str(), environment.parent_id.clone()) {
|
||||
("workspace", _) if importing_into_existing => {
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.parent_id = None;
|
||||
let source_name = environment.name.clone();
|
||||
environment.name = format!("{} (Imported)", environment.name);
|
||||
separated_base_environments.push((
|
||||
source_name,
|
||||
environment.name.clone(),
|
||||
environment.variables.len(),
|
||||
));
|
||||
}
|
||||
("workspace", _) => {
|
||||
environment.parent_id = None;
|
||||
if !base_environment_workspaces.insert(environment.workspace_id.clone()) {
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.name = format!("{} (Imported)", environment.name);
|
||||
converted_duplicate_base_environment = true;
|
||||
}
|
||||
}
|
||||
("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => {
|
||||
environment.parent_id = folder_ids.get(&parent_id).cloned();
|
||||
if let Some(parent_id) = &environment.parent_id
|
||||
&& !folder_environment_ids.insert(parent_id.clone())
|
||||
{
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.parent_id = None;
|
||||
converted_duplicate_folder_environment = true;
|
||||
}
|
||||
}
|
||||
("folder", _) => {
|
||||
// Never attach an imported folder environment to an existing folder: the model
|
||||
// layer permits only one and would otherwise delete the destination's value.
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.parent_id = None;
|
||||
}
|
||||
("environment", _) => {
|
||||
environment.parent_id = None;
|
||||
}
|
||||
_ => {
|
||||
environment.parent_model = "environment".to_string();
|
||||
environment.parent_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
environment
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (source_name, imported_name, variable_count) in separated_base_environments {
|
||||
let variables = if variable_count == 1 { "variable" } else { "variables" };
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Base environment kept separate".to_string(),
|
||||
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
|
||||
});
|
||||
}
|
||||
if converted_duplicate_base_environment {
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Base environments separated".to_string(),
|
||||
detail: "Only the first remains the base environment".to_string(),
|
||||
});
|
||||
}
|
||||
if converted_duplicate_folder_environment {
|
||||
warnings.push(ImportPlanWarning {
|
||||
title: "Folder environments separated".to_string(),
|
||||
detail: "Only the first remains attached to each folder".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ImportPlan {
|
||||
importer,
|
||||
destination,
|
||||
resources: BatchUpsertResult {
|
||||
workspaces,
|
||||
environments,
|
||||
folders,
|
||||
http_requests,
|
||||
grpc_requests,
|
||||
websocket_requests,
|
||||
},
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Commit a previously prepared plan in one transaction.
|
||||
pub fn commit_import_plan(
|
||||
query_manager: &QueryManager,
|
||||
plan: ImportPlan,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
validate_plan(&plan)?;
|
||||
let resources = plan.resources;
|
||||
|
||||
info!("Committing staged import from {}", plan.importer);
|
||||
query_manager.with_tx(|tx| {
|
||||
validate_destination_db(tx, &plan.destination)?;
|
||||
tx.batch_upsert(
|
||||
resources.workspaces,
|
||||
resources.environments,
|
||||
resources.folders,
|
||||
resources.http_requests,
|
||||
resources.grpc_requests,
|
||||
resources.websocket_requests,
|
||||
&UpdateSource::Import,
|
||||
)
|
||||
.map_err(crate::Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_destination(
|
||||
query_manager: &QueryManager,
|
||||
destination: &ImportDestination,
|
||||
) -> Result<()> {
|
||||
let db = query_manager.connect();
|
||||
validate_destination_db(&db, destination)
|
||||
}
|
||||
|
||||
fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> {
|
||||
let ImportDestination::ExistingWorkspace { workspace_id, folder_id } = destination else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
db.get_workspace(workspace_id)?;
|
||||
if let Some(folder_id) = folder_id {
|
||||
let folder = db.get_folder(folder_id)?;
|
||||
if folder.workspace_id != *workspace_id {
|
||||
return Err(yaak_models::error::Error::GenericError(format!(
|
||||
"Folder {folder_id} does not belong to workspace {workspace_id}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_plan(plan: &ImportPlan) -> Result<()> {
|
||||
let invalid = |message: String| -> Result<()> {
|
||||
Err(yaak_models::error::Error::GenericError(message).into())
|
||||
};
|
||||
|
||||
match &plan.destination {
|
||||
ImportDestination::ExistingWorkspace { workspace_id, .. } => {
|
||||
if !plan.resources.workspaces.is_empty() {
|
||||
return invalid(
|
||||
"An existing-workspace import plan must not contain workspace updates"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let all_workspace_ids = plan
|
||||
.resources
|
||||
.environments
|
||||
.iter()
|
||||
.map(|v| &v.workspace_id)
|
||||
.chain(plan.resources.folders.iter().map(|v| &v.workspace_id))
|
||||
.chain(plan.resources.http_requests.iter().map(|v| &v.workspace_id))
|
||||
.chain(plan.resources.grpc_requests.iter().map(|v| &v.workspace_id))
|
||||
.chain(plan.resources.websocket_requests.iter().map(|v| &v.workspace_id));
|
||||
if all_workspace_ids.into_iter().any(|id| id != workspace_id) {
|
||||
return invalid(
|
||||
"An existing-workspace import plan contains resources for another workspace"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if plan.resources.environments.iter().any(|v| v.parent_model == "workspace") {
|
||||
return invalid(
|
||||
"An existing-workspace import plan must not replace the base environment"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
ImportDestination::NewWorkspace => {
|
||||
let workspace_ids =
|
||||
plan.resources.workspaces.iter().map(|v| v.id.as_str()).collect::<BTreeSet<_>>();
|
||||
if workspace_ids.is_empty() {
|
||||
return invalid("A new-workspace import plan has no workspace".to_string());
|
||||
}
|
||||
let all_workspace_ids = plan
|
||||
.resources
|
||||
.environments
|
||||
.iter()
|
||||
.map(|v| v.workspace_id.as_str())
|
||||
.chain(plan.resources.folders.iter().map(|v| v.workspace_id.as_str()))
|
||||
.chain(plan.resources.http_requests.iter().map(|v| v.workspace_id.as_str()))
|
||||
.chain(plan.resources.grpc_requests.iter().map(|v| v.workspace_id.as_str()))
|
||||
.chain(plan.resources.websocket_requests.iter().map(|v| v.workspace_id.as_str()));
|
||||
if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) {
|
||||
return invalid(
|
||||
"A new-workspace import plan contains resources outside its workspaces"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut base_environment_workspaces = BTreeSet::new();
|
||||
if plan.resources.environments.iter().any(|v| {
|
||||
v.parent_model == "workspace"
|
||||
&& !base_environment_workspaces.insert(v.workspace_id.as_str())
|
||||
}) {
|
||||
return invalid(
|
||||
"A new-workspace import plan contains multiple base environments for one workspace"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let planned_folder_ids =
|
||||
plan.resources.folders.iter().map(|v| v.id.as_str()).collect::<BTreeSet<_>>();
|
||||
if plan.resources.environments.iter().any(|v| {
|
||||
v.parent_model == "folder"
|
||||
&& v.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id))
|
||||
}) {
|
||||
return invalid(
|
||||
"An import plan must not replace an existing folder environment".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_importer_name(importer: &str) -> &str {
|
||||
importer.strip_prefix("@yaak/importer-").unwrap_or(importer)
|
||||
}
|
||||
|
||||
fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> {
|
||||
let mut fields = Vec::new();
|
||||
if source.name != destination.name {
|
||||
fields.push("workspace name");
|
||||
}
|
||||
if source.description != destination.description {
|
||||
fields.push("description");
|
||||
}
|
||||
if source.authentication != destination.authentication
|
||||
|| source.authentication_type != destination.authentication_type
|
||||
{
|
||||
fields.push("authentication");
|
||||
}
|
||||
if source.headers != destination.headers {
|
||||
fields.push("default headers");
|
||||
}
|
||||
if source.encryption_key_challenge != destination.encryption_key_challenge {
|
||||
fields.push("encryption configuration");
|
||||
}
|
||||
if source.setting_validate_certificates != destination.setting_validate_certificates {
|
||||
fields.push("certificate validation");
|
||||
}
|
||||
if source.setting_follow_redirects != destination.setting_follow_redirects {
|
||||
fields.push("redirect behavior");
|
||||
}
|
||||
if source.setting_request_timeout != destination.setting_request_timeout {
|
||||
fields.push("request timeout");
|
||||
}
|
||||
if source.setting_request_message_size != destination.setting_request_message_size {
|
||||
fields.push("request message size");
|
||||
}
|
||||
if source.setting_dns_overrides != destination.setting_dns_overrides {
|
||||
fields.push("DNS overrides");
|
||||
}
|
||||
if source.setting_send_cookies != destination.setting_send_cookies
|
||||
|| source.setting_store_cookies != destination.setting_store_cookies
|
||||
{
|
||||
fields.push("cookie behavior");
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
fn display_list(items: &BTreeSet<&str>) -> String {
|
||||
let items = items.iter().copied().collect::<Vec<_>>();
|
||||
match items.as_slice() {
|
||||
[] => String::new(),
|
||||
[item] => (*item).to_string(),
|
||||
[first, second] => format!("{first} and {second}"),
|
||||
_ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use yaak_models::models::{EnvironmentVariable, HttpRequestHeader};
|
||||
|
||||
fn destination_workspace() -> Workspace {
|
||||
Workspace {
|
||||
id: "wk_destination".to_string(),
|
||||
model: "workspace".to_string(),
|
||||
name: "Destination".to_string(),
|
||||
authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]),
|
||||
authentication_type: Some("bearer".to_string()),
|
||||
headers: vec![HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "X-Destination".to_string(),
|
||||
value: "preserved".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
setting_validate_certificates: false,
|
||||
setting_follow_redirects: false,
|
||||
setting_request_timeout: 1234,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn imported_resources() -> ImportResources {
|
||||
ImportResources {
|
||||
workspaces: vec![Workspace {
|
||||
id: "wk_source".to_string(),
|
||||
model: "workspace".to_string(),
|
||||
name: "Imported".to_string(),
|
||||
authentication_type: Some("basic".to_string()),
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
}],
|
||||
environments: vec![Environment {
|
||||
id: "ev_source_base".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Global Variables".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "imported".to_string(),
|
||||
value: "yes".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
folders: vec![Folder {
|
||||
id: "fl_source".to_string(),
|
||||
model: "folder".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Imported Folder".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
http_requests: vec![
|
||||
HttpRequest {
|
||||
id: "rq_root".to_string(),
|
||||
model: "http_request".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Root Request".to_string(),
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.com/root".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
HttpRequest {
|
||||
id: "rq_nested".to_string(),
|
||||
model: "http_request".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
folder_id: Some("fl_source".to_string()),
|
||||
name: "Nested Request".to_string(),
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.com/nested".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let mut destination = destination_workspace();
|
||||
let selected_folder = Folder {
|
||||
id: "fl_selected".to_string(),
|
||||
model: "folder".to_string(),
|
||||
workspace_id: destination.id.clone(),
|
||||
name: "Selected Folder".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
destination = db
|
||||
.upsert_workspace(&destination, &UpdateSource::Import)
|
||||
.expect("create destination");
|
||||
db.upsert_folder(&selected_folder, &UpdateSource::Import)
|
||||
.expect("create selected folder");
|
||||
db.upsert_environment(
|
||||
&Environment {
|
||||
id: "ev_destination_base".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: destination.id.clone(),
|
||||
name: "Destination Variables".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "destination".to_string(),
|
||||
value: "keep".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Import,
|
||||
)
|
||||
.expect("create base environment");
|
||||
}
|
||||
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"OpenAPI".to_string(),
|
||||
ImportDestination::ExistingWorkspace {
|
||||
workspace_id: destination.id.clone(),
|
||||
folder_id: Some(selected_folder.id.clone()),
|
||||
},
|
||||
imported_resources(),
|
||||
)
|
||||
.expect("plan import");
|
||||
|
||||
// Planning performed only reads.
|
||||
{
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
|
||||
assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1);
|
||||
assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty());
|
||||
assert_eq!(
|
||||
db.list_environments_ensure_base(&destination.id).expect("list environments").len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
|
||||
}
|
||||
|
||||
assert!(plan.resources.workspaces.is_empty());
|
||||
assert_eq!(plan.resources.folders[0].workspace_id, destination.id);
|
||||
assert_eq!(
|
||||
plan.resources.folders[0].folder_id.as_deref(),
|
||||
Some(selected_folder.id.as_str())
|
||||
);
|
||||
let root_request = plan
|
||||
.resources
|
||||
.http_requests
|
||||
.iter()
|
||||
.find(|v| v.name == "Root Request")
|
||||
.expect("root request");
|
||||
assert_eq!(root_request.folder_id.as_deref(), Some(selected_folder.id.as_str()));
|
||||
let nested_request = plan
|
||||
.resources
|
||||
.http_requests
|
||||
.iter()
|
||||
.find(|v| v.name == "Nested Request")
|
||||
.expect("nested request");
|
||||
assert_eq!(nested_request.folder_id, Some(plan.resources.folders[0].id.clone()));
|
||||
assert_eq!(plan.resources.environments[0].parent_model, "environment");
|
||||
assert!(plan.resources.environments[0].name.ends_with("(Imported)"));
|
||||
assert_eq!(plan.warnings.len(), 2);
|
||||
assert!(plan.warnings.iter().any(|warning| {
|
||||
warning.title == "Workspace settings skipped"
|
||||
&& warning.detail.starts_with("Imported ·")
|
||||
&& warning.detail.contains("authentication")
|
||||
&& warning.detail.contains("default headers")
|
||||
}));
|
||||
assert!(plan.warnings.iter().any(|warning| {
|
||||
warning.title == "Base environment kept separate"
|
||||
&& warning.detail == "Global Variables → Global Variables (Imported) · 1 variable"
|
||||
}));
|
||||
|
||||
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
|
||||
assert!(committed.workspaces.is_empty());
|
||||
assert_eq!(committed.http_requests.len(), 2);
|
||||
assert_eq!(
|
||||
query_manager
|
||||
.connect()
|
||||
.get_workspace(&destination.id)
|
||||
.expect("get destination after commit"),
|
||||
destination
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_collisions_are_explicit_and_do_not_overwrite() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let mut resources = imported_resources();
|
||||
resources.environments.extend([
|
||||
Environment {
|
||||
id: "ev_second_base".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Second Base".to_string(),
|
||||
parent_model: "workspace".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
Environment {
|
||||
id: "ev_folder_one".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Folder One".to_string(),
|
||||
parent_model: "folder".to_string(),
|
||||
parent_id: Some("fl_source".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
Environment {
|
||||
id: "ev_folder_two".to_string(),
|
||||
model: "environment".to_string(),
|
||||
workspace_id: "wk_source".to_string(),
|
||||
name: "Folder Two".to_string(),
|
||||
parent_model: "folder".to_string(),
|
||||
parent_id: Some("fl_source".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
]);
|
||||
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"Yaak".to_string(),
|
||||
ImportDestination::NewWorkspace,
|
||||
resources,
|
||||
)
|
||||
.expect("plan import");
|
||||
|
||||
assert_eq!(
|
||||
plan.resources.environments.iter().filter(|v| v.parent_model == "workspace").count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
plan.resources.environments.iter().filter(|v| v.parent_model == "folder").count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(plan.warnings.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importer_id_conventions_all_flow_through_the_same_planner() {
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_in_memory().expect("initialize database");
|
||||
let destination = destination_workspace();
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(&destination, &UpdateSource::Import)
|
||||
.expect("create destination");
|
||||
let resources = ImportResources {
|
||||
workspaces: vec![
|
||||
Workspace {
|
||||
id: "GENERATE_ID::WORKSPACE_0".to_string(),
|
||||
model: "workspace".to_string(),
|
||||
name: "Generated ID Importer".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
Workspace {
|
||||
id: "wk_exported".to_string(),
|
||||
model: "workspace".to_string(),
|
||||
name: "Stable ID Importer".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
http_requests: [
|
||||
"GENERATE_ID::WORKSPACE_0",
|
||||
"wk_exported",
|
||||
"CURRENT_WORKSPACE",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, workspace_id)| HttpRequest {
|
||||
id: format!("GENERATE_ID::HTTP_REQUEST_{index}"),
|
||||
model: "http_request".to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
name: format!("Request {index}"),
|
||||
method: "GET".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"Compatibility".to_string(),
|
||||
ImportDestination::ExistingWorkspace {
|
||||
workspace_id: destination.id.clone(),
|
||||
folder_id: None,
|
||||
},
|
||||
resources,
|
||||
)
|
||||
.expect("plan import");
|
||||
|
||||
assert!(plan.resources.workspaces.is_empty());
|
||||
assert!(plan.resources.http_requests.iter().all(|v| v.workspace_id == destination.id));
|
||||
assert_eq!(
|
||||
plan.resources
|
||||
.http_requests
|
||||
.iter()
|
||||
.map(|v| v.id.as_str())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_rolls_back_every_resource_when_a_late_write_fails() {
|
||||
let dir = tempfile::tempdir().expect("create temp directory");
|
||||
let db_path = dir.path().join("models.sqlite");
|
||||
let blob_path = dir.path().join("blobs.sqlite");
|
||||
let (query_manager, _blob_manager, _rx) =
|
||||
yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database");
|
||||
let plan = plan_import_resources(
|
||||
&query_manager,
|
||||
"OpenAPI".to_string(),
|
||||
ImportDestination::NewWorkspace,
|
||||
imported_resources(),
|
||||
)
|
||||
.expect("plan import");
|
||||
let workspace_id = plan.resources.workspaces[0].id.clone();
|
||||
let environment_id = plan.resources.environments[0].id.clone();
|
||||
|
||||
let connection = rusqlite::Connection::open(&db_path).expect("open test database");
|
||||
connection
|
||||
.execute_batch(&format!(
|
||||
"CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \
|
||||
WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;"
|
||||
))
|
||||
.expect("install failure trigger");
|
||||
drop(connection);
|
||||
|
||||
assert!(commit_import_plan(&query_manager, plan).is_err());
|
||||
let db = query_manager.connect();
|
||||
assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back");
|
||||
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
// Anything that needs files the page can't reach.
|
||||
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "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_export_data: ["Exporting to a file isn't available in the browser yet", "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"],
|
||||
|
||||
+1
-1
@@ -474,7 +474,7 @@ export type ImportRequest = { content: string, };
|
||||
|
||||
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
|
||||
export type ImportResponse = { resources: ImportResources, };
|
||||
export type ImportResponse = { importer: string, resources: ImportResources, };
|
||||
|
||||
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ export class PluginInstance {
|
||||
if (reply != null) {
|
||||
const replyPayload: InternalEventPayload = {
|
||||
type: "import_response",
|
||||
importer: this.#mod.importer.name,
|
||||
resources: reply.resources as ImportResources,
|
||||
};
|
||||
this.#sendPayload(context, replyPayload, replyId);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { forwardRef } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { LoadingIcon } from "./LoadingIcon";
|
||||
|
||||
type ButtonVariant = "border" | "solid";
|
||||
type ButtonVariant = "border" | "solid" | "input";
|
||||
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
|
||||
|
||||
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
|
||||
@@ -88,6 +88,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
resolvedColor !== "custom" &&
|
||||
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
|
||||
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
|
||||
// Chrome of a form input rather than a button: no hover state, and colors resolve from the
|
||||
// surrounding x-theme-input rather than a button theme.
|
||||
variant === "input" && "border-border text-text",
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
|
||||
Reference in New Issue
Block a user