mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-06 18:07:18 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d11a5c4ea3 |
@@ -0,0 +1,3 @@
|
|||||||
|
**/bindings/**
|
||||||
|
**/routeTree.gen.ts
|
||||||
|
crates/yaak-templates/pkg/**
|
||||||
Generated
-3
@@ -11223,12 +11223,9 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
|
||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
"md5 0.8.0",
|
"md5 0.8.0",
|
||||||
"rusqlite",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { linter } from "@codemirror/lint";
|
import { linter } from "@codemirror/lint";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||||
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
|
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||||
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
|
import classNames from "classnames";
|
||||||
import {
|
import {
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
jsonCompletion,
|
jsonCompletion,
|
||||||
@@ -10,20 +11,12 @@ import {
|
|||||||
stateExtensions,
|
stateExtensions,
|
||||||
updateSchema,
|
updateSchema,
|
||||||
} from "codemirror-json-schema";
|
} from "codemirror-json-schema";
|
||||||
import type { JSONSchema7 } from "json-schema";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
|
||||||
import { showAlert } from "../lib/alert";
|
import { showAlert } from "../lib/alert";
|
||||||
import { showConfirm } from "../lib/confirm";
|
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
|
||||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { queryClient } from "../lib/queryClient";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Dropdown } from "./core/Dropdown";
|
|
||||||
import type { EditorProps } from "./core/Editor/Editor";
|
import type { EditorProps } from "./core/Editor/Editor";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||||
@@ -36,11 +29,6 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
|
|||||||
protoFiles: string[];
|
protoFiles: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type MethodSchema =
|
|
||||||
| { type: "none" }
|
|
||||||
| { type: "schema"; schema: JsonSchema }
|
|
||||||
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
|
|
||||||
|
|
||||||
export function GrpcEditor({
|
export function GrpcEditor({
|
||||||
services,
|
services,
|
||||||
reflectionError,
|
reflectionError,
|
||||||
@@ -54,16 +42,21 @@ export function GrpcEditor({
|
|||||||
setEditorView(h);
|
setEditorView(h);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Find the schema for the selected service and method
|
// Find the schema for the selected service and method and update the editor
|
||||||
const methodSchema = useMemo<MethodSchema>(() => {
|
useEffect(() => {
|
||||||
if (services === null || request.service === null || request.method === null) {
|
if (
|
||||||
return { type: "none" };
|
editorView == null ||
|
||||||
|
services === null ||
|
||||||
|
request.service === null ||
|
||||||
|
request.method === null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = services.find((s) => s.name === request.service);
|
const s = services.find((s) => s.name === request.service);
|
||||||
if (s == null) {
|
if (s == null) {
|
||||||
return {
|
console.log("Failed to find service", { service: request.service, services });
|
||||||
type: "error",
|
showAlert({
|
||||||
id: "grpc-find-service-error",
|
id: "grpc-find-service-error",
|
||||||
title: "Couldn't Find Service",
|
title: "Couldn't Find Service",
|
||||||
body: (
|
body: (
|
||||||
@@ -71,14 +64,14 @@ export function GrpcEditor({
|
|||||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
log: ["Failed to find service", { service: request.service, services }],
|
});
|
||||||
};
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||||
if (schema == null) {
|
if (request.method != null && schema == null) {
|
||||||
return {
|
console.log("Failed to find method", { method: request.method, methods: s?.methods });
|
||||||
type: "error",
|
showAlert({
|
||||||
id: "grpc-find-schema-error",
|
id: "grpc-find-schema-error",
|
||||||
title: "Couldn't Find Method",
|
title: "Couldn't Find Method",
|
||||||
body: (
|
body: (
|
||||||
@@ -87,15 +80,18 @@ export function GrpcEditor({
|
|||||||
<InlineCode>{request.service}</InlineCode> in schema
|
<InlineCode>{request.service}</InlineCode> in schema
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
log: ["Failed to find method", { method: request.method, methods: s.methods }],
|
});
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
|
updateSchema(editorView, JSON.parse(schema));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
showAlert({
|
||||||
type: "error",
|
|
||||||
id: "grpc-parse-schema-error",
|
id: "grpc-parse-schema-error",
|
||||||
title: "Failed to Parse Schema",
|
title: "Failed to Parse Schema",
|
||||||
body: (
|
body: (
|
||||||
@@ -107,22 +103,9 @@ export function GrpcEditor({
|
|||||||
<FormattedError>{String(err)}</FormattedError>
|
<FormattedError>{String(err)}</FormattedError>
|
||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
log: ["Failed to parse schema", err],
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}, [services, request.method, request.service]);
|
}, [editorView, 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(
|
const extraExtensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -141,145 +124,45 @@ export function GrpcEditor({
|
|||||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
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(
|
const actions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
// Matches the GraphQL editor: one always-visible control labelled by schema state,
|
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
|
||||||
// with everything schema-related behind it.
|
<Button
|
||||||
<div key="schema" className="opacity-100!">
|
size="xs"
|
||||||
<Dropdown
|
color={
|
||||||
items={[
|
reflectionLoading
|
||||||
{
|
? "secondary"
|
||||||
// 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} />,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="border"
|
|
||||||
title="Schema"
|
|
||||||
forDropdown
|
|
||||||
isLoading={reflectionLoading}
|
|
||||||
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
|
|
||||||
>
|
|
||||||
{reflectionLoading
|
|
||||||
? "Inspecting Schema"
|
|
||||||
: reflectionUnavailable
|
: reflectionUnavailable
|
||||||
? "Select Proto Files"
|
? "info"
|
||||||
: reflectionError
|
: reflectionError
|
||||||
? "Server Error"
|
? "danger"
|
||||||
: protoFiles.length > 0
|
: "secondary"
|
||||||
? pluralizeCount("File", protoFiles.length)
|
}
|
||||||
: services != null
|
isLoading={reflectionLoading}
|
||||||
? "Schema Detected"
|
onClick={() => {
|
||||||
: "Select Schema"}
|
showDialog({
|
||||||
</Button>
|
title: "Configure Schema",
|
||||||
</Dropdown>
|
size: "md",
|
||||||
|
id: "reflection-failed",
|
||||||
|
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>
|
||||||
</div>,
|
</div>,
|
||||||
],
|
],
|
||||||
[
|
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
|
||||||
handleGenerateExample,
|
|
||||||
handleReloadSchema,
|
|
||||||
handleShowReflectionError,
|
|
||||||
methodSchema.type,
|
|
||||||
protoFiles.length,
|
|
||||||
reflectionError,
|
|
||||||
reflectionLoading,
|
|
||||||
reflectionUnavailable,
|
|
||||||
services,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -194,6 +194,13 @@ export function GrpcRequestPane({
|
|||||||
type: "default",
|
type: "default",
|
||||||
shortLabel: o.label,
|
shortLabel: o.label,
|
||||||
}))}
|
}))}
|
||||||
|
itemsAfter={[
|
||||||
|
{
|
||||||
|
label: "Refresh",
|
||||||
|
type: "default",
|
||||||
|
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,38 +1,15 @@
|
|||||||
import {
|
|
||||||
type Folder,
|
|
||||||
type ImportDestination,
|
|
||||||
type ImportPlan,
|
|
||||||
type ImportPlanItem,
|
|
||||||
type ImportSource,
|
|
||||||
type Workspace,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useLocalStorage } from "react-use";
|
||||||
import { pluralize } from "../lib/pluralize";
|
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
|
||||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
|
||||||
import { CheckboxTree } from "./core/CheckboxTree";
|
|
||||||
import { IconTooltip } from "./core/IconTooltip";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { Select } from "./core/Select";
|
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentWorkspace: Workspace | null;
|
importFile: (filePath: string) => Promise<void>;
|
||||||
workspaces: Workspace[];
|
importUrl: (url: string) => Promise<void>;
|
||||||
selectedFolder: Folder | null;
|
|
||||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
|
||||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
|
||||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
|
||||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
|
||||||
commit: (plan: ImportPlan) => Promise<void>;
|
|
||||||
cancel: () => void;
|
|
||||||
onError: (err: unknown) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,74 +31,10 @@ function fileName(path: string): string {
|
|||||||
return path.split(/[/\\]/).at(-1) || path;
|
return path.split(/[/\\]/).at(-1) || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||||
* Loads the current workspace's linked sources before rendering the dialog, so the inner
|
|
||||||
* component can construct its initial state (prefilled path, destination) in one pass instead of
|
|
||||||
* patching it in with effects after the first paint.
|
|
||||||
*/
|
|
||||||
export function ImportDataDialog(props: Props) {
|
|
||||||
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
|
|
||||||
const { currentWorkspace, listSources } = props;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
|
|
||||||
load
|
|
||||||
.then((sources) => {
|
|
||||||
if (!cancelled) setInitialSources(sources);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setInitialSources([]);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [currentWorkspace, listSources]);
|
|
||||||
|
|
||||||
if (initialSources == null) return null;
|
|
||||||
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestSource(sources: ImportSource[]): ImportSource | null {
|
|
||||||
return sources.reduce<ImportSource | null>(
|
|
||||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadedImportDataDialog({
|
|
||||||
currentWorkspace,
|
|
||||||
workspaces,
|
|
||||||
selectedFolder,
|
|
||||||
planFile,
|
|
||||||
planUrl,
|
|
||||||
listSources,
|
|
||||||
findSourcesForOrigin,
|
|
||||||
commit,
|
|
||||||
cancel,
|
|
||||||
onError,
|
|
||||||
initialSources,
|
|
||||||
}: Props & { initialSources: ImportSource[] }) {
|
|
||||||
// A workspace with a linked source is probably being re-imported, so start from that source
|
|
||||||
const prefill = latestSource(initialSources);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
|
||||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
|
||||||
// null means no explicit choice yet, so the default below applies
|
|
||||||
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
|
||||||
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
|
|
||||||
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
|
|
||||||
prefill != null ? initialSources : [],
|
|
||||||
);
|
|
||||||
const [originSources, setOriginSources] = useState<ImportSource[]>(
|
|
||||||
prefill != null ? [prefill] : [],
|
|
||||||
);
|
|
||||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||||
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
|
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -152,277 +65,25 @@ function LoadedImportDataDialog({
|
|||||||
});
|
});
|
||||||
}, [isHovering, setSource]);
|
}, [isHovering, setSource]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (trimmedSource === "") {
|
|
||||||
setOriginSources([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
|
|
||||||
.then((sources) => {
|
|
||||||
if (!cancelled) setOriginSources(sources);
|
|
||||||
})
|
|
||||||
.catch(() => setOriginSources([]));
|
|
||||||
}, 300);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, [trimmedSource, filePath, findSourcesForOrigin]);
|
|
||||||
|
|
||||||
// The one workspace this file is linked to, if there is exactly one.
|
|
||||||
const linkedWorkspace = useMemo(() => {
|
|
||||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
|
||||||
if (ids.length !== 1) return null;
|
|
||||||
return workspaces.find((w) => w.id === ids[0]) ?? null;
|
|
||||||
}, [originSources, workspaces]);
|
|
||||||
|
|
||||||
// A file linked to the current workspace defaults back into it, so re-importing doesn't
|
|
||||||
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
|
|
||||||
// silently targeting a workspace that is neither new nor current is too surprising. An
|
|
||||||
// explicit choice always wins.
|
|
||||||
const destinationKind =
|
|
||||||
destinationChoice ??
|
|
||||||
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
|
|
||||||
|
|
||||||
const destinationWorkspaceId =
|
|
||||||
destinationKind === "current"
|
|
||||||
? (currentWorkspace?.id ?? null)
|
|
||||||
: destinationKind === "other"
|
|
||||||
? otherWorkspaceId
|
|
||||||
: null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (destinationWorkspaceId == null) {
|
|
||||||
setLinkedSources([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
listSources(destinationWorkspaceId)
|
|
||||||
.then((sources) => {
|
|
||||||
if (!cancelled) setLinkedSources(sources);
|
|
||||||
})
|
|
||||||
.catch(() => setLinkedSources([]));
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [destinationWorkspaceId, listSources]);
|
|
||||||
|
|
||||||
const handleSelectFile = async () => {
|
const handleSelectFile = async () => {
|
||||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
||||||
if (selected == null) return;
|
if (selected == null) return;
|
||||||
selectSource(selected);
|
selectSource(selected);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
|
const handleImport = async () => {
|
||||||
// is also the destination.
|
|
||||||
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
|
|
||||||
|
|
||||||
const destination = (): ImportDestination => {
|
|
||||||
if (destinationWorkspaceId == null) {
|
|
||||||
return { type: "new_workspace" };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "existing_workspace",
|
|
||||||
workspaceId: destinationWorkspaceId,
|
|
||||||
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreview = async () => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const nextPlan =
|
if (filePath != null) {
|
||||||
filePath != null
|
await importFile(filePath);
|
||||||
? await planFile(filePath, destination())
|
} else {
|
||||||
: await planUrl(trimmedSource, destination());
|
await importUrl(trimmedSource);
|
||||||
setPlan(nextPlan);
|
}
|
||||||
setItems(nextPlan.items);
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommit = async () => {
|
|
||||||
if (plan == null) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await commit({ ...plan, items });
|
|
||||||
} catch (err) {
|
|
||||||
onError(err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
|
||||||
|
|
||||||
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
|
|
||||||
// updates toggle together, while removals only ever cascade beneath a removed folder. Checking
|
|
||||||
// anything also brings back the folders it needs to live in.
|
|
||||||
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
|
|
||||||
const targets = new Set(
|
|
||||||
collectItems(node)
|
|
||||||
.filter((i) => togglesWith(node.data, i))
|
|
||||||
.map((i) => i.modelId),
|
|
||||||
);
|
|
||||||
if (checked) {
|
|
||||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
|
||||||
for (const ancestor of ancestorsOf(node.data, byId)) {
|
|
||||||
if (ancestor.action === "not_imported") targets.add(ancestor.modelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
|
|
||||||
setItems((prev) =>
|
|
||||||
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
|
|
||||||
// new folder can't exist, and a removed folder takes its contents with it.
|
|
||||||
const disabledIds = useMemo(() => {
|
|
||||||
const disabled = new Set<string>();
|
|
||||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
|
||||||
for (const item of items) {
|
|
||||||
for (const parent of ancestorsOf(item, byId)) {
|
|
||||||
if (parent.model !== "folder") break;
|
|
||||||
const missing =
|
|
||||||
(parent.action === "create" || parent.action === "not_imported") && !parent.selected;
|
|
||||||
// A not-imported row stays checkable: checking it brings its folders back with it
|
|
||||||
if (missing && item.action !== "delete" && item.action !== "not_imported") {
|
|
||||||
disabled.add(item.modelId);
|
|
||||||
}
|
|
||||||
if (parent.action === "delete" && parent.selected && item.action === "delete") {
|
|
||||||
disabled.add(item.modelId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return disabled;
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
if (plan != null) {
|
|
||||||
const unchanged = items.filter((i) => i.action === "unchanged");
|
|
||||||
const footerNote =
|
|
||||||
unchanged.length > 0
|
|
||||||
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
|
|
||||||
: "";
|
|
||||||
const changeCount = items.filter((item) => {
|
|
||||||
if (disabledIds.has(item.modelId)) return false;
|
|
||||||
if (item.action === "conflict") return item.resolution === "take_source";
|
|
||||||
if (item.action === "unchanged") return false;
|
|
||||||
return item.selected;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
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;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
|
||||||
// it — so its checkbox only aggregates the subtree.
|
|
||||||
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
|
|
||||||
const planned = plan.resources.workspaces[0];
|
|
||||||
const planDestination = plan.destination;
|
|
||||||
const existing =
|
|
||||||
planDestination.type === "existing_workspace"
|
|
||||||
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
key: existing?.id ?? planned?.id ?? "workspace",
|
|
||||||
data: {
|
|
||||||
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
|
|
||||||
model: "workspace",
|
|
||||||
modelId: existing?.id ?? planned?.id ?? "workspace",
|
|
||||||
name: existing?.name ?? planned?.name ?? "New workspace",
|
|
||||||
selected: true,
|
|
||||||
changedFields: [],
|
|
||||||
},
|
|
||||||
children: itemTree,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
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 className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
|
|
||||||
<CheckboxTree
|
|
||||||
node={workspaceRoot}
|
|
||||||
checked={nodeCheckedStatus}
|
|
||||||
onCheck={toggleNode}
|
|
||||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
|
||||||
isCollapsedByDefault={(n) => n.data.action === "not_imported"}
|
|
||||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
|
||||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
|
||||||
/>
|
|
||||||
</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} alignItems="center" className="mt-3">
|
|
||||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => {
|
|
||||||
setPlan(null);
|
|
||||||
setItems([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
|
|
||||||
{isLoading
|
|
||||||
? "Importing"
|
|
||||||
: changeCount > 0
|
|
||||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
|
||||||
: "Done"}
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastImported =
|
|
||||||
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
|
|
||||||
linkedSources.reduce<ImportSource | null>(
|
|
||||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack ref={ref} space={4} className="pb-4">
|
<VStack ref={ref} space={4} className="pb-4">
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||||
@@ -433,9 +94,7 @@ function LoadedImportDataDialog({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||||
"flex flex-col items-center gap-1 text-center",
|
"flex flex-col items-center gap-1 text-center",
|
||||||
isHovering
|
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||||
? "border-notice bg-surface-highlight"
|
|
||||||
: "border-border hover:border-text-subtle",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||||
@@ -456,285 +115,25 @@ function LoadedImportDataDialog({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</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}>
|
<VStack space={2}>
|
||||||
<Select
|
<PlainInput
|
||||||
name="import-destination-kind"
|
label="Or enter a file path or URL"
|
||||||
label="Import location"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
value={destinationKind}
|
placeholder="https://example.com/openapi.json"
|
||||||
onChange={setDestinationChoice}
|
defaultValue={source ?? ""}
|
||||||
options={[
|
forceUpdateKey={String(forceUpdateKey)}
|
||||||
{ value: "new", label: "New Workspace" },
|
onChange={setSource}
|
||||||
...(currentWorkspace != null
|
|
||||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
|
||||||
: []),
|
|
||||||
{ value: "other", label: "Other Workspace" },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
{destinationKind === "other" && (
|
|
||||||
<Select
|
|
||||||
name="import-destination-workspace"
|
|
||||||
label="Workspace"
|
|
||||||
hideLabel
|
|
||||||
size="sm"
|
|
||||||
value={otherWorkspaceId ?? ""}
|
|
||||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
|
||||||
filterable
|
|
||||||
options={[
|
|
||||||
{ value: "", label: "Select a workspace" },
|
|
||||||
...workspaces
|
|
||||||
.filter((w) => w.id !== currentWorkspace?.id)
|
|
||||||
.map((w) => ({ value: w.id, label: w.name })),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{lastImported != null && destinationWorkspaceId != null ? (
|
|
||||||
<div className="text-xs text-text-subtle">
|
|
||||||
Last imported from {lastImported.originLabel} ·{" "}
|
|
||||||
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
|
|
||||||
</div>
|
|
||||||
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
|
|
||||||
<div className="text-xs text-text-subtle">
|
|
||||||
This file was last imported into{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline hocus:text-text"
|
|
||||||
onClick={() => {
|
|
||||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
|
||||||
setDestinationChoice("current");
|
|
||||||
} else {
|
|
||||||
setDestinationChoice("other");
|
|
||||||
setOtherWorkspaceId(linkedWorkspace.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{linkedWorkspace.name}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{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
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
disabled={
|
disabled={trimmedSource === "" || isLoading}
|
||||||
trimmedSource === "" ||
|
|
||||||
isLoading ||
|
|
||||||
(destinationKind === "other" && otherWorkspaceId == null)
|
|
||||||
}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onClick={handlePreview}
|
size="sm"
|
||||||
|
onClick={handleImport}
|
||||||
>
|
>
|
||||||
{isLoading ? "Analyzing" : "Preview Import"}
|
{isLoading ? "Importing" : "Import"}
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</VStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportTreeRow({
|
|
||||||
item,
|
|
||||||
onResolveConflict,
|
|
||||||
}: {
|
|
||||||
item: ImportPlanItem;
|
|
||||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
|
|
||||||
<Icon
|
|
||||||
color="secondary"
|
|
||||||
icon={
|
|
||||||
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden className="w-4" />
|
|
||||||
)}
|
|
||||||
<div className="truncate flex-1">{item.name}</div>
|
|
||||||
{item.action === "conflict" ? (
|
|
||||||
<div className="shrink-0">
|
|
||||||
<SegmentedControl
|
|
||||||
name={`conflict-${item.modelId}`}
|
|
||||||
label={`Resolve conflict for ${item.name}`}
|
|
||||||
hideLabel
|
|
||||||
size="2xs"
|
|
||||||
help={actionHelp(item)}
|
|
||||||
value={item.resolution ?? "keep_mine"}
|
|
||||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
|
||||||
options={[
|
|
||||||
{ value: "keep_mine", label: "Keep mine" },
|
|
||||||
{ value: "take_source", label: "Take source" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
actionLabel(item) && (
|
|
||||||
<InlineCode
|
|
||||||
className={classNames(
|
|
||||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
|
||||||
"inline-flex items-center justify-center gap-1.5",
|
|
||||||
item.action === "create" && "text-success",
|
|
||||||
item.action === "update" && "text-info",
|
|
||||||
item.action === "delete" && "text-danger",
|
|
||||||
item.action === "keep_local" && item.selected && "text-warning",
|
|
||||||
item.action === "not_imported" && "text-text-subtlest",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{actionLabel(item)}
|
|
||||||
<IconTooltip content={actionHelp(item)} iconSize="xs" />
|
|
||||||
</InlineCode>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionLabel(item: ImportPlanItem): string | null {
|
|
||||||
switch (item.action) {
|
|
||||||
case "create":
|
|
||||||
return "new";
|
|
||||||
case "update":
|
|
||||||
return "updated";
|
|
||||||
case "delete":
|
|
||||||
return "removed";
|
|
||||||
case "keep_local":
|
|
||||||
return "edited";
|
|
||||||
case "not_imported":
|
|
||||||
return "not imported";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionHelp(item: ImportPlanItem): string | null {
|
|
||||||
const help = (text: string) =>
|
|
||||||
item.changedFields.length > 0
|
|
||||||
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
|
|
||||||
: text;
|
|
||||||
switch (item.action) {
|
|
||||||
case "create":
|
|
||||||
return "Added since the last import";
|
|
||||||
case "update":
|
|
||||||
return help("Changed since the last import");
|
|
||||||
case "delete":
|
|
||||||
return item.reason === "moved_into_not_imported_folder"
|
|
||||||
? "Moved into a folder that isn't imported. Import that folder instead to follow the move"
|
|
||||||
: "Deleted since the last import";
|
|
||||||
case "keep_local":
|
|
||||||
return help("Local edits made since the last import. Importing will revert them if checked");
|
|
||||||
case "conflict":
|
|
||||||
return help("Changed both here and in the file since the last import");
|
|
||||||
case "not_imported":
|
|
||||||
return "In the file, but not imported. Check it to import it";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldLabel(field: string): string {
|
|
||||||
return field.replace(/([A-Z])/g, " $1").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every plan item above `item`, nearest first. */
|
|
||||||
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
|
|
||||||
const ancestors: ImportPlanItem[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
let parentId = item.parentId;
|
|
||||||
while (parentId != null && !seen.has(parentId)) {
|
|
||||||
seen.add(parentId);
|
|
||||||
const parent = byId.get(parentId);
|
|
||||||
if (parent == null) break;
|
|
||||||
ancestors.push(parent);
|
|
||||||
parentId = parent.parentId;
|
|
||||||
}
|
|
||||||
return ancestors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
|
|
||||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
|
||||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
|
||||||
const roots: ImportPlanItem[] = [];
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.parentId != null && byId.has(item.parentId)) {
|
|
||||||
const siblings = childrenOf.get(item.parentId) ?? [];
|
|
||||||
siblings.push(item);
|
|
||||||
childrenOf.set(item.parentId, siblings);
|
|
||||||
} else {
|
|
||||||
roots.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const foldersFirst = (list: ImportPlanItem[]) => [
|
|
||||||
...list.filter((i) => i.model === "folder"),
|
|
||||||
...list.filter((i) => i.model !== "folder"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
|
|
||||||
key: item.modelId,
|
|
||||||
data: item,
|
|
||||||
children: seen.has(item.modelId)
|
|
||||||
? []
|
|
||||||
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
|
|
||||||
toNode(c, new Set([...seen, item.modelId])),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
return foldersFirst(roots).map((r) => toNode(r, new Set()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
|
|
||||||
return [node.data, ...node.children.flatMap(collectItems)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
|
|
||||||
* (deletions, reverting local edits) never ride along with a parent toggle.
|
|
||||||
*/
|
|
||||||
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
|
|
||||||
if (item.model === "workspace") return false;
|
|
||||||
if (root.action === "delete") return item.action === "delete";
|
|
||||||
if (item.action === "keep_local") {
|
|
||||||
return root.modelId === item.modelId && item.model !== "folder";
|
|
||||||
}
|
|
||||||
return item.action === "create" || item.action === "update" || item.action === "not_imported";
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeCheckedStatus(
|
|
||||||
node: CheckboxTreeNode<ImportPlanItem>,
|
|
||||||
): boolean | "indeterminate" | "hidden" {
|
|
||||||
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
|
|
||||||
if (covered.length === 0) return "hidden";
|
|
||||||
const selected = covered.filter((i) => i.selected).length;
|
|
||||||
if (selected === covered.length) return true;
|
|
||||||
if (selected === 0) return false;
|
|
||||||
return "indeterminate";
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { CheckboxProps } from "./Checkbox";
|
|
||||||
import { Checkbox } from "./Checkbox";
|
|
||||||
|
|
||||||
export interface CheckboxTreeNode<T> {
|
|
||||||
key: string;
|
|
||||||
data: T;
|
|
||||||
children: CheckboxTreeNode<T>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props<T> {
|
|
||||||
node: CheckboxTreeNode<T>;
|
|
||||||
depth?: number;
|
|
||||||
/** Return "hidden" to render row alignment space instead of a checkbox */
|
|
||||||
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
|
|
||||||
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
|
|
||||||
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
|
|
||||||
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
|
||||||
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
|
||||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
|
|
||||||
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
|
|
||||||
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
|
|
||||||
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
|
|
||||||
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
|
||||||
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
|
||||||
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CheckboxTree<T>(props: Props<T>) {
|
|
||||||
const { node, depth = 0 } = props;
|
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(
|
|
||||||
() => props.isCollapsedByDefault?.(node) ?? false,
|
|
||||||
);
|
|
||||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
|
||||||
|
|
||||||
const checked = props.checked(node);
|
|
||||||
const selected = props.isRowSelected?.(node) ?? false;
|
|
||||||
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
|
|
||||||
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
|
|
||||||
const rowContent = (
|
|
||||||
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
|
|
||||||
{props.renderRow(node)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"relative flex gap-1 w-full h-xs items-center",
|
|
||||||
selected ? "text-text" : "text-text-subtle",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selected && (
|
|
||||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
|
||||||
)}
|
|
||||||
{hasVisibleChildren ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={collapsed ? "Expand" : "Collapse"}
|
|
||||||
aria-expanded={!collapsed}
|
|
||||||
className="shrink-0 text-text-subtlest hocus:text-text"
|
|
||||||
onClick={() => setCollapsed((v) => !v)}
|
|
||||||
>
|
|
||||||
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden className="w-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
{checked === "hidden" ? (
|
|
||||||
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
title={props.checkboxTitle?.(node) ?? "Toggle"}
|
|
||||||
hideLabel
|
|
||||||
disabled={props.isCheckboxDisabled?.(node)}
|
|
||||||
onChange={(checked) => props.onCheck(node, checked)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectable ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex-1 min-w-0 flex text-left"
|
|
||||||
onClick={() => props.onSelectRow?.(node)}
|
|
||||||
>
|
|
||||||
{rowContent}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
rowContent
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!collapsed &&
|
|
||||||
node.children.map((child) => (
|
|
||||||
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasRelevantNode<T>(
|
|
||||||
node: CheckboxTreeNode<T>,
|
|
||||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
|
|
||||||
): boolean {
|
|
||||||
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
|
|
||||||
}
|
|
||||||
@@ -10,13 +10,11 @@ export interface DialogProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
disableBackdropClose?: boolean;
|
||||||
disableClose?: boolean;
|
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: DialogSize;
|
size?: DialogSize;
|
||||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
|
||||||
hideX?: boolean;
|
hideX?: boolean;
|
||||||
noPadding?: boolean;
|
noPadding?: boolean;
|
||||||
noScroll?: boolean;
|
noScroll?: boolean;
|
||||||
@@ -29,7 +27,7 @@ export function Dialog({
|
|||||||
size = "full",
|
size = "full",
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
disableClose,
|
disableBackdropClose,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
hideX,
|
hideX,
|
||||||
@@ -44,7 +42,7 @@ export function Dialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -60,7 +58,7 @@ export function Dialog({
|
|||||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||||
// dialogs and can be intercepted by children if needed.
|
// dialogs and can be intercepted by children if needed.
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
if (!disableClose) onClose?.();
|
onClose?.();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@@ -112,7 +110,7 @@ export function Dialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||||
{!disableClose && !hideX && (
|
{!hideX && (
|
||||||
<div className="ml-auto absolute right-1 top-1">
|
<div className="ml-auto absolute right-1 top-1">
|
||||||
<IconButton
|
<IconButton
|
||||||
className="opacity-70 hover:opacity-100"
|
className="opacity-70 hover:opacity-100"
|
||||||
|
|||||||
@@ -601,8 +601,6 @@ function getExtensions({
|
|||||||
EditorView.contentAttributes.of({
|
EditorView.contentAttributes.of({
|
||||||
autocapitalize: "off",
|
autocapitalize: "off",
|
||||||
autocorrect: "off",
|
autocorrect: "off",
|
||||||
// Keeps macOS Writing Tools from offering to write code for us
|
|
||||||
writingsuggestions: "false",
|
|
||||||
}),
|
}),
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
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,232 +1,7 @@
|
|||||||
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
|
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||||
import type { EditorState, Extension, Text } from "@codemirror/state";
|
import type { Extension } from "@codemirror/state";
|
||||||
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
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
|
* A CodeMirror extension that displays the total number of search matches
|
||||||
* inside the built-in search panel.
|
* inside the built-in search panel.
|
||||||
@@ -235,7 +10,6 @@ export function searchMatchCount(): Extension {
|
|||||||
return ViewPlugin.fromClass(
|
return ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
private countEl: HTMLElement | null = null;
|
private countEl: HTMLElement | null = null;
|
||||||
private counter = new MatchCounter();
|
|
||||||
|
|
||||||
constructor(private view: EditorView) {
|
constructor(private view: EditorView) {
|
||||||
this.updateCount();
|
this.updateCount();
|
||||||
@@ -264,21 +38,38 @@ export function searchMatchCount(): Extension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.ensureCountEl();
|
this.ensureCountEl();
|
||||||
if (this.countEl == null) return;
|
|
||||||
|
|
||||||
if (!query.search) {
|
if (!query.search) {
|
||||||
this.countEl.textContent = "0/0";
|
if (this.countEl) {
|
||||||
|
this.countEl.textContent = "0/0";
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = this.counter.matches(state, query);
|
const selection = state.selection.main;
|
||||||
if (matches.length > MAX_COUNT) {
|
let count = 0;
|
||||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
let currentIndex = 0;
|
||||||
} else if (matches.length === 0) {
|
const MAX_COUNT = 9999;
|
||||||
this.countEl.textContent = "0/0";
|
const cursor = query.getCursor(state);
|
||||||
} else {
|
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||||
const current = currentMatch(matches, state.selection.main);
|
count++;
|
||||||
this.countEl.textContent = `${current}/${matches.length}`;
|
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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|||||||
import { generateId } from "../../lib/generateId";
|
import { generateId } from "../../lib/generateId";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||||
import { IconTooltip } from "./IconTooltip";
|
|
||||||
import { Label } from "./Label";
|
import { Label } from "./Label";
|
||||||
|
|
||||||
interface Props<T extends string> {
|
interface Props<T extends string> {
|
||||||
@@ -37,15 +36,11 @@ export function SegmentedControl<T extends string>({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const id = useRef(`input-${generateId()}`);
|
const id = useRef(`input-${generateId()}`);
|
||||||
|
|
||||||
// A visually hidden label has nowhere to show the help, so the last option carries it
|
|
||||||
const inlineHelp =
|
|
||||||
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full grid">
|
<div className="w-full grid">
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id.current}
|
htmlFor={id.current}
|
||||||
help={hideLabel ? undefined : help}
|
help={help}
|
||||||
visuallyHidden={hideLabel}
|
visuallyHidden={hideLabel}
|
||||||
className={classNames(labelClassName)}
|
className={classNames(labelClassName)}
|
||||||
>
|
>
|
||||||
@@ -83,10 +78,9 @@ export function SegmentedControl<T extends string>({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{options.map((o, i) => {
|
{options.map((o) => {
|
||||||
const isSelected = selectedValue === o.value;
|
const isSelected = selectedValue === o.value;
|
||||||
const isActive = value === o.value;
|
const isActive = value === o.value;
|
||||||
const rightSlot = i === options.length - 1 ? inlineHelp : null;
|
|
||||||
if (o.icon == null) {
|
if (o.icon == null) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -101,7 +95,6 @@ export function SegmentedControl<T extends string>({
|
|||||||
isActive && "text-text!",
|
isActive && "text-text!",
|
||||||
"focus:ring-1 focus:ring-border-focus",
|
"focus:ring-1 focus:ring-border-focus",
|
||||||
)}
|
)}
|
||||||
rightSlot={rightSlot}
|
|
||||||
onClick={() => onChange(o.value)}
|
onClick={() => onChange(o.value)}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
@@ -124,7 +117,6 @@ export function SegmentedControl<T extends string>({
|
|||||||
)}
|
)}
|
||||||
title={o.label}
|
title={o.label}
|
||||||
icon={o.icon}
|
icon={o.icon}
|
||||||
rightSlot={rightSlot}
|
|
||||||
onClick={() => onChange(o.value)}
|
onClick={() => onChange(o.value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -119,13 +119,9 @@ export function Select<T extends string>({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
className={classNames(
|
className="w-full text-sm font-mono"
|
||||||
"w-full text-sm font-mono",
|
|
||||||
disabled && "border-dotted",
|
|
||||||
isInvalidSelection && "border-danger",
|
|
||||||
)}
|
|
||||||
justify="start"
|
justify="start"
|
||||||
variant="input"
|
variant="border"
|
||||||
size={size}
|
size={size}
|
||||||
leftSlot={leftSlot}
|
leftSlot={leftSlot}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
|
|||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import type { CheckboxProps } from "../core/Checkbox";
|
import type { CheckboxProps } from "../core/Checkbox";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
import type { CheckboxTreeNode } from "../core/CheckboxTree";
|
|
||||||
import { CheckboxTree } from "../core/CheckboxTree";
|
|
||||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
import { DiffViewer } from "../core/Editor/DiffViewer";
|
||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
@@ -45,7 +43,10 @@ interface CommitTreeNode {
|
|||||||
|
|
||||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||||
const callbacks = useGitCallbacks(syncDir);
|
const callbacks = useGitCallbacks(syncDir);
|
||||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
|
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
|
||||||
|
syncDir,
|
||||||
|
callbacks,
|
||||||
|
);
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
const [commitError, setCommitError] = useState<string | null>(null);
|
const [commitError, setCommitError] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
@@ -142,15 +143,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
return next(workspace, []);
|
return next(workspace, []);
|
||||||
}, [workspace, internalEntries]);
|
}, [workspace, internalEntries]);
|
||||||
|
|
||||||
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
|
|
||||||
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
|
|
||||||
key: n.status.relaPath + n.status.status + n.status.staged,
|
|
||||||
data: n,
|
|
||||||
children: n.children.map(toTreeNode),
|
|
||||||
});
|
|
||||||
return tree == null ? null : toTreeNode(tree);
|
|
||||||
}, [tree]);
|
|
||||||
|
|
||||||
const checkNode = useCallback(
|
const checkNode = useCallback(
|
||||||
(treeNode: CommitTreeNode) => {
|
(treeNode: CommitTreeNode) => {
|
||||||
const checked = nodeCheckedStatus(treeNode);
|
const checked = nodeCheckedStatus(treeNode);
|
||||||
@@ -198,7 +190,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
[restore],
|
[restore],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tree == null || treeNode == null) {
|
if (tree == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,18 +221,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
style={innerStyle}
|
style={innerStyle}
|
||||||
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
|
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
|
||||||
>
|
>
|
||||||
<CheckboxTree
|
<TreeNodeChildren
|
||||||
node={treeNode}
|
node={tree}
|
||||||
checked={(n) => nodeCheckedStatus(n.data)}
|
depth={0}
|
||||||
onCheck={(n) => checkNode(n.data)}
|
onCheck={checkNode}
|
||||||
checkboxTitle={(n) =>
|
onSelect={handleSelectChild}
|
||||||
nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
|
selectedPath={selectedEntry?.relaPath ?? null}
|
||||||
}
|
|
||||||
isRelevant={(n) => n.data.status.status !== "current"}
|
|
||||||
canSelectRow={(n) => n.data.status.status !== "current"}
|
|
||||||
onSelectRow={(n) => handleSelectChild(n.data.status)}
|
|
||||||
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
|
|
||||||
renderRow={(n) => <CommitTreeRow node={n.data} />}
|
|
||||||
/>
|
/>
|
||||||
{externalEntries.find((e) => e.status !== "current") && (
|
{externalEntries.find((e) => e.status !== "current") && (
|
||||||
<>
|
<>
|
||||||
@@ -258,7 +244,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
secondSlot={({ style: innerStyle }) => (
|
secondSlot={({ style: innerStyle }) => (
|
||||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
<div
|
||||||
|
style={innerStyle}
|
||||||
|
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
className="text-base! font-sans rounded-md"
|
className="text-base! font-sans rounded-md"
|
||||||
placeholder="Commit message..."
|
placeholder="Commit message..."
|
||||||
@@ -312,39 +301,96 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
|
function TreeNodeChildren({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
onCheck,
|
||||||
|
onSelect,
|
||||||
|
selectedPath,
|
||||||
|
}: {
|
||||||
|
node: CommitTreeNode | null;
|
||||||
|
depth: number;
|
||||||
|
onCheck: (node: CommitTreeNode, checked: boolean) => void;
|
||||||
|
onSelect: (entry: GitStatusEntry) => void;
|
||||||
|
selectedPath: string | null;
|
||||||
|
}) {
|
||||||
|
if (node === null) return null;
|
||||||
|
if (!isNodeRelevant(node)) return null;
|
||||||
|
|
||||||
|
const checked = nodeCheckedStatus(node);
|
||||||
|
const isSelected = selectedPath === node.status.relaPath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
{node.model.model !== "http_request" &&
|
className={classNames(
|
||||||
node.model.model !== "grpc_request" &&
|
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||||
node.model.model !== "websocket_request" ? (
|
)}
|
||||||
<Icon
|
>
|
||||||
color="secondary"
|
<div
|
||||||
icon={
|
className={classNames(
|
||||||
node.model.model === "folder"
|
"relative flex gap-1 w-full h-xs items-center",
|
||||||
? "folder"
|
isSelected ? "text-text" : "text-text-subtle",
|
||||||
: node.model.model === "environment"
|
)}
|
||||||
? "variable"
|
>
|
||||||
: "house"
|
{isSelected && (
|
||||||
}
|
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||||
|
)}
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
title={checked ? "Unstage change" : "Stage change"}
|
||||||
|
hideLabel
|
||||||
|
onChange={(checked) => onCheck(node, checked)}
|
||||||
/>
|
/>
|
||||||
) : (
|
<button
|
||||||
<span aria-hidden className="w-4" />
|
type="button"
|
||||||
)}
|
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
|
||||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
onClick={() => node.status.status !== "current" && onSelect(node.status)}
|
||||||
{node.status.status !== "current" && (
|
|
||||||
<InlineCode
|
|
||||||
className={classNames(
|
|
||||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
|
||||||
node.status.status === "modified" && "text-info",
|
|
||||||
node.status.status === "untracked" && "text-success",
|
|
||||||
node.status.status === "removed" && "text-danger",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{node.status.status}
|
{node.model.model !== "http_request" &&
|
||||||
</InlineCode>
|
node.model.model !== "grpc_request" &&
|
||||||
)}
|
node.model.model !== "websocket_request" ? (
|
||||||
</>
|
<Icon
|
||||||
|
color="secondary"
|
||||||
|
icon={
|
||||||
|
node.model.model === "folder"
|
||||||
|
? "folder"
|
||||||
|
: node.model.model === "environment"
|
||||||
|
? "variable"
|
||||||
|
: "house"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden className="w-4" />
|
||||||
|
)}
|
||||||
|
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||||
|
{node.status.status !== "current" && (
|
||||||
|
<InlineCode
|
||||||
|
className={classNames(
|
||||||
|
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||||
|
node.status.status === "modified" && "text-info",
|
||||||
|
node.status.status === "untracked" && "text-success",
|
||||||
|
node.status.status === "removed" && "text-danger",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{node.status.status}
|
||||||
|
</InlineCode>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.children.map((childNode) => {
|
||||||
|
return (
|
||||||
|
<TreeNodeChildren
|
||||||
|
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
|
||||||
|
node={childNode}
|
||||||
|
depth={depth + 1}
|
||||||
|
onCheck={onCheck}
|
||||||
|
onSelect={onSelect}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +495,15 @@ function setCheckedAndChildren(
|
|||||||
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
|
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNodeRelevant(node: CommitTreeNode): boolean {
|
||||||
|
if (node.status.status !== "current") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively check children
|
||||||
|
return node.children.some((c) => isNodeRelevant(c));
|
||||||
|
}
|
||||||
|
|
||||||
function DiffPanel({
|
function DiffPanel({
|
||||||
entry,
|
entry,
|
||||||
onDiscardChanges,
|
onDiscardChanges,
|
||||||
@@ -471,11 +526,13 @@ function DiffPanel({
|
|||||||
size="2xs"
|
size="2xs"
|
||||||
variant="border"
|
variant="border"
|
||||||
onClick={() => onDiscardChanges(entry)}
|
onClick={() => onDiscardChanges(entry)}
|
||||||
>
|
>Discard Changes</Button>
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
|
<DiffViewer
|
||||||
|
original={prevYaml ?? ""}
|
||||||
|
modified={nextYaml ?? ""}
|
||||||
|
className="flex-1 min-h-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ export async function promptDivergedStrategy({
|
|||||||
showDialog({
|
showDialog({
|
||||||
id: "git-diverged",
|
id: "git-diverged",
|
||||||
title: "Branches Diverged",
|
title: "Branches Diverged",
|
||||||
|
hideX: true,
|
||||||
size: "sm",
|
size: "sm",
|
||||||
disableClose: true,
|
disableBackdropClose: true,
|
||||||
|
onClose: () => resolve("cancel"),
|
||||||
render: ({ hide }) =>
|
render: ({ hide }) =>
|
||||||
DivergedDialog({
|
DivergedDialog({
|
||||||
remote,
|
remote,
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
|
|||||||
showDialog({
|
showDialog({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
hideX: true,
|
||||||
size,
|
size,
|
||||||
disableClose: true,
|
disableBackdropClose: true, // Prevent accidental dismisses
|
||||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export async function showConfirm({
|
|||||||
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
||||||
showDialog({
|
showDialog({
|
||||||
...extraProps,
|
...extraProps,
|
||||||
|
hideX: true,
|
||||||
size,
|
size,
|
||||||
disableClose: true,
|
disableBackdropClose: true, // Prevent accidental dismisses
|
||||||
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,14 @@
|
|||||||
import {
|
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||||
type BatchUpsertResult,
|
|
||||||
type ImportDestination,
|
|
||||||
type ImportPlan,
|
|
||||||
type ImportSource,
|
|
||||||
workspacesAtom,
|
|
||||||
} from "@yaakapp-internal/models";
|
|
||||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||||
import { Button } from "../components/core/Button";
|
import { Button } from "../components/core/Button";
|
||||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { showAlert } from "./alert";
|
import { showAlert } from "./alert";
|
||||||
import { showDialog } from "./dialog";
|
import { showDialog } from "./dialog";
|
||||||
import { jotaiStore } from "./jotai";
|
|
||||||
import { pluralizeCount } from "./pluralize";
|
import { pluralizeCount } from "./pluralize";
|
||||||
import { router } from "./router";
|
import { router } from "./router";
|
||||||
import { rpc } from "./rpc";
|
import { rpc } from "./rpc";
|
||||||
|
|
||||||
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
|
||||||
// fetches) every time the dialog container re-renders.
|
|
||||||
const planFile = (filePath: string, destination: ImportDestination) =>
|
|
||||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
|
|
||||||
const planUrl = (url: string, destination: ImportDestination) =>
|
|
||||||
rpc<ImportPlan>("cmd_import_url", { url, destination });
|
|
||||||
const listSources = (workspaceId: string) =>
|
|
||||||
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
|
||||||
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
|
||||||
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
|
|
||||||
|
|
||||||
export const importData = createFastMutation({
|
export const importData = createFastMutation({
|
||||||
mutationKey: ["import_data"],
|
mutationKey: ["import_data"],
|
||||||
onError: (err: string) => {
|
onError: (err: string) => {
|
||||||
@@ -41,41 +21,29 @@ export const importData = createFastMutation({
|
|||||||
},
|
},
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
|
||||||
const workspaces = jotaiStore.get(workspacesAtom);
|
|
||||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
|
||||||
showDialog({
|
showDialog({
|
||||||
id: "import",
|
id: "import",
|
||||||
title: "Import Data",
|
title: "Import Data",
|
||||||
size: "lg",
|
size: "sm",
|
||||||
disableClose: true,
|
|
||||||
render: ({ hide }) => {
|
render: ({ hide }) => {
|
||||||
const cancel = () => {
|
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||||
hide();
|
try {
|
||||||
resolve();
|
await finishImport(await runImport());
|
||||||
};
|
resolve();
|
||||||
const fail = (err: unknown) => {
|
} catch (err) {
|
||||||
hide();
|
reject(err);
|
||||||
reject(err);
|
} finally {
|
||||||
};
|
hide();
|
||||||
const commit = async (plan: ImportPlan) => {
|
}
|
||||||
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
|
|
||||||
hide();
|
|
||||||
await finishImport(imported);
|
|
||||||
resolve();
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ImportDataDialog
|
<ImportDataDialog
|
||||||
currentWorkspace={currentWorkspace}
|
importFile={(filePath) =>
|
||||||
workspaces={workspaces}
|
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||||
selectedFolder={selectedFolder}
|
}
|
||||||
planFile={planFile}
|
importUrl={(url) =>
|
||||||
planUrl={planUrl}
|
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||||
listSources={listSources}
|
}
|
||||||
findSourcesForOrigin={findSourcesForOrigin}
|
|
||||||
commit={commit}
|
|
||||||
cancel={cancel}
|
|
||||||
onError={fail}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,8 +25,13 @@ export async function showPromptForm({
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
hideX: true,
|
||||||
size: size ?? "sm",
|
size: size ?? "sm",
|
||||||
disableClose: true,
|
disableBackdropClose: true, // Prevent accidental dismisses
|
||||||
|
onClose: () => {
|
||||||
|
// Click backdrop, close, or escape
|
||||||
|
resolve(null);
|
||||||
|
},
|
||||||
render: ({ hide }) =>
|
render: ({ hide }) =>
|
||||||
Prompt({
|
Prompt({
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
|
|||||||
@@ -5,20 +5,16 @@ use std::fs;
|
|||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use yaak::export::{self, ExportDataParams};
|
use yaak::export::{self, ExportDataParams};
|
||||||
use yaak::import;
|
use yaak::import;
|
||||||
use yaak_models::util::{
|
use yaak_core::WorkspaceContext;
|
||||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
use yaak_models::util::BatchUpsertResult;
|
||||||
};
|
|
||||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||||
|
|
||||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||||
|
|
||||||
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
||||||
match import(ctx, args).await {
|
match import(ctx, args).await {
|
||||||
Ok((result, items)) => {
|
Ok(result) => {
|
||||||
println!("Imported {}", format_counts(&result));
|
println!("Imported {}", format_counts(&result));
|
||||||
if let Some(skipped) = format_skipped(&items) {
|
|
||||||
println!("Skipped {skipped}");
|
|
||||||
}
|
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -41,10 +37,7 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn import(
|
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
|
||||||
ctx: &CliContext,
|
|
||||||
args: ImportArgs,
|
|
||||||
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>)> {
|
|
||||||
if let Some(workspace_id) = args.workspace_id.as_deref() {
|
if let Some(workspace_id) = args.workspace_id.as_deref() {
|
||||||
ctx.db()
|
ctx.db()
|
||||||
.get_workspace(workspace_id)
|
.get_workspace(workspace_id)
|
||||||
@@ -58,7 +51,6 @@ async fn import(
|
|||||||
.import_data(&plugin_context, &file_contents)
|
.import_data(&plugin_context, &file_contents)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||||
let importer = import_result.importer;
|
|
||||||
let resources = import_result.resources;
|
let resources = import_result.resources;
|
||||||
let workspace_id = args.workspace_id;
|
let workspace_id = args.workspace_id;
|
||||||
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||||
@@ -67,62 +59,15 @@ async fn import(
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let destination = match workspace_id {
|
let workspace_context = WorkspaceContext {
|
||||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
workspace_id,
|
||||||
None => ImportDestination::NewWorkspace,
|
environment_id: None,
|
||||||
|
cookie_jar_id: None,
|
||||||
|
request_id: None,
|
||||||
};
|
};
|
||||||
let plan = import::plan_import_resources(
|
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||||
ctx.query_manager(),
|
|
||||||
importer,
|
|
||||||
destination,
|
|
||||||
resources,
|
|
||||||
import_result.source_keys,
|
|
||||||
Some(file_origin(&args.file)),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("Failed to plan import: {e}"))?;
|
|
||||||
let items = plan.items.clone();
|
|
||||||
let imported = import::commit_import_plan(ctx.query_manager(), plan)
|
|
||||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||||
Ok((imported, items))
|
Ok(imported)
|
||||||
}
|
|
||||||
|
|
||||||
fn file_origin(path: &std::path::Path) -> ImportOrigin {
|
|
||||||
let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
|
||||||
let label = canonical
|
|
||||||
.file_name()
|
|
||||||
.map(|name| name.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| path.display().to_string());
|
|
||||||
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Summarize what the default selection left untouched during a merging re-import.
|
|
||||||
fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
|
||||||
let count = |action: ImportPlanAction| items.iter().filter(|i| i.action == action).count();
|
|
||||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
|
||||||
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
let deletions = count(ImportPlanAction::Delete);
|
|
||||||
if deletions > 0 {
|
|
||||||
parts.push(format!("{deletions} removed from source (not deleted locally)"));
|
|
||||||
}
|
|
||||||
let conflicts = count(ImportPlanAction::Conflict);
|
|
||||||
if conflicts > 0 {
|
|
||||||
parts.push(format!("{conflicts} conflict{} (kept local changes)", plural(conflicts)));
|
|
||||||
}
|
|
||||||
let keep_local = count(ImportPlanAction::KeepLocal);
|
|
||||||
if keep_local > 0 {
|
|
||||||
parts.push(format!("{keep_local} with local edits"));
|
|
||||||
}
|
|
||||||
let not_imported = count(ImportPlanAction::NotImported);
|
|
||||||
if not_imported > 0 {
|
|
||||||
parts.push(format!("{not_imported} previously not imported"));
|
|
||||||
}
|
|
||||||
let unchanged = count(ImportPlanAction::Unchanged);
|
|
||||||
if unchanged > 0 {
|
|
||||||
parts.push(format!("{unchanged} unchanged"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts.is_empty() { None } else { Some(parts.join(", ")) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
|
|||||||
use predicates::str::contains;
|
use predicates::str::contains;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use yaak_models::util::UpdateSource;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn export_writes_yaak_workspace_file() {
|
fn export_writes_yaak_workspace_file() {
|
||||||
@@ -82,21 +81,14 @@ fn import_reads_yaak_workspace_file() {
|
|||||||
|
|
||||||
let query_manager = query_manager(data_dir);
|
let query_manager = query_manager(data_dir);
|
||||||
let db = query_manager.connect();
|
let db = query_manager.connect();
|
||||||
let workspaces = db.list_workspaces().expect("list imported workspaces");
|
assert_eq!(
|
||||||
let workspace = workspaces
|
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||||
.iter()
|
"Imported Workspace"
|
||||||
.find(|workspace| workspace.name == "Imported Workspace")
|
);
|
||||||
.expect("workspace imported");
|
assert_eq!(
|
||||||
assert_ne!(workspace.id, "wrk_import");
|
db.get_http_request("req_import").expect("request imported").url,
|
||||||
|
"https://example.com"
|
||||||
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) {
|
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||||
@@ -168,150 +160,3 @@ fn import_postman_environment_uses_workspace_id() {
|
|||||||
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
||||||
assert_eq!(imported_environment.workspace_id, workspace_id);
|
assert_eq!(imported_environment.workspace_id, workspace_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_linked_fixture(path: &std::path::Path, requests: &[(&str, &str, &str)]) {
|
|
||||||
let requests = requests
|
|
||||||
.iter()
|
|
||||||
.map(|(id, name, url)| {
|
|
||||||
format!(
|
|
||||||
r#"{{ "model": "http_request", "id": "{id}", "workspaceId": "wrk_link",
|
|
||||||
"name": "{name}", "method": "GET", "url": "{url}" }}"#
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
std::fs::write(
|
|
||||||
path,
|
|
||||||
format!(
|
|
||||||
r#"{{
|
|
||||||
"yaakVersion": "test",
|
|
||||||
"yaakSchema": 4,
|
|
||||||
"resources": {{
|
|
||||||
"workspaces": [{{ "model": "workspace", "id": "wrk_link", "name": "Linked Workspace" }}],
|
|
||||||
"httpRequests": [{requests}]
|
|
||||||
}}
|
|
||||||
}}"#
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.expect("write linked fixture");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn re_import_merges_into_linked_workspace() {
|
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
||||||
let data_dir = temp_dir.path();
|
|
||||||
let import_path = temp_dir.path().join("linked.json");
|
|
||||||
|
|
||||||
write_linked_fixture(
|
|
||||||
&import_path,
|
|
||||||
&[
|
|
||||||
("req_a", "Request A", "https://example.com/a"),
|
|
||||||
("req_b", "Request B", "https://example.com/b"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
cli_cmd(data_dir)
|
|
||||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
|
||||||
.assert()
|
|
||||||
.success()
|
|
||||||
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
|
|
||||||
|
|
||||||
let workspace_id = {
|
|
||||||
let query_manager = query_manager(data_dir);
|
|
||||||
let db = query_manager.connect();
|
|
||||||
db.list_workspaces()
|
|
||||||
.expect("list workspaces")
|
|
||||||
.into_iter()
|
|
||||||
.find(|w| w.name == "Linked Workspace")
|
|
||||||
.expect("workspace imported")
|
|
||||||
.id
|
|
||||||
};
|
|
||||||
|
|
||||||
// The source doc changes A, drops B, and adds C. The default selection applies the
|
|
||||||
// update and the create but leaves the removal as an offer.
|
|
||||||
write_linked_fixture(
|
|
||||||
&import_path,
|
|
||||||
&[
|
|
||||||
("req_a", "Request A", "https://example.com/a-v2"),
|
|
||||||
("req_c", "Request C", "https://example.com/c"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
cli_cmd(data_dir)
|
|
||||||
.args([
|
|
||||||
"import",
|
|
||||||
import_path.to_str().expect("import path is utf-8"),
|
|
||||||
"--workspace-id",
|
|
||||||
&workspace_id,
|
|
||||||
])
|
|
||||||
.assert()
|
|
||||||
.success()
|
|
||||||
.stdout(contains("Imported 2 HTTP requests"))
|
|
||||||
.stdout(contains("Skipped 1 removed from source"));
|
|
||||||
|
|
||||||
let query_manager = query_manager(data_dir);
|
|
||||||
let db = query_manager.connect();
|
|
||||||
let requests = db.list_http_requests(&workspace_id).expect("list requests");
|
|
||||||
assert_eq!(requests.len(), 3, "merge must not duplicate: {requests:?}");
|
|
||||||
assert_eq!(
|
|
||||||
requests.iter().find(|r| r.name == "Request A").expect("request A").url,
|
|
||||||
"https://example.com/a-v2"
|
|
||||||
);
|
|
||||||
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
|
|
||||||
assert!(requests.iter().any(|r| r.name == "Request C"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn re_import_leaves_deleted_resources_alone() {
|
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
||||||
let data_dir = temp_dir.path();
|
|
||||||
let import_path = temp_dir.path().join("linked.json");
|
|
||||||
|
|
||||||
write_linked_fixture(
|
|
||||||
&import_path,
|
|
||||||
&[
|
|
||||||
("req_a", "Request A", "https://example.com/a"),
|
|
||||||
("req_b", "Request B", "https://example.com/b"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
cli_cmd(data_dir)
|
|
||||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
|
|
||||||
let workspace_id = {
|
|
||||||
let query_manager = query_manager(data_dir);
|
|
||||||
let db = query_manager.connect();
|
|
||||||
let workspace_id = db
|
|
||||||
.list_workspaces()
|
|
||||||
.expect("list workspaces")
|
|
||||||
.into_iter()
|
|
||||||
.find(|w| w.name == "Linked Workspace")
|
|
||||||
.expect("workspace imported")
|
|
||||||
.id;
|
|
||||||
let request_b = db
|
|
||||||
.list_http_requests(&workspace_id)
|
|
||||||
.expect("list requests")
|
|
||||||
.into_iter()
|
|
||||||
.find(|r| r.name == "Request B")
|
|
||||||
.expect("request B imported");
|
|
||||||
db.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync)
|
|
||||||
.expect("delete request B");
|
|
||||||
workspace_id
|
|
||||||
};
|
|
||||||
|
|
||||||
cli_cmd(data_dir)
|
|
||||||
.args([
|
|
||||||
"import",
|
|
||||||
import_path.to_str().expect("import path is utf-8"),
|
|
||||||
"--workspace-id",
|
|
||||||
&workspace_id,
|
|
||||||
])
|
|
||||||
.assert()
|
|
||||||
.success()
|
|
||||||
.stdout(contains("Skipped 1 previously not imported"));
|
|
||||||
|
|
||||||
let query_manager = query_manager(data_dir);
|
|
||||||
let requests =
|
|
||||||
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
|
|
||||||
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
|
|
||||||
assert_eq!(requests[0].name, "Request A");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ use std::collections::BTreeMap;
|
|||||||
use crate::PluginContextExt;
|
use crate::PluginContextExt;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use KeyAndValueRef::{Ascii, Binary};
|
use KeyAndValueRef::{Ascii, Binary};
|
||||||
use tauri::{Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||||
use yaak_models::models::GrpcRequest;
|
use yaak_models::models::GrpcRequest;
|
||||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
|
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
|
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
|
||||||
let mut entries = BTreeMap::new();
|
let mut entries = BTreeMap::new();
|
||||||
@@ -25,7 +26,7 @@ pub(crate) async fn build_metadata<R: Runtime>(
|
|||||||
request: &GrpcRequest,
|
request: &GrpcRequest,
|
||||||
authentication_context_id: &str,
|
authentication_context_id: &str,
|
||||||
) -> Result<BTreeMap<String, String>> {
|
) -> Result<BTreeMap<String, String>> {
|
||||||
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
|
let plugin_manager = window.state::<PluginManager>();
|
||||||
let mut metadata = BTreeMap::new();
|
let mut metadata = BTreeMap::new();
|
||||||
|
|
||||||
// Add the rest of metadata
|
// Add the rest of metadata
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use yaak_http::manager::HttpConnectionManager;
|
|||||||
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
|
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_plugins::events::PluginContext;
|
use yaak_plugins::events::PluginContext;
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
/// Context for managing response state during HTTP transactions.
|
/// Context for managing response state during HTTP transactions.
|
||||||
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
|
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
|
||||||
@@ -148,7 +149,7 @@ async fn send_http_request_inner<R: Runtime>(
|
|||||||
response_ctx: &mut ResponseContext<R>,
|
response_ctx: &mut ResponseContext<R>,
|
||||||
) -> Result<SentHttpRequest> {
|
) -> Result<SentHttpRequest> {
|
||||||
let app_handle = window.app_handle().clone();
|
let app_handle = window.app_handle().clone();
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let connection_manager = app_handle.state::<HttpConnectionManager>();
|
let connection_manager = app_handle.state::<HttpConnectionManager>();
|
||||||
let environment_id = environment.map(|e| e.id);
|
let environment_id = environment.map(|e| e.id);
|
||||||
|
|||||||
@@ -4,84 +4,53 @@ use crate::models_ext::QueryManagerExt;
|
|||||||
use std::fs::read_to_string;
|
use std::fs::read_to_string;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use tauri::{Manager, Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
use yaak::import::{self, PlanImportDataParams};
|
use yaak::import::{self, ImportDataParams};
|
||||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
|
use yaak_core::WorkspaceContext;
|
||||||
|
use yaak_models::util::BatchUpsertResult;
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
|
|
||||||
pub(crate) async fn import_data<R: Runtime>(
|
pub(crate) async fn import_data<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
origin: Option<ImportOrigin>,
|
|
||||||
) -> Result<BatchUpsertResult> {
|
) -> Result<BatchUpsertResult> {
|
||||||
let contents = read_import_file(file_path)?;
|
let contents = read_import_file(file_path)?;
|
||||||
let plan =
|
import_contents(window, &contents).await
|
||||||
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
|
|
||||||
commit_import(window, plan)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn plan_import_data<R: Runtime>(
|
pub(crate) async fn import_url<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, Some(file_origin(file_path))).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn plan_import_url<R: Runtime>(
|
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
url: &str,
|
url: &str,
|
||||||
destination: ImportDestination,
|
) -> Result<BatchUpsertResult> {
|
||||||
) -> Result<ImportPlan> {
|
let contents = fetch_import_url(window, url).await?;
|
||||||
let url = normalize_import_url(url)?;
|
import_contents(window, &contents).await
|
||||||
let contents = fetch_import_url(window, &url).await?;
|
|
||||||
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn plan_import_contents<R: Runtime>(
|
async fn import_contents<R: Runtime>(
|
||||||
window: &WebviewWindow<R>,
|
window: &WebviewWindow<R>,
|
||||||
contents: &str,
|
contents: &str,
|
||||||
destination: ImportDestination,
|
) -> Result<BatchUpsertResult> {
|
||||||
origin: Option<ImportOrigin>,
|
let plugin_manager = window.state::<PluginManager>();
|
||||||
) -> Result<ImportPlan> {
|
|
||||||
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
|
|
||||||
let query_manager = window.db_manager();
|
let query_manager = window.db_manager();
|
||||||
let plugin_context = window.plugin_context();
|
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::plan_import_data(PlanImportDataParams {
|
Ok(import::import_data(ImportDataParams {
|
||||||
query_manager: &query_manager,
|
query_manager: &query_manager,
|
||||||
plugin_manager: &plugin_manager,
|
plugin_manager: &plugin_manager,
|
||||||
plugin_context: &plugin_context,
|
plugin_context: &plugin_context,
|
||||||
destination,
|
workspace_context,
|
||||||
contents,
|
contents,
|
||||||
origin,
|
|
||||||
})
|
})
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonicalize so re-importing the same file through a different spelling of its path still
|
|
||||||
/// matches the linked source.
|
|
||||||
pub(crate) fn file_origin(file_path: &str) -> ImportOrigin {
|
|
||||||
let path = std::path::Path::new(file_path);
|
|
||||||
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
|
||||||
let label = canonical
|
|
||||||
.file_name()
|
|
||||||
.map(|name| name.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| file_path.to_string());
|
|
||||||
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn url_origin(url: &str) -> ImportOrigin {
|
|
||||||
ImportOrigin { origin: url.to_string(), label: url.to_string() }
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
|
||||||
/// pipeline as a file on disk.
|
/// pipeline as a file on disk.
|
||||||
///
|
///
|
||||||
@@ -110,7 +79,7 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
|
|||||||
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
|
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
|
fn normalize_import_url(url: &str) -> Result<String> {
|
||||||
let url = url.trim();
|
let url = url.trim();
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
return Err(Error::GenericError("Import URL must not be empty".to_string()));
|
return Err(Error::GenericError("Import URL must not be empty".to_string()));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::grpc::{build_metadata, metadata_to_map};
|
use crate::grpc::{build_metadata, metadata_to_map};
|
||||||
use crate::http_request::send_http_request;
|
use crate::http_request::send_http_request;
|
||||||
use crate::import::{commit_import, plan_import_data, plan_import_url};
|
use crate::import::{import_data, import_url};
|
||||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||||
use crate::notifications::YaakNotifier;
|
use crate::notifications::YaakNotifier;
|
||||||
use crate::render::{render_grpc_request, render_template};
|
use crate::render::{render_grpc_request, render_template};
|
||||||
@@ -40,11 +40,12 @@ use yaak_models::models::{
|
|||||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||||
};
|
};
|
||||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||||
RenderPurpose, ShowToastRequest,
|
RenderPurpose, ShowToastRequest,
|
||||||
};
|
};
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||||
use yaak_sse::sse::ServerSentEvent;
|
use yaak_sse::sse::ServerSentEvent;
|
||||||
@@ -249,7 +250,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
|||||||
let resolved_settings =
|
let resolved_settings =
|
||||||
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
||||||
|
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let req = render_grpc_request(
|
let req = render_grpc_request(
|
||||||
&resolved_request,
|
&resolved_request,
|
||||||
@@ -309,7 +310,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
let resolved_settings =
|
let resolved_settings =
|
||||||
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
||||||
|
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let request = render_grpc_request(
|
let request = render_grpc_request(
|
||||||
&resolved_request,
|
&resolved_request,
|
||||||
@@ -961,6 +962,7 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
|||||||
|
|
||||||
async fn cmd_http_response_body<R: Runtime>(
|
async fn cmd_http_response_body<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
|
plugin_manager: State<'_, PluginManager>,
|
||||||
response_id: &str,
|
response_id: &str,
|
||||||
filter: Option<&str>,
|
filter: Option<&str>,
|
||||||
) -> YaakResult<FilterResponse> {
|
) -> YaakResult<FilterResponse> {
|
||||||
@@ -975,8 +977,7 @@ async fn cmd_http_response_body<R: Runtime>(
|
|||||||
.ok_or(GenericError("Failed to find response body".to_string()))?;
|
.ok_or(GenericError("Failed to find response body".to_string()))?;
|
||||||
|
|
||||||
match filter {
|
match filter {
|
||||||
Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window)
|
Some(filter) if !filter.is_empty() => Ok(plugin_manager
|
||||||
.await?
|
|
||||||
.filter_data(&window.plugin_context(), filter, &body, content_type)
|
.filter_data(&window.plugin_context(), filter, &body, content_type)
|
||||||
.await?),
|
.await?),
|
||||||
_ => Ok(FilterResponse { content: body, error: None }),
|
_ => Ok(FilterResponse { content: body, error: None }),
|
||||||
@@ -1013,24 +1014,15 @@ async fn cmd_get_sse_events<R: Runtime>(
|
|||||||
async fn cmd_import_data<R: Runtime>(
|
async fn cmd_import_data<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
destination: ImportDestination,
|
) -> YaakResult<BatchUpsertResult> {
|
||||||
) -> YaakResult<ImportPlan> {
|
import_data(&window, file_path).await
|
||||||
plan_import_data(&window, file_path, destination).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_import_url<R: Runtime>(
|
async fn cmd_import_url<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
url: &str,
|
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> {
|
) -> YaakResult<BatchUpsertResult> {
|
||||||
commit_import(&window, plan)
|
import_url(&window, url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1449,10 +1441,7 @@ fn safe_uri(endpoint: &str) -> String {
|
|||||||
fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||||
let app_handle = app_handle.clone();
|
let app_handle = app_handle.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let plugin_manager = match plugins_ext::plugin_manager(&app_handle).await {
|
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
||||||
Ok(pm) => pm,
|
|
||||||
Err(_) => return, // The runtime failed to boot; there are no events
|
|
||||||
};
|
|
||||||
let (rx_id, mut rx) = plugin_manager.subscribe("app").await;
|
let (rx_id, mut rx) = plugin_manager.subscribe("app").await;
|
||||||
|
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
@@ -1493,13 +1482,9 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match plugins_ext::plugin_manager(&app_handle).await {
|
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
||||||
Ok(pm) => {
|
if let Err(e) = plugin_manager.reply(&event, &ev).await {
|
||||||
if let Err(e) = pm.reply(&event, &ev).await {
|
warn!("Failed to reply to plugin manager: {:?}", e)
|
||||||
warn!("Failed to reply to plugin manager: {:?}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!("Failed to get plugin manager for reply: {e:?}"),
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use yaak_plugins::events::{
|
|||||||
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
|
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
|
||||||
WorkspaceInfo,
|
WorkspaceInfo,
|
||||||
};
|
};
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::plugin_handle::PluginHandle;
|
use yaak_plugins::plugin_handle::PluginHandle;
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
@@ -204,7 +205,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
|||||||
req.grpc_request.folder_id.as_deref(),
|
req.grpc_request.folder_id.as_deref(),
|
||||||
environment_id.as_deref(),
|
environment_id.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let cb = PluginTemplateCallback::new(
|
let cb = PluginTemplateCallback::new(
|
||||||
plugin_manager,
|
plugin_manager,
|
||||||
@@ -230,7 +231,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
|||||||
req.http_request.folder_id.as_deref(),
|
req.http_request.folder_id.as_deref(),
|
||||||
environment_id.as_deref(),
|
environment_id.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let cb = PluginTemplateCallback::new(
|
let cb = PluginTemplateCallback::new(
|
||||||
plugin_manager,
|
plugin_manager,
|
||||||
@@ -266,7 +267,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
|||||||
folder_id.as_deref(),
|
folder_id.as_deref(),
|
||||||
environment_id.as_deref(),
|
environment_id.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let cb = PluginTemplateCallback::new(
|
let cb = PluginTemplateCallback::new(
|
||||||
plugin_manager,
|
plugin_manager,
|
||||||
|
|||||||
@@ -31,43 +31,10 @@ use yaak_plugins::api::{
|
|||||||
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
|
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
|
||||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::error::Error::PluginErr;
|
|
||||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||||
|
|
||||||
static EXITING: AtomicBool = AtomicBool::new(false);
|
static EXITING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Plugin Manager Handle
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// The plugin runtime boots in the background so startup doesn't wait on it.
|
|
||||||
/// This handle is the only way to reach the manager: [`PluginManagerHandle::get`]
|
|
||||||
/// resolves once boot completes, so callers can never observe a
|
|
||||||
/// partially-initialized runtime.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct PluginManagerHandle {
|
|
||||||
rx: tokio::sync::watch::Receiver<Option<std::result::Result<PluginManager, String>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PluginManagerHandle {
|
|
||||||
pub async fn get(&self) -> yaak_plugins::error::Result<PluginManager> {
|
|
||||||
let mut rx = self.rx.clone();
|
|
||||||
let result = rx
|
|
||||||
.wait_for(|v| v.is_some())
|
|
||||||
.await
|
|
||||||
.map_err(|_| PluginErr("Plugin runtime boot task died".to_string()))?;
|
|
||||||
result.clone().unwrap().map_err(PluginErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wait for the plugin runtime to finish booting and return the manager.
|
|
||||||
pub async fn plugin_manager<R: Runtime>(
|
|
||||||
manager: &impl Manager<R>,
|
|
||||||
) -> yaak_plugins::error::Result<PluginManager> {
|
|
||||||
let handle = manager.state::<PluginManagerHandle>().inner().clone();
|
|
||||||
handle.get().await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plugin Updater
|
// Plugin Updater
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -179,7 +146,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
|
|||||||
name: &str,
|
name: &str,
|
||||||
version: Option<String>,
|
version: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||||
let app_version = window.app_handle().package_info().version.to_string();
|
let app_version = window.app_handle().package_info().version.to_string();
|
||||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||||
@@ -200,9 +167,6 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
|||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
directory: &str,
|
directory: &str,
|
||||||
) -> Result<Plugin> {
|
) -> Result<Plugin> {
|
||||||
// Resolve the manager before writing the row so startup's plugin snapshot
|
|
||||||
// can't include it and boot it a second time
|
|
||||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
|
||||||
let plugin = window.db().upsert_plugin(
|
let plugin = window.db().upsert_plugin(
|
||||||
&Plugin {
|
&Plugin {
|
||||||
directory: directory.into(),
|
directory: directory.into(),
|
||||||
@@ -214,6 +178,7 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
|||||||
&UpdateSource::from_window_label(window.label()),
|
&UpdateSource::from_window_label(window.label()),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||||
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
|
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
|
||||||
|
|
||||||
Ok(plugin)
|
Ok(plugin)
|
||||||
@@ -223,7 +188,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
|
|||||||
plugin_id: &str,
|
plugin_id: &str,
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
) -> Result<Plugin> {
|
) -> Result<Plugin> {
|
||||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||||
let plugin_context = window.plugin_context();
|
let plugin_context = window.plugin_context();
|
||||||
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
||||||
@@ -252,7 +217,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let plugin_manager = Arc::new(plugin_manager(&window).await?);
|
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||||
let plugin_context = window.plugin_context();
|
let plugin_context = window.plugin_context();
|
||||||
|
|
||||||
@@ -335,38 +300,20 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
let query_manager =
|
let query_manager =
|
||||||
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
||||||
|
|
||||||
// Boot the plugin runtime in the background so the window shows
|
// Create plugin manager asynchronously
|
||||||
// immediately. Everything that needs plugins resolves the handle,
|
|
||||||
// which waits for this task to finish.
|
|
||||||
let (tx, rx) = tokio::sync::watch::channel(None);
|
|
||||||
app_handle.manage(PluginManagerHandle { rx });
|
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
let result = tokio::time::timeout(
|
let manager = PluginManager::new(
|
||||||
Duration::from_secs(60),
|
vendored_plugin_dir,
|
||||||
PluginManager::new(
|
installed_plugin_dir,
|
||||||
vendored_plugin_dir,
|
node_bin_path,
|
||||||
installed_plugin_dir,
|
plugin_runtime_main,
|
||||||
node_bin_path,
|
&query_manager,
|
||||||
plugin_runtime_main,
|
&PluginContext::new_empty(),
|
||||||
&query_manager,
|
dev_mode,
|
||||||
&PluginContext::new_empty(),
|
|
||||||
dev_mode,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
|
.expect("Failed to start plugin runtime");
|
||||||
"Timed out starting the plugin runtime".to_string(),
|
|
||||||
)));
|
|
||||||
|
|
||||||
let manager = match result {
|
|
||||||
Ok(manager) => manager,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to start plugin runtime: {e:?}");
|
|
||||||
let _ = tx.send(Some(Err(e.to_string())));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Surface unexpected runtime crashes to the user
|
// Surface unexpected runtime crashes to the user
|
||||||
let mut crash_rx = manager.runtime_crash_rx();
|
let mut crash_rx = manager.runtime_crash_rx();
|
||||||
@@ -392,7 +339,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = tx.send(Some(Ok(manager)));
|
app_handle_clone.manage(manager);
|
||||||
});
|
});
|
||||||
|
|
||||||
let plugin_updater = PluginUpdater::new();
|
let plugin_updater = PluginUpdater::new();
|
||||||
@@ -408,14 +355,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
api.prevent_exit();
|
api.prevent_exit();
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
info!("Exiting plugin runtime due to app exit");
|
info!("Exiting plugin runtime due to app exit");
|
||||||
// Bound the wait in case the exit comes while boot is still
|
let manager: State<PluginManager> = app.state();
|
||||||
// in flight
|
manager.terminate().await;
|
||||||
let get_manager = plugin_manager(app);
|
|
||||||
if let Ok(Ok(manager)) =
|
|
||||||
tokio::time::timeout(Duration::from_secs(5), get_manager).await
|
|
||||||
{
|
|
||||||
manager.terminate().await;
|
|
||||||
}
|
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,10 @@ use yaak_grpc::ServiceDefinition;
|
|||||||
use yaak_models::blob_manager::BlobManager;
|
use yaak_models::blob_manager::BlobManager;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||||
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
|
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||||
WorkspaceMeta,
|
|
||||||
};
|
};
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
use yaak_models::util::BatchUpsertResult;
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||||
@@ -110,11 +109,10 @@ impl<R: Runtime> Host for ClientCtx<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<R: Runtime> ClientCtx<R> {
|
impl<R: Runtime> ClientCtx<R> {
|
||||||
/// The plugin runtime this window talks to, once it finishes booting.
|
/// The plugin runtime this window talks to. Only the `PluginHost` impl
|
||||||
/// Only the `PluginHost` impl below uses it; everything else goes through
|
/// below uses it; everything else goes through the trait.
|
||||||
/// the trait.
|
fn pm(&self) -> State<'_, PluginManager> {
|
||||||
async fn pm(&self) -> yaak_plugins::error::Result<PluginManager> {
|
self.window.state::<PluginManager>()
|
||||||
crate::plugins_ext::plugin_manager(&self.window).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,40 +122,35 @@ impl<R: Runtime> ClientCtx<R> {
|
|||||||
/// providing them.
|
/// providing them.
|
||||||
impl<R: Runtime> PluginHost for ClientCtx<R> {
|
impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||||
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
|
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
|
||||||
let handle = self.pm().await.ok()?.get_plugin_by_dir(directory).await?;
|
let handle = self.pm().get_plugin_by_dir(directory).await?;
|
||||||
Some(handle.info())
|
Some(handle.info())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
||||||
match self.pm().await {
|
self.pm().take_init_errors().await
|
||||||
Ok(pm) => pm.take_init_errors().await,
|
|
||||||
Err(_) => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||||
match self.pm().await {
|
self.pm().resolve_plugins_for_runtime_from_db(plugins).await
|
||||||
Ok(pm) => pm.resolve_plugins_for_runtime_from_db(plugins).await,
|
|
||||||
Err(_) => plugins,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn template_callback(
|
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback {
|
||||||
&self,
|
PluginTemplateCallback::new(
|
||||||
purpose: RenderPurpose,
|
Arc::new((*self.pm()).clone()),
|
||||||
) -> yaak_commands::Result<impl TemplateCallback> {
|
|
||||||
Ok(PluginTemplateCallback::new(
|
|
||||||
Arc::new(self.pm().await?),
|
|
||||||
Arc::new(self.encryption_manager().clone()),
|
Arc::new(self.encryption_manager().clone()),
|
||||||
&self.plugin_context(),
|
&self.plugin_context(),
|
||||||
purpose,
|
purpose,
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn template_function_summaries(
|
async fn template_function_summaries(
|
||||||
&self,
|
&self,
|
||||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||||
Ok(self.pm().await?.get_template_function_summaries(&self.plugin_context()).await?)
|
Ok(self
|
||||||
|
.window
|
||||||
|
.state::<PluginManager>()
|
||||||
|
.get_template_function_summaries(&self.plugin_context())
|
||||||
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn template_function_config(
|
async fn template_function_config(
|
||||||
@@ -167,81 +160,81 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
|||||||
model_id: &str,
|
model_id: &str,
|
||||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.pm()
|
.window
|
||||||
.await?
|
.state::<PluginManager>()
|
||||||
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
|
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||||
Ok(self.pm().await?.get_themes(&self.plugin_context()).await?)
|
Ok(self.pm().get_themes(&self.plugin_context()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn http_request_actions(
|
async fn http_request_actions(
|
||||||
&self,
|
&self,
|
||||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||||
Ok(self.pm().await?.get_http_request_actions(&self.plugin_context()).await?)
|
Ok(self.pm().get_http_request_actions(&self.plugin_context()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn websocket_request_actions(
|
async fn websocket_request_actions(
|
||||||
&self,
|
&self,
|
||||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||||
Ok(self.pm().await?.get_websocket_request_actions(&self.plugin_context()).await?)
|
Ok(self.pm().get_websocket_request_actions(&self.plugin_context()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn grpc_request_actions(
|
async fn grpc_request_actions(
|
||||||
&self,
|
&self,
|
||||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||||
Ok(self.pm().await?.get_grpc_request_actions(&self.plugin_context()).await?)
|
Ok(self.pm().get_grpc_request_actions(&self.plugin_context()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||||
Ok(self.pm().await?.get_workspace_actions(&self.plugin_context()).await?)
|
Ok(self.pm().get_workspace_actions(&self.plugin_context()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||||
Ok(self.pm().await?.get_folder_actions(&self.plugin_context()).await?)
|
Ok(self.pm().get_folder_actions(&self.plugin_context()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_http_request_action(
|
async fn call_http_request_action(
|
||||||
&self,
|
&self,
|
||||||
req: CallHttpRequestActionRequest,
|
req: CallHttpRequestActionRequest,
|
||||||
) -> yaak_commands::Result<()> {
|
) -> yaak_commands::Result<()> {
|
||||||
Ok(self.pm().await?.call_http_request_action(&self.plugin_context(), req).await?)
|
Ok(self.pm().call_http_request_action(&self.plugin_context(), req).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_grpc_request_action(
|
async fn call_grpc_request_action(
|
||||||
&self,
|
&self,
|
||||||
req: CallGrpcRequestActionRequest,
|
req: CallGrpcRequestActionRequest,
|
||||||
) -> yaak_commands::Result<()> {
|
) -> yaak_commands::Result<()> {
|
||||||
Ok(self.pm().await?.call_grpc_request_action(&self.plugin_context(), req).await?)
|
Ok(self.pm().call_grpc_request_action(&self.plugin_context(), req).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_websocket_request_action(
|
async fn call_websocket_request_action(
|
||||||
&self,
|
&self,
|
||||||
req: CallWebsocketRequestActionRequest,
|
req: CallWebsocketRequestActionRequest,
|
||||||
) -> yaak_commands::Result<()> {
|
) -> yaak_commands::Result<()> {
|
||||||
Ok(self.pm().await?.call_websocket_request_action(&self.plugin_context(), req).await?)
|
Ok(self.pm().call_websocket_request_action(&self.plugin_context(), req).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_workspace_action(
|
async fn call_workspace_action(
|
||||||
&self,
|
&self,
|
||||||
req: CallWorkspaceActionRequest,
|
req: CallWorkspaceActionRequest,
|
||||||
) -> yaak_commands::Result<()> {
|
) -> yaak_commands::Result<()> {
|
||||||
Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?)
|
Ok(self.pm().call_workspace_action(&self.plugin_context(), req).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_folder_action(
|
async fn call_folder_action(
|
||||||
&self,
|
&self,
|
||||||
req: CallFolderActionRequest,
|
req: CallFolderActionRequest,
|
||||||
) -> yaak_commands::Result<()> {
|
) -> yaak_commands::Result<()> {
|
||||||
Ok(self.pm().await?.call_folder_action(&self.plugin_context(), req).await?)
|
Ok(self.pm().call_folder_action(&self.plugin_context(), req).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn http_authentication_summaries(
|
async fn http_authentication_summaries(
|
||||||
&self,
|
&self,
|
||||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||||
let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
|
let results = self.pm().get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +246,6 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
|||||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.pm()
|
.pm()
|
||||||
.await?
|
|
||||||
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
|
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
@@ -267,7 +259,6 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
|||||||
) -> yaak_commands::Result<()> {
|
) -> yaak_commands::Result<()> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.pm()
|
.pm()
|
||||||
.await?
|
|
||||||
.call_http_authentication_action(
|
.call_http_authentication_action(
|
||||||
&self.plugin_context(),
|
&self.plugin_context(),
|
||||||
auth_name,
|
auth_name,
|
||||||
@@ -279,18 +270,15 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
|
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||||
Ok(self.pm().await?.import_data(&self.plugin_context(), content).await?)
|
Ok(self.pm().import_data(&self.plugin_context(), content).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||||
match self.pm().await {
|
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await
|
||||||
Ok(pm) => pm.initialize_all_plugins(plugins, &self.plugin_context()).await,
|
|
||||||
Err(e) => vec![("*".to_string(), e.to_string())],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
|
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
|
||||||
let plugin_manager = Arc::new(self.pm().await?);
|
let plugin_manager = Arc::new((*self.pm()).clone());
|
||||||
let encryption_manager = Arc::new(self.encryption_manager().clone());
|
let encryption_manager = Arc::new(self.encryption_manager().clone());
|
||||||
Ok(encrypt_secure_template_function(
|
Ok(encrypt_secure_template_function(
|
||||||
plugin_manager,
|
plugin_manager,
|
||||||
@@ -434,7 +422,7 @@ async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphq
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
|
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
|
||||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?)
|
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
|
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
|
||||||
@@ -453,34 +441,12 @@ 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?)
|
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<ImportPlan> {
|
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, req.destination).await?)
|
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
|
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
|
||||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
|
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).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_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
|
|
||||||
use crate::models_ext::QueryManagerExt;
|
|
||||||
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
|
|
||||||
use crate::models_ext::QueryManagerExt;
|
|
||||||
let origin = match (req.file_path, req.url) {
|
|
||||||
(Some(file_path), _) => crate::import::file_origin(&file_path).origin,
|
|
||||||
(None, Some(url)) => match crate::import::normalize_import_url(&url) {
|
|
||||||
Ok(url) => crate::import::url_origin(&url).origin,
|
|
||||||
Err(_) => return Ok(Vec::new()),
|
|
||||||
},
|
|
||||||
(None, None) => return Ok(Vec::new()),
|
|
||||||
};
|
|
||||||
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||||
@@ -847,7 +813,7 @@ async fn cmd_ws_close<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsCloseReq) -> Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
|
async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
|
||||||
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
|
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
|
||||||
@@ -877,3 +843,4 @@ 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>> {
|
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?)
|
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use tokio::task::block_in_place;
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_models::util::generate_id;
|
use yaak_models::util::generate_id;
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use yaak_api::get_system_proxy_url;
|
use yaak_api::get_system_proxy_url;
|
||||||
@@ -97,9 +98,8 @@ impl YaakUpdater {
|
|||||||
block_in_place(|| {
|
block_in_place(|| {
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
info!("Shutting down plugin manager before update");
|
info!("Shutting down plugin manager before update");
|
||||||
if let Ok(plugin_manager) = crate::plugins_ext::plugin_manager(&w).await {
|
let plugin_manager = w.state::<PluginManager>();
|
||||||
plugin_manager.terminate().await;
|
plugin_manager.terminate().await;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::PluginContextExt;
|
use crate::PluginContextExt;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::import::{file_origin, import_data, url_origin};
|
use crate::import::import_data;
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -12,6 +12,7 @@ use yaak_api::{ApiClientKind, yaak_api_client};
|
|||||||
use yaak_models::util::generate_id;
|
use yaak_models::util::generate_id;
|
||||||
use yaak_plugins::events::{Color, ShowToastRequest};
|
use yaak_plugins::events::{Color, ShowToastRequest};
|
||||||
use yaak_plugins::install::download_and_install;
|
use yaak_plugins::install::download_and_install;
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
pub(crate) async fn handle_deep_link<R: Runtime>(
|
pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||||
app_handle: &AppHandle<R>,
|
app_handle: &AppHandle<R>,
|
||||||
@@ -43,7 +44,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(window).await?);
|
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||||
let query_manager = app_handle.db_manager();
|
let query_manager = app_handle.db_manager();
|
||||||
let app_version = app_handle.package_info().version.to_string();
|
let app_version = app_handle.package_info().version.to_string();
|
||||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||||
@@ -69,7 +70,6 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
|||||||
}
|
}
|
||||||
"import-data" => {
|
"import-data" => {
|
||||||
let mut file_path = query_map.get("path").map(|s| s.to_owned());
|
let mut file_path = query_map.get("path").map(|s| s.to_owned());
|
||||||
let mut origin = None;
|
|
||||||
let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string());
|
let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string());
|
||||||
_ = window.set_focus();
|
_ = window.set_focus();
|
||||||
|
|
||||||
@@ -99,7 +99,6 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
|||||||
.to_string();
|
.to_string();
|
||||||
fs::write(&p, json)?;
|
fs::write(&p, json)?;
|
||||||
file_path = Some(p);
|
file_path = Some(p);
|
||||||
origin = Some(url_origin(file_url));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_path = match file_path {
|
let file_path = match file_path {
|
||||||
@@ -118,8 +117,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let origin = origin.unwrap_or_else(|| file_origin(&file_path));
|
let results = import_data(window, &file_path).await?;
|
||||||
let results = import_data(window, &file_path, Some(origin)).await?;
|
|
||||||
window.emit(
|
window.emit(
|
||||||
"show_toast",
|
"show_toast",
|
||||||
ShowToastRequest {
|
ShowToastRequest {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use yaak_models::models::{
|
|||||||
};
|
};
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||||
@@ -76,7 +77,7 @@ async fn send_websocket_message<R: Runtime>(
|
|||||||
)?;
|
)?;
|
||||||
let (resolved_request, _auth_context_id) =
|
let (resolved_request, _auth_context_id) =
|
||||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let request = render_websocket_request(
|
let request = render_websocket_request(
|
||||||
&resolved_request,
|
&resolved_request,
|
||||||
@@ -141,6 +142,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
cookie_jar_id: Option<&str>,
|
cookie_jar_id: Option<&str>,
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
|
_plugin_manager: State<'_, PluginManager>,
|
||||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||||
) -> Result<WebsocketConnection> {
|
) -> Result<WebsocketConnection> {
|
||||||
let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
|
let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
|
||||||
@@ -154,7 +156,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
let settings = app_handle.db().get_settings();
|
let settings = app_handle.db().get_settings();
|
||||||
let (resolved_request, auth_context_id) =
|
let (resolved_request, auth_context_id) =
|
||||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||||
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
let request = render_websocket_request(
|
let request = render_websocket_request(
|
||||||
&resolved_request,
|
&resolved_request,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export type AnyModel =
|
|||||||
| HttpRequest
|
| HttpRequest
|
||||||
| HttpResponse
|
| HttpResponse
|
||||||
| HttpResponseEvent
|
| HttpResponseEvent
|
||||||
| ImportSource
|
|
||||||
| KeyValue
|
| KeyValue
|
||||||
| Plugin
|
| Plugin
|
||||||
| Settings
|
| Settings
|
||||||
@@ -319,18 +318,6 @@ export type HttpUrlParameter = {
|
|||||||
|
|
||||||
export type HttpVersion = "auto" | "http1" | "http2";
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
export type ImportSource = {
|
|
||||||
model: "import_source";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
workspaceId: string;
|
|
||||||
importer: string;
|
|
||||||
origin: string;
|
|
||||||
originLabel: string;
|
|
||||||
lastImportedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|||||||
+5
-11
File diff suppressed because one or more lines are too long
+2
-100
@@ -1,102 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type {
|
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||||
Environment,
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from "./gen_models";
|
|
||||||
|
|
||||||
export type BatchUpsertResult = {
|
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||||
workspaces: Array<Workspace>;
|
|
||||||
environments: Array<Environment>;
|
|
||||||
folders: Array<Folder>;
|
|
||||||
httpRequests: Array<HttpRequest>;
|
|
||||||
grpcRequests: Array<GrpcRequest>;
|
|
||||||
websocketRequests: Array<WebsocketRequest>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImportConflictResolution = "keep_mine" | "take_source";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where an import's contents came from, used to link the committed workspace back to it.
|
|
||||||
*/
|
|
||||||
export type ImportOrigin = {
|
|
||||||
/**
|
|
||||||
* The absolute file path or URL the contents were read from.
|
|
||||||
*/
|
|
||||||
origin: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImportPlan = {
|
|
||||||
importer: string;
|
|
||||||
destination: ImportDestination;
|
|
||||||
resources: BatchUpsertResult;
|
|
||||||
warnings: Array<ImportPlanWarning>;
|
|
||||||
/**
|
|
||||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
|
||||||
*/
|
|
||||||
sourceKeys: { [key in string]?: string };
|
|
||||||
/**
|
|
||||||
* One entry per plannable resource; commit applies only the selected ones.
|
|
||||||
*/
|
|
||||||
items: Array<ImportPlanItem>;
|
|
||||||
origin?: ImportOrigin;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImportPlanAction =
|
|
||||||
| "create"
|
|
||||||
| "update"
|
|
||||||
| "delete"
|
|
||||||
| "unchanged"
|
|
||||||
| "keep_local"
|
|
||||||
| "conflict"
|
|
||||||
| "not_imported";
|
|
||||||
|
|
||||||
export type ImportPlanItem = {
|
|
||||||
action: ImportPlanAction;
|
|
||||||
model: ImportResourceType;
|
|
||||||
modelId: string;
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
|
||||||
*/
|
|
||||||
parentId?: string;
|
|
||||||
selected: boolean;
|
|
||||||
resolution?: ImportConflictResolution;
|
|
||||||
reason?: ImportPlanReason;
|
|
||||||
/**
|
|
||||||
* Fields where the source and the local copy disagree, so the preview can say why
|
|
||||||
*/
|
|
||||||
changedFields: Array<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
|
||||||
*/
|
|
||||||
export type ImportPlanReason = "moved_into_not_imported_folder";
|
|
||||||
|
|
||||||
export type ImportPlanWarning = { title: string; detail: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The model types an import plan can contain.
|
|
||||||
*/
|
|
||||||
export type ImportResourceType =
|
|
||||||
| "environment"
|
|
||||||
| "folder"
|
|
||||||
| "grpc_request"
|
|
||||||
| "http_request"
|
|
||||||
| "websocket_request"
|
|
||||||
| "workspace";
|
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ use yaak_git::{
|
|||||||
use yaak_grpc::ServiceDefinition;
|
use yaak_grpc::ServiceDefinition;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||||
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
|
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||||
WorkspaceMeta,
|
|
||||||
};
|
};
|
||||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
|
use yaak_models::util::BatchUpsertResult;
|
||||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||||
@@ -230,7 +229,6 @@ pub struct CmdGetHttpResponseEventsReq {
|
|||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct CmdImportDataReq {
|
pub struct CmdImportDataReq {
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub destination: ImportDestination,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
@@ -238,31 +236,6 @@ pub struct CmdImportDataReq {
|
|||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub struct CmdImportUrlReq {
|
pub struct CmdImportUrlReq {
|
||||||
pub url: String,
|
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)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct CmdListImportSourcesReq {
|
|
||||||
pub workspace_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
|
||||||
pub struct CmdImportSourcesForOriginReq {
|
|
||||||
#[ts(optional)]
|
|
||||||
pub file_path: Option<String>,
|
|
||||||
#[ts(optional)]
|
|
||||||
pub url: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
@@ -936,11 +909,8 @@ macro_rules! with_commands {
|
|||||||
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
|
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
|
||||||
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
||||||
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
||||||
cmd_import_data(CmdImportDataReq) -> ImportPlan,
|
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
|
||||||
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
|
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
|
||||||
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
|
|
||||||
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
|
|
||||||
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
|
|
||||||
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
|
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
|
||||||
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
|
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
|
||||||
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
|
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
|
||||||
|
|||||||
@@ -121,12 +121,7 @@ pub trait PluginHost: Host {
|
|||||||
/// a render — the variables come from the environment chain, which is an
|
/// a render — the variables come from the environment chain, which is an
|
||||||
/// ordinary database read — so handing back the callback keeps the rest of
|
/// ordinary database read — so handing back the callback keeps the rest of
|
||||||
/// rendering shared instead of pushing whole commands behind this trait.
|
/// rendering shared instead of pushing whole commands behind this trait.
|
||||||
/// Async so hosts that finish booting their plugin runtime in the
|
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback;
|
||||||
/// background can wait for it here.
|
|
||||||
fn template_callback(
|
|
||||||
&self,
|
|
||||||
purpose: RenderPurpose,
|
|
||||||
) -> impl Future<Output = crate::Result<impl TemplateCallback>>;
|
|
||||||
|
|
||||||
/// Every template function the installed plugins expose, for the
|
/// Every template function the installed plugins expose, for the
|
||||||
/// autocomplete menu.
|
/// autocomplete menu.
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ pub(crate) async fn render_form_values<H: PluginHost>(
|
|||||||
let environment_chain =
|
let environment_chain =
|
||||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||||
|
|
||||||
let cb = host.template_callback(purpose).await?;
|
let cb = host.template_callback(purpose);
|
||||||
let rendered =
|
let rendered =
|
||||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub async fn cmd_render_template<H: PluginHost>(
|
|||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let environment_chain =
|
let environment_chain =
|
||||||
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
|
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
|
||||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)).await?;
|
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
|
||||||
let options = RenderOptions {
|
let options = RenderOptions {
|
||||||
// A preview that throws would show the user an error where they expect
|
// A preview that throws would show the user an error where they expect
|
||||||
// to see the value so far, so callers rendering *into the UI* ask for
|
// to see the value so far, so callers rendering *into the UI* ask for
|
||||||
@@ -41,7 +41,7 @@ pub async fn cmd_template_tokens_to_string<H: PluginHost>(
|
|||||||
host: H,
|
host: H,
|
||||||
req: CmdTemplateTokensToStringReq,
|
req: CmdTemplateTokensToStringReq,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let cb = host.template_callback(RenderPurpose::Preview).await?;
|
let cb = host.template_callback(RenderPurpose::Preview);
|
||||||
Ok(transform_args(req.tokens, &cb)?.to_string())
|
Ok(transform_args(req.tokens, &cb)?.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,11 +250,8 @@ impl PluginHost for SingleThreadedHost {
|
|||||||
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
|
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn template_callback(
|
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
|
||||||
&self,
|
NoTemplateFunctions
|
||||||
_purpose: RenderPurpose,
|
|
||||||
) -> yaak_commands::Result<impl TemplateCallback> {
|
|
||||||
Ok(NoTemplateFunctions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn template_function_summaries(
|
async fn template_function_summaries(
|
||||||
|
|||||||
@@ -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/
|
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
|
||||||
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
|
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
|
||||||
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
|
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
|
||||||
"google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
|
"google.protobuf.Duration" => JsonSchemaEntry::string(),
|
||||||
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
|
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
|
||||||
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
|
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
|
||||||
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
|
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
|
||||||
|
|||||||
-30
@@ -12,7 +12,6 @@ export type AnyModel =
|
|||||||
| HttpRequest
|
| HttpRequest
|
||||||
| HttpResponse
|
| HttpResponse
|
||||||
| HttpResponseEvent
|
| HttpResponseEvent
|
||||||
| ImportSource
|
|
||||||
| KeyValue
|
| KeyValue
|
||||||
| Plugin
|
| Plugin
|
||||||
| Settings
|
| Settings
|
||||||
@@ -337,35 +336,6 @@ export type HttpUrlParameter = {
|
|||||||
|
|
||||||
export type HttpVersion = "auto" | "http1" | "http2";
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
export type ImportSource = {
|
|
||||||
model: "import_source";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
workspaceId: string;
|
|
||||||
importer: string;
|
|
||||||
origin: string;
|
|
||||||
originLabel: string;
|
|
||||||
lastImportedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImportSourceResource = {
|
|
||||||
model: "import_source_resource";
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
importSourceId: string;
|
|
||||||
sourceKey: string;
|
|
||||||
modelType: string;
|
|
||||||
/**
|
|
||||||
* `None` once the user has decided not to import this key
|
|
||||||
*/
|
|
||||||
modelId?: string;
|
|
||||||
/**
|
|
||||||
* Hash of the resource as last applied or decided from the source, if one was recorded
|
|
||||||
*/
|
|
||||||
contentHash?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|||||||
Generated
+2
-100
@@ -1,102 +1,4 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type {
|
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||||
Environment,
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from "./gen_models";
|
|
||||||
|
|
||||||
export type BatchUpsertResult = {
|
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||||
workspaces: Array<Workspace>;
|
|
||||||
environments: Array<Environment>;
|
|
||||||
folders: Array<Folder>;
|
|
||||||
httpRequests: Array<HttpRequest>;
|
|
||||||
grpcRequests: Array<GrpcRequest>;
|
|
||||||
websocketRequests: Array<WebsocketRequest>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImportConflictResolution = "keep_mine" | "take_source";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where an import's contents came from, used to link the committed workspace back to it.
|
|
||||||
*/
|
|
||||||
export type ImportOrigin = {
|
|
||||||
/**
|
|
||||||
* The absolute file path or URL the contents were read from.
|
|
||||||
*/
|
|
||||||
origin: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImportPlan = {
|
|
||||||
importer: string;
|
|
||||||
destination: ImportDestination;
|
|
||||||
resources: BatchUpsertResult;
|
|
||||||
warnings: Array<ImportPlanWarning>;
|
|
||||||
/**
|
|
||||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
|
||||||
*/
|
|
||||||
sourceKeys: { [key in string]?: string };
|
|
||||||
/**
|
|
||||||
* One entry per plannable resource; commit applies only the selected ones.
|
|
||||||
*/
|
|
||||||
items: Array<ImportPlanItem>;
|
|
||||||
origin?: ImportOrigin;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImportPlanAction =
|
|
||||||
| "create"
|
|
||||||
| "update"
|
|
||||||
| "delete"
|
|
||||||
| "unchanged"
|
|
||||||
| "keep_local"
|
|
||||||
| "conflict"
|
|
||||||
| "not_imported";
|
|
||||||
|
|
||||||
export type ImportPlanItem = {
|
|
||||||
action: ImportPlanAction;
|
|
||||||
model: ImportResourceType;
|
|
||||||
modelId: string;
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
|
||||||
*/
|
|
||||||
parentId?: string;
|
|
||||||
selected: boolean;
|
|
||||||
resolution?: ImportConflictResolution;
|
|
||||||
reason?: ImportPlanReason;
|
|
||||||
/**
|
|
||||||
* Fields where the source and the local copy disagree, so the preview can say why
|
|
||||||
*/
|
|
||||||
changedFields: Array<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
|
||||||
*/
|
|
||||||
export type ImportPlanReason = "moved_into_not_imported_folder";
|
|
||||||
|
|
||||||
export type ImportPlanWarning = { title: string; detail: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The model types an import plan can contain.
|
|
||||||
*/
|
|
||||||
export type ImportResourceType =
|
|
||||||
| "environment"
|
|
||||||
| "folder"
|
|
||||||
| "grpc_request"
|
|
||||||
| "http_request"
|
|
||||||
| "websocket_request"
|
|
||||||
| "workspace";
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export function newStoreData(): ModelStoreData {
|
|||||||
http_request: {},
|
http_request: {},
|
||||||
http_response: {},
|
http_response: {},
|
||||||
http_response_event: {},
|
http_response_event: {},
|
||||||
import_source: {},
|
|
||||||
key_value: {},
|
key_value: {},
|
||||||
plugin: {},
|
plugin: {},
|
||||||
settings: {},
|
settings: {},
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
CREATE TABLE import_sources
|
|
||||||
(
|
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
|
||||||
model TEXT DEFAULT 'import_source' NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
workspace_id TEXT NOT NULL,
|
|
||||||
importer TEXT NOT NULL,
|
|
||||||
origin TEXT NOT NULL,
|
|
||||||
origin_label TEXT NOT NULL,
|
|
||||||
last_imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE import_source_resources
|
|
||||||
(
|
|
||||||
model TEXT DEFAULT 'import_source_resource' NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
import_source_id TEXT NOT NULL,
|
|
||||||
source_key TEXT NOT NULL,
|
|
||||||
model_type TEXT NOT NULL,
|
|
||||||
model_id TEXT NOT NULL,
|
|
||||||
snapshot TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (import_source_id, source_key)
|
|
||||||
);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
|
|
||||||
-- so a resource the user chose not to import can be remembered.
|
|
||||||
CREATE TABLE import_source_resources_new
|
|
||||||
(
|
|
||||||
model TEXT DEFAULT 'import_source_resource' NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
import_source_id TEXT NOT NULL,
|
|
||||||
source_key TEXT NOT NULL,
|
|
||||||
model_type TEXT NOT NULL,
|
|
||||||
model_id TEXT,
|
|
||||||
content_hash TEXT,
|
|
||||||
PRIMARY KEY (import_source_id, source_key)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
|
|
||||||
source_key, model_type, model_id, content_hash)
|
|
||||||
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
|
|
||||||
FROM import_source_resources;
|
|
||||||
|
|
||||||
DROP TABLE import_source_resources;
|
|
||||||
|
|
||||||
ALTER TABLE import_source_resources_new
|
|
||||||
RENAME TO import_source_resources;
|
|
||||||
@@ -3022,127 +3022,6 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
|
||||||
#[enum_def(table_name = "import_sources")]
|
|
||||||
pub struct ImportSource {
|
|
||||||
#[ts(type = "\"import_source\"")]
|
|
||||||
pub model: String,
|
|
||||||
pub id: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
pub workspace_id: String,
|
|
||||||
|
|
||||||
pub importer: String,
|
|
||||||
pub origin: String,
|
|
||||||
pub origin_label: String,
|
|
||||||
pub last_imported_at: NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UpsertModelInfo for ImportSource {
|
|
||||||
fn table_name() -> impl IntoTableRef + IntoIden {
|
|
||||||
ImportSourceIden::Table
|
|
||||||
}
|
|
||||||
|
|
||||||
fn id_column() -> impl IntoIden + Eq + Clone {
|
|
||||||
ImportSourceIden::Id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_id() -> String {
|
|
||||||
generate_prefixed_id("im")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn order_by() -> (impl IntoColumnRef, Order) {
|
|
||||||
(ImportSourceIden::CreatedAt, Desc)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_id(&self) -> String {
|
|
||||||
self.id.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_values(
|
|
||||||
self,
|
|
||||||
source: &UpdateSource,
|
|
||||||
) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
|
||||||
use ImportSourceIden::*;
|
|
||||||
Ok(vec![
|
|
||||||
(CreatedAt, upsert_date(source, self.created_at)),
|
|
||||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
|
||||||
(WorkspaceId, self.workspace_id.into()),
|
|
||||||
(Importer, self.importer.into()),
|
|
||||||
(Origin, self.origin.into()),
|
|
||||||
(OriginLabel, self.origin_label.into()),
|
|
||||||
(LastImportedAt, self.last_imported_at.into()),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_columns() -> Vec<impl IntoIden> {
|
|
||||||
vec![
|
|
||||||
ImportSourceIden::UpdatedAt,
|
|
||||||
ImportSourceIden::Importer,
|
|
||||||
ImportSourceIden::Origin,
|
|
||||||
ImportSourceIden::OriginLabel,
|
|
||||||
ImportSourceIden::LastImportedAt,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_row(row: &Row) -> rusqlite::Result<Self>
|
|
||||||
where
|
|
||||||
Self: Sized,
|
|
||||||
{
|
|
||||||
Ok(Self {
|
|
||||||
id: row.get("id")?,
|
|
||||||
model: row.get("model")?,
|
|
||||||
created_at: row.get("created_at")?,
|
|
||||||
updated_at: row.get("updated_at")?,
|
|
||||||
workspace_id: row.get("workspace_id")?,
|
|
||||||
importer: row.get("importer")?,
|
|
||||||
origin: row.get("origin")?,
|
|
||||||
origin_label: row.get("origin_label")?,
|
|
||||||
last_imported_at: row.get("last_imported_at")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
|
||||||
#[enum_def(table_name = "import_source_resources")]
|
|
||||||
pub struct ImportSourceResource {
|
|
||||||
#[ts(type = "\"import_source_resource\"")]
|
|
||||||
pub model: String,
|
|
||||||
pub created_at: NaiveDateTime,
|
|
||||||
pub updated_at: NaiveDateTime,
|
|
||||||
|
|
||||||
pub import_source_id: String,
|
|
||||||
pub source_key: String,
|
|
||||||
pub model_type: String,
|
|
||||||
/// `None` once the user has decided not to import this key
|
|
||||||
#[ts(optional)]
|
|
||||||
pub model_id: Option<String>,
|
|
||||||
/// Hash of the resource as last applied or decided from the source, if one was recorded
|
|
||||||
#[ts(optional)]
|
|
||||||
pub content_hash: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
|
|
||||||
type Error = rusqlite::Error;
|
|
||||||
|
|
||||||
fn try_from(r: &Row<'s>) -> std::result::Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
model: r.get("model")?,
|
|
||||||
created_at: r.get("created_at")?,
|
|
||||||
updated_at: r.get("updated_at")?,
|
|
||||||
import_source_id: r.get("import_source_id")?,
|
|
||||||
source_key: r.get("source_key")?,
|
|
||||||
model_type: r.get("model_type")?,
|
|
||||||
model_id: r.get("model_id")?,
|
|
||||||
content_hash: r.get("content_hash")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Only used as a `from_row` fallback for an unparseable settings column. The
|
/// Only used as a `from_row` fallback for an unparseable settings column. The
|
||||||
/// value a *new* model gets comes from that model's `Default` impl.
|
/// value a *new* model gets comes from that model's `Default` impl.
|
||||||
fn default_request_message_size_setting() -> InheritedIntSetting {
|
fn default_request_message_size_setting() -> InheritedIntSetting {
|
||||||
@@ -3214,7 +3093,6 @@ define_any_model! {
|
|||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
HttpResponseEvent,
|
HttpResponseEvent,
|
||||||
ImportSource,
|
|
||||||
KeyValue,
|
KeyValue,
|
||||||
Plugin,
|
Plugin,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -3247,7 +3125,6 @@ impl<'de> Deserialize<'de> for AnyModel {
|
|||||||
Some(m) if m == "http_request" => HttpRequest(fv(value).unwrap()),
|
Some(m) if m == "http_request" => HttpRequest(fv(value).unwrap()),
|
||||||
Some(m) if m == "http_response" => HttpResponse(fv(value).unwrap()),
|
Some(m) if m == "http_response" => HttpResponse(fv(value).unwrap()),
|
||||||
Some(m) if m == "http_response_event" => HttpResponseEvent(fv(value).unwrap()),
|
Some(m) if m == "http_response_event" => HttpResponseEvent(fv(value).unwrap()),
|
||||||
Some(m) if m == "import_source" => ImportSource(fv(value).unwrap()),
|
|
||||||
Some(m) if m == "key_value" => KeyValue(fv(value).unwrap()),
|
Some(m) if m == "key_value" => KeyValue(fv(value).unwrap()),
|
||||||
Some(m) if m == "plugin" => Plugin(fv(value).unwrap()),
|
Some(m) if m == "plugin" => Plugin(fv(value).unwrap()),
|
||||||
Some(m) if m == "settings" => Settings(fv(value).unwrap()),
|
Some(m) if m == "settings" => Settings(fv(value).unwrap()),
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
use crate::client_db::ClientDb;
|
|
||||||
use crate::error::Result;
|
|
||||||
use crate::models::{ImportSourceResource, ImportSourceResourceIden};
|
|
||||||
use sea_query::ExprTrait;
|
|
||||||
use sea_query::Keyword::CurrentTimestamp;
|
|
||||||
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
|
|
||||||
use sea_query_rusqlite::RusqliteBinder;
|
|
||||||
|
|
||||||
impl<'a> ClientDb<'a> {
|
|
||||||
pub fn list_import_source_resources(
|
|
||||||
&self,
|
|
||||||
import_source_id: &str,
|
|
||||||
) -> Result<Vec<ImportSourceResource>> {
|
|
||||||
let (sql, params) = Query::select()
|
|
||||||
.from(ImportSourceResourceIden::Table)
|
|
||||||
.column(Asterisk)
|
|
||||||
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
|
|
||||||
.build_rusqlite(SqliteQueryBuilder);
|
|
||||||
let mut stmt = self.conn().prepare(sql.as_str())?;
|
|
||||||
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
|
|
||||||
Ok(items.filter_map(|v| v.ok()).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn upsert_import_source_resource(
|
|
||||||
&self,
|
|
||||||
resource: &ImportSourceResource,
|
|
||||||
) -> Result<ImportSourceResource> {
|
|
||||||
let (sql, params) = Query::insert()
|
|
||||||
.into_table(ImportSourceResourceIden::Table)
|
|
||||||
.columns([
|
|
||||||
ImportSourceResourceIden::CreatedAt,
|
|
||||||
ImportSourceResourceIden::UpdatedAt,
|
|
||||||
ImportSourceResourceIden::ImportSourceId,
|
|
||||||
ImportSourceResourceIden::SourceKey,
|
|
||||||
ImportSourceResourceIden::ModelType,
|
|
||||||
ImportSourceResourceIden::ModelId,
|
|
||||||
ImportSourceResourceIden::ContentHash,
|
|
||||||
])
|
|
||||||
.values_panic([
|
|
||||||
CurrentTimestamp.into(),
|
|
||||||
CurrentTimestamp.into(),
|
|
||||||
resource.import_source_id.as_str().into(),
|
|
||||||
resource.source_key.as_str().into(),
|
|
||||||
resource.model_type.as_str().into(),
|
|
||||||
resource.model_id.clone().into(),
|
|
||||||
resource.content_hash.clone().into(),
|
|
||||||
])
|
|
||||||
.on_conflict(
|
|
||||||
OnConflict::columns([
|
|
||||||
ImportSourceResourceIden::ImportSourceId,
|
|
||||||
ImportSourceResourceIden::SourceKey,
|
|
||||||
])
|
|
||||||
.update_columns([
|
|
||||||
ImportSourceResourceIden::UpdatedAt,
|
|
||||||
ImportSourceResourceIden::ModelType,
|
|
||||||
ImportSourceResourceIden::ModelId,
|
|
||||||
ImportSourceResourceIden::ContentHash,
|
|
||||||
])
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.returning_all()
|
|
||||||
.build_rusqlite(SqliteQueryBuilder);
|
|
||||||
|
|
||||||
let mut stmt = self.conn().prepare(sql.as_str())?;
|
|
||||||
let m = stmt.query_row(&*params.as_params(), |row| row.try_into())?;
|
|
||||||
Ok(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_import_source_resource(
|
|
||||||
&self,
|
|
||||||
import_source_id: &str,
|
|
||||||
source_key: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let (sql, params) = Query::delete()
|
|
||||||
.from_table(ImportSourceResourceIden::Table)
|
|
||||||
.cond_where(
|
|
||||||
Cond::all()
|
|
||||||
.add(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
|
|
||||||
.add(Expr::col(ImportSourceResourceIden::SourceKey).eq(source_key)),
|
|
||||||
)
|
|
||||||
.build_rusqlite(SqliteQueryBuilder);
|
|
||||||
self.conn().execute(sql.as_str(), &*params.as_params())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_import_source_resources(&self, import_source_id: &str) -> Result<()> {
|
|
||||||
let (sql, params) = Query::delete()
|
|
||||||
.from_table(ImportSourceResourceIden::Table)
|
|
||||||
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
|
|
||||||
.build_rusqlite(SqliteQueryBuilder);
|
|
||||||
self.conn().execute(sql.as_str(), &*params.as_params())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use crate::client_db::ClientDb;
|
|
||||||
use crate::error::Result;
|
|
||||||
use crate::models::{ImportSource, ImportSourceIden};
|
|
||||||
use crate::util::UpdateSource;
|
|
||||||
|
|
||||||
impl<'a> ClientDb<'a> {
|
|
||||||
pub fn get_import_source(&self, id: &str) -> Result<ImportSource> {
|
|
||||||
self.find_one(ImportSourceIden::Id, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_import_sources(&self, workspace_id: &str) -> Result<Vec<ImportSource>> {
|
|
||||||
self.find_many(ImportSourceIden::WorkspaceId, workspace_id, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_import_sources_by_origin(&self, origin: &str) -> Result<Vec<ImportSource>> {
|
|
||||||
self.find_many(ImportSourceIden::Origin, origin, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_import_source(
|
|
||||||
&self,
|
|
||||||
workspace_id: &str,
|
|
||||||
importer: &str,
|
|
||||||
origin: &str,
|
|
||||||
) -> Result<Option<ImportSource>> {
|
|
||||||
let sources = self.list_import_sources(workspace_id)?;
|
|
||||||
Ok(sources.into_iter().find(|s| s.importer == importer && s.origin == origin))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn upsert_import_source(
|
|
||||||
&self,
|
|
||||||
import_source: &ImportSource,
|
|
||||||
source: &UpdateSource,
|
|
||||||
) -> Result<ImportSource> {
|
|
||||||
self.upsert(import_source, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_import_source(
|
|
||||||
&self,
|
|
||||||
import_source: &ImportSource,
|
|
||||||
source: &UpdateSource,
|
|
||||||
) -> Result<ImportSource> {
|
|
||||||
self.delete_import_source_resources(&import_source.id)?;
|
|
||||||
self.delete(import_source, source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ mod grpc_requests;
|
|||||||
mod http_requests;
|
mod http_requests;
|
||||||
mod http_response_events;
|
mod http_response_events;
|
||||||
mod http_responses;
|
mod http_responses;
|
||||||
mod import_source_resources;
|
|
||||||
mod import_sources;
|
|
||||||
mod key_values;
|
mod key_values;
|
||||||
mod model_changes;
|
mod model_changes;
|
||||||
mod plugin_key_values;
|
mod plugin_key_values;
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ use crate::models::{
|
|||||||
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
|
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
|
||||||
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
|
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
|
||||||
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||||
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
|
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden,
|
||||||
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
|
ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, WebsocketConnection,
|
||||||
WebsocketConnection,
|
|
||||||
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
|
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
|
||||||
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
|
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
|
||||||
};
|
};
|
||||||
@@ -86,10 +85,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
|
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
|
||||||
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
|
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
|
||||||
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
|
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
|
||||||
for import_source in self.list_import_sources(wid)? {
|
|
||||||
self.delete_import_source_resources(&import_source.id)?;
|
|
||||||
}
|
|
||||||
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
|
|
||||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||||
self.delete(workspace, source)
|
self.delete(workspace, source)
|
||||||
|
|||||||
@@ -85,152 +85,6 @@ pub struct BatchUpsertResult {
|
|||||||
pub websocket_requests: Vec<WebsocketRequest>,
|
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where an import's contents came from, used to link the committed workspace back to it.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_util.ts")]
|
|
||||||
pub struct ImportOrigin {
|
|
||||||
/// The absolute file path or URL the contents were read from.
|
|
||||||
pub origin: String,
|
|
||||||
pub label: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The model types an import plan can contain.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_util.ts")]
|
|
||||||
pub enum ImportResourceType {
|
|
||||||
Environment,
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportResourceType {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
ImportResourceType::Environment => "environment",
|
|
||||||
ImportResourceType::Folder => "folder",
|
|
||||||
ImportResourceType::GrpcRequest => "grpc_request",
|
|
||||||
ImportResourceType::HttpRequest => "http_request",
|
|
||||||
ImportResourceType::WebsocketRequest => "websocket_request",
|
|
||||||
ImportResourceType::Workspace => "workspace",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_str(value: &str) -> Option<Self> {
|
|
||||||
match value {
|
|
||||||
"environment" => Some(ImportResourceType::Environment),
|
|
||||||
"folder" => Some(ImportResourceType::Folder),
|
|
||||||
"grpc_request" => Some(ImportResourceType::GrpcRequest),
|
|
||||||
"http_request" => Some(ImportResourceType::HttpRequest),
|
|
||||||
"websocket_request" => Some(ImportResourceType::WebsocketRequest),
|
|
||||||
"workspace" => Some(ImportResourceType::Workspace),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_util.ts")]
|
|
||||||
pub enum ImportPlanAction {
|
|
||||||
Create,
|
|
||||||
Update,
|
|
||||||
Delete,
|
|
||||||
Unchanged,
|
|
||||||
KeepLocal,
|
|
||||||
Conflict,
|
|
||||||
/// Present in the source but previously turned down; selecting it imports it again
|
|
||||||
NotImported,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_util.ts")]
|
|
||||||
pub enum ImportPlanReason {
|
|
||||||
MovedIntoNotImportedFolder,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
#[ts(export, export_to = "gen_util.ts")]
|
|
||||||
pub enum ImportConflictResolution {
|
|
||||||
KeepMine,
|
|
||||||
TakeSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[ts(export, export_to = "gen_util.ts")]
|
|
||||||
pub struct ImportPlanItem {
|
|
||||||
pub action: ImportPlanAction,
|
|
||||||
pub model: ImportResourceType,
|
|
||||||
pub model_id: String,
|
|
||||||
pub name: String,
|
|
||||||
/// Planned parent folder ID for incoming resources; current parent for deletions.
|
|
||||||
#[ts(optional)]
|
|
||||||
pub parent_id: Option<String>,
|
|
||||||
pub selected: bool,
|
|
||||||
#[ts(optional)]
|
|
||||||
pub resolution: Option<ImportConflictResolution>,
|
|
||||||
#[ts(optional)]
|
|
||||||
pub reason: Option<ImportPlanReason>,
|
|
||||||
/// Fields where the source and the local copy disagree, so the preview can say why
|
|
||||||
#[serde(default)]
|
|
||||||
pub changed_fields: Vec<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>,
|
|
||||||
|
|
||||||
/// Stable source key for every model in `resources`, keyed by its planned ID.
|
|
||||||
pub source_keys: BTreeMap<String, String>,
|
|
||||||
|
|
||||||
/// One entry per plannable resource; commit applies only the selected ones.
|
|
||||||
#[serde(default)]
|
|
||||||
pub items: Vec<ImportPlanItem>,
|
|
||||||
|
|
||||||
#[serde(default)]
|
|
||||||
#[ts(optional)]
|
|
||||||
pub origin: Option<ImportOrigin>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_workspace_export_resources(
|
pub fn get_workspace_export_resources(
|
||||||
db: &ClientDb,
|
db: &ClientDb,
|
||||||
yaak_version: &str,
|
yaak_version: &str,
|
||||||
|
|||||||
+1
-12
@@ -474,18 +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 ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||||
|
|
||||||
export type ImportResponse = {
|
export type ImportResponse = { resources: ImportResources, };
|
||||||
/**
|
|
||||||
* Display name of the importer that recognized the input.
|
|
||||||
*/
|
|
||||||
importer: string, resources: ImportResources,
|
|
||||||
/**
|
|
||||||
* Identifies the same source element across re-parses, keyed by the IDs in `resources`.
|
|
||||||
*
|
|
||||||
* Must come from the document, never from anything the user can rename in Yaak. Only set
|
|
||||||
* for formats that carry their own identifiers; the host derives the rest.
|
|
||||||
*/
|
|
||||||
sourceKeys?: { [key in string]?: string }, };
|
|
||||||
|
|
||||||
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
||||||
|
|
||||||
|
|||||||
-13
@@ -11,7 +11,6 @@ export type AnyModel =
|
|||||||
| HttpRequest
|
| HttpRequest
|
||||||
| HttpResponse
|
| HttpResponse
|
||||||
| HttpResponseEvent
|
| HttpResponseEvent
|
||||||
| ImportSource
|
|
||||||
| KeyValue
|
| KeyValue
|
||||||
| Plugin
|
| Plugin
|
||||||
| Settings
|
| Settings
|
||||||
@@ -319,18 +318,6 @@ export type HttpUrlParameter = {
|
|||||||
|
|
||||||
export type HttpVersion = "auto" | "http1" | "http2";
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
export type ImportSource = {
|
|
||||||
model: "import_source";
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
workspaceId: string;
|
|
||||||
importer: string;
|
|
||||||
origin: string;
|
|
||||||
originLabel: string;
|
|
||||||
lastImportedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::HashMap;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,
|
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,
|
||||||
@@ -247,16 +247,7 @@ pub struct ImportRequest {
|
|||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_events.ts")]
|
#[ts(export, export_to = "gen_events.ts")]
|
||||||
pub struct ImportResponse {
|
pub struct ImportResponse {
|
||||||
/// Display name of the importer that recognized the input.
|
|
||||||
pub importer: String,
|
|
||||||
pub resources: ImportResources,
|
pub resources: ImportResources,
|
||||||
|
|
||||||
/// Identifies the same source element across re-parses, keyed by the IDs in `resources`.
|
|
||||||
///
|
|
||||||
/// Must come from the document, never from anything the user can rename in Yaak. Only set
|
|
||||||
/// for formats that carry their own identifiers; the host derives the rest.
|
|
||||||
#[ts(optional)]
|
|
||||||
pub source_keys: Option<BTreeMap<String, String>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||||
|
|||||||
@@ -187,25 +187,24 @@ impl PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
|
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
|
||||||
// Scope the db connection so the future stays Send across the await below
|
let db = query_manager.connect();
|
||||||
let plugins = {
|
for dir in &bundled_dirs {
|
||||||
let db = query_manager.connect();
|
if db.get_plugin_by_directory(dir).is_none() {
|
||||||
for dir in &bundled_dirs {
|
db.upsert_plugin(
|
||||||
if db.get_plugin_by_directory(dir).is_none() {
|
&Plugin {
|
||||||
db.upsert_plugin(
|
directory: dir.clone(),
|
||||||
&Plugin {
|
enabled: true,
|
||||||
directory: dir.clone(),
|
url: None,
|
||||||
enabled: true,
|
source: PluginSource::Bundled,
|
||||||
url: None,
|
..Default::default()
|
||||||
source: PluginSource::Bundled,
|
},
|
||||||
..Default::default()
|
&UpdateSource::Background,
|
||||||
},
|
)?;
|
||||||
&UpdateSource::Background,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
db.list_plugins()?
|
}
|
||||||
};
|
|
||||||
|
let plugins = db.list_plugins()?;
|
||||||
|
drop(db);
|
||||||
|
|
||||||
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
|
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
|
||||||
if !init_errors.is_empty() {
|
if !init_errors.is_empty() {
|
||||||
@@ -1105,19 +1104,8 @@ impl PluginManager {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// TODO: Don't just return the first valid response
|
// TODO: Don't just return the first valid response
|
||||||
let result = reply_events.into_iter().find_map(|e| match e {
|
let result = reply_events.into_iter().find_map(|e| match e.payload {
|
||||||
InternalEvent {
|
InternalEventPayload::ImportResponse(resp) => Some(resp),
|
||||||
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,
|
_ => None,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,6 @@ impl TryFrom<AnyModel> for SyncModel {
|
|||||||
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
|
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)),
|
AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)),
|
AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::ImportSource(m) => return Err(UnknownModel(m.model)),
|
|
||||||
AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)),
|
AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::Plugin(m) => return Err(UnknownModel(m.model)),
|
AnyModel::Plugin(m) => return Err(UnknownModel(m.model)),
|
||||||
AnyModel::Settings(m) => return Err(UnknownModel(m.model)),
|
AnyModel::Settings(m) => return Err(UnknownModel(m.model)),
|
||||||
@@ -227,14 +226,6 @@ mod migration_tests {
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::SyncModel;
|
use crate::models::SyncModel;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn import_sources_are_excluded_from_sync() {
|
|
||||||
let model = yaak_models::models::AnyModel::ImportSource(
|
|
||||||
yaak_models::models::ImportSource::default(),
|
|
||||||
);
|
|
||||||
assert!(SyncModel::try_from(model).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> {
|
fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> {
|
||||||
let raw = r#"
|
let raw = r#"
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ async-trait = "0.1"
|
|||||||
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
md5 = "0.8.0"
|
md5 = "0.8.0"
|
||||||
sha2 = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||||
@@ -23,6 +21,5 @@ yaak-templates = { workspace = true }
|
|||||||
yaak-tls = { workspace = true }
|
yaak-tls = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
|
|||||||
+87
-2722
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -100,7 +100,7 @@
|
|||||||
"lint:vp": "vp lint",
|
"lint:vp": "vp lint",
|
||||||
"lint:workspaces": "npm run --workspaces --if-present lint",
|
"lint:workspaces": "npm run --workspaces --if-present lint",
|
||||||
"replace-version": "node scripts/replace-version.cjs",
|
"replace-version": "node scripts/replace-version.cjs",
|
||||||
"format": "vp fmt",
|
"format": "vp fmt --ignore-path .oxfmtignore",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"client:tauri-before-build": "npm run bootstrap",
|
"client:tauri-before-build": "npm run bootstrap",
|
||||||
"client:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-client",
|
"client:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-client",
|
||||||
|
|||||||
@@ -267,9 +267,6 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
|||||||
// Anything that needs files the page can't reach.
|
// 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_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_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_list_import_sources: ["Importing isn't available in the browser yet", null],
|
|
||||||
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
|
|
||||||
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
|
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_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
|
||||||
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
|
||||||
|
|||||||
+1
-12
@@ -474,18 +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 ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||||
|
|
||||||
export type ImportResponse = {
|
export type ImportResponse = { resources: ImportResources, };
|
||||||
/**
|
|
||||||
* Display name of the importer that recognized the input.
|
|
||||||
*/
|
|
||||||
importer: string, resources: ImportResources,
|
|
||||||
/**
|
|
||||||
* Identifies the same source element across re-parses, keyed by the IDs in `resources`.
|
|
||||||
*
|
|
||||||
* Must come from the document, never from anything the user can rename in Yaak. Only set
|
|
||||||
* for formats that carry their own identifiers; the host derives the rest.
|
|
||||||
*/
|
|
||||||
sourceKeys?: { [key in string]?: string }, };
|
|
||||||
|
|
||||||
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ImportResources, ImportResponse } from "../bindings/gen_events";
|
import type { ImportResources } from "../bindings/gen_events";
|
||||||
import type { AtLeast, MaybePromise } from "../helpers";
|
import type { AtLeast, MaybePromise } from "../helpers";
|
||||||
import type { Context } from "./Context";
|
import type { Context } from "./Context";
|
||||||
|
|
||||||
@@ -14,12 +14,9 @@ export type PartialImportResources = {
|
|||||||
websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>;
|
websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** `importer` is omitted because the host fills it in from the plugin's own name. */
|
export type ImportPluginResponse = null | {
|
||||||
export type ImportPluginResponse =
|
resources: PartialImportResources;
|
||||||
| null
|
};
|
||||||
| (Omit<ImportResponse, "importer" | "resources"> & {
|
|
||||||
resources: PartialImportResources;
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ImporterPlugin = {
|
export type ImporterPlugin = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -167,9 +167,7 @@ export class PluginInstance {
|
|||||||
if (reply != null) {
|
if (reply != null) {
|
||||||
const replyPayload: InternalEventPayload = {
|
const replyPayload: InternalEventPayload = {
|
||||||
type: "import_response",
|
type: "import_response",
|
||||||
importer: this.#mod.importer.name,
|
|
||||||
resources: reply.resources as ImportResources,
|
resources: reply.resources as ImportResources,
|
||||||
sourceKeys: reply.sourceKeys ?? null,
|
|
||||||
};
|
};
|
||||||
this.#sendPayload(context, replyPayload, replyId);
|
this.#sendPayload(context, replyPayload, replyId);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { forwardRef } from "react";
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { LoadingIcon } from "./LoadingIcon";
|
import { LoadingIcon } from "./LoadingIcon";
|
||||||
|
|
||||||
type ButtonVariant = "border" | "solid" | "input";
|
type ButtonVariant = "border" | "solid";
|
||||||
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
|
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
|
||||||
|
|
||||||
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
|
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
|
||||||
@@ -88,9 +88,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
|||||||
resolvedColor !== "custom" &&
|
resolvedColor !== "custom" &&
|
||||||
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
|
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
|
||||||
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
|
"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}
|
disabled={isDisabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|||||||
@@ -959,10 +959,6 @@ describe("importer-curl", () => {
|
|||||||
{ enabled: true, name: "q", value: "a=b" },
|
{ enabled: true, name: "q", value: "a=b" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Emits no source keys", () => {
|
|
||||||
expect(convertCurl("curl https://yaak.app")).not.toHaveProperty("sourceKeys");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const idCount: Partial<Record<string, number>> = {};
|
const idCount: Partial<Record<string, number>> = {};
|
||||||
|
|||||||
@@ -15,21 +15,6 @@ export function convertId(id: string): string {
|
|||||||
return `GENERATE_ID::${id}`;
|
return `GENERATE_ID::${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSourceKeys() {
|
|
||||||
const keys: Record<string, string> = {};
|
|
||||||
return {
|
|
||||||
/** Convert a resource's own document ID, keeping it as that resource's source key. */
|
|
||||||
own(id: string): string {
|
|
||||||
const converted = convertId(id);
|
|
||||||
keys[converted] = id;
|
|
||||||
return converted;
|
|
||||||
},
|
|
||||||
all: (): Record<string, string> => keys,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SourceKeys = ReturnType<typeof createSourceKeys>;
|
|
||||||
|
|
||||||
export function importHttpBodyAndHeaders(obj: any) {
|
export function importHttpBodyAndHeaders(obj: any) {
|
||||||
const { headers } = importHeaders(obj);
|
const { headers } = importHeaders(obj);
|
||||||
const { body, bodyType } = importHttpBody(obj.body);
|
const { body, bodyType } = importHttpBody(obj.body);
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
/* oxlint-disable no-explicit-any */
|
/* oxlint-disable no-explicit-any */
|
||||||
import type { PartialImportResources } from "@yaakapp/api";
|
import type { PartialImportResources } from "@yaakapp/api";
|
||||||
import {
|
import { convertId, convertTemplateSyntax, importHttpBodyAndHeaders, isJSObject } from "./common";
|
||||||
convertId,
|
|
||||||
convertTemplateSyntax,
|
|
||||||
createSourceKeys,
|
|
||||||
importHttpBodyAndHeaders,
|
|
||||||
isJSObject,
|
|
||||||
type SourceKeys,
|
|
||||||
} from "./common";
|
|
||||||
|
|
||||||
export function convertInsomniaV4(parsed: any) {
|
export function convertInsomniaV4(parsed: any) {
|
||||||
if (!Array.isArray(parsed.resources)) return null;
|
if (!Array.isArray(parsed.resources)) return null;
|
||||||
|
|
||||||
const keys = createSourceKeys();
|
|
||||||
const resources: PartialImportResources = {
|
const resources: PartialImportResources = {
|
||||||
environments: [],
|
environments: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
@@ -28,7 +20,7 @@ export function convertInsomniaV4(parsed: any) {
|
|||||||
);
|
);
|
||||||
for (const w of workspacesToImport) {
|
for (const w of workspacesToImport) {
|
||||||
resources.workspaces.push({
|
resources.workspaces.push({
|
||||||
id: keys.own(w._id),
|
id: convertId(w._id),
|
||||||
createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined,
|
createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined,
|
||||||
model: "workspace",
|
model: "workspace",
|
||||||
@@ -39,7 +31,7 @@ export function convertInsomniaV4(parsed: any) {
|
|||||||
(r: any) => isJSObject(r) && r._type === "environment",
|
(r: any) => isJSObject(r) && r._type === "environment",
|
||||||
);
|
);
|
||||||
resources.environments.push(
|
resources.environments.push(
|
||||||
...environmentsToImport.map((r: any) => importEnvironment(r, w._id, keys)),
|
...environmentsToImport.map((r: any) => importEnvironment(r, w._id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextFolder = (parentId: string) => {
|
const nextFolder = (parentId: string) => {
|
||||||
@@ -48,12 +40,12 @@ export function convertInsomniaV4(parsed: any) {
|
|||||||
if (!isJSObject(child)) continue;
|
if (!isJSObject(child)) continue;
|
||||||
|
|
||||||
if (child._type === "request_group") {
|
if (child._type === "request_group") {
|
||||||
resources.folders.push(importFolder(child, w._id, keys));
|
resources.folders.push(importFolder(child, w._id));
|
||||||
nextFolder(child._id);
|
nextFolder(child._id);
|
||||||
} else if (child._type === "request") {
|
} else if (child._type === "request") {
|
||||||
resources.httpRequests.push(importHttpRequest(child, w._id, keys));
|
resources.httpRequests.push(importHttpRequest(child, w._id));
|
||||||
} else if (child._type === "grpc_request") {
|
} else if (child._type === "grpc_request") {
|
||||||
resources.grpcRequests.push(importGrpcRequest(child, w._id, keys));
|
resources.grpcRequests.push(importGrpcRequest(child, w._id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -68,14 +60,10 @@ export function convertInsomniaV4(parsed: any) {
|
|||||||
resources.environments = resources.environments.filter(Boolean);
|
resources.environments = resources.environments.filter(Boolean);
|
||||||
resources.workspaces = resources.workspaces.filter(Boolean);
|
resources.workspaces = resources.workspaces.filter(Boolean);
|
||||||
|
|
||||||
return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
|
return { resources: convertTemplateSyntax(resources) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function importHttpRequest(
|
function importHttpRequest(r: any, workspaceId: string): PartialImportResources["httpRequests"][0] {
|
||||||
r: any,
|
|
||||||
workspaceId: string,
|
|
||||||
keys: SourceKeys,
|
|
||||||
): PartialImportResources["httpRequests"][0] {
|
|
||||||
let authenticationType: string | null = null;
|
let authenticationType: string | null = null;
|
||||||
let authentication = {};
|
let authentication = {};
|
||||||
if (r.authentication.type === "bearer") {
|
if (r.authentication.type === "bearer") {
|
||||||
@@ -92,7 +80,7 @@ function importHttpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: keys.own(r.meta?.id ?? r._id),
|
id: convertId(r.meta?.id ?? r._id),
|
||||||
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
|
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
@@ -114,17 +102,13 @@ function importHttpRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function importGrpcRequest(
|
function importGrpcRequest(r: any, workspaceId: string): PartialImportResources["grpcRequests"][0] {
|
||||||
r: any,
|
|
||||||
workspaceId: string,
|
|
||||||
keys: SourceKeys,
|
|
||||||
): PartialImportResources["grpcRequests"][0] {
|
|
||||||
const parts = r.protoMethodName.split("/").filter((p: any) => p !== "");
|
const parts = r.protoMethodName.split("/").filter((p: any) => p !== "");
|
||||||
const service = parts[0] ?? null;
|
const service = parts[0] ?? null;
|
||||||
const method = parts[1] ?? null;
|
const method = parts[1] ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: keys.own(r.meta?.id ?? r._id),
|
id: convertId(r.meta?.id ?? r._id),
|
||||||
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
|
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
@@ -147,13 +131,9 @@ function importGrpcRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function importFolder(
|
function importFolder(f: any, workspaceId: string): PartialImportResources["folders"][0] {
|
||||||
f: any,
|
|
||||||
workspaceId: string,
|
|
||||||
keys: SourceKeys,
|
|
||||||
): PartialImportResources["folders"][0] {
|
|
||||||
return {
|
return {
|
||||||
id: keys.own(f._id),
|
id: convertId(f._id),
|
||||||
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined,
|
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined,
|
||||||
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
|
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
|
||||||
@@ -167,12 +147,11 @@ function importFolder(
|
|||||||
function importEnvironment(
|
function importEnvironment(
|
||||||
e: any,
|
e: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
keys: SourceKeys,
|
|
||||||
isParentOg?: boolean,
|
isParentOg?: boolean,
|
||||||
): PartialImportResources["environments"][0] {
|
): PartialImportResources["environments"][0] {
|
||||||
const isParent = isParentOg ?? e.parentId === workspaceId;
|
const isParent = isParentOg ?? e.parentId === workspaceId;
|
||||||
return {
|
return {
|
||||||
id: keys.own(e._id),
|
id: convertId(e._id),
|
||||||
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined,
|
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import type { PartialImportResources } from "@yaakapp/api";
|
|||||||
import {
|
import {
|
||||||
convertId,
|
convertId,
|
||||||
convertTemplateSyntax,
|
convertTemplateSyntax,
|
||||||
createSourceKeys,
|
|
||||||
importHeaders,
|
importHeaders,
|
||||||
importHttpBodyAndHeaders,
|
importHttpBodyAndHeaders,
|
||||||
isJSObject,
|
isJSObject,
|
||||||
type SourceKeys,
|
|
||||||
} from "./common";
|
} from "./common";
|
||||||
|
|
||||||
export function convertInsomniaV5(parsed: any) {
|
export function convertInsomniaV5(parsed: any) {
|
||||||
@@ -20,7 +18,6 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = createSourceKeys();
|
|
||||||
const resources: PartialImportResources = {
|
const resources: PartialImportResources = {
|
||||||
environments: [],
|
environments: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
@@ -33,7 +30,7 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
// Import workspaces
|
// Import workspaces
|
||||||
const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>;
|
const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>;
|
||||||
resources.workspaces.push({
|
resources.workspaces.push({
|
||||||
id: keys.own(meta.id ?? "collection"),
|
id: convertId(meta.id ?? "collection"),
|
||||||
createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : undefined,
|
createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined,
|
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined,
|
||||||
model: "workspace",
|
model: "workspace",
|
||||||
@@ -45,10 +42,8 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
|
|
||||||
// Import environments
|
// Import environments
|
||||||
resources.environments.push(
|
resources.environments.push(
|
||||||
importEnvironment(parsed.environments, meta.id, keys, true),
|
importEnvironment(parsed.environments, meta.id, true),
|
||||||
...(parsed.environments.subEnvironments ?? []).map((r: any) =>
|
...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)),
|
||||||
importEnvironment(r, meta.id, keys),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Import folders
|
// Import folders
|
||||||
@@ -57,16 +52,16 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
if (!isJSObject(child)) continue;
|
if (!isJSObject(child)) continue;
|
||||||
|
|
||||||
if (Array.isArray(child.children)) {
|
if (Array.isArray(child.children)) {
|
||||||
const { folder, environment } = importFolder(child, meta.id, parentId, keys);
|
const { folder, environment } = importFolder(child, meta.id, parentId);
|
||||||
resources.folders.push(folder);
|
resources.folders.push(folder);
|
||||||
if (environment) resources.environments.push(environment);
|
if (environment) resources.environments.push(environment);
|
||||||
nextFolder(child.children, child.meta.id);
|
nextFolder(child.children, child.meta.id);
|
||||||
} else if (child.method) {
|
} else if (child.method) {
|
||||||
resources.httpRequests.push(importHttpRequest(child, meta.id, parentId, keys));
|
resources.httpRequests.push(importHttpRequest(child, meta.id, parentId));
|
||||||
} else if (child.protoFileId) {
|
} else if (child.protoFileId) {
|
||||||
resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId, keys));
|
resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId));
|
||||||
} else if (child.url) {
|
} else if (child.url) {
|
||||||
resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId, keys));
|
resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -80,14 +75,13 @@ export function convertInsomniaV5(parsed: any) {
|
|||||||
resources.environments = resources.environments.filter(Boolean);
|
resources.environments = resources.environments.filter(Boolean);
|
||||||
resources.workspaces = resources.workspaces.filter(Boolean);
|
resources.workspaces = resources.workspaces.filter(Boolean);
|
||||||
|
|
||||||
return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
|
return { resources: convertTemplateSyntax(resources) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function importHttpRequest(
|
function importHttpRequest(
|
||||||
r: any,
|
r: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
keys: SourceKeys,
|
|
||||||
): PartialImportResources["httpRequests"][0] {
|
): PartialImportResources["httpRequests"][0] {
|
||||||
const id = r.meta?.id ?? r._id;
|
const id = r.meta?.id ?? r._id;
|
||||||
const created = r.meta?.created ?? r.created;
|
const created = r.meta?.created ?? r.created;
|
||||||
@@ -95,7 +89,7 @@ function importHttpRequest(
|
|||||||
const sortKey = r.meta?.sortKey ?? r.sortKey;
|
const sortKey = r.meta?.sortKey ?? r.sortKey;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: keys.own(id),
|
id: convertId(id),
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
@@ -120,7 +114,6 @@ function importGrpcRequest(
|
|||||||
r: any,
|
r: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
keys: SourceKeys,
|
|
||||||
): PartialImportResources["grpcRequests"][0] {
|
): PartialImportResources["grpcRequests"][0] {
|
||||||
const id = r.meta?.id ?? r._id;
|
const id = r.meta?.id ?? r._id;
|
||||||
const created = r.meta?.created ?? r.created;
|
const created = r.meta?.created ?? r.created;
|
||||||
@@ -133,7 +126,7 @@ function importGrpcRequest(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
model: "grpc_request",
|
model: "grpc_request",
|
||||||
id: keys.own(id),
|
id: convertId(id),
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
@@ -159,7 +152,6 @@ function importWebsocketRequest(
|
|||||||
r: any,
|
r: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
keys: SourceKeys,
|
|
||||||
): PartialImportResources["websocketRequests"][0] {
|
): PartialImportResources["websocketRequests"][0] {
|
||||||
const id = r.meta?.id ?? r._id;
|
const id = r.meta?.id ?? r._id;
|
||||||
const created = r.meta?.created ?? r.created;
|
const created = r.meta?.created ?? r.created;
|
||||||
@@ -168,7 +160,7 @@ function importWebsocketRequest(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
model: "websocket_request",
|
model: "websocket_request",
|
||||||
id: keys.own(id),
|
id: convertId(id),
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
@@ -206,7 +198,6 @@ function importFolder(
|
|||||||
f: any,
|
f: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
parentId: string,
|
parentId: string,
|
||||||
keys: SourceKeys,
|
|
||||||
): {
|
): {
|
||||||
folder: PartialImportResources["folders"][0];
|
folder: PartialImportResources["folders"][0];
|
||||||
environment: PartialImportResources["environments"][0] | null;
|
environment: PartialImportResources["environments"][0] | null;
|
||||||
@@ -219,7 +210,7 @@ function importFolder(
|
|||||||
let environment: PartialImportResources["environments"][0] | null = null;
|
let environment: PartialImportResources["environments"][0] | null = null;
|
||||||
if (Object.keys(f.environment ?? {}).length > 0) {
|
if (Object.keys(f.environment ?? {}).length > 0) {
|
||||||
environment = {
|
environment = {
|
||||||
id: keys.own(`${id}folder`),
|
id: convertId(`${id}folder`),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
@@ -239,7 +230,7 @@ function importFolder(
|
|||||||
return {
|
return {
|
||||||
folder: {
|
folder: {
|
||||||
model: "folder",
|
model: "folder",
|
||||||
id: keys.own(id),
|
id: convertId(id),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
folderId: parentId === workspaceId ? null : convertId(parentId),
|
folderId: parentId === workspaceId ? null : convertId(parentId),
|
||||||
@@ -257,7 +248,6 @@ function importFolder(
|
|||||||
function importEnvironment(
|
function importEnvironment(
|
||||||
e: any,
|
e: any,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
keys: SourceKeys,
|
|
||||||
isParent?: boolean,
|
isParent?: boolean,
|
||||||
): PartialImportResources["environments"][0] {
|
): PartialImportResources["environments"][0] {
|
||||||
const id = e.meta?.id ?? e._id;
|
const id = e.meta?.id ?? e._id;
|
||||||
@@ -266,7 +256,7 @@ function importEnvironment(
|
|||||||
const sortKey = e.meta?.sortKey ?? e.sortKey;
|
const sortKey = e.meta?.sortKey ?? e.sortKey;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: keys.own(id),
|
id: convertId(id),
|
||||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||||
workspaceId: convertId(workspaceId),
|
workspaceId: convertId(workspaceId),
|
||||||
|
|||||||
@@ -132,13 +132,5 @@
|
|||||||
"name": "Dummy"
|
"name": "Dummy"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::env_16c0dec5b77c414ae0e419b8f10c3701300c5900": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
|
|
||||||
"GENERATE_ID::env_799ae3d723ef44af91b4817e5d057e6d": "env_799ae3d723ef44af91b4817e5d057e6d",
|
|
||||||
"GENERATE_ID::env_030fbfdbb274426ebd78e2e6518f8553": "env_030fbfdbb274426ebd78e2e6518f8553",
|
|
||||||
"GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3": "fld_859d1df78261463480b6a3a1419517e3",
|
|
||||||
"GENERATE_ID::req_84cd9ae4bd034dd8bb730e856a665cbb": "req_84cd9ae4bd034dd8bb730e856a665cbb",
|
|
||||||
"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019": "wrk_d4d92f7c0ee947b89159243506687019"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,13 +116,5 @@
|
|||||||
"headers": []
|
"headers": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce": "env_e46dc73e8ccda30ca132153e8f11183bd08119ce",
|
|
||||||
"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7folder": "fld_296933ea4ea84783a775d199997e9be7folder",
|
|
||||||
"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7": "fld_296933ea4ea84783a775d199997e9be7",
|
|
||||||
"GENERATE_ID::req_9a80320365ac4509ade406359dbc6a71": "req_9a80320365ac4509ade406359dbc6a71",
|
|
||||||
"GENERATE_ID::req_e3f8cdbd58784a539dd4c1e127d73451": "req_e3f8cdbd58784a539dd4c1e127d73451",
|
|
||||||
"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c": "wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,15 +189,5 @@
|
|||||||
"headers": []
|
"headers": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::env_20945044d3c8497ca8b717bef750987e": "env_20945044d3c8497ca8b717bef750987e",
|
|
||||||
"GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2": "env_6f7728bb7fc04d558d668e954d756ea2",
|
|
||||||
"GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243": "env_976a8b6eb5d44fb6a20150f65c32d243",
|
|
||||||
"GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c": "fld_42eb2e2bb22b4cedacbd3d057634e80c",
|
|
||||||
"GENERATE_ID::greq_06d659324df94504a4d64632be7106b3": "greq_06d659324df94504a4d64632be7106b3",
|
|
||||||
"GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785": "req_d72fff2a6b104b91a2ebe9de9edd2785",
|
|
||||||
"GENERATE_ID::ws-req_5d1a4c7c79494743962e5176f6add270": "ws-req_5d1a4c7c79494743962e5176f6add270",
|
|
||||||
"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53": "wrk_c1eacfa750a04f3ea9985ef28043fa53"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,38 +24,6 @@ describe("importer-yaak", () => {
|
|||||||
expect(result).toEqual(parseJsonOrYaml(expected));
|
expect(result).toEqual(parseJsonOrYaml(expected));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Keys resources by their Insomnia _id, unchanged by a rename", () => {
|
|
||||||
const collection = (requestName: string) =>
|
|
||||||
YAML.stringify({
|
|
||||||
type: "collection.insomnia.rest/5.0",
|
|
||||||
name: "Keys",
|
|
||||||
meta: { id: "wrk_1" },
|
|
||||||
environments: { meta: { id: "env_1" }, name: "Base", data: {} },
|
|
||||||
collection: [
|
|
||||||
{
|
|
||||||
meta: { id: "fld_1" },
|
|
||||||
name: "Folder",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
meta: { id: "req_1" },
|
|
||||||
name: requestName,
|
|
||||||
method: "GET",
|
|
||||||
url: "https://yaak.app",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const before = convertInsomnia(collection("Original"));
|
|
||||||
const after = convertInsomnia(collection("Renamed"));
|
|
||||||
|
|
||||||
expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("req_1");
|
|
||||||
expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("req_1");
|
|
||||||
expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("fld_1");
|
|
||||||
expect(before?.sourceKeys?.[before.resources.workspaces[0]!.id]).toBe("wrk_1");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseJsonOrYaml(text: string): unknown {
|
function parseJsonOrYaml(text: string): unknown {
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
|
|
||||||
const folderIdsByTag = new Map<string, string>();
|
const folderIdsByTag = new Map<string, string>();
|
||||||
const routeLabels = new Map<string, string>();
|
const routeLabels = new Map<string, string>();
|
||||||
const sourceKeys: Record<string, string> = {};
|
|
||||||
for (const tag of toArray(spec.tags)) {
|
for (const tag of toArray(spec.tags)) {
|
||||||
const tagRecord = toRecord(tag);
|
const tagRecord = toRecord(tag);
|
||||||
const name = stringAt(tagRecord, "name");
|
const name = stringAt(tagRecord, "name");
|
||||||
@@ -127,7 +126,6 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
};
|
};
|
||||||
resources.folders.push(folder);
|
resources.folders.push(folder);
|
||||||
folderIdsByTag.set(name, folder.id);
|
folderIdsByTag.set(name, folder.id);
|
||||||
sourceKeys[folder.id] = tagSourceKey(name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
|
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
|
||||||
@@ -141,7 +139,6 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
importState,
|
importState,
|
||||||
operation,
|
operation,
|
||||||
resources,
|
resources,
|
||||||
sourceKeys,
|
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,11 +160,6 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
authenticationVariables,
|
authenticationVariables,
|
||||||
});
|
});
|
||||||
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
|
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
|
||||||
sourceKeys[request.id] = operationSourceKey(
|
|
||||||
stringAt(operation, "operationId"),
|
|
||||||
method,
|
|
||||||
rawPath,
|
|
||||||
);
|
|
||||||
resources.httpRequests.push(request);
|
resources.httpRequests.push(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +241,6 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
|||||||
websocketRequests: [],
|
websocketRequests: [],
|
||||||
workspaces: resources.workspaces,
|
workspaces: resources.workspaces,
|
||||||
}) as PartialImportResources,
|
}) as PartialImportResources,
|
||||||
sourceKeys,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,14 +681,12 @@ function findOrCreateFolderId({
|
|||||||
importState,
|
importState,
|
||||||
operation,
|
operation,
|
||||||
resources,
|
resources,
|
||||||
sourceKeys,
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
}: {
|
}: {
|
||||||
folderIdsByTag: Map<string, string>;
|
folderIdsByTag: Map<string, string>;
|
||||||
importState: ImportState;
|
importState: ImportState;
|
||||||
operation: UnknownRecord;
|
operation: UnknownRecord;
|
||||||
resources: ImportResources;
|
resources: ImportResources;
|
||||||
sourceKeys: Record<string, string>;
|
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
|
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
|
||||||
@@ -716,20 +705,9 @@ function findOrCreateFolderId({
|
|||||||
};
|
};
|
||||||
resources.folders.push(folder);
|
resources.folders.push(folder);
|
||||||
folderIdsByTag.set(tag, folder.id);
|
folderIdsByTag.set(tag, folder.id);
|
||||||
sourceKeys[folder.id] = tagSourceKey(tag);
|
|
||||||
return folder.id;
|
return folder.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function operationSourceKey(operationId: string | undefined, method: string, path: string): string {
|
|
||||||
return operationId != null && operationId !== ""
|
|
||||||
? `op:${operationId}`
|
|
||||||
: `route:${method.toUpperCase()} ${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagSourceKey(tag: string): string {
|
|
||||||
return `tag:${tag}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Yaak's `:name` placeholders only substitute when they span a whole path
|
* Yaak's `:name` placeholders only substitute when they span a whole path
|
||||||
* segment and hold a single plain value. Templates elsewhere in a segment
|
* segment and hold a single plain value. Templates elsewhere in a segment
|
||||||
|
|||||||
@@ -308,16 +308,6 @@ License: CC0 1.0 (https://github.com/APIs-guru/openapi-directory#licenses)",
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::FOLDER_0": "tag:APIs",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_0": "op:listAPIs",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_1": "op:getMetrics",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_2": "op:getProviders",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_3": "op:getAPI",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_4": "op:getServiceAPI",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_5": "op:getProvider",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_6": "op:getServices",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -2610,97 +2600,6 @@ Contact: me@kennethreitz.org",
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::FOLDER_0": "tag:HTTP Methods",
|
|
||||||
"GENERATE_ID::FOLDER_1": "tag:Auth",
|
|
||||||
"GENERATE_ID::FOLDER_10": "tag:Anything",
|
|
||||||
"GENERATE_ID::FOLDER_2": "tag:Status codes",
|
|
||||||
"GENERATE_ID::FOLDER_3": "tag:Request inspection",
|
|
||||||
"GENERATE_ID::FOLDER_4": "tag:Response inspection",
|
|
||||||
"GENERATE_ID::FOLDER_5": "tag:Response formats",
|
|
||||||
"GENERATE_ID::FOLDER_6": "tag:Dynamic data",
|
|
||||||
"GENERATE_ID::FOLDER_7": "tag:Cookies",
|
|
||||||
"GENERATE_ID::FOLDER_8": "tag:Images",
|
|
||||||
"GENERATE_ID::FOLDER_9": "tag:Redirects",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /absolute-redirect/{n}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_1": "route:DELETE /anything",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_10": "route:POST /anything/{anything}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_11": "route:PUT /anything/{anything}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_12": "route:TRACE /anything/{anything}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_13": "route:GET /base64/{value}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_14": "route:GET /basic-auth/{user}/{passwd}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_15": "route:GET /bearer",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_16": "route:GET /brotli",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_17": "route:GET /bytes/{n}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_18": "route:GET /cache",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_19": "route:GET /cache/{value}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_2": "route:GET /anything",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_20": "route:GET /cookies",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_21": "route:GET /cookies/delete",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_22": "route:GET /cookies/set",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_23": "route:GET /cookies/set/{name}/{value}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_24": "route:GET /deflate",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_25": "route:DELETE /delay/{delay}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_26": "route:GET /delay/{delay}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_27": "route:PATCH /delay/{delay}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_28": "route:POST /delay/{delay}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_29": "route:PUT /delay/{delay}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_3": "route:PATCH /anything",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_30": "route:TRACE /delay/{delay}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_31": "route:DELETE /delete",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_32": "route:GET /deny",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_33": "route:GET /digest-auth/{qop}/{user}/{passwd}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_34": "route:GET /digest-auth/{qop}/{user}/{passwd}/{algorithm}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_35": "route:GET /digest-auth/{qop}/{user}/{passwd}/{algorithm}/{stale_after}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_36": "route:GET /drip",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_37": "route:GET /encoding/utf8",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_38": "route:GET /etag/{etag}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_39": "route:GET /get",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_4": "route:POST /anything",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_40": "route:GET /gzip",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_41": "route:GET /headers",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_42": "route:GET /hidden-basic-auth/{user}/{passwd}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_43": "route:GET /html",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_44": "route:GET /image",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_45": "route:GET /image/jpeg",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_46": "route:GET /image/png",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_47": "route:GET /image/svg",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_48": "route:GET /image/webp",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_49": "route:GET /ip",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_5": "route:PUT /anything",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_50": "route:GET /json",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_51": "route:GET /links/{n}/{offset}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_52": "route:PATCH /patch",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_53": "route:POST /post",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_54": "route:PUT /put",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_55": "route:GET /range/{numbytes}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_56": "route:DELETE /redirect-to",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_57": "route:GET /redirect-to",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_58": "route:PATCH /redirect-to",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_59": "route:POST /redirect-to",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_6": "route:TRACE /anything",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_60": "route:PUT /redirect-to",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_61": "route:TRACE /redirect-to",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_62": "route:GET /redirect/{n}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_63": "route:GET /relative-redirect/{n}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_64": "route:GET /response-headers",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_65": "route:POST /response-headers",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_66": "route:GET /robots.txt",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_67": "route:DELETE /status/{codes}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_68": "route:GET /status/{codes}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_69": "route:PATCH /status/{codes}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_7": "route:DELETE /anything/{anything}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_70": "route:POST /status/{codes}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_71": "route:PUT /status/{codes}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_72": "route:TRACE /status/{codes}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_73": "route:GET /stream-bytes/{n}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_74": "route:GET /stream/{n}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_75": "route:GET /user-agent",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_76": "route:GET /uuid",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_77": "route:GET /xml",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_8": "route:GET /anything/{anything}",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_9": "route:PATCH /anything/{anything}",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -2835,10 +2734,6 @@ License: Apache 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html)",
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::FOLDER_0": "tag:request tag",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /apod",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -2939,9 +2834,5 @@ Responses:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /info.0.json",
|
|
||||||
"GENERATE_ID::HTTP_REQUEST_1": "route:GET /{comicId}/info.0.json",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -2382,38 +2382,4 @@ describe("importer-openapi", () => {
|
|||||||
expect(imported).toMatchSnapshot();
|
expect(imported).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Keys operations by operationId, unchanged by a rename", async () => {
|
|
||||||
const spec = (summary: string) =>
|
|
||||||
JSON.stringify({
|
|
||||||
openapi: "3.0.0",
|
|
||||||
info: { title: "Keys", version: "1" },
|
|
||||||
paths: {
|
|
||||||
"/pets": {
|
|
||||||
get: { operationId: "listPets", summary, tags: ["pets"], responses: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const before = await convertOpenApi(spec("List pets"));
|
|
||||||
const after = await convertOpenApi(spec("Fetch every pet"));
|
|
||||||
|
|
||||||
expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("op:listPets");
|
|
||||||
expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("op:listPets");
|
|
||||||
expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("tag:pets");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Falls back to the route when an operation has no operationId", async () => {
|
|
||||||
const imported = await convertOpenApi(
|
|
||||||
JSON.stringify({
|
|
||||||
openapi: "3.0.0",
|
|
||||||
info: { title: "Keys", version: "1" },
|
|
||||||
paths: { "/pets/{id}": { delete: { summary: "Remove", responses: {} } } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(imported?.sourceKeys?.[imported.resources.httpRequests[0]!.id]).toBe(
|
|
||||||
"route:DELETE /pets/{id}",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,12 +49,6 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
|
|
||||||
const globalAuth = importAuth(root.auth);
|
const globalAuth = importAuth(root.auth);
|
||||||
|
|
||||||
const sourceKeys: Record<string, string> = {};
|
|
||||||
const trackSourceKey = (modelId: string, v: Record<string, unknown>, prefix: string) => {
|
|
||||||
const id = v.id ?? v._postman_id;
|
|
||||||
if (typeof id === "string" && id !== "") sourceKeys[modelId] = `${prefix}:${id}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportResources: ExportResources = {
|
const exportResources: ExportResources = {
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
environments: [],
|
environments: [],
|
||||||
@@ -69,7 +63,6 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
description: importDescription(info.description),
|
description: importDescription(info.description),
|
||||||
...globalAuth,
|
...globalAuth,
|
||||||
};
|
};
|
||||||
trackSourceKey(workspace.id, info, "collection");
|
|
||||||
exportResources.workspaces.push(workspace);
|
exportResources.workspaces.push(workspace);
|
||||||
|
|
||||||
// Create the base environment
|
// Create the base environment
|
||||||
@@ -99,7 +92,6 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
name: v.name,
|
name: v.name,
|
||||||
folderId,
|
folderId,
|
||||||
};
|
};
|
||||||
trackSourceKey(folder.id, v, "item");
|
|
||||||
exportResources.folders.push(folder);
|
exportResources.folders.push(folder);
|
||||||
for (const child of v.item) {
|
for (const child of v.item) {
|
||||||
importItem(child, folder.id);
|
importItem(child, folder.id);
|
||||||
@@ -150,7 +142,6 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
headers,
|
headers,
|
||||||
...requestAuth,
|
...requestAuth,
|
||||||
};
|
};
|
||||||
trackSourceKey(request.id, v, "item");
|
|
||||||
exportResources.httpRequests.push(request);
|
exportResources.httpRequests.push(request);
|
||||||
} else {
|
} else {
|
||||||
console.log("Unknown item", v, folderId);
|
console.log("Unknown item", v, folderId);
|
||||||
@@ -165,7 +156,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
convertTemplateSyntax(exportResources),
|
convertTemplateSyntax(exportResources),
|
||||||
) as PartialImportResources;
|
) as PartialImportResources;
|
||||||
|
|
||||||
return { resources, sourceKeys };
|
return { resources };
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
|
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
|
||||||
|
|||||||
@@ -300,8 +300,5 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"folders": []
|
"folders": []
|
||||||
},
|
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::WORKSPACE_0": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,8 +88,5 @@
|
|||||||
"folderId": "GENERATE_ID::FOLDER_0"
|
"folderId": "GENERATE_ID::FOLDER_0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::WORKSPACE_1": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,5 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"folders": []
|
"folders": []
|
||||||
},
|
|
||||||
"sourceKeys": {
|
|
||||||
"GENERATE_ID::WORKSPACE_2": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,55 +87,4 @@ describe("importer-postman", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Keys items by their Postman ID, unchanged by a rename", () => {
|
|
||||||
const collection = (requestName: string) =>
|
|
||||||
JSON.stringify({
|
|
||||||
info: {
|
|
||||||
_postman_id: "collection-id",
|
|
||||||
name: "Keys",
|
|
||||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
|
||||||
},
|
|
||||||
item: [
|
|
||||||
{
|
|
||||||
id: "folder-id",
|
|
||||||
name: "Folder",
|
|
||||||
item: [
|
|
||||||
{
|
|
||||||
id: "request-id",
|
|
||||||
name: requestName,
|
|
||||||
request: { method: "GET", url: "https://yaak.app" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const before = convertPostman(collection("Original"));
|
|
||||||
const after = convertPostman(collection("Renamed"));
|
|
||||||
|
|
||||||
const keyOf = (result: ReturnType<typeof convertPostman>, id: string | undefined) =>
|
|
||||||
id == null ? undefined : result?.sourceKeys?.[id];
|
|
||||||
|
|
||||||
expect(keyOf(before, before?.resources.httpRequests[0]?.id)).toBe("item:request-id");
|
|
||||||
expect(keyOf(after, after?.resources.httpRequests[0]?.id)).toBe("item:request-id");
|
|
||||||
expect(keyOf(before, before?.resources.folders[0]?.id)).toBe("item:folder-id");
|
|
||||||
expect(keyOf(before, before?.resources.workspaces[0]?.id)).toBe("collection:collection-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Omits keys for items the collection never identified", () => {
|
|
||||||
const result = convertPostman(
|
|
||||||
JSON.stringify({
|
|
||||||
info: {
|
|
||||||
name: "No IDs",
|
|
||||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
|
||||||
},
|
|
||||||
item: [{ name: "Request", request: { method: "GET", url: "https://yaak.app" } }],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const requestId = result?.resources.httpRequests[0]?.id;
|
|
||||||
expect(requestId).toBeDefined();
|
|
||||||
expect(result?.sourceKeys).not.toHaveProperty(requestId as string);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,15 +80,7 @@ export function migrateImport(contents: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceKeys: Record<string, string> = {};
|
return { resources: parsed.resources };
|
||||||
for (const models of Object.values(parsed.resources)) {
|
|
||||||
if (!Array.isArray(models)) continue;
|
|
||||||
for (const model of models) {
|
|
||||||
if (typeof model?.id === "string") sourceKeys[model.id] = model.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { resources: parsed.resources, sourceKeys };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isJSObject(obj: unknown) {
|
function isJSObject(obj: unknown) {
|
||||||
|
|||||||
@@ -148,32 +148,4 @@ describe("importer-yaak", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Keys models by their Yaak ID, unchanged by a rename", () => {
|
|
||||||
const exported = (requestName: string) =>
|
|
||||||
JSON.stringify({
|
|
||||||
yaakSchema: 5,
|
|
||||||
resources: {
|
|
||||||
workspaces: [{ id: "wk_1", model: "workspace", name: "Keys" }],
|
|
||||||
httpRequests: [
|
|
||||||
{
|
|
||||||
id: "rq_1",
|
|
||||||
model: "http_request",
|
|
||||||
workspaceId: "wk_1",
|
|
||||||
name: requestName,
|
|
||||||
url: "https://yaak.app",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(migrateImport(exported("Original"))?.sourceKeys).toEqual({
|
|
||||||
wk_1: "wk_1",
|
|
||||||
rq_1: "rq_1",
|
|
||||||
});
|
|
||||||
expect(migrateImport(exported("Renamed"))?.sourceKeys).toEqual({
|
|
||||||
wk_1: "wk_1",
|
|
||||||
rq_1: "rq_1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-16
@@ -4,23 +4,8 @@ export default defineConfig({
|
|||||||
staged: {
|
staged: {
|
||||||
"*": "vp check --fix",
|
"*": "vp check --fix",
|
||||||
},
|
},
|
||||||
// Generated output, reformatted only to be undone by the next regen. Read by every formatter
|
|
||||||
// entry point, including the `staged` task above.
|
|
||||||
fmt: {
|
|
||||||
ignorePatterns: [
|
|
||||||
"**/bindings/**",
|
|
||||||
"**/routeTree.gen.ts",
|
|
||||||
"crates/yaak-templates/pkg/**",
|
|
||||||
"crates/yaak-wasm/pkg/**",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
lint: {
|
lint: {
|
||||||
ignorePatterns: [
|
ignorePatterns: ["npm/**", "crates/yaak-templates/pkg/**", "crates/yaak-wasm/pkg/**", "**/bindings/gen_*.ts"],
|
||||||
"npm/**",
|
|
||||||
"crates/yaak-templates/pkg/**",
|
|
||||||
"crates/yaak-wasm/pkg/**",
|
|
||||||
"**/bindings/gen_*.ts",
|
|
||||||
],
|
|
||||||
options: {
|
options: {
|
||||||
typeAware: true,
|
typeAware: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user