Compare commits

...
Author SHA1 Message Date
Gregory Schier d2d4b80a09 feat(import): merge re-imports from a linked source instead of duplicating (#618) 2026-09-01 11:12:07 -07:00
Gregory Schier d461c982ec Don't block window creation on plugin runtime boot (#616) 2026-09-01 09:14:50 -07:00
Gregory SchierandClaude Fable 5 8eebee460e fix(pair-editor): remount the editor when its stateKey changes (#617)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 08:58:55 -07:00
Gregory SchierandClaude Opus 5 81a2a5d955 feat(import): add stable per-resource source keys to the importer contract (#614)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:11:54 -07:00
Gregory SchierandClaude Opus 5 e63a87718c fix(tooling): keep the formatter off generated files in every entry point (#615)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:40:42 -07:00
Gregory Schier 222ea9cb83 fix(editor): opt editors out of macOS Writing Tools suggestions
A "Write with Siri" pill appears over empty editors on the macOS 27 beta,
because Codemirror's contentDOM is a contenteditable like any other. The
`writingsuggestions` attribute opts an editable element out, and setting
it here covers every editor at once.

If the pill turns out to be separate from writing suggestions, the
remaining lever is WKWebView's writingToolsBehavior, which would disable
Writing Tools for the whole window.
2026-08-31 10:55:05 -07:00
Gregory Schier 360c098b09 fix(grpc): remove the dead Refresh item from the method picker
It has never had an onSelect. Introduced inert in #193 and carried
through the codebase split, so clicking it only ever closed the dropdown.
Reload Schema in the message editor's schema dropdown does the job it
looked like it was for.
2026-08-31 10:54:40 -07:00
Gregory Schier fce1039bac feat(grpc): generate an example message from the method schema (#613) 2026-08-31 10:44:31 -07:00
Gregory Schier f18f93d613 Stage imports before committing (#571) 2026-08-31 10:20:44 -07:00
Gregory SchierandClaude Opus 5 661a384bed Faster codemirror search match counting (#612)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:09:50 -07:00
82 changed files with 5515 additions and 575 deletions
-3
View File
@@ -1,3 +0,0 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
Generated
+2
View File
@@ -11223,8 +11223,10 @@ 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",
"tempfile", "tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
+180 -63
View File
@@ -1,9 +1,8 @@
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 } from "@yaakapp-internal/models"; import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { import {
handleRefresh, handleRefresh,
jsonCompletion, jsonCompletion,
@@ -11,12 +10,20 @@ 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";
@@ -29,6 +36,11 @@ 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,
@@ -42,21 +54,16 @@ export function GrpcEditor({
setEditorView(h); setEditorView(h);
}, []); }, []);
// Find the schema for the selected service and method and update the editor // Find the schema for the selected service and method
useEffect(() => { const methodSchema = useMemo<MethodSchema>(() => {
if ( if (services === null || request.service === null || request.method === null) {
editorView == null || return { type: "none" };
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) {
console.log("Failed to find service", { service: request.service, services }); return {
showAlert({ type: "error",
id: "grpc-find-service-error", id: "grpc-find-service-error",
title: "Couldn't Find Service", title: "Couldn't Find Service",
body: ( body: (
@@ -64,14 +71,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 (request.method != null && schema == null) { if (schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods }); return {
showAlert({ type: "error",
id: "grpc-find-schema-error", id: "grpc-find-schema-error",
title: "Couldn't Find Method", title: "Couldn't Find Method",
body: ( body: (
@@ -80,18 +87,15 @@ 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 {
updateSchema(editorView, JSON.parse(schema)); return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
} catch (err) { } catch (err) {
showAlert({ return {
type: "error",
id: "grpc-parse-schema-error", id: "grpc-parse-schema-error",
title: "Failed to Parse Schema", title: "Failed to Parse Schema",
body: ( body: (
@@ -103,9 +107,22 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError> <FormattedError>{String(err)}</FormattedError>
</VStack> </VStack>
), ),
}); log: ["Failed to parse schema", err],
};
} }
}, [editorView, services, request.method, request.service]); }, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
const extraExtensions = useMemo( const extraExtensions = useMemo(
() => [ () => [
@@ -124,45 +141,145 @@ 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(
() => [ () => [
<div key="reflection" className={classNames(services == null && "opacity-100!")}> // Matches the GraphQL editor: one always-visible control labelled by schema state,
<Button // with everything schema-related behind it.
size="xs" <div key="schema" className="opacity-100!">
color={ <Dropdown
reflectionLoading items={[
? "secondary" {
: reflectionUnavailable // Hidden for servers without reflection, which isn't an error
? "info" hidden: !reflectionError,
: reflectionError type: "content",
? "danger" label: (
: "secondary" <Banner color="danger">
} <p className="mb-1">Reflection failed</p>
isLoading={reflectionLoading} <Button
onClick={() => { size="xs"
showDialog({ color="danger"
title: "Configure Schema", variant="border"
size: "md", onClick={handleShowReflectionError}
id: "reflection-failed", >
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />, View Error
}); </Button>
}} </Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
> >
{reflectionLoading <Button
? "Inspecting Schema" size="sm"
: reflectionUnavailable variant="border"
? "Select Proto Files" title="Schema"
: reflectionError forDropdown
? "Server Error" isLoading={reflectionLoading}
: protoFiles.length > 0 color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
? pluralizeCount("File", protoFiles.length) >
: services != null && protoFiles.length === 0 {reflectionLoading
? "Schema Detected" ? "Inspecting Schema"
: "Select Schema"} : reflectionUnavailable
</Button> ? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
</div>, </div>,
], ],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services], [
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
); );
return ( return (
@@ -194,13 +194,6 @@ 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"
+588 -25
View File
@@ -1,15 +1,38 @@
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 { useEffect, useRef, useState } from "react"; import { formatDistanceToNowStrict } from "date-fns";
import { useLocalStorage } from "react-use"; import { useEffect, useMemo, useRef, useState } from "react";
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 {
importFile: (filePath: string) => Promise<void>; currentWorkspace: Workspace | null;
importUrl: (url: string) => Promise<void>; workspaces: Workspace[];
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;
} }
/** /**
@@ -31,10 +54,74 @@ 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] = useLocalStorage<string | null>("importPathOrUrl", null); const [source, setSource] = useState<string | null>(prefill?.origin ?? 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);
@@ -65,25 +152,270 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
}); });
}, [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);
}; };
const handleImport = async () => { // The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && 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 {
if (filePath != null) { const nextPlan =
await importFile(filePath); filePath != null
} else { ? await planFile(filePath, destination())
await importUrl(trimmedSource); : await planUrl(trimmedSource, destination());
} 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.
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
const targets = new Set(
collectItems(node)
.filter((i) => togglesWith(node.data, i))
.map((i) => i.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) {
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 || parent.model !== "folder") break;
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
disabled.add(item.modelId);
}
if (parent.action === "delete" && parent.selected && item.action === "delete") {
disabled.add(item.modelId);
}
parentId = parent.parentId;
}
}
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,
},
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)}
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"}`
: "Apply"}
</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?" />
@@ -94,7 +426,9 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
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 ? "border-notice bg-surface-highlight" : "border-border hover:border-text", isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)} )}
> >
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" /> <Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
@@ -115,25 +449,254 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
</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}>
<PlainInput <Select
label="Or enter a file path or URL" name="import-destination-kind"
label="Import location"
size="sm" size="sm"
placeholder="https://example.com/openapi.json" value={destinationKind}
defaultValue={source ?? ""} onChange={setDestinationChoice}
forceUpdateKey={String(forceUpdateKey)} options={[
onChange={setSource} { value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/> />
{destinationKind === "other" && (
<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={trimmedSource === "" || isLoading} disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading} isLoading={isLoading}
size="sm" onClick={handlePreview}
onClick={handleImport}
> >
{isLoading ? "Importing" : "Import"} {isLoading ? "Analyzing" : "Preview Import"}
</Button> </Button>
</VStack> </HStack>
</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 flex items-center gap-1.5">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
{ value: "keep_mine", label: "Keep mine" },
{ value: "take_source", label: "Take source" },
]}
/>
<IconTooltip content={actionHelp(item)} iconSize="sm" />
</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",
)}
>
{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";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "Added since the last import";
case "update":
return "Changed since the last import";
case "delete":
return "Deleted since the last import";
case "keep_local":
return "Local edits made since the last import. Importing will revert them if checked";
case "conflict":
return "Changed both here and in the file since the last import";
default:
return null;
}
}
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";
}
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>
);
}
@@ -0,0 +1,110 @@
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;
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>(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));
}
+7 -5
View File
@@ -10,11 +10,13 @@ export interface DialogProps {
children: ReactNode; children: ReactNode;
open: boolean; open: boolean;
onClose?: () => void; onClose?: () => void;
disableBackdropClose?: boolean; /** Block dismissal from the backdrop, Escape key, and built-in close button. */
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;
@@ -27,7 +29,7 @@ export function Dialog({
size = "full", size = "full",
open, open,
onClose, onClose,
disableBackdropClose, disableClose,
title, title,
description, description,
hideX, hideX,
@@ -42,7 +44,7 @@ export function Dialog({
); );
return ( return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog"> <Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div <div
role="dialog" role="dialog"
className={classNames( className={classNames(
@@ -58,7 +60,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") {
onClose?.(); if (!disableClose) onClose?.();
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
} }
@@ -110,7 +112,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*/}
{!hideX && ( {!disableClose && !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,6 +601,8 @@ 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: () => {
@@ -0,0 +1,202 @@
import { SearchQuery } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { describe, expect, test } from "vite-plus/test";
import {
currentMatch,
literalSearch,
MAX_COUNT,
MatchCounter,
normalizeDoc,
normalizeSearch,
scanNormalized,
scanQuery,
} from "./searchMatchCount";
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
const stateOf = (doc: string) => EditorState.create({ doc });
/**
* The matches the counter finds, having checked them against the search panel's own cursor.
*
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
*/
function matchesOf(doc: string, config: QueryConfig) {
const state = stateOf(doc);
const query = new SearchQuery(config);
const matches = new MatchCounter().matches(state, query);
expect(matches).toEqual(scanQuery(state, query));
return matches;
}
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
describe("counting", () => {
test("counts every match, whatever the case", () => {
expect(countOf("one Two three two", { search: "two" })).toBe(2);
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
});
test("skips matches overlapping an earlier one", () => {
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
expect(countOf("ababa", { search: "aba" })).toBe(1);
});
test("treats a query as text, not as a pattern", () => {
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
});
test("unquotes escapes unless the query is literal", () => {
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
});
test("counts regexp and whole word queries through the cursor", () => {
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
});
test("stops counting at the cap", () => {
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
});
test("reports where the matches are", () => {
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
{ from: 0, to: 2 },
{ from: 4, to: 6 },
]);
});
test("finds nothing to match with an empty needle", () => {
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
});
});
describe("normalization", () => {
test("finds what a character decomposes into", () => {
// The é is one character holding an `e`, and the match covers the whole of it
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
expect(countOf("one two", { search: "one two" })).toBe(1);
expect(countOf("full width", { search: "full" })).toBe(1);
});
test("matches a decomposed query against composed text, and the reverse", () => {
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
});
test("keeps offsets straight after an expansion", () => {
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
});
test("normalizes the query whole, the document by character", () => {
expect(normalizeSearch("CAFÉ", false)).toBe("café");
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
});
test("leaves a document that normalizes to itself untouched", () => {
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
expect(text).toBe("plain 日本 🎉 text");
expect(expansions).toEqual([]);
});
});
describe("current match", () => {
const matches = [
{ from: 0, to: 2 },
{ from: 4, to: 6 },
{ from: 8, to: 10 },
];
test("counts from one, and reports 0 off a match", () => {
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
});
test("moving the selection doesn't scan again", () => {
const state = stateOf("a1 b2 c3");
const query = new SearchQuery({ search: "\\d", regexp: true });
const counter = new MatchCounter();
const found = counter.matches(state, query);
// The document a selection-only transaction leaves behind is the one already scanned
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
expect(counter.matches(moved, query)).toBe(found);
expect(currentMatch(found, moved.selection.main)).toBe(2);
});
});
/**
* The mapping from normalized offsets back to document offsets is the part of this that can go
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
*/
describe("against the cursor, on awkward text", () => {
const ALPHABET = [
..."abcABC .\\\n".split(""),
"é",
"é",
"fi",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"",
"①",
"́",
];
/** Seeded, so a failure is the same failure next run */
function random(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
for (const caseSensitive of [false, true]) {
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
const next = random(caseSensitive ? 20260831 : 7);
for (let round = 0; round < 400; round++) {
const doc = Array.from(
{ length: 2 + Math.floor(next() * 60) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
// Half the queries are lifted out of the document, so matches are actually found
const start = Math.floor(next() * doc.length);
const search =
next() < 0.5
? doc.slice(start, start + 1 + Math.floor(next() * 3))
: Array.from(
{ length: 1 + Math.floor(next() * 2) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
if (search === "") continue;
const state = stateOf(doc);
const query = new SearchQuery({ search, caseSensitive });
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
}
});
}
});
@@ -1,7 +1,232 @@
import { getSearchQuery, searchPanelOpen } from "@codemirror/search"; import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state"; import type { EditorState, Extension, Text } 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.
@@ -10,6 +235,7 @@ 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();
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
} }
this.ensureCountEl(); this.ensureCountEl();
if (this.countEl == null) return;
if (!query.search) { if (!query.search) {
if (this.countEl) { this.countEl.textContent = "0/0";
this.countEl.textContent = "0/0";
}
return; return;
} }
const selection = state.selection.main; const matches = this.counter.matches(state, query);
let count = 0; if (matches.length > MAX_COUNT) {
let currentIndex = 0; this.countEl.textContent = `${MAX_COUNT}+`;
const MAX_COUNT = 9999; } else if (matches.length === 0) {
const cursor = query.getCursor(state); this.countEl.textContent = "0/0";
for (let result = cursor.next(); !result.done; result = cursor.next()) { } else {
count++; const current = currentMatch(matches, state.selection.main);
const match = result.value; this.countEl.textContent = `${current}/${matches.length}`;
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}`;
}
} }
} }
@@ -113,7 +113,15 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
/** Max number of pairs to show before prompting the user to reveal the rest */ /** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30; const MAX_INITIAL_PAIRS = 30;
export function PairEditor({ // Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
// fast path below would swap in the new data without rebuilding the row editors, leaving any
// still-focused input showing the old owner's text.
export function PairEditor(props: PairEditorProps) {
return <PairEditorInner key={props.stateKey} {...props} />;
}
function PairEditorInner({
allowFileValues, allowFileValues,
allowMultilineValues, allowMultilineValues,
className, className,
+6 -2
View File
@@ -119,9 +119,13 @@ export function Select<T extends string>({
)} )}
> >
<Button <Button
className="w-full text-sm font-mono" className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start" justify="start"
variant="border" variant="input"
size={size} size={size}
leftSlot={leftSlot} leftSlot={leftSlot}
disabled={disabled} disabled={disabled}
@@ -21,6 +21,8 @@ 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";
@@ -43,10 +45,7 @@ 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( const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
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>("");
@@ -143,6 +142,15 @@ 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);
@@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
[restore], [restore],
); );
if (tree == null) { if (tree == null || treeNode == null) {
return null; return null;
} }
@@ -221,12 +229,18 @@ 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"
> >
<TreeNodeChildren <CheckboxTree
node={tree} node={treeNode}
depth={0} checked={(n) => nodeCheckedStatus(n.data)}
onCheck={checkNode} onCheck={(n) => checkNode(n.data)}
onSelect={handleSelectChild} checkboxTitle={(n) =>
selectedPath={selectedEntry?.relaPath ?? null} nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
}
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") && (
<> <>
@@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div> </div>
)} )}
secondSlot={({ style: innerStyle }) => ( secondSlot={({ style: innerStyle }) => (
<div <div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
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..."
@@ -301,96 +312,39 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
); );
} }
function TreeNodeChildren({ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
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 <>
className={classNames( {node.model.model !== "http_request" &&
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative", node.model.model !== "grpc_request" &&
)} node.model.model !== "websocket_request" ? (
> <Icon
<div color="secondary"
className={classNames( icon={
"relative flex gap-1 w-full h-xs items-center", node.model.model === "folder"
isSelected ? "text-text" : "text-text-subtle", ? "folder"
)} : node.model.model === "environment"
> ? "variable"
{isSelected && ( : "house"
<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 ) : (
type="button" <span aria-hidden className="w-4" />
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")} )}
onClick={() => node.status.status !== "current" && onSelect(node.status)} <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.model.model !== "http_request" && {node.status.status}
node.model.model !== "grpc_request" && </InlineCode>
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>
); );
} }
@@ -495,15 +449,6 @@ 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,
@@ -526,13 +471,11 @@ 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 <DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
original={prevYaml ?? ""}
modified={nextYaml ?? ""}
className="flex-1 min-h-0"
/>
</div> </div>
); );
} }
+1 -3
View File
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
showDialog({ showDialog({
id: "git-diverged", id: "git-diverged",
title: "Branches Diverged", title: "Branches Diverged",
hideX: true,
size: "sm", size: "sm",
disableBackdropClose: true, disableClose: true,
onClose: () => resolve("cancel"),
render: ({ hide }) => render: ({ hide }) =>
DivergedDialog({ DivergedDialog({
remote, remote,
+1 -2
View File
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({ showDialog({
id, id,
title, title,
hideX: true,
size, size,
disableBackdropClose: true, // Prevent accidental dismisses disableClose: true,
render: ({ hide }) => Alert({ onHide: hide, body }), render: ({ hide }) => Alert({ onHide: hide, body }),
}); });
} }
+1 -2
View File
@@ -18,9 +18,8 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => { return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({ showDialog({
...extraProps, ...extraProps,
hideX: true,
size, size,
disableBackdropClose: true, // Prevent accidental dismisses disableClose: true,
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }), render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
}); });
}); });
+49 -17
View File
@@ -1,14 +1,34 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models"; import {
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) => {
@@ -21,29 +41,41 @@ 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: "sm", size: "lg",
disableClose: true,
render: ({ hide }) => { render: ({ hide }) => {
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => { const cancel = () => {
try { hide();
await finishImport(await runImport()); resolve();
resolve(); };
} catch (err) { const fail = (err: unknown) => {
reject(err); hide();
} finally { reject(err);
hide(); };
} const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
}; };
return ( return (
<ImportDataDialog <ImportDataDialog
importFile={(filePath) => currentWorkspace={currentWorkspace}
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath })) workspaces={workspaces}
} selectedFolder={selectedFolder}
importUrl={(url) => planFile={planFile}
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url })) planUrl={planUrl}
} listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
cancel={cancel}
onError={fail}
/> />
); );
}, },
@@ -0,0 +1,251 @@
import { describe, expect, test } from "vite-plus/test";
import type { JsonSchema } from "./jsonSchemaExample";
import { buildExampleFromSchema } from "./jsonSchemaExample";
describe("buildExampleFromSchema", () => {
test("fills scalar fields with placeholders", () => {
const schema: JsonSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number", format: "int32" },
active: { type: "boolean" },
data: { type: "string", format: "byte" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
name: "",
age: 0,
active: false,
data: "",
});
});
test("encodes 64-bit integers as strings", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string", format: "int64" },
count: { type: "string", format: "uint64" },
offset: { type: "string", format: "sfixed64" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
});
test("fills date-time with a parseable timestamp", () => {
const schema: JsonSchema = {
type: "object",
properties: { createdAt: { type: "string", format: "date-time" } },
};
const example = buildExampleFromSchema(schema) as { createdAt: string };
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
});
test("fills a duration with a value that parses", () => {
const schema: JsonSchema = {
type: "object",
properties: { timeout: { type: "string", format: "duration" } },
};
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
});
test("expands nested messages through $defs", () => {
const schema: JsonSchema = {
type: "object",
properties: { user: { $ref: "#/$defs/example.User" } },
$defs: {
"example.User": {
type: "object",
properties: {
name: { type: "string" },
address: { $ref: "#/$defs/example.Address" },
},
},
"example.Address": {
type: "object",
properties: { city: { type: "string" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
user: { name: "", address: { city: "" } },
});
});
test("gives repeated fields a single placeholder item", () => {
const schema: JsonSchema = {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
unknown: { type: "array" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
tags: [""],
users: [{ name: "" }],
unknown: [],
});
});
test("uses the first value of an enum", () => {
const schema: JsonSchema = {
type: "object",
properties: {
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
empty: { type: "string", enum: [] },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
});
test("gives maps a single placeholder entry", () => {
const schema: JsonSchema = {
type: "object",
properties: {
labels: { type: "object", additionalProperties: { type: "string" } },
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
labels: { key: "" },
users: { key: { name: "" } },
});
});
test("stops at the root self-reference", () => {
const schema: JsonSchema = {
type: "object",
properties: {
value: { type: "string" },
children: { type: "array", items: { $ref: "#" } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
});
test("stops at a cycle between messages", () => {
const schema: JsonSchema = {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
$defs: {
"example.Node": {
type: "object",
properties: {
name: { type: "string" },
parent: { $ref: "#/$defs/example.Node" },
leaf: { $ref: "#/$defs/example.Leaf" },
},
},
"example.Leaf": {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
node: { name: "", parent: {}, leaf: { node: {} } },
});
});
test("expands the same message twice when it is not on the same path", () => {
const schema: JsonSchema = {
type: "object",
properties: {
from: { $ref: "#/$defs/example.User" },
to: { $ref: "#/$defs/example.User" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
});
test("fills every branch of a flattened oneof", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
image: { $ref: "#/$defs/example.Image" },
},
$defs: {
"example.Image": { type: "object", properties: { url: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
id: "",
text: "",
image: { url: "" },
});
});
test("stops expanding once the node budget runs out", () => {
// Every level references the next one twice, so an unbounded walk would build 2^depth
// nodes without ever repeating a ref on the same path.
const depth = 16;
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
for (let i = 0; i < depth; i++) {
$defs[`d${i}`] = {
type: "object",
properties: {
a: { $ref: `#/$defs/d${i + 1}` },
b: { $ref: `#/$defs/d${i + 1}` },
},
};
}
const example = buildExampleFromSchema({
type: "object",
properties: { root: { $ref: "#/$defs/d0" } },
$defs,
});
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
expect(countNodes(example)).toBeLessThan(10_000);
});
test("handles messages without a known type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
empty: {},
struct: { type: "object" },
missing: { $ref: "#/$defs/example.Nope" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
});
});
function countNodes(value: unknown): number {
if (Array.isArray(value)) {
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
}
if (value !== null && typeof value === "object") {
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
}
return 1;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
*/
export type JsonSchema = {
type?: string;
format?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
additionalProperties?: JsonSchema;
enum?: unknown[];
$defs?: Record<string, JsonSchema>;
$ref?: string;
};
const DEFS_PREFIX = "#/$defs/";
const ROOT_REF = "#";
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
// Refs on sibling branches each expand their own subtree, so a schema that references the
// same messages repeatedly can produce exponentially many nodes without ever cycling.
const MAX_NODES = 5000;
type Budget = { remaining: number };
/** Build a sample message with placeholder values for every field in the schema */
export function buildExampleFromSchema(schema: JsonSchema): unknown {
// The root is already being built, so a `#` ref anywhere below it is a cycle
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
}
function buildValue(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
return null;
}
budget.remaining -= 1;
if (typeof schema.$ref === "string") {
if (refPath.has(schema.$ref)) {
return {};
}
const resolved = resolveRef(schema.$ref, root);
if (resolved == null) {
return {};
}
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
}
if (Array.isArray(schema.enum)) {
return schema.enum[0] ?? "";
}
switch (schema.type) {
case "object":
return buildObject(schema, root, refPath, budget);
case "array":
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
case "string":
return buildString(schema.format);
case "number":
return 0;
case "boolean":
return false;
default:
return null;
}
}
function buildObject(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema.properties != null && typeof schema.properties === "object") {
const example: Record<string, unknown> = {};
for (const [name, propertySchema] of Object.entries(schema.properties)) {
example[name] = buildValue(propertySchema, root, refPath, budget);
}
return example;
}
// Maps have no properties, only a value schema
if (schema.additionalProperties != null) {
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
}
return {};
}
function buildString(format: string | undefined): string {
if (format === "date-time") {
return new Date().toISOString();
}
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
if (format === "duration") {
return "0s";
}
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
return "0";
}
return "";
}
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
if (ref === ROOT_REF) {
return root;
}
if (!ref.startsWith(DEFS_PREFIX)) {
return null;
}
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
}
+1 -6
View File
@@ -25,13 +25,8 @@ export async function showPromptForm({
id, id,
title, title,
description, description,
hideX: true,
size: size ?? "sm", size: size ?? "sm",
disableBackdropClose: true, // Prevent accidental dismisses disableClose: true,
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
render: ({ hide }) => render: ({ hide }) =>
Prompt({ Prompt({
onCancel: () => { onCancel: () => {
@@ -5,16 +5,20 @@ 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_core::WorkspaceContext; use yaak_models::util::{
use yaak_models::util::BatchUpsertResult; BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
};
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) => { Ok((result, items)) => {
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) => {
@@ -37,7 +41,10 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
} }
} }
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> { async fn import(
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)
@@ -51,6 +58,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.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) {
@@ -59,15 +67,58 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.to_string(), .to_string(),
); );
} }
let workspace_context = WorkspaceContext { let destination = match workspace_id {
workspace_id, Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
environment_id: None, None => ImportDestination::NewWorkspace,
cookie_jar_id: None,
request_id: None,
}; };
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources) let plan = import::plan_import_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) Ok((imported, items))
}
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 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> {
@@ -81,14 +81,21 @@ 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();
assert_eq!( let workspaces = db.list_workspaces().expect("list imported workspaces");
db.get_workspace("wrk_import").expect("workspace imported").name, let workspace = workspaces
"Imported Workspace" .iter()
); .find(|workspace| workspace.name == "Imported Workspace")
assert_eq!( .expect("workspace imported");
db.get_http_request("req_import").expect("request imported").url, assert_ne!(workspace.id, "wrk_import");
"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) {
@@ -160,3 +167,93 @@ 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"));
}
+2 -3
View File
@@ -3,11 +3,10 @@ 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::{Manager, Runtime, WebviewWindow}; use tauri::{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();
@@ -26,7 +25,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 = window.state::<PluginManager>(); let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let mut metadata = BTreeMap::new(); let mut metadata = BTreeMap::new();
// Add the rest of metadata // Add the rest of metadata
@@ -14,7 +14,6 @@ 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).
@@ -149,7 +148,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((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
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);
+53 -22
View File
@@ -4,53 +4,84 @@ 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, ImportDataParams}; use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client}; use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_core::WorkspaceContext; use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
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)?;
import_contents(window, &contents).await let plan =
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
commit_import(window, plan)
} }
pub(crate) async fn import_url<R: Runtime>( pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination, 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,
) -> Result<BatchUpsertResult> { destination: ImportDestination,
let contents = fetch_import_url(window, url).await?; ) -> Result<ImportPlan> {
import_contents(window, &contents).await let url = normalize_import_url(url)?;
let contents = fetch_import_url(window, &url).await?;
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
} }
async fn import_contents<R: Runtime>( async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
contents: &str, contents: &str,
) -> Result<BatchUpsertResult> { destination: ImportDestination,
let plugin_manager = window.state::<PluginManager>(); origin: Option<ImportOrigin>,
) -> 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::import_data(ImportDataParams { Ok(import::plan_import_data(PlanImportDataParams {
query_manager: &query_manager, query_manager: &query_manager,
plugin_manager: &plugin_manager, plugin_manager: &plugin_manager,
plugin_context: &plugin_context, plugin_context: &plugin_context,
workspace_context, destination,
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.
/// ///
@@ -79,7 +110,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}")))
} }
fn normalize_import_url(url: &str) -> Result<String> { pub(crate) 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()));
+29 -14
View File
@@ -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::{import_data, import_url}; use crate::import::{commit_import, plan_import_data, plan_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,12 +40,11 @@ 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, UpdateSource}; use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, 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;
@@ -250,7 +249,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((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
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,
@@ -310,7 +309,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((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
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,
@@ -962,7 +961,6 @@ 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> {
@@ -977,7 +975,8 @@ 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(plugin_manager Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window)
.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 }),
@@ -1014,15 +1013,24 @@ 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,
) -> YaakResult<BatchUpsertResult> { destination: ImportDestination,
import_data(&window, file_path).await ) -> YaakResult<ImportPlan> {
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> {
import_url(&window, url).await commit_import(&window, plan)
} }
@@ -1441,7 +1449,10 @@ 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: State<'_, PluginManager> = app_handle.state(); let plugin_manager = match plugins_ext::plugin_manager(&app_handle).await {
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 {
@@ -1482,9 +1493,13 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
} }
}; };
let plugin_manager: State<'_, PluginManager> = app_handle.state(); match plugins_ext::plugin_manager(&app_handle).await {
if let Err(e) = plugin_manager.reply(&event, &ev).await { Ok(pm) => {
warn!("Failed to reply to plugin manager: {:?}", e) if let Err(e) = pm.reply(&event, &ev).await {
warn!("Failed to reply to plugin manager: {:?}", e)
}
}
Err(e) => warn!("Failed to get plugin manager for reply: {e:?}"),
} }
}); });
} }
@@ -32,7 +32,6 @@ 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;
@@ -205,7 +204,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((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
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,
@@ -231,7 +230,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((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
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,
@@ -267,7 +266,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((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
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,
+77 -18
View File
@@ -31,10 +31,43 @@ 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
// ============================================================================ // ============================================================================
@@ -146,7 +179,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((*window.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(plugin_manager(&window).await?);
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>();
@@ -167,6 +200,9 @@ 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(),
@@ -178,7 +214,6 @@ 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)
@@ -188,7 +223,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((*window.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(plugin_manager(&window).await?);
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?)
@@ -217,7 +252,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
return Ok(Vec::new()); return Ok(Vec::new());
} }
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(plugin_manager(&window).await?);
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();
@@ -300,20 +335,38 @@ 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();
// Create plugin manager asynchronously // Boot the plugin runtime in the background so the window shows
// 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::block_on(async move { tauri::async_runtime::spawn(async move {
let manager = PluginManager::new( let result = tokio::time::timeout(
vendored_plugin_dir, Duration::from_secs(60),
installed_plugin_dir, PluginManager::new(
node_bin_path, vendored_plugin_dir,
plugin_runtime_main, installed_plugin_dir,
&query_manager, node_bin_path,
&PluginContext::new_empty(), plugin_runtime_main,
dev_mode, &query_manager,
&PluginContext::new_empty(),
dev_mode,
),
) )
.await .await
.expect("Failed to start plugin runtime"); .unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
"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();
@@ -339,7 +392,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
} }
}); });
app_handle_clone.manage(manager); let _ = tx.send(Some(Ok(manager)));
}); });
let plugin_updater = PluginUpdater::new(); let plugin_updater = PluginUpdater::new();
@@ -355,8 +408,14 @@ 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");
let manager: State<PluginManager> = app.state(); // Bound the wait in case the exit comes while boot is still
manager.terminate().await; // in flight
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);
}); });
} }
+75 -42
View File
@@ -37,10 +37,11 @@ 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, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
WorkspaceMeta,
}; };
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::util::BatchUpsertResult; use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::events::{ use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
@@ -109,10 +110,11 @@ impl<R: Runtime> Host for ClientCtx<R> {
} }
impl<R: Runtime> ClientCtx<R> { impl<R: Runtime> ClientCtx<R> {
/// The plugin runtime this window talks to. Only the `PluginHost` impl /// The plugin runtime this window talks to, once it finishes booting.
/// below uses it; everything else goes through the trait. /// Only the `PluginHost` impl below uses it; everything else goes through
fn pm(&self) -> State<'_, PluginManager> { /// the trait.
self.window.state::<PluginManager>() async fn pm(&self) -> yaak_plugins::error::Result<PluginManager> {
crate::plugins_ext::plugin_manager(&self.window).await
} }
} }
@@ -122,35 +124,40 @@ 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().get_plugin_by_dir(directory).await?; let handle = self.pm().await.ok()?.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)> {
self.pm().take_init_errors().await match self.pm().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> {
self.pm().resolve_plugins_for_runtime_from_db(plugins).await match self.pm().await {
Ok(pm) => pm.resolve_plugins_for_runtime_from_db(plugins).await,
Err(_) => plugins,
}
} }
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback { async fn template_callback(
PluginTemplateCallback::new( &self,
Arc::new((*self.pm()).clone()), purpose: RenderPurpose,
) -> 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 Ok(self.pm().await?.get_template_function_summaries(&self.plugin_context()).await?)
.window
.state::<PluginManager>()
.get_template_function_summaries(&self.plugin_context())
.await?)
} }
async fn template_function_config( async fn template_function_config(
@@ -160,81 +167,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
.window .pm()
.state::<PluginManager>() .await?
.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().get_themes(&self.plugin_context()).await?) Ok(self.pm().await?.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().get_http_request_actions(&self.plugin_context()).await?) Ok(self.pm().await?.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().get_websocket_request_actions(&self.plugin_context()).await?) Ok(self.pm().await?.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().get_grpc_request_actions(&self.plugin_context()).await?) Ok(self.pm().await?.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().get_workspace_actions(&self.plugin_context()).await?) Ok(self.pm().await?.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().get_folder_actions(&self.plugin_context()).await?) Ok(self.pm().await?.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().call_http_request_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.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().call_grpc_request_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.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().call_websocket_request_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.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().call_workspace_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.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().call_folder_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.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().get_http_authentication_summaries(&self.plugin_context()).await?; let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
Ok(results.into_iter().map(|(_, a)| a).collect()) Ok(results.into_iter().map(|(_, a)| a).collect())
} }
@@ -246,6 +253,7 @@ 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?)
} }
@@ -259,6 +267,7 @@ 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,
@@ -270,15 +279,18 @@ 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().import_data(&self.plugin_context(), content).await?) Ok(self.pm().await?.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)> {
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await match self.pm().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()).clone()); let plugin_manager = Arc::new(self.pm().await?);
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,
@@ -422,7 +434,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(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?) Ok(crate::cmd_http_response_body(ctx.window.clone(), &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>> {
@@ -441,12 +453,34 @@ 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<BatchUpsertResult> { async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?) Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
} }
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> { async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?) Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
}
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
}
async fn cmd_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>> {
@@ -813,7 +847,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::<PluginManager>(), 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::<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> {
@@ -843,4 +877,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> { 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?)
} }
+3 -3
View File
@@ -14,7 +14,6 @@ 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;
@@ -98,8 +97,9 @@ 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");
let plugin_manager = w.state::<PluginManager>(); if let Ok(plugin_manager) = crate::plugins_ext::plugin_manager(&w).await {
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::import_data; use crate::import::{file_origin, import_data, url_origin};
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,7 +12,6 @@ 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>,
@@ -44,7 +43,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
return Ok(()); return Ok(());
} }
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(window).await?);
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)?;
@@ -70,6 +69,7 @@ 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,6 +99,7 @@ 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 {
@@ -117,7 +118,8 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
} }
}; };
let results = import_data(window, &file_path).await?; let origin = origin.unwrap_or_else(|| file_origin(&file_path));
let results = import_data(window, &file_path, Some(origin)).await?;
window.emit( window.emit(
"show_toast", "show_toast",
ShowToastRequest { ShowToastRequest {
+2 -4
View File
@@ -22,7 +22,6 @@ 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};
@@ -77,7 +76,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((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
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,
@@ -142,7 +141,6 @@ 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)?;
@@ -156,7 +154,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((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
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,
+24
View File
@@ -11,6 +11,7 @@ export type AnyModel =
| HttpRequest | HttpRequest
| HttpResponse | HttpResponse
| HttpResponseEvent | HttpResponseEvent
| ImportSource
| KeyValue | KeyValue
| Plugin | Plugin
| Settings | Settings
@@ -318,6 +319,29 @@ 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;
modelId: string;
snapshot: 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 };
File diff suppressed because one or more lines are too long
+44
View File
@@ -2,3 +2,47 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = { 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";
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, };
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";
+34 -4
View File
@@ -21,9 +21,10 @@ 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, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
WorkspaceMeta,
}; };
use yaak_models::util::BatchUpsertResult; use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse}; use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{ use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
@@ -229,6 +230,7 @@ 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)]
@@ -236,6 +238,31 @@ 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)]
@@ -909,8 +936,11 @@ 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) -> BatchUpsertResult, cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult, cmd_import_url(CmdImportUrlReq) -> ImportPlan,
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) -> (),
+6 -1
View File
@@ -121,7 +121,12 @@ 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.
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback; /// Async so hosts that finish booting their plugin runtime in the
/// 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.
+1 -1
View File
@@ -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); let cb = host.template_callback(purpose).await?;
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?;
+2 -2
View File
@@ -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)); let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)).await?;
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); let cb = host.template_callback(RenderPurpose::Preview).await?;
Ok(transform_args(req.tokens, &cb)?.to_string()) Ok(transform_args(req.tokens, &cb)?.to_string())
} }
+5 -2
View File
@@ -250,8 +250,11 @@ 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()))
} }
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback { async fn template_callback(
NoTemplateFunctions &self,
_purpose: RenderPurpose,
) -> yaak_commands::Result<impl TemplateCallback> {
Ok(NoTemplateFunctions)
} }
async fn template_function_summaries( async fn template_function_summaries(
+1 -1
View File
@@ -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(), "google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
"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"),
+24
View File
@@ -12,6 +12,7 @@ export type AnyModel =
| HttpRequest | HttpRequest
| HttpResponse | HttpResponse
| HttpResponseEvent | HttpResponseEvent
| ImportSource
| KeyValue | KeyValue
| Plugin | Plugin
| Settings | Settings
@@ -336,6 +337,29 @@ 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;
modelId: string;
snapshot: 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 };
+44
View File
@@ -2,3 +2,47 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = { 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";
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, };
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";
+1
View File
@@ -12,6 +12,7 @@ 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: {},
@@ -0,0 +1,25 @@
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)
);
+119
View File
@@ -3022,6 +3022,123 @@ 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,
pub model_id: String,
pub snapshot: 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")?,
snapshot: r.get("snapshot")?,
})
}
}
/// 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 {
@@ -3093,6 +3210,7 @@ define_any_model! {
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
HttpResponseEvent, HttpResponseEvent,
ImportSource,
KeyValue, KeyValue,
Plugin, Plugin,
Settings, Settings,
@@ -3125,6 +3243,7 @@ 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()),
@@ -0,0 +1,94 @@
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::Snapshot,
])
.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.as_str().into(),
resource.snapshot.as_str().into(),
])
.on_conflict(
OnConflict::columns([
ImportSourceResourceIden::ImportSourceId,
ImportSourceResourceIden::SourceKey,
])
.update_columns([
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot,
])
.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(())
}
}
@@ -0,0 +1,45 @@
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)
}
}
+2
View File
@@ -11,6 +11,8 @@ 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;
+7 -2
View File
@@ -6,8 +6,9 @@ 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, HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, WebsocketConnection, ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
WebsocketConnection,
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
}; };
@@ -85,6 +86,10 @@ 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)
+131
View File
@@ -85,6 +85,137 @@ 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,
}
#[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>,
}
#[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,
+12 -1
View File
@@ -474,7 +474,18 @@ 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 = { resources: ImportResources, }; export type ImportResponse = {
/**
* 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, };
+10 -1
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::{BTreeMap, 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,7 +247,16 @@ 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)]
+31 -19
View File
@@ -187,24 +187,25 @@ impl PluginManager {
} }
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?; let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
let db = query_manager.connect(); // Scope the db connection so the future stays Send across the await below
for dir in &bundled_dirs { let plugins = {
if db.get_plugin_by_directory(dir).is_none() { let db = query_manager.connect();
db.upsert_plugin( for dir in &bundled_dirs {
&Plugin { if db.get_plugin_by_directory(dir).is_none() {
directory: dir.clone(), db.upsert_plugin(
enabled: true, &Plugin {
url: None, directory: dir.clone(),
source: PluginSource::Bundled, enabled: true,
..Default::default() url: None,
}, source: PluginSource::Bundled,
&UpdateSource::Background, ..Default::default()
)?; },
&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() {
@@ -1104,8 +1105,19 @@ 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.payload { let result = reply_events.into_iter().find_map(|e| match e {
InternalEventPayload::ImportResponse(resp) => Some(resp), InternalEvent {
plugin_name,
payload: InternalEventPayload::ImportResponse(mut resp),
..
} => {
// Older plugin runtimes do not include the importer's display name. The plugin
// package name is still enough to identify the detected format in that case.
if resp.importer.is_empty() {
resp.importer = plugin_name;
}
Some(resp)
}
_ => None, _ => None,
}); });
+9
View File
@@ -209,6 +209,7 @@ 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)),
@@ -226,6 +227,14 @@ 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#"
+2
View File
@@ -9,6 +9,7 @@ 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"
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"] }
@@ -21,5 +22,6 @@ 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"] }
+2131 -81
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 --ignore-path .oxfmtignore", "format": "vp fmt",
"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",
+3
View File
@@ -267,6 +267,9 @@ 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"],
+12 -1
View File
@@ -474,7 +474,18 @@ 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 = { resources: ImportResources, }; export type ImportResponse = {
/**
* 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 } from "../bindings/gen_events"; import type { ImportResources, ImportResponse } 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,9 +14,12 @@ export type PartialImportResources = {
websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>; websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>;
}; };
export type ImportPluginResponse = null | { /** `importer` is omitted because the host fills it in from the plugin's own name. */
resources: PartialImportResources; export type ImportPluginResponse =
}; | null
| (Omit<ImportResponse, "importer" | "resources"> & {
resources: PartialImportResources;
});
export type ImporterPlugin = { export type ImporterPlugin = {
name: string; name: string;
@@ -167,7 +167,9 @@ 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;
+4 -1
View File
@@ -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"; type ButtonVariant = "border" | "solid" | "input";
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,6 +88,9 @@ 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,6 +959,10 @@ 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
View File
@@ -15,6 +15,21 @@ 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);
+35 -14
View File
@@ -1,10 +1,18 @@
/* oxlint-disable no-explicit-any */ /* oxlint-disable no-explicit-any */
import type { PartialImportResources } from "@yaakapp/api"; import type { PartialImportResources } from "@yaakapp/api";
import { convertId, convertTemplateSyntax, importHttpBodyAndHeaders, isJSObject } from "./common"; import {
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: [],
@@ -20,7 +28,7 @@ export function convertInsomniaV4(parsed: any) {
); );
for (const w of workspacesToImport) { for (const w of workspacesToImport) {
resources.workspaces.push({ resources.workspaces.push({
id: convertId(w._id), id: keys.own(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",
@@ -31,7 +39,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)), ...environmentsToImport.map((r: any) => importEnvironment(r, w._id, keys)),
); );
const nextFolder = (parentId: string) => { const nextFolder = (parentId: string) => {
@@ -40,12 +48,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)); resources.folders.push(importFolder(child, w._id, keys));
nextFolder(child._id); nextFolder(child._id);
} else if (child._type === "request") { } else if (child._type === "request") {
resources.httpRequests.push(importHttpRequest(child, w._id)); resources.httpRequests.push(importHttpRequest(child, w._id, keys));
} else if (child._type === "grpc_request") { } else if (child._type === "grpc_request") {
resources.grpcRequests.push(importGrpcRequest(child, w._id)); resources.grpcRequests.push(importGrpcRequest(child, w._id, keys));
} }
} }
}; };
@@ -60,10 +68,14 @@ 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) }; return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
} }
function importHttpRequest(r: any, workspaceId: string): PartialImportResources["httpRequests"][0] { function importHttpRequest(
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") {
@@ -80,7 +92,7 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
} }
return { return {
id: convertId(r.meta?.id ?? r._id), id: keys.own(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),
@@ -102,13 +114,17 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
}; };
} }
function importGrpcRequest(r: any, workspaceId: string): PartialImportResources["grpcRequests"][0] { function importGrpcRequest(
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: convertId(r.meta?.id ?? r._id), id: keys.own(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),
@@ -131,9 +147,13 @@ function importGrpcRequest(r: any, workspaceId: string): PartialImportResources[
}; };
} }
function importFolder(f: any, workspaceId: string): PartialImportResources["folders"][0] { function importFolder(
f: any,
workspaceId: string,
keys: SourceKeys,
): PartialImportResources["folders"][0] {
return { return {
id: convertId(f._id), id: keys.own(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),
@@ -147,11 +167,12 @@ function importFolder(f: any, workspaceId: string): PartialImportResources["fold
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: convertId(e._id), id: keys.own(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),
+24 -14
View File
@@ -3,9 +3,11 @@ 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) {
@@ -18,6 +20,7 @@ export function convertInsomniaV5(parsed: any) {
return null; return null;
} }
const keys = createSourceKeys();
const resources: PartialImportResources = { const resources: PartialImportResources = {
environments: [], environments: [],
folders: [], folders: [],
@@ -30,7 +33,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: convertId(meta.id ?? "collection"), id: keys.own(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",
@@ -42,8 +45,10 @@ export function convertInsomniaV5(parsed: any) {
// Import environments // Import environments
resources.environments.push( resources.environments.push(
importEnvironment(parsed.environments, meta.id, true), importEnvironment(parsed.environments, meta.id, keys, true),
...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)), ...(parsed.environments.subEnvironments ?? []).map((r: any) =>
importEnvironment(r, meta.id, keys),
),
); );
// Import folders // Import folders
@@ -52,16 +57,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); const { folder, environment } = importFolder(child, meta.id, parentId, keys);
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)); resources.httpRequests.push(importHttpRequest(child, meta.id, parentId, keys));
} else if (child.protoFileId) { } else if (child.protoFileId) {
resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId)); resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId, keys));
} else if (child.url) { } else if (child.url) {
resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId)); resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId, keys));
} }
} }
}; };
@@ -75,13 +80,14 @@ 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) }; return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
} }
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;
@@ -89,7 +95,7 @@ function importHttpRequest(
const sortKey = r.meta?.sortKey ?? r.sortKey; const sortKey = r.meta?.sortKey ?? r.sortKey;
return { return {
id: convertId(id), id: keys.own(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,
@@ -114,6 +120,7 @@ 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;
@@ -126,7 +133,7 @@ function importGrpcRequest(
return { return {
model: "grpc_request", model: "grpc_request",
id: convertId(id), id: keys.own(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,
@@ -152,6 +159,7 @@ 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;
@@ -160,7 +168,7 @@ function importWebsocketRequest(
return { return {
model: "websocket_request", model: "websocket_request",
id: convertId(id), id: keys.own(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,
@@ -198,6 +206,7 @@ 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;
@@ -210,7 +219,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: convertId(`${id}folder`), id: keys.own(`${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),
@@ -230,7 +239,7 @@ function importFolder(
return { return {
folder: { folder: {
model: "folder", model: "folder",
id: convertId(id), id: keys.own(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),
@@ -248,6 +257,7 @@ 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;
@@ -256,7 +266,7 @@ function importEnvironment(
const sortKey = e.meta?.sortKey ?? e.sortKey; const sortKey = e.meta?.sortKey ?? e.sortKey;
return { return {
id: convertId(id), id: keys.own(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,5 +132,13 @@
"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,5 +116,13 @@
"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,5 +189,15 @@
"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,6 +24,38 @@ 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 {
+22
View File
@@ -110,6 +110,7 @@ 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");
@@ -126,6 +127,7 @@ 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))) {
@@ -139,6 +141,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
importState, importState,
operation, operation,
resources, resources,
sourceKeys,
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
@@ -160,6 +163,11 @@ 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);
} }
} }
@@ -241,6 +249,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
websocketRequests: [], websocketRequests: [],
workspaces: resources.workspaces, workspaces: resources.workspaces,
}) as PartialImportResources, }) as PartialImportResources,
sourceKeys,
}; };
} }
@@ -681,12 +690,14 @@ 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");
@@ -705,9 +716,20 @@ 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,6 +308,16 @@ 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",
},
} }
`; `;
@@ -2600,6 +2610,97 @@ 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}",
},
} }
`; `;
@@ -2734,6 +2835,10 @@ 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",
},
} }
`; `;
@@ -2834,5 +2939,9 @@ Responses:
}, },
], ],
}, },
"sourceKeys": {
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /info.0.json",
"GENERATE_ID::HTTP_REQUEST_1": "route:GET /{comicId}/info.0.json",
},
} }
`; `;
@@ -2382,4 +2382,38 @@ 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}",
);
});
}); });
+10 -1
View File
@@ -49,6 +49,12 @@ 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: [],
@@ -63,6 +69,7 @@ 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
@@ -92,6 +99,7 @@ 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);
@@ -142,6 +150,7 @@ 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);
@@ -156,7 +165,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
convertTemplateSyntax(exportResources), convertTemplateSyntax(exportResources),
) as PartialImportResources; ) as PartialImportResources;
return { resources }; return { resources, sourceKeys };
} }
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> { function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
@@ -300,5 +300,8 @@
} }
], ],
"folders": [] "folders": []
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_0": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
} }
} }
@@ -88,5 +88,8 @@
"folderId": "GENERATE_ID::FOLDER_0" "folderId": "GENERATE_ID::FOLDER_0"
} }
] ]
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_1": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
} }
} }
@@ -100,5 +100,8 @@
} }
], ],
"folders": [] "folders": []
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_2": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
} }
} }
@@ -87,4 +87,55 @@ 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);
});
}); });
+9 -1
View File
@@ -80,7 +80,15 @@ export function migrateImport(contents: string) {
} }
} }
return { resources: parsed.resources }; const sourceKeys: Record<string, string> = {};
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) {
+28
View File
@@ -148,4 +148,32 @@ 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",
});
});
}); });
+16 -1
View File
@@ -4,8 +4,23 @@ 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: ["npm/**", "crates/yaak-templates/pkg/**", "crates/yaak-wasm/pkg/**", "**/bindings/gen_*.ts"], ignorePatterns: [
"npm/**",
"crates/yaak-templates/pkg/**",
"crates/yaak-wasm/pkg/**",
"**/bindings/gen_*.ts",
],
options: { options: {
typeAware: true, typeAware: true,
}, },