mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-06 18:07:18 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
646f5a09b0 | ||
|
|
e47088b507 | ||
|
|
3ad6172bb1 | ||
|
|
7bd159b0b1 | ||
|
|
b08b3277da | ||
|
|
19a43e3785 | ||
|
|
77fe1367a0 | ||
|
|
862fb6d65a | ||
|
|
d2d4b80a09 | ||
|
|
d461c982ec | ||
|
|
8eebee460e | ||
|
|
81a2a5d955 | ||
|
|
e63a87718c | ||
|
|
222ea9cb83 | ||
|
|
360c098b09 | ||
|
|
fce1039bac | ||
|
|
f18f93d613 | ||
|
|
661a384bed |
@@ -1,3 +0,0 @@
|
||||
**/bindings/**
|
||||
**/routeTree.gen.ts
|
||||
crates/yaak-templates/pkg/**
|
||||
Generated
+2
@@ -11223,8 +11223,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Request versioning (IntelliJ Local History style)
|
||||
|
||||
Working plan for `feat/request-versioning`. Tracks
|
||||
[save-request-data-for-response-history](https://yaak.app/feedback/posts/save-request-data-for-response-history).
|
||||
|
||||
Selecting an old response should be able to show, and restore, the request that produced it.
|
||||
|
||||
## Model
|
||||
|
||||
One table, `model_versions`, versions every request type:
|
||||
|
||||
| column | meaning |
|
||||
| --- | --- |
|
||||
| `id`, `model`, `created_at`, `updated_at` | usual model columns |
|
||||
| `workspace_id` | owning workspace |
|
||||
| `model_type` | `http_request` / `grpc_request` / `websocket_request` |
|
||||
| `model_id` | the request the version belongs to |
|
||||
| `content_hash` | sha256 of the canonical document |
|
||||
| `document` | JSON of the request's editable content |
|
||||
| `reason` | `send` / `switch` / `idle` / `restore` / `manual` |
|
||||
|
||||
`http_responses`, `grpc_connections` and `websocket_connections` each gain a nullable
|
||||
`version_id`.
|
||||
|
||||
A version's `document` is the model's JSON with bookkeeping keys removed — `model`, `id`,
|
||||
`createdAt`, `updatedAt`, `workspaceId`, `folderId`, `sortPriority`. One rule, applied the same
|
||||
way to all three request types; the hash is taken over exactly what the document holds, so moving
|
||||
a request between folders or re-sorting it never mints a version.
|
||||
|
||||
`(model_id, content_hash)` is unique, so dedup is the database's job rather than a code path that
|
||||
can be forgotten. Sending an unchanged request ten times leaves one version and ten responses
|
||||
pointing at it.
|
||||
|
||||
## Snapshot
|
||||
|
||||
One primitive, `ClientDb::snapshot_request(request, reason)`: build the document, hash it, return
|
||||
the existing row for that hash or insert a new one, then prune. Everything calls it.
|
||||
|
||||
- **Sends.** `resolve_send_inputs` (HTTP, every host — desktop, CLI, plugin-triggered) snapshots
|
||||
before the response row is created, and the resulting id rides down to the response.
|
||||
gRPC and WebSocket connect paths do the same at their own connection upserts.
|
||||
- **Edit-session boundaries the frontend can see**, all through one RPC: switching to another
|
||||
request, window blur, app close, and a 60s idle timer after the last edit.
|
||||
|
||||
Over-triggering is free, so the trigger code stays dumb.
|
||||
|
||||
## Restore
|
||||
|
||||
`restore_request_version(version_id)`:
|
||||
|
||||
1. Snapshot the live request (reason `restore`), so anything newer than its last version is kept.
|
||||
2. Merge the version's document over the live model, keeping bookkeeping fields.
|
||||
3. Upsert. The written content's hash already exists, so no new version row appears.
|
||||
|
||||
The frontend calls `wasUpdatedExternally` afterwards so open editors reload.
|
||||
|
||||
## Retention
|
||||
|
||||
An unreferenced version survives only while it is among the newest 50 for its request *and* newer
|
||||
than 30 days. A version referenced by a response lives as long as that response. Deleting a
|
||||
request deletes its versions. Versions are local history: not synced to the filesystem, not in
|
||||
Git, not exported.
|
||||
|
||||
## UI (v1, HTTP)
|
||||
|
||||
When the selected response's version differs from the live request, the response header grows a
|
||||
state-labelled dropdown ("Request Changed", following the GraphQL editor's pattern) with **View
|
||||
Diff** and **Restore**. The diff reuses the Git dialog's `DiffViewer` over YAML renderings of the
|
||||
two documents. No versions timeline panel in v1; gRPC and WebSocket are wired on the backend from
|
||||
day one and their UI can follow.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Migration + `ModelVersion` model + bindings
|
||||
- [x] Hashing / document extraction, with tests
|
||||
- [x] Queries: snapshot, prune, restore, cascade
|
||||
- [x] Send pipelines: HTTP, gRPC, WebSocket (plus the browser host's own)
|
||||
- [x] RPC commands + web/wasm host
|
||||
- [x] Frontend: snapshot triggers, dropdown, diff dialog, restore
|
||||
|
||||
## Deliberately not in v1
|
||||
|
||||
- **No versions timeline panel.** The only entry point is a response, which is
|
||||
what the feedback asked for. A "browse all versions of this request" view is a
|
||||
second feature on the same data and can land later without a schema change.
|
||||
- **No gRPC or WebSocket UI.** Both record versions from day one, so the history
|
||||
is accumulating; only the indicator is HTTP-only.
|
||||
- **No `manual` trigger.** The reason exists so that adding a "Save version now"
|
||||
action later is a UI change and not a migration.
|
||||
- **Folders, environments and workspaces are not versioned.** The response
|
||||
timeline already records what a send inherited from them.
|
||||
|
||||
## Notes for later
|
||||
|
||||
- The version's `document` is the model minus bookkeeping keys, so a restore
|
||||
merges over the live model and a field added after a version was captured
|
||||
keeps its live value rather than being blanked.
|
||||
- `content_hash` sorts object keys before hashing. Relying on
|
||||
`serde_json::Map` being a `BTreeMap` is not safe: `preserve_order` is on in
|
||||
some builds of this workspace and off in others, which is exactly the bug the
|
||||
`key_order_does_not_change_the_hash` test caught.
|
||||
@@ -1,9 +1,8 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
@@ -11,12 +10,20 @@ import {
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from "codemirror-json-schema";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { showAlert } from "../lib/alert";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
|
||||
protoFiles: string[];
|
||||
};
|
||||
|
||||
type MethodSchema =
|
||||
| { type: "none" }
|
||||
| { type: "schema"; schema: JsonSchema }
|
||||
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
|
||||
|
||||
export function GrpcEditor({
|
||||
services,
|
||||
reflectionError,
|
||||
@@ -42,21 +54,16 @@ export function GrpcEditor({
|
||||
setEditorView(h);
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method and update the editor
|
||||
useEffect(() => {
|
||||
if (
|
||||
editorView == null ||
|
||||
services === null ||
|
||||
request.service === null ||
|
||||
request.method === null
|
||||
) {
|
||||
return;
|
||||
// Find the schema for the selected service and method
|
||||
const methodSchema = useMemo<MethodSchema>(() => {
|
||||
if (services === null || request.service === null || request.method === null) {
|
||||
return { type: "none" };
|
||||
}
|
||||
|
||||
const s = services.find((s) => s.name === request.service);
|
||||
if (s == null) {
|
||||
console.log("Failed to find service", { service: request.service, services });
|
||||
showAlert({
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-service-error",
|
||||
title: "Couldn't Find Service",
|
||||
body: (
|
||||
@@ -64,14 +71,14 @@ export function GrpcEditor({
|
||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
log: ["Failed to find service", { service: request.service, services }],
|
||||
};
|
||||
}
|
||||
|
||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||
if (request.method != null && schema == null) {
|
||||
console.log("Failed to find method", { method: request.method, methods: s?.methods });
|
||||
showAlert({
|
||||
if (schema == null) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-schema-error",
|
||||
title: "Couldn't Find Method",
|
||||
body: (
|
||||
@@ -80,18 +87,15 @@ export function GrpcEditor({
|
||||
<InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema == null) {
|
||||
return;
|
||||
log: ["Failed to find method", { method: request.method, methods: s.methods }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
updateSchema(editorView, JSON.parse(schema));
|
||||
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
|
||||
} catch (err) {
|
||||
showAlert({
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-parse-schema-error",
|
||||
title: "Failed to Parse Schema",
|
||||
body: (
|
||||
@@ -103,9 +107,22 @@ export function GrpcEditor({
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
log: ["Failed to parse schema", err],
|
||||
};
|
||||
}
|
||||
}, [editorView, services, request.method, request.service]);
|
||||
}, [services, request.method, request.service]);
|
||||
|
||||
useEffect(() => {
|
||||
if (methodSchema.type !== "error") return;
|
||||
console.log(...methodSchema.log);
|
||||
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
|
||||
}, [methodSchema]);
|
||||
|
||||
// Update the editor whenever the schema changes
|
||||
useEffect(() => {
|
||||
if (editorView == null || methodSchema.type !== "schema") return;
|
||||
updateSchema(editorView, methodSchema.schema as JSONSchema7);
|
||||
}, [editorView, methodSchema]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
@@ -124,45 +141,145 @@ export function GrpcEditor({
|
||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
||||
|
||||
const handleGenerateExample = useCallback(async () => {
|
||||
if (methodSchema.type !== "schema") return;
|
||||
|
||||
if (request.message.trim() !== "") {
|
||||
const confirmed = await showConfirm({
|
||||
id: "grpc-generate-example",
|
||||
title: "Generate Example",
|
||||
description: "The current message will be replaced with an example.",
|
||||
confirmText: "Generate",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
|
||||
await patchModel(request, { message });
|
||||
|
||||
// Force the editor to pick up the new message
|
||||
wasUpdatedExternally(request.id);
|
||||
}, [methodSchema, request]);
|
||||
|
||||
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
|
||||
// reaches it without threading a refetch down from the connection layout.
|
||||
const handleReloadSchema = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
|
||||
[request.id],
|
||||
);
|
||||
|
||||
const handleShowReflectionError = useCallback(() => {
|
||||
showDialog({
|
||||
id: "grpc-reflection-error",
|
||||
title: "Reflection Failed",
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<>
|
||||
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
|
||||
<div className="w-full my-4">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await handleReloadSchema();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}, [handleReloadSchema, reflectionError]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
|
||||
<Button
|
||||
size="xs"
|
||||
color={
|
||||
reflectionLoading
|
||||
? "secondary"
|
||||
: reflectionUnavailable
|
||||
? "info"
|
||||
: reflectionError
|
||||
? "danger"
|
||||
: "secondary"
|
||||
}
|
||||
isLoading={reflectionLoading}
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "reflection-failed",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
}}
|
||||
// Matches the GraphQL editor: one always-visible control labelled by schema state,
|
||||
// with everything schema-related behind it.
|
||||
<div key="schema" className="opacity-100!">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
// Hidden for servers without reflection, which isn't an error
|
||||
hidden: !reflectionError,
|
||||
type: "content",
|
||||
label: (
|
||||
<Banner color="danger">
|
||||
<p className="mb-1">Reflection failed</p>
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={handleShowReflectionError}
|
||||
>
|
||||
View Error
|
||||
</Button>
|
||||
</Banner>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Generate Example Message",
|
||||
leftSlot: <Icon icon="magic_wand" />,
|
||||
disabled: methodSchema.type !== "schema",
|
||||
onSelect: handleGenerateExample,
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Reload Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleReloadSchema,
|
||||
},
|
||||
{
|
||||
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "grpc-configure-schema",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null && protoFiles.length === 0
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
title="Schema"
|
||||
forDropdown
|
||||
isLoading={reflectionLoading}
|
||||
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
|
||||
[
|
||||
handleGenerateExample,
|
||||
handleReloadSchema,
|
||||
handleShowReflectionError,
|
||||
methodSchema.type,
|
||||
protoFiles.length,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
reflectionUnavailable,
|
||||
services,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -194,13 +194,6 @@ export function GrpcRequestPane({
|
||||
type: "default",
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
itemsAfter={[
|
||||
{
|
||||
label: "Refresh",
|
||||
type: "default",
|
||||
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -30,6 +30,7 @@ import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestVersionDropdown } from "./RequestVersionDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
import { ResponseCookies } from "./ResponseCookies";
|
||||
import { ResponseHeaders } from "./ResponseHeaders";
|
||||
@@ -263,13 +264,14 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="justify-self-end shrink-0">
|
||||
<HStack space={1} className="justify-self-end shrink-0">
|
||||
<RequestVersionDropdown response={activeResponse} />
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
@@ -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 { Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { pluralize } from "../lib/pluralize";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
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 { Select } from "./core/Select";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
|
||||
interface Props {
|
||||
importFile: (filePath: string) => Promise<void>;
|
||||
importUrl: (url: string) => Promise<void>;
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
selectedFolder: Folder | null;
|
||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
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;
|
||||
}
|
||||
|
||||
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 [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
|
||||
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
|
||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -65,25 +152,270 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
});
|
||||
}, [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 selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
||||
if (selected == null) return;
|
||||
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);
|
||||
try {
|
||||
if (filePath != null) {
|
||||
await importFile(filePath);
|
||||
} else {
|
||||
await importUrl(trimmedSource);
|
||||
}
|
||||
const nextPlan =
|
||||
filePath != null
|
||||
? await planFile(filePath, destination())
|
||||
: await planUrl(trimmedSource, destination());
|
||||
setPlan(nextPlan);
|
||||
setItems(nextPlan.items);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
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 (
|
||||
<VStack ref={ref} space={4} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
@@ -94,7 +426,9 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
className={classNames(
|
||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||
"flex flex-col items-center gap-1 text-center",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||
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" />
|
||||
@@ -115,25 +449,254 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
/>
|
||||
|
||||
<VStack space={2}>
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import location"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
value={destinationKind}
|
||||
onChange={setDestinationChoice}
|
||||
options={[
|
||||
{ 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
|
||||
color="primary"
|
||||
disabled={trimmedSource === "" || isLoading}
|
||||
disabled={
|
||||
trimmedSource === "" ||
|
||||
isLoading ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null)
|
||||
}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
{isLoading ? "Analyzing" : "Preview Import"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</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,79 @@
|
||||
import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import { stringify } from "yaml";
|
||||
import { useRequestVersion } from "../hooks/useRequestVersion";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { restoreRequestVersion } from "../lib/restoreRequestVersion";
|
||||
import { Button } from "./core/Button";
|
||||
import { DiffViewer } from "./core/Editor/DiffViewer";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
|
||||
interface Props {
|
||||
response: Pick<HttpResponse, "requestId" | "versionId">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers the request a response was sent from, when that is no longer the
|
||||
* request you have.
|
||||
*
|
||||
* Hidden while the two agree, which is the overwhelmingly common case and the
|
||||
* one where there is nothing to say. Responses recorded before versioning
|
||||
* existed have no version and stay quiet forever.
|
||||
*/
|
||||
export function RequestVersionDropdown({ response }: Props) {
|
||||
const { data: comparison } = useRequestVersion(response.versionId, response.requestId);
|
||||
if (comparison == null || !comparison.differs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "View Diff",
|
||||
leftSlot: <Icon icon="git_branch" />,
|
||||
onSelect: () => showRequestVersionDiff(comparison),
|
||||
},
|
||||
{
|
||||
label: "Restore This Version",
|
||||
leftSlot: <Icon icon="history" />,
|
||||
onSelect: () => restoreRequestVersion(comparison.version),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="notice"
|
||||
className="font-sans"
|
||||
title="This request has changed since this response was sent"
|
||||
forDropdown
|
||||
>
|
||||
Request Changed
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function showRequestVersionDiff(comparison: RequestVersionComparison) {
|
||||
showDialog({
|
||||
id: "request-version-diff",
|
||||
title: "Request Changes Since This Response",
|
||||
size: "full",
|
||||
noPadding: true,
|
||||
render: () => (
|
||||
<div className="h-full flex flex-col px-4 pb-4">
|
||||
<DiffViewer
|
||||
original={toYaml(comparison.version.document)}
|
||||
modified={toYaml(comparison.currentDocument)}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Matches how the Git dialog renders a model for diffing. */
|
||||
function toYaml(document: unknown): string {
|
||||
return stringify(document, { indent: 2, lineWidth: 0 });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -10,11 +10,13 @@ export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
disableBackdropClose?: boolean;
|
||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
||||
disableClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
@@ -27,7 +29,7 @@ export function Dialog({
|
||||
size = "full",
|
||||
open,
|
||||
onClose,
|
||||
disableBackdropClose,
|
||||
disableClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
@@ -42,7 +44,7 @@ export function Dialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
|
||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
||||
<div
|
||||
role="dialog"
|
||||
className={classNames(
|
||||
@@ -58,7 +60,7 @@ export function Dialog({
|
||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||
// dialogs and can be intercepted by children if needed.
|
||||
if (e.key === "Escape") {
|
||||
onClose?.();
|
||||
if (!disableClose) onClose?.();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -110,7 +112,7 @@ export function Dialog({
|
||||
</div>
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!hideX && (
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
<IconButton
|
||||
className="opacity-70 hover:opacity-100"
|
||||
|
||||
@@ -601,6 +601,8 @@ function getExtensions({
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: "off",
|
||||
autocorrect: "off",
|
||||
// Keeps macOS Writing Tools from offering to write code for us
|
||||
writingsuggestions: "false",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { SearchQuery } from "@codemirror/search";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
currentMatch,
|
||||
literalSearch,
|
||||
MAX_COUNT,
|
||||
MatchCounter,
|
||||
normalizeDoc,
|
||||
normalizeSearch,
|
||||
scanNormalized,
|
||||
scanQuery,
|
||||
} from "./searchMatchCount";
|
||||
|
||||
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
|
||||
|
||||
const stateOf = (doc: string) => EditorState.create({ doc });
|
||||
|
||||
/**
|
||||
* The matches the counter finds, having checked them against the search panel's own cursor.
|
||||
*
|
||||
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
|
||||
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
|
||||
*/
|
||||
function matchesOf(doc: string, config: QueryConfig) {
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery(config);
|
||||
const matches = new MatchCounter().matches(state, query);
|
||||
expect(matches).toEqual(scanQuery(state, query));
|
||||
return matches;
|
||||
}
|
||||
|
||||
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
|
||||
|
||||
describe("counting", () => {
|
||||
test("counts every match, whatever the case", () => {
|
||||
expect(countOf("one Two three two", { search: "two" })).toBe(2);
|
||||
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("skips matches overlapping an earlier one", () => {
|
||||
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
|
||||
expect(countOf("ababa", { search: "aba" })).toBe(1);
|
||||
});
|
||||
|
||||
test("treats a query as text, not as a pattern", () => {
|
||||
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
|
||||
});
|
||||
|
||||
test("unquotes escapes unless the query is literal", () => {
|
||||
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
|
||||
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("counts regexp and whole word queries through the cursor", () => {
|
||||
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
|
||||
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
|
||||
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
|
||||
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
|
||||
});
|
||||
|
||||
test("stops counting at the cap", () => {
|
||||
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
|
||||
});
|
||||
|
||||
test("reports where the matches are", () => {
|
||||
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds nothing to match with an empty needle", () => {
|
||||
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization", () => {
|
||||
test("finds what a character decomposes into", () => {
|
||||
// The é is one character holding an `e`, and the match covers the whole of it
|
||||
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
|
||||
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
|
||||
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
|
||||
expect(countOf("one two", { search: "one two" })).toBe(1);
|
||||
expect(countOf("full width", { search: "full" })).toBe(1);
|
||||
});
|
||||
|
||||
test("matches a decomposed query against composed text, and the reverse", () => {
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps offsets straight after an expansion", () => {
|
||||
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
|
||||
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
|
||||
});
|
||||
|
||||
test("normalizes the query whole, the document by character", () => {
|
||||
expect(normalizeSearch("CAFÉ", false)).toBe("café");
|
||||
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
|
||||
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
|
||||
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
|
||||
});
|
||||
|
||||
test("leaves a document that normalizes to itself untouched", () => {
|
||||
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
|
||||
expect(text).toBe("plain 日本 🎉 text");
|
||||
expect(expansions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current match", () => {
|
||||
const matches = [
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
{ from: 8, to: 10 },
|
||||
];
|
||||
|
||||
test("counts from one, and reports 0 off a match", () => {
|
||||
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
|
||||
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
|
||||
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
|
||||
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
test("moving the selection doesn't scan again", () => {
|
||||
const state = stateOf("a1 b2 c3");
|
||||
const query = new SearchQuery({ search: "\\d", regexp: true });
|
||||
const counter = new MatchCounter();
|
||||
const found = counter.matches(state, query);
|
||||
|
||||
// The document a selection-only transaction leaves behind is the one already scanned
|
||||
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
|
||||
expect(counter.matches(moved, query)).toBe(found);
|
||||
expect(currentMatch(found, moved.selection.main)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The mapping from normalized offsets back to document offsets is the part of this that can go
|
||||
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
|
||||
*/
|
||||
describe("against the cursor, on awkward text", () => {
|
||||
const ALPHABET = [
|
||||
..."abcABC .\\\n".split(""),
|
||||
"é",
|
||||
"é",
|
||||
"fi",
|
||||
"…",
|
||||
" ",
|
||||
"İ",
|
||||
"Σ",
|
||||
"ς",
|
||||
"日",
|
||||
"🎉",
|
||||
"Ⅻ",
|
||||
"f",
|
||||
"①",
|
||||
"́",
|
||||
];
|
||||
|
||||
/** Seeded, so a failure is the same failure next run */
|
||||
function random(seed: number) {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
for (const caseSensitive of [false, true]) {
|
||||
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
|
||||
const next = random(caseSensitive ? 20260831 : 7);
|
||||
|
||||
for (let round = 0; round < 400; round++) {
|
||||
const doc = Array.from(
|
||||
{ length: 2 + Math.floor(next() * 60) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
|
||||
// Half the queries are lifted out of the document, so matches are actually found
|
||||
const start = Math.floor(next() * doc.length);
|
||||
const search =
|
||||
next() < 0.5
|
||||
? doc.slice(start, start + 1 + Math.floor(next() * 3))
|
||||
: Array.from(
|
||||
{ length: 1 + Math.floor(next() * 2) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
if (search === "") continue;
|
||||
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery({ search, caseSensitive });
|
||||
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
|
||||
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,232 @@
|
||||
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { EditorState, Extension, Text } from "@codemirror/state";
|
||||
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
|
||||
/** Matches are counted no further than this, since an exact total stops being useful long before */
|
||||
export const MAX_COUNT = 9999;
|
||||
|
||||
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
|
||||
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
|
||||
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
|
||||
|
||||
export interface Match {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** A character whose normalized form is a different length, shifting every offset past it */
|
||||
interface Expansion {
|
||||
normFrom: number;
|
||||
normTo: number;
|
||||
docFrom: number;
|
||||
docTo: number;
|
||||
}
|
||||
|
||||
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
|
||||
export interface NormalizedDoc {
|
||||
text: string;
|
||||
expansions: Expansion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
|
||||
* case-sensitive — in a single pass rather than one call per code point.
|
||||
*
|
||||
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
|
||||
* makes counting matches in a large response slow. Doing it a character at a time still matters
|
||||
* for the result, since it keeps NFKD from reordering marks across characters, so the
|
||||
* granularity stays and only the repeated work goes: each distinct character is normalized once
|
||||
* and the answer reused, and ASCII runs never reach ICU at all.
|
||||
*/
|
||||
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
|
||||
const rewritten = new Map<string, string>();
|
||||
const expansions: Expansion[] = [];
|
||||
let shift = 0;
|
||||
|
||||
const normalized = text.replace(
|
||||
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
|
||||
(chunk: string, at: number) => {
|
||||
// An ASCII run only ever folds case, which can't change its length
|
||||
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
|
||||
|
||||
let out = rewritten.get(chunk);
|
||||
if (out === undefined) {
|
||||
out = chunk.normalize("NFKD");
|
||||
if (!caseSensitive) out = out.toLowerCase();
|
||||
rewritten.set(chunk, out);
|
||||
}
|
||||
|
||||
if (out.length !== chunk.length) {
|
||||
expansions.push({
|
||||
normFrom: at + shift,
|
||||
normTo: at + shift + out.length,
|
||||
docFrom: at,
|
||||
docTo: at + chunk.length,
|
||||
});
|
||||
shift += out.length - chunk.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
);
|
||||
|
||||
return { text: normalized, expansions };
|
||||
}
|
||||
|
||||
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
|
||||
export function normalizeSearch(search: string, caseSensitive: boolean): string {
|
||||
const normalized = search.normalize("NFKD");
|
||||
return caseSensitive ? normalized : normalized.toLowerCase();
|
||||
}
|
||||
|
||||
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
|
||||
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
|
||||
const matches: Match[] = [];
|
||||
if (needle === "") return matches;
|
||||
|
||||
const { text } = doc;
|
||||
let pos = text.indexOf(needle);
|
||||
while (pos >= 0) {
|
||||
let end = pos + needle.length;
|
||||
// However the query was cut, a match ends on a whole code point, as the cursor's do
|
||||
if (isLowSurrogate(text.charCodeAt(end))) end++;
|
||||
|
||||
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
|
||||
pos = text.indexOf(needle, resumeAfter(doc, end));
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** The same through the query's own cursor, which handles regexps and whole words */
|
||||
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
|
||||
const matches: Match[] = [];
|
||||
const cursor = query.getCursor(state);
|
||||
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
matches.push({ from: result.value.from, to: result.value.to });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
|
||||
|
||||
/** The last character expansion beginning at or before `offset`, if there is one */
|
||||
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
|
||||
let low = 0;
|
||||
let high = expansions.length - 1;
|
||||
let found: Expansion | null = null;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (expansions[mid]!.normFrom <= offset) {
|
||||
found = expansions[mid]!;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function docStart(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
// A match starting inside a character's expansion starts at the character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docFrom
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where scanning picks up after a match ending at `offset`.
|
||||
*
|
||||
* The cursor moves through the document a character at a time, so once a match ends inside a
|
||||
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
|
||||
* ever counts as one match of ".".
|
||||
*/
|
||||
function resumeAfter(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
|
||||
? expansion.normTo
|
||||
: offset;
|
||||
}
|
||||
|
||||
function docEnd(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
if (offset <= expansion.normFrom) return expansion.docFrom;
|
||||
// A match ending inside a character's expansion covers the whole character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docTo
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
|
||||
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
|
||||
let index = 0;
|
||||
for (const match of matches) {
|
||||
index++;
|
||||
if (match.from <= selection.from && match.to >= selection.to) return index;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The text a plain query looks for, or null when only the cursor can answer it */
|
||||
export function literalSearch(query: SearchQuery): string | null {
|
||||
if (query.regexp || query.wholeWord || query.test != null) return null;
|
||||
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
|
||||
return query.literal
|
||||
? query.search
|
||||
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
|
||||
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the matches for the search panel, keeping the normalized document and the matches it
|
||||
* last found, so neither moving the selection nor typing another character starts over.
|
||||
*/
|
||||
export class MatchCounter {
|
||||
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
|
||||
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
|
||||
|
||||
matches(state: EditorState, query: SearchQuery): Match[] {
|
||||
const last = this.last;
|
||||
if (last != null && last.doc === state.doc && last.query.eq(query)) {
|
||||
return last.matches;
|
||||
}
|
||||
|
||||
const matches = this.scan(state, query);
|
||||
this.last = { doc: state.doc, query, matches };
|
||||
return matches;
|
||||
}
|
||||
|
||||
private scan(state: EditorState, query: SearchQuery): Match[] {
|
||||
const search = literalSearch(query);
|
||||
if (search == null) return scanQuery(state, query);
|
||||
|
||||
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
|
||||
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
|
||||
}
|
||||
|
||||
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
|
||||
const cached = this.doc;
|
||||
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
|
||||
return cached.normalized;
|
||||
}
|
||||
|
||||
const normalized = normalizeDoc(doc.toString(), caseSensitive);
|
||||
this.doc = { doc, caseSensitive, normalized };
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A CodeMirror extension that displays the total number of search matches
|
||||
* inside the built-in search panel.
|
||||
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
private countEl: HTMLElement | null = null;
|
||||
private counter = new MatchCounter();
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.updateCount();
|
||||
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
|
||||
}
|
||||
|
||||
this.ensureCountEl();
|
||||
if (this.countEl == null) return;
|
||||
|
||||
if (!query.search) {
|
||||
if (this.countEl) {
|
||||
this.countEl.textContent = "0/0";
|
||||
}
|
||||
this.countEl.textContent = "0/0";
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = state.selection.main;
|
||||
let count = 0;
|
||||
let currentIndex = 0;
|
||||
const MAX_COUNT = 9999;
|
||||
const cursor = query.getCursor(state);
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
count++;
|
||||
const match = result.value;
|
||||
if (match.from <= selection.from && match.to >= selection.to) {
|
||||
currentIndex = count;
|
||||
}
|
||||
if (count > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
if (this.countEl) {
|
||||
if (count > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (count === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else if (currentIndex > 0) {
|
||||
this.countEl.textContent = `${currentIndex}/${count}`;
|
||||
} else {
|
||||
this.countEl.textContent = `0/${count}`;
|
||||
}
|
||||
const matches = this.counter.matches(state, query);
|
||||
if (matches.length > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (matches.length === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else {
|
||||
const current = currentMatch(matches, state.selection.main);
|
||||
this.countEl.textContent = `${current}/${matches.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
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,
|
||||
allowMultilineValues,
|
||||
className,
|
||||
|
||||
@@ -119,9 +119,13 @@ export function Select<T extends string>({
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="w-full text-sm font-mono"
|
||||
className={classNames(
|
||||
"w-full text-sm font-mono",
|
||||
disabled && "border-dotted",
|
||||
isInvalidSelection && "border-danger",
|
||||
)}
|
||||
justify="start"
|
||||
variant="border"
|
||||
variant="input"
|
||||
size={size}
|
||||
leftSlot={leftSlot}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { Button } from "../core/Button";
|
||||
import type { CheckboxProps } 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 { Input } from "../core/Input";
|
||||
import { Separator } from "../core/Separator";
|
||||
@@ -43,10 +45,7 @@ interface CommitTreeNode {
|
||||
|
||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
const callbacks = useGitCallbacks(syncDir);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
|
||||
syncDir,
|
||||
callbacks,
|
||||
);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [commitError, setCommitError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
@@ -143,6 +142,15 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
return next(workspace, []);
|
||||
}, [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(
|
||||
(treeNode: CommitTreeNode) => {
|
||||
const checked = nodeCheckedStatus(treeNode);
|
||||
@@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
[restore],
|
||||
);
|
||||
|
||||
if (tree == null) {
|
||||
if (tree == null || treeNode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -221,12 +229,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
style={innerStyle}
|
||||
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
|
||||
>
|
||||
<TreeNodeChildren
|
||||
node={tree}
|
||||
depth={0}
|
||||
onCheck={checkNode}
|
||||
onSelect={handleSelectChild}
|
||||
selectedPath={selectedEntry?.relaPath ?? null}
|
||||
<CheckboxTree
|
||||
node={treeNode}
|
||||
checked={(n) => nodeCheckedStatus(n.data)}
|
||||
onCheck={(n) => checkNode(n.data)}
|
||||
checkboxTitle={(n) =>
|
||||
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") && (
|
||||
<>
|
||||
@@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style: innerStyle }) => (
|
||||
<div
|
||||
style={innerStyle}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
|
||||
>
|
||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<Input
|
||||
className="text-base! font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
@@ -301,96 +312,39 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNodeChildren({
|
||||
node,
|
||||
depth,
|
||||
onCheck,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
}: {
|
||||
node: CommitTreeNode | null;
|
||||
depth: number;
|
||||
onCheck: (node: CommitTreeNode, checked: boolean) => void;
|
||||
onSelect: (entry: GitStatusEntry) => void;
|
||||
selectedPath: string | null;
|
||||
}) {
|
||||
if (node === null) return null;
|
||||
if (!isNodeRelevant(node)) return null;
|
||||
|
||||
const checked = nodeCheckedStatus(node);
|
||||
const isSelected = selectedPath === node.status.relaPath;
|
||||
|
||||
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
|
||||
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",
|
||||
isSelected ? "text-text" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
title={checked ? "Unstage change" : "Stage change"}
|
||||
hideLabel
|
||||
onChange={(checked) => onCheck(node, checked)}
|
||||
<>
|
||||
{node.model.model !== "http_request" &&
|
||||
node.model.model !== "grpc_request" &&
|
||||
node.model.model !== "websocket_request" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === "folder"
|
||||
? "folder"
|
||||
: node.model.model === "environment"
|
||||
? "variable"
|
||||
: "house"
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
) : (
|
||||
<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.model.model !== "http_request" &&
|
||||
node.model.model !== "grpc_request" &&
|
||||
node.model.model !== "websocket_request" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === "folder"
|
||||
? "folder"
|
||||
: node.model.model === "environment"
|
||||
? "variable"
|
||||
: "house"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== "current" && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||
node.status.status === "modified" && "text-info",
|
||||
node.status.status === "untracked" && "text-success",
|
||||
node.status.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{node.children.map((childNode) => {
|
||||
return (
|
||||
<TreeNodeChildren
|
||||
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
|
||||
node={childNode}
|
||||
depth={depth + 1}
|
||||
onCheck={onCheck}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -495,15 +449,6 @@ function setCheckedAndChildren(
|
||||
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({
|
||||
entry,
|
||||
onDiscardChanges,
|
||||
@@ -526,13 +471,11 @@ function DiffPanel({
|
||||
size="2xs"
|
||||
variant="border"
|
||||
onClick={() => onDiscardChanges(entry)}
|
||||
>Discard Changes</Button>
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</div>
|
||||
<DiffViewer
|
||||
original={prevYaml ?? ""}
|
||||
modified={nextYaml ?? ""}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
|
||||
showDialog({
|
||||
id: "git-diverged",
|
||||
title: "Branches Diverged",
|
||||
hideX: true,
|
||||
size: "sm",
|
||||
disableBackdropClose: true,
|
||||
onClose: () => resolve("cancel"),
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
DivergedDialog({
|
||||
remote,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { RequestVersionComparison } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { allRequestsAtom } from "./useAllRequests";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
/**
|
||||
* The request version a response was sent from, alongside the request as it
|
||||
* stands now.
|
||||
*
|
||||
* Refetches when the live request is written, which is what keeps the "has this
|
||||
* changed?" answer honest while someone edits. The comparison itself is the
|
||||
* backend's — the frontend never hashes anything.
|
||||
*/
|
||||
export function useRequestVersion(versionId: string | null | undefined, requestId: string | null) {
|
||||
const requests = useAtomValue(allRequestsAtom);
|
||||
const liveUpdatedAt = requests.find((r) => r.id === requestId)?.updatedAt;
|
||||
|
||||
return useQuery({
|
||||
placeholderData: (prev) => prev,
|
||||
queryKey: ["request_version", versionId, liveUpdatedAt],
|
||||
enabled: versionId != null,
|
||||
queryFn: () =>
|
||||
rpc<RequestVersionComparison>("models_request_version", { versionId: versionId! }),
|
||||
});
|
||||
}
|
||||
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
|
||||
showDialog({
|
||||
id,
|
||||
title,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ export async function showConfirm({
|
||||
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
||||
showDialog({
|
||||
...extraProps,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ModelVersionReason } from "@yaakapp-internal/models";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { EditSessionTracker } from "./editSessionTracker";
|
||||
|
||||
type Capture = [requestId: string, reason: ModelVersionReason];
|
||||
|
||||
function tracker(idleMs = 1000) {
|
||||
const captured: Capture[] = [];
|
||||
return {
|
||||
captured,
|
||||
tracker: new EditSessionTracker((id, reason) => captured.push([id, reason]), idleMs),
|
||||
};
|
||||
}
|
||||
|
||||
describe("EditSessionTracker", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
test("captures a request once it has been left alone", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteEdit("rq_1");
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(captured).toEqual([["rq_1", "idle"]]);
|
||||
});
|
||||
|
||||
test("a burst of edits is one capture, not one per keystroke", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
t.noteEdit("rq_1");
|
||||
vi.advanceTimersByTime(500);
|
||||
}
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(captured).toEqual([["rq_1", "idle"]]);
|
||||
});
|
||||
|
||||
test("captures the request being left, not the one being opened", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
t.noteActiveRequest("rq_2");
|
||||
expect(captured).toEqual([["rq_1", "switch"]]);
|
||||
});
|
||||
|
||||
test("re-selecting the same request is not a boundary", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
t.noteActiveRequest("rq_1");
|
||||
expect(captured).toEqual([]);
|
||||
});
|
||||
|
||||
test("blur and close capture the request still on screen", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
t.noteBoundary();
|
||||
t.noteBoundary();
|
||||
expect(captured).toEqual([
|
||||
["rq_1", "switch"],
|
||||
["rq_1", "switch"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("nothing is captured before a request is open", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteBoundary();
|
||||
t.noteActiveRequest(null);
|
||||
expect(captured).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ModelVersionReason } from "@yaakapp-internal/models";
|
||||
|
||||
/**
|
||||
* How long a request has to sit untouched before its edits become a version.
|
||||
* Long enough that typing a URL is one version rather than forty, short enough
|
||||
* that walking away from a half-finished edit still records it.
|
||||
*/
|
||||
export const IDLE_MS = 60_000;
|
||||
|
||||
type Snapshot = (requestId: string, reason: ModelVersionReason) => void;
|
||||
|
||||
/**
|
||||
* When a request's editing session ends.
|
||||
*
|
||||
* The backend versions a request on every send, which covers "what produced
|
||||
* this response". This covers the rest: an edit someone made and then walked
|
||||
* away from, which no send would ever have captured.
|
||||
*
|
||||
* It deliberately knows nothing about *what* changed. Versions are
|
||||
* content-addressed, so a boundary that turns out to have nothing behind it
|
||||
* costs one query and creates nothing — which is what lets this stay a timer
|
||||
* and two assignments instead of a change-tracking system.
|
||||
*/
|
||||
export class EditSessionTracker {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private idleRequestId: string | null = null;
|
||||
private activeRequestId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly snapshot: Snapshot,
|
||||
private readonly idleMs: number = IDLE_MS,
|
||||
) {}
|
||||
|
||||
/** A request was written. Restarts its idle countdown. */
|
||||
noteEdit(requestId: string) {
|
||||
if (this.timer != null) clearTimeout(this.timer);
|
||||
this.idleRequestId = requestId;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const requestId = this.idleRequestId;
|
||||
if (requestId != null) this.snapshot(requestId, "idle");
|
||||
}, this.idleMs);
|
||||
}
|
||||
|
||||
/** The user moved to a different request, so the one they left is finished. */
|
||||
noteActiveRequest(requestId: string | null) {
|
||||
if (requestId === this.activeRequestId) return;
|
||||
const left = this.activeRequestId;
|
||||
this.activeRequestId = requestId;
|
||||
if (left != null) this.snapshot(left, "switch");
|
||||
}
|
||||
|
||||
/** The window lost focus or is closing. */
|
||||
noteBoundary() {
|
||||
if (this.activeRequestId != null) this.snapshot(this.activeRequestId, "switch");
|
||||
}
|
||||
}
|
||||
@@ -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 { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
// 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({
|
||||
mutationKey: ["import_data"],
|
||||
onError: (err: string) => {
|
||||
@@ -21,29 +41,41 @@ export const importData = createFastMutation({
|
||||
},
|
||||
mutationFn: async () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "sm",
|
||||
size: "lg",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||
try {
|
||||
await finishImport(await runImport());
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
const cancel = () => {
|
||||
hide();
|
||||
resolve();
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
hide();
|
||||
reject(err);
|
||||
};
|
||||
const commit = async (plan: ImportPlan) => {
|
||||
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
|
||||
hide();
|
||||
await finishImport(imported);
|
||||
resolve();
|
||||
};
|
||||
return (
|
||||
<ImportDataDialog
|
||||
importFile={(filePath) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||
}
|
||||
importUrl={(url) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||
}
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={planFile}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
|
||||
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
type?: string;
|
||||
format?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
additionalProperties?: JsonSchema;
|
||||
enum?: unknown[];
|
||||
$defs?: Record<string, JsonSchema>;
|
||||
$ref?: string;
|
||||
};
|
||||
|
||||
const DEFS_PREFIX = "#/$defs/";
|
||||
const ROOT_REF = "#";
|
||||
|
||||
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
|
||||
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
|
||||
|
||||
// Refs on sibling branches each expand their own subtree, so a schema that references the
|
||||
// same messages repeatedly can produce exponentially many nodes without ever cycling.
|
||||
const MAX_NODES = 5000;
|
||||
|
||||
type Budget = { remaining: number };
|
||||
|
||||
/** Build a sample message with placeholder values for every field in the schema */
|
||||
export function buildExampleFromSchema(schema: JsonSchema): unknown {
|
||||
// The root is already being built, so a `#` ref anywhere below it is a cycle
|
||||
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
|
||||
}
|
||||
|
||||
function buildValue(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.remaining -= 1;
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
if (refPath.has(schema.$ref)) {
|
||||
return {};
|
||||
}
|
||||
const resolved = resolveRef(schema.$ref, root);
|
||||
if (resolved == null) {
|
||||
return {};
|
||||
}
|
||||
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return schema.enum[0] ?? "";
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "object":
|
||||
return buildObject(schema, root, refPath, budget);
|
||||
case "array":
|
||||
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
|
||||
case "string":
|
||||
return buildString(schema.format);
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildObject(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema.properties != null && typeof schema.properties === "object") {
|
||||
const example: Record<string, unknown> = {};
|
||||
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
||||
example[name] = buildValue(propertySchema, root, refPath, budget);
|
||||
}
|
||||
return example;
|
||||
}
|
||||
|
||||
// Maps have no properties, only a value schema
|
||||
if (schema.additionalProperties != null) {
|
||||
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildString(format: string | undefined): string {
|
||||
if (format === "date-time") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
|
||||
if (format === "duration") {
|
||||
return "0s";
|
||||
}
|
||||
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
|
||||
return "0";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
|
||||
if (ref === ROOT_REF) {
|
||||
return root;
|
||||
}
|
||||
if (!ref.startsWith(DEFS_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
|
||||
}
|
||||
@@ -25,13 +25,8 @@ export async function showPromptForm({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
hideX: true,
|
||||
size: size ?? "sm",
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
onClose: () => {
|
||||
// Click backdrop, close, or escape
|
||||
resolve(null);
|
||||
},
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
Prompt({
|
||||
onCancel: () => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { flushAllPendingPatches } from "@yaakapp-internal/models";
|
||||
import type { ModelPayload, ModelVersion, ModelVersionReason } from "@yaakapp-internal/models";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { EditSessionTracker } from "./editSessionTracker";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
const REQUEST_MODELS = ["http_request", "grpc_request", "websocket_request"];
|
||||
|
||||
/**
|
||||
* Ask the backend to capture a request's current content.
|
||||
*
|
||||
* Quiet by design: a version that fails to write is not worth a toast, because
|
||||
* every caller below is reacting to the user leaving rather than asking for
|
||||
* anything.
|
||||
*/
|
||||
export function snapshotRequestVersion(requestId: string, reason: ModelVersionReason) {
|
||||
// Edits reach the database on a debounce, so flush before asking for a
|
||||
// version of what is in it
|
||||
flushAllPendingPatches();
|
||||
rpc<ModelVersion>("models_snapshot_request", { requestId, reason }).catch((err: unknown) => {
|
||||
console.warn("Failed to snapshot request version", err);
|
||||
});
|
||||
}
|
||||
|
||||
export function initRequestVersionSnapshots() {
|
||||
const tracker = new EditSessionTracker(snapshotRequestVersion);
|
||||
|
||||
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||
for (const payload of payloads) {
|
||||
if (payload.change.type !== "upsert") continue;
|
||||
if (!REQUEST_MODELS.includes(payload.model.model)) continue;
|
||||
tracker.noteEdit(payload.model.id);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
|
||||
jotaiStore.sub(activeRequestIdAtom, () => {
|
||||
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
|
||||
});
|
||||
|
||||
platform.window.onFocusChanged((focused) => {
|
||||
if (!focused) tracker.noteBoundary();
|
||||
});
|
||||
|
||||
// Closing is the last boundary there is. Nothing can be awaited here, but the
|
||||
// write is already on its way and the backend outlives the window.
|
||||
window.addEventListener("beforeunload", () => tracker.noteBoundary());
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||
import type { ModelVersion } from "@yaakapp-internal/models";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { fireAndForget } from "./fireAndForget";
|
||||
import { rpc } from "./rpc";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
/**
|
||||
* Put an old version's content back into the live request.
|
||||
*
|
||||
* The backend captures whatever the request currently holds before
|
||||
* overwriting it, so this is not a destructive action even when the last edit
|
||||
* was never versioned — but the request is still rewritten under the user's
|
||||
* cursor, so it is announced.
|
||||
*/
|
||||
export function restoreRequestVersion(version: ModelVersion) {
|
||||
fireAndForget(
|
||||
(async () => {
|
||||
// The backend restores from the database, so anything still sitting in a
|
||||
// debounce has to land first — otherwise it would overwrite the restore
|
||||
await flushAllModelWrites();
|
||||
const requestId = await rpc<string>("models_restore_request_version", {
|
||||
versionId: version.id,
|
||||
});
|
||||
// The write came from this window, so the store's echo suppression would
|
||||
// otherwise leave open editors showing what was there before
|
||||
wasUpdatedExternally(requestId);
|
||||
showToast({
|
||||
id: "request-version-restored",
|
||||
color: "success",
|
||||
message: "Restored the request that produced this response",
|
||||
});
|
||||
})(),
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { initGit } from "./init/git";
|
||||
import { initSync } from "./init/sync";
|
||||
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
||||
import { initRequestVersionSnapshots } from "./lib/requestVersions";
|
||||
import { jotaiStore } from "./lib/jotai";
|
||||
import { router } from "./lib/router";
|
||||
|
||||
@@ -36,6 +37,7 @@ initGit();
|
||||
initSync();
|
||||
initModelStore(jotaiStore);
|
||||
initGlobalListeners();
|
||||
initRequestVersionSnapshots();
|
||||
await changeModelStoreWorkspace(null); // Load global models
|
||||
|
||||
console.log("Creating React root");
|
||||
|
||||
@@ -5,16 +5,20 @@ use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
||||
};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
||||
match import(ctx, args).await {
|
||||
Ok(result) => {
|
||||
Ok((result, items)) => {
|
||||
println!("Imported {}", format_counts(&result));
|
||||
if let Some(skipped) = format_skipped(&items) {
|
||||
println!("Skipped {skipped}");
|
||||
}
|
||||
0
|
||||
}
|
||||
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() {
|
||||
ctx.db()
|
||||
.get_workspace(workspace_id)
|
||||
@@ -51,6 +58,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.import_data(&plugin_context, &file_contents)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
let importer = import_result.importer;
|
||||
let resources = import_result.resources;
|
||||
let workspace_id = args.workspace_id;
|
||||
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||
@@ -59,15 +67,58 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id,
|
||||
environment_id: None,
|
||||
cookie_jar_id: None,
|
||||
request_id: None,
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||
let plan = import::plan_import_resources(
|
||||
ctx.query_manager(),
|
||||
importer,
|
||||
destination,
|
||||
resources,
|
||||
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}"))?;
|
||||
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> {
|
||||
|
||||
@@ -81,14 +81,21 @@ fn import_reads_yaak_workspace_file() {
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(
|
||||
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||
"Imported Workspace"
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_http_request("req_import").expect("request imported").url,
|
||||
"https://example.com"
|
||||
);
|
||||
let workspaces = db.list_workspaces().expect("list imported workspaces");
|
||||
let workspace = workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.name == "Imported Workspace")
|
||||
.expect("workspace imported");
|
||||
assert_ne!(workspace.id, "wrk_import");
|
||||
|
||||
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
|
||||
let request = requests
|
||||
.iter()
|
||||
.find(|request| request.name == "Imported Request")
|
||||
.expect("request imported");
|
||||
assert_ne!(request.id, "req_import");
|
||||
assert_eq!(request.workspace_id, workspace.id);
|
||||
assert_eq!(request.url, "https://example.com");
|
||||
}
|
||||
|
||||
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||
@@ -160,3 +167,93 @@ fn import_postman_environment_uses_workspace_id() {
|
||||
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ use std::collections::BTreeMap;
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use KeyAndValueRef::{Ascii, Binary};
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use tauri::{Runtime, WebviewWindow};
|
||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||
use yaak_models::models::GrpcRequest;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
|
||||
let mut entries = BTreeMap::new();
|
||||
@@ -26,7 +25,7 @@ pub(crate) async fn build_metadata<R: Runtime>(
|
||||
request: &GrpcRequest,
|
||||
authentication_context_id: &str,
|
||||
) -> 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();
|
||||
|
||||
// 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::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
/// Context for managing response state during HTTP transactions.
|
||||
/// 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>,
|
||||
) -> Result<SentHttpRequest> {
|
||||
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 connection_manager = app_handle.state::<HttpConnectionManager>();
|
||||
let environment_id = environment.map(|e| e.id);
|
||||
|
||||
@@ -4,53 +4,84 @@ use crate::models_ext::QueryManagerExt;
|
||||
use std::fs::read_to_string;
|
||||
use std::io::ErrorKind;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak::import::{self, ImportDataParams};
|
||||
use yaak::import::{self, PlanImportDataParams};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
|
||||
|
||||
pub(crate) async fn import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
origin: Option<ImportOrigin>,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
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>,
|
||||
url: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = fetch_import_url(window, url).await?;
|
||||
import_contents(window, &contents).await
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
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>,
|
||||
contents: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
destination: ImportDestination,
|
||||
origin: Option<ImportOrigin>,
|
||||
) -> Result<ImportPlan> {
|
||||
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
|
||||
let query_manager = window.db_manager();
|
||||
let plugin_context = window.plugin_context();
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id: window.workspace_id(),
|
||||
environment_id: window.environment_id(),
|
||||
cookie_jar_id: window.cookie_jar_id(),
|
||||
request_id: None,
|
||||
};
|
||||
|
||||
Ok(import::import_data(ImportDataParams {
|
||||
Ok(import::plan_import_data(PlanImportDataParams {
|
||||
query_manager: &query_manager,
|
||||
plugin_manager: &plugin_manager,
|
||||
plugin_context: &plugin_context,
|
||||
workspace_context,
|
||||
destination,
|
||||
contents,
|
||||
origin,
|
||||
})
|
||||
.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
|
||||
/// 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}")))
|
||||
}
|
||||
|
||||
fn normalize_import_url(url: &str) -> Result<String> {
|
||||
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return Err(Error::GenericError("Import URL must not be empty".to_string()));
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map};
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::import::{import_data, import_url};
|
||||
use crate::import::{commit_import, plan_import_data, plan_import_url};
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
@@ -40,12 +40,12 @@ use yaak_models::models::{
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
RenderPurpose, ShowToastRequest,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
@@ -250,7 +250,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
let resolved_settings =
|
||||
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 req = render_grpc_request(
|
||||
&resolved_request,
|
||||
@@ -310,7 +310,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let resolved_settings =
|
||||
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 request = render_grpc_request(
|
||||
&resolved_request,
|
||||
@@ -331,6 +331,12 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let settings = app_handle.db().get_settings();
|
||||
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
|
||||
|
||||
// Capture the stored request, not the rendered one: what a restore should
|
||||
// put back is what the user typed
|
||||
let version_id = app_handle
|
||||
.db()
|
||||
.snapshot_request_for_send(&AnyRequest::GrpcRequest(unrendered_request.clone()));
|
||||
|
||||
let conn = app_handle.db().upsert_grpc_connection(
|
||||
&GrpcConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
@@ -339,6 +345,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
elapsed: 0,
|
||||
state: GrpcConnectionState::Initialized,
|
||||
url: request.url.clone(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
@@ -962,7 +969,6 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
||||
|
||||
async fn cmd_http_response_body<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
response_id: &str,
|
||||
filter: Option<&str>,
|
||||
) -> YaakResult<FilterResponse> {
|
||||
@@ -977,7 +983,8 @@ async fn cmd_http_response_body<R: Runtime>(
|
||||
.ok_or(GenericError("Failed to find response body".to_string()))?;
|
||||
|
||||
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)
|
||||
.await?),
|
||||
_ => Ok(FilterResponse { content: body, error: None }),
|
||||
@@ -1014,15 +1021,24 @@ async fn cmd_get_sse_events<R: Runtime>(
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_data(&window, file_path).await
|
||||
destination: ImportDestination,
|
||||
) -> YaakResult<ImportPlan> {
|
||||
plan_import_data(&window, file_path, destination).await
|
||||
}
|
||||
|
||||
async fn cmd_import_url<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
url: &str,
|
||||
destination: ImportDestination,
|
||||
) -> YaakResult<ImportPlan> {
|
||||
plan_import_url(&window, url, destination).await
|
||||
}
|
||||
|
||||
async fn cmd_commit_import<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plan: ImportPlan,
|
||||
) -> YaakResult<BatchUpsertResult> {
|
||||
import_url(&window, url).await
|
||||
commit_import(&window, plan)
|
||||
}
|
||||
|
||||
|
||||
@@ -1441,7 +1457,10 @@ fn safe_uri(endpoint: &str) -> String {
|
||||
fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
let app_handle = app_handle.clone();
|
||||
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;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
@@ -1482,9 +1501,13 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
}
|
||||
};
|
||||
|
||||
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
||||
if let Err(e) = plugin_manager.reply(&event, &ev).await {
|
||||
warn!("Failed to reply to plugin manager: {:?}", e)
|
||||
match plugins_ext::plugin_manager(&app_handle).await {
|
||||
Ok(pm) => {
|
||||
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,
|
||||
WorkspaceInfo,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_handle::PluginHandle;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
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(),
|
||||
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 cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
@@ -231,7 +230,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
req.http_request.folder_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 cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
@@ -267,7 +266,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
folder_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 cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
|
||||
@@ -31,10 +31,43 @@ use yaak_plugins::api::{
|
||||
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
|
||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -146,7 +179,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
|
||||
name: &str,
|
||||
version: Option<String>,
|
||||
) -> 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 http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
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>,
|
||||
directory: &str,
|
||||
) -> 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(
|
||||
&Plugin {
|
||||
directory: directory.into(),
|
||||
@@ -178,7 +214,6 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
|
||||
|
||||
Ok(plugin)
|
||||
@@ -188,7 +223,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
plugin_id: &str,
|
||||
window: WebviewWindow<R>,
|
||||
) -> 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 plugin_context = window.plugin_context();
|
||||
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());
|
||||
}
|
||||
|
||||
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 plugin_context = window.plugin_context();
|
||||
|
||||
@@ -300,20 +335,38 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
let query_manager =
|
||||
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();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let manager = PluginManager::new(
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
node_bin_path,
|
||||
plugin_runtime_main,
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
dev_mode,
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
PluginManager::new(
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
node_bin_path,
|
||||
plugin_runtime_main,
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
dev_mode,
|
||||
),
|
||||
)
|
||||
.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
|
||||
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();
|
||||
@@ -355,8 +408,14 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
api.prevent_exit();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
info!("Exiting plugin runtime due to app exit");
|
||||
let manager: State<PluginManager> = app.state();
|
||||
manager.terminate().await;
|
||||
// Bound the wait in case the exit comes while boot is still
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,10 +37,11 @@ use yaak_grpc::ServiceDefinition;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{
|
||||
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
HttpResponseEvent, ImportSource, ModelVersion, Plugin, RequestVersionComparison, Settings,
|
||||
WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
@@ -109,10 +110,11 @@ impl<R: Runtime> Host for ClientCtx<R> {
|
||||
}
|
||||
|
||||
impl<R: Runtime> ClientCtx<R> {
|
||||
/// The plugin runtime this window talks to. Only the `PluginHost` impl
|
||||
/// below uses it; everything else goes through the trait.
|
||||
fn pm(&self) -> State<'_, PluginManager> {
|
||||
self.window.state::<PluginManager>()
|
||||
/// The plugin runtime this window talks to, once it finishes booting.
|
||||
/// Only the `PluginHost` impl below uses it; everything else goes through
|
||||
/// the trait.
|
||||
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.
|
||||
impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
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())
|
||||
}
|
||||
|
||||
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> {
|
||||
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 {
|
||||
PluginTemplateCallback::new(
|
||||
Arc::new((*self.pm()).clone()),
|
||||
async fn template_callback(
|
||||
&self,
|
||||
purpose: RenderPurpose,
|
||||
) -> yaak_commands::Result<impl TemplateCallback> {
|
||||
Ok(PluginTemplateCallback::new(
|
||||
Arc::new(self.pm().await?),
|
||||
Arc::new(self.encryption_manager().clone()),
|
||||
&self.plugin_context(),
|
||||
purpose,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.get_template_function_summaries(&self.plugin_context())
|
||||
.await?)
|
||||
Ok(self.pm().await?.get_template_function_summaries(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
@@ -160,81 +167,81 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.pm()
|
||||
.await?
|
||||
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
) -> 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(
|
||||
&self,
|
||||
) -> 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(
|
||||
&self,
|
||||
) -> 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>> {
|
||||
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>> {
|
||||
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(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> 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(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> 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(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> 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(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> 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(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> 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(
|
||||
&self,
|
||||
) -> 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())
|
||||
}
|
||||
|
||||
@@ -246,6 +253,7 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.await?
|
||||
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
@@ -259,6 +267,7 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.await?
|
||||
.call_http_authentication_action(
|
||||
&self.plugin_context(),
|
||||
auth_name,
|
||||
@@ -270,15 +279,18 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
}
|
||||
|
||||
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)> {
|
||||
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> {
|
||||
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());
|
||||
Ok(encrypt_secure_template_function(
|
||||
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> {
|
||||
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>> {
|
||||
@@ -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?)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
|
||||
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
|
||||
}
|
||||
|
||||
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
|
||||
}
|
||||
|
||||
async fn cmd_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>> {
|
||||
@@ -619,6 +653,18 @@ async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq
|
||||
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_snapshot_request<R: Runtime>(ctx: ClientCtx<R>, req: ModelsSnapshotRequestReq) -> Result<ModelVersion> {
|
||||
Ok(yaak_commands::models::models_snapshot_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_request_version<R: Runtime>(ctx: ClientCtx<R>, req: ModelsRequestVersionReq) -> Result<RequestVersionComparison> {
|
||||
Ok(yaak_commands::models::models_request_version(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_restore_request_version<R: Runtime>(ctx: ClientCtx<R>, req: ModelsRestoreRequestVersionReq) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_restore_request_version(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
|
||||
}
|
||||
@@ -813,7 +859,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> {
|
||||
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> {
|
||||
@@ -843,4 +889,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
|
||||
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ use tokio::task::block_in_place;
|
||||
use tokio::time::sleep;
|
||||
use ts_rs::TS;
|
||||
use yaak_models::util::generate_id;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
use url::Url;
|
||||
use yaak_api::get_system_proxy_url;
|
||||
@@ -98,8 +97,9 @@ impl YaakUpdater {
|
||||
block_in_place(|| {
|
||||
tauri::async_runtime::block_on(async move {
|
||||
info!("Shutting down plugin manager before update");
|
||||
let plugin_manager = w.state::<PluginManager>();
|
||||
plugin_manager.terminate().await;
|
||||
if let Ok(plugin_manager) = crate::plugins_ext::plugin_manager(&w).await {
|
||||
plugin_manager.terminate().await;
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::import::import_data;
|
||||
use crate::import::{file_origin, import_data, url_origin};
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::{info, warn};
|
||||
use std::collections::HashMap;
|
||||
@@ -12,7 +12,6 @@ use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_models::util::generate_id;
|
||||
use yaak_plugins::events::{Color, ShowToastRequest};
|
||||
use yaak_plugins::install::download_and_install;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
@@ -44,7 +43,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
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 app_version = app_handle.package_info().version.to_string();
|
||||
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" => {
|
||||
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());
|
||||
_ = window.set_focus();
|
||||
|
||||
@@ -99,6 +99,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
|
||||
.to_string();
|
||||
fs::write(&p, json)?;
|
||||
file_path = Some(p);
|
||||
origin = Some(url_origin(file_url));
|
||||
}
|
||||
|
||||
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(
|
||||
"show_toast",
|
||||
ShowToastRequest {
|
||||
|
||||
@@ -20,9 +20,9 @@ use yaak_models::models::{
|
||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||
WebsocketEventType,
|
||||
};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
@@ -77,7 +77,7 @@ async fn send_websocket_message<R: Runtime>(
|
||||
)?;
|
||||
let (resolved_request, _auth_context_id) =
|
||||
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 request = render_websocket_request(
|
||||
&resolved_request,
|
||||
@@ -142,7 +142,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
cookie_jar_id: Option<&str>,
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
_plugin_manager: State<'_, PluginManager>,
|
||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||
) -> Result<WebsocketConnection> {
|
||||
let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
|
||||
@@ -156,7 +155,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
let settings = app_handle.db().get_settings();
|
||||
let (resolved_request, auth_context_id) =
|
||||
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 request = render_websocket_request(
|
||||
&resolved_request,
|
||||
@@ -171,10 +170,17 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Capture the stored request, not the rendered one: what a restore should
|
||||
// put back is what the user typed
|
||||
let version_id = app_handle
|
||||
.db()
|
||||
.snapshot_request_for_send(&AnyRequest::WebsocketRequest(unrendered_request.clone()));
|
||||
|
||||
let connection = app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AnyModel =
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
@@ -137,6 +138,10 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -241,6 +246,10 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -318,6 +327,18 @@ export type HttpUrlParameter = {
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
@@ -334,6 +355,28 @@ export type KeyValue = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ModelVersion = {
|
||||
model: "model_version";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
/**
|
||||
* The `model` field of the versioned model, eg. `http_request`.
|
||||
*/
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
contentHash: string;
|
||||
document: Record<string, any>;
|
||||
reason: ModelVersionReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a version was captured. Not a UI label — the frontend decides how to
|
||||
* phrase these — but it is what makes a history readable when debugging.
|
||||
*/
|
||||
export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual";
|
||||
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
@@ -361,6 +404,23 @@ export type ProxySetting =
|
||||
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
/**
|
||||
* One version, next to the request as it stands now.
|
||||
*
|
||||
* Both halves come from the same place so they are guaranteed comparable: the
|
||||
* frontend renders them side by side, and `differs` is the same content-hash
|
||||
* comparison the backend uses everywhere else rather than a second opinion
|
||||
* formed in TypeScript.
|
||||
*/
|
||||
export type RequestVersionComparison = {
|
||||
version: ModelVersion;
|
||||
/**
|
||||
* The live request's editable content, in the same shape as the version's document.
|
||||
*/
|
||||
currentDocument: Record<string, any>;
|
||||
differs: boolean;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
@@ -425,6 +485,10 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
+17
-5
File diff suppressed because one or more lines are too long
+44
@@ -2,3 +2,47 @@
|
||||
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 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";
|
||||
|
||||
@@ -21,9 +21,10 @@ use yaak_git::{
|
||||
use yaak_grpc::ServiceDefinition;
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
HttpResponseEvent, ImportSource, ModelVersion, ModelVersionReason, Plugin,
|
||||
RequestVersionComparison, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
@@ -229,6 +230,7 @@ pub struct CmdGetHttpResponseEventsReq {
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportDataReq {
|
||||
pub file_path: String,
|
||||
pub destination: ImportDestination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
@@ -236,6 +238,31 @@ pub struct CmdImportDataReq {
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdImportUrlReq {
|
||||
pub url: String,
|
||||
pub destination: ImportDestination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdCommitImportReq {
|
||||
pub plan: ImportPlan,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[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)]
|
||||
@@ -507,6 +534,28 @@ pub struct ModelsDuplicateReq {
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsSnapshotRequestReq {
|
||||
pub request_id: String,
|
||||
pub reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsRequestVersionReq {
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsRestoreRequestVersionReq {
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
@@ -909,8 +958,11 @@ macro_rules! with_commands {
|
||||
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
|
||||
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
||||
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
||||
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
|
||||
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
|
||||
cmd_import_data(CmdImportDataReq) -> ImportPlan,
|
||||
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
|
||||
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
|
||||
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
|
||||
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
|
||||
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
|
||||
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
|
||||
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
|
||||
@@ -951,6 +1003,9 @@ macro_rules! with_commands {
|
||||
models_upsert(ModelsUpsertReq) -> String,
|
||||
models_delete(ModelsDeleteReq) -> String,
|
||||
models_duplicate(ModelsDuplicateReq) -> String,
|
||||
models_snapshot_request(ModelsSnapshotRequestReq) -> ModelVersion,
|
||||
models_request_version(ModelsRequestVersionReq) -> RequestVersionComparison,
|
||||
models_restore_request_version(ModelsRestoreRequestVersionReq) -> String,
|
||||
models_websocket_events(ModelsWebsocketEventsReq) -> Vec<WebsocketEvent>,
|
||||
models_grpc_events(ModelsGrpcEventsReq) -> Vec<GrpcEvent>,
|
||||
models_get_settings(ModelsGetSettingsReq) -> Settings,
|
||||
|
||||
@@ -121,7 +121,12 @@ pub trait PluginHost: Host {
|
||||
/// a render — the variables come from the environment chain, which is an
|
||||
/// ordinary database read — so handing back the callback keeps the rest of
|
||||
/// 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
|
||||
/// autocomplete menu.
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
use crate::error::Result;
|
||||
use crate::host::{Host, PluginHost};
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, ModelVersion,
|
||||
RequestVersionComparison, Settings, WebsocketEvent, WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::versions::version_document;
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
@@ -45,6 +46,42 @@ pub async fn models_duplicate<H: Host>(host: H, req: ModelsDuplicateReq) -> Resu
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Capture the request's current content, from an edit-session boundary the
|
||||
/// frontend can see: switching away, losing focus, closing, or falling idle.
|
||||
///
|
||||
/// The frontend does not track whether anything actually changed — versions are
|
||||
/// content-addressed, so an unchanged request returns the version it already
|
||||
/// had and the trigger code stays a one-liner.
|
||||
pub async fn models_snapshot_request<H: Host>(
|
||||
host: H,
|
||||
req: ModelsSnapshotRequestReq,
|
||||
) -> Result<ModelVersion> {
|
||||
Ok(host.db().snapshot_request_by_id(&req.request_id, req.reason)?)
|
||||
}
|
||||
|
||||
/// A version and the live request side by side, for the diff and for deciding
|
||||
/// whether there is anything worth offering.
|
||||
pub async fn models_request_version<H: Host>(
|
||||
host: H,
|
||||
req: ModelsRequestVersionReq,
|
||||
) -> Result<RequestVersionComparison> {
|
||||
let db = host.db();
|
||||
let version = db.get_model_version(&req.version_id)?;
|
||||
let current_document = version_document(&db.get_any_request(&version.model_id)?.to_value()?)?;
|
||||
let differs = !db.request_matches_version(&version)?;
|
||||
Ok(RequestVersionComparison { version, current_document, differs })
|
||||
}
|
||||
|
||||
/// Returns the id of the request that was restored.
|
||||
pub async fn models_restore_request_version<H: Host>(
|
||||
host: H,
|
||||
req: ModelsRestoreRequestVersionReq,
|
||||
) -> Result<String> {
|
||||
let source = host.update_source();
|
||||
let restored = host.db().restore_request_version(&req.version_id, &source)?;
|
||||
Ok(restored.id().to_string())
|
||||
}
|
||||
|
||||
pub async fn models_websocket_events<H: Host>(
|
||||
host: H,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
|
||||
@@ -66,7 +66,7 @@ pub(crate) async fn render_form_values<H: PluginHost>(
|
||||
let environment_chain =
|
||||
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 =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn cmd_render_template<H: PluginHost>(
|
||||
) -> Result<String> {
|
||||
let environment_chain =
|
||||
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 {
|
||||
// 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
|
||||
@@ -41,7 +41,7 @@ pub async fn cmd_template_tokens_to_string<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> 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())
|
||||
}
|
||||
|
||||
|
||||
@@ -250,8 +250,11 @@ impl PluginHost for SingleThreadedHost {
|
||||
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
|
||||
}
|
||||
|
||||
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
NoTemplateFunctions
|
||||
async fn template_callback(
|
||||
&self,
|
||||
_purpose: RenderPurpose,
|
||||
) -> yaak_commands::Result<impl TemplateCallback> {
|
||||
Ok(NoTemplateFunctions)
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
|
||||
@@ -210,7 +210,7 @@ fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEn
|
||||
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
|
||||
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
|
||||
"google.protobuf.Duration" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
|
||||
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
|
||||
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
|
||||
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
|
||||
|
||||
+75
@@ -12,6 +12,7 @@ export type AnyModel =
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
@@ -138,6 +139,10 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -242,6 +247,10 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -336,6 +345,29 @@ export type HttpUrlParameter = {
|
||||
|
||||
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 InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
@@ -358,6 +390,28 @@ export type ModelPayload = {
|
||||
change: ModelChangeEvent;
|
||||
};
|
||||
|
||||
export type ModelVersion = {
|
||||
model: "model_version";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
/**
|
||||
* The `model` field of the versioned model, eg. `http_request`.
|
||||
*/
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
contentHash: string;
|
||||
document: Record<string, any>;
|
||||
reason: ModelVersionReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a version was captured. Not a UI label — the frontend decides how to
|
||||
* phrase these — but it is what makes a history readable when debugging.
|
||||
*/
|
||||
export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual";
|
||||
|
||||
export type ParentAuthentication = {
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
@@ -401,6 +455,23 @@ export type ProxySetting =
|
||||
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
/**
|
||||
* One version, next to the request as it stands now.
|
||||
*
|
||||
* Both halves come from the same place so they are guaranteed comparable: the
|
||||
* frontend renders them side by side, and `differs` is the same content-hash
|
||||
* comparison the backend uses everywhere else rather than a second opinion
|
||||
* formed in TypeScript.
|
||||
*/
|
||||
export type RequestVersionComparison = {
|
||||
version: ModelVersion;
|
||||
/**
|
||||
* The live request's editable content, in the same shape as the version's document.
|
||||
*/
|
||||
currentDocument: Record<string, any>;
|
||||
differs: boolean;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
@@ -464,6 +535,10 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
Generated
+44
@@ -2,3 +2,47 @@
|
||||
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 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";
|
||||
|
||||
@@ -12,6 +12,7 @@ export function newStoreData(): ModelStoreData {
|
||||
http_request: {},
|
||||
http_response: {},
|
||||
http_response_event: {},
|
||||
import_source: {},
|
||||
key_value: {},
|
||||
plugin: {},
|
||||
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)
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE model_versions
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
model TEXT DEFAULT 'model_version' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
document TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Content addressing, enforced by the database rather than by every caller.
|
||||
CREATE UNIQUE INDEX model_versions_content ON model_versions (model_id, content_hash);
|
||||
|
||||
ALTER TABLE http_responses ADD COLUMN version_id TEXT;
|
||||
ALTER TABLE grpc_connections ADD COLUMN version_id TEXT;
|
||||
ALTER TABLE websocket_connections ADD COLUMN version_id TEXT;
|
||||
@@ -118,6 +118,20 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(m.clone())
|
||||
}
|
||||
|
||||
/// Upsert a model WITHOUT recording a model change or emitting an event.
|
||||
///
|
||||
/// Only for rows that are nobody's business but this process's — model
|
||||
/// versions, whose whole point is that they are local history. Anything the
|
||||
/// frontend, sync or another window should learn about goes through
|
||||
/// [`Self::upsert`].
|
||||
pub(crate) fn upsert_untracked<M>(&self, model: &M) -> Result<M>
|
||||
where
|
||||
M: UpsertModelInfo + Clone,
|
||||
{
|
||||
let (m, _created) = self.ctx.upsert(model, &UpdateSource::Background.to_db())?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn record_model_change(&self, payload: &ModelPayload) -> Result<()> {
|
||||
let payload_json = serde_json::to_string(payload)?;
|
||||
let source_json = serde_json::to_string(&payload.update_source)?;
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod queries;
|
||||
pub mod query_manager;
|
||||
pub mod render;
|
||||
pub mod util;
|
||||
pub mod versions;
|
||||
|
||||
/// Per-connection setup, applied by every pool on every connection it opens.
|
||||
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
|
||||
@@ -1539,6 +1539,8 @@ pub struct WebsocketConnection {
|
||||
pub state: WebsocketConnectionState,
|
||||
pub status: i32,
|
||||
pub url: String,
|
||||
/// The request version this connection was opened from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for WebsocketConnection {
|
||||
@@ -1578,6 +1580,7 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
(State, serde_json::to_value(&self.state)?.as_str().into()),
|
||||
(Status, self.status.into()),
|
||||
(Url, self.url.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1590,6 +1593,7 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
WebsocketConnectionIden::State,
|
||||
WebsocketConnectionIden::Status,
|
||||
WebsocketConnectionIden::Url,
|
||||
WebsocketConnectionIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1612,6 +1616,7 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
error: row.get("error")?,
|
||||
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
|
||||
status: row.get("status")?,
|
||||
version_id: row.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1965,6 +1970,8 @@ pub struct HttpResponse {
|
||||
pub state: HttpResponseState,
|
||||
pub url: String,
|
||||
pub version: Option<String>,
|
||||
/// The request version this response was sent from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for HttpResponse {
|
||||
@@ -2014,6 +2021,7 @@ impl UpsertModelInfo for HttpResponse {
|
||||
(Url, self.url.into()),
|
||||
(Version, self.version.into()),
|
||||
(RequestContentLength, self.request_content_length.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2036,6 +2044,7 @@ impl UpsertModelInfo for HttpResponse {
|
||||
HttpResponseIden::StatusReason,
|
||||
HttpResponseIden::Url,
|
||||
HttpResponseIden::Version,
|
||||
HttpResponseIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2071,6 +2080,7 @@ impl UpsertModelInfo for HttpResponse {
|
||||
r.get::<_, String>("request_headers").unwrap_or_default().as_str(),
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
version_id: r.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2516,6 +2526,8 @@ pub struct GrpcConnection {
|
||||
pub state: GrpcConnectionState,
|
||||
pub trailers: BTreeMap<String, String>,
|
||||
pub url: String,
|
||||
/// The request version this connection was opened from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for GrpcConnection {
|
||||
@@ -2557,6 +2569,7 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
(Error, self.error.as_ref().map(|s| s.as_str()).into()),
|
||||
(Trailers, serde_json::to_string(&self.trailers)?.into()),
|
||||
(Url, self.url.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2571,6 +2584,7 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
GrpcConnectionIden::Error,
|
||||
GrpcConnectionIden::Trailers,
|
||||
GrpcConnectionIden::Url,
|
||||
GrpcConnectionIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2595,6 +2609,7 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
url: row.get("url")?,
|
||||
error: row.get("error")?,
|
||||
trailers: serde_json::from_str(trailers.as_str()).unwrap_or_default(),
|
||||
version_id: row.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3022,6 +3037,271 @@ 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")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a version was captured. Not a UI label — the frontend decides how to
|
||||
/// phrase these — but it is what makes a history readable when debugging.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub enum ModelVersionReason {
|
||||
Send,
|
||||
Switch,
|
||||
Idle,
|
||||
Restore,
|
||||
/// Reserved: an explicit "save a version now" action, which has no UI yet.
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Default for ModelVersionReason {
|
||||
fn default() -> Self {
|
||||
Self::Manual
|
||||
}
|
||||
}
|
||||
|
||||
/// A point-in-time copy of one request's editable content.
|
||||
///
|
||||
/// Versions are content-addressed: `content_hash` covers exactly what
|
||||
/// `document` holds, and `(model_id, content_hash)` is unique, so capturing the
|
||||
/// same content twice returns the row that already exists. That is what lets
|
||||
/// every send snapshot unconditionally without growing the table.
|
||||
///
|
||||
/// Deliberately absent from [`AnyModel`]: versions are local history. They are
|
||||
/// not synced, not exported, and not mirrored into the frontend's model store —
|
||||
/// the frontend asks for the one version it needs to show.
|
||||
impl Default for ModelVersion {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "model_version".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
model_type: String::new(),
|
||||
model_id: String::new(),
|
||||
content_hash: String::new(),
|
||||
document: Value::Object(Default::default()),
|
||||
reason: ModelVersionReason::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "model_versions")]
|
||||
pub struct ModelVersion {
|
||||
#[ts(type = "\"model_version\"")]
|
||||
pub model: String,
|
||||
pub id: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub workspace_id: String,
|
||||
|
||||
/// The `model` field of the versioned model, eg. `http_request`.
|
||||
pub model_type: String,
|
||||
pub model_id: String,
|
||||
pub content_hash: String,
|
||||
#[ts(type = "Record<string, any>")]
|
||||
pub document: Value,
|
||||
pub reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for ModelVersion {
|
||||
fn table_name() -> impl IntoTableRef + IntoIden {
|
||||
ModelVersionIden::Table
|
||||
}
|
||||
|
||||
fn id_column() -> impl IntoIden + Eq + Clone {
|
||||
ModelVersionIden::Id
|
||||
}
|
||||
|
||||
fn generate_id() -> String {
|
||||
generate_prefixed_id("mv")
|
||||
}
|
||||
|
||||
fn order_by() -> (impl IntoColumnRef, Order) {
|
||||
(ModelVersionIden::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 ModelVersionIden::*;
|
||||
Ok(vec![
|
||||
(CreatedAt, upsert_date(source, self.created_at)),
|
||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
||||
(WorkspaceId, self.workspace_id.into()),
|
||||
(ModelType, self.model_type.into()),
|
||||
(ModelId, self.model_id.into()),
|
||||
(ContentHash, self.content_hash.into()),
|
||||
(Document, serde_json::to_string(&self.document)?.into()),
|
||||
(Reason, serde_json::to_value(self.reason)?.as_str().into()),
|
||||
])
|
||||
}
|
||||
|
||||
fn update_columns() -> Vec<impl IntoIden> {
|
||||
vec![ModelVersionIden::UpdatedAt]
|
||||
}
|
||||
|
||||
fn from_row(row: &Row) -> rusqlite::Result<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let document: String = row.get("document")?;
|
||||
let reason: String = row.get("reason")?;
|
||||
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")?,
|
||||
model_type: row.get("model_type")?,
|
||||
model_id: row.get("model_id")?,
|
||||
content_hash: row.get("content_hash")?,
|
||||
document: serde_json::from_str(&document).unwrap_or_default(),
|
||||
reason: serde_json::from_str(format!(r#""{reason}""#).as_str()).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One version, next to the request as it stands now.
|
||||
///
|
||||
/// Both halves come from the same place so they are guaranteed comparable: the
|
||||
/// frontend renders them side by side, and `differs` is the same content-hash
|
||||
/// comparison the backend uses everywhere else rather than a second opinion
|
||||
/// formed in TypeScript.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct RequestVersionComparison {
|
||||
pub version: ModelVersion,
|
||||
/// The live request's editable content, in the same shape as the version's document.
|
||||
#[ts(type = "Record<string, any>")]
|
||||
pub current_document: Value,
|
||||
pub differs: bool,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn default_request_message_size_setting() -> InheritedIntSetting {
|
||||
@@ -3093,6 +3373,7 @@ define_any_model! {
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseEvent,
|
||||
ImportSource,
|
||||
KeyValue,
|
||||
Plugin,
|
||||
Settings,
|
||||
@@ -3125,6 +3406,7 @@ impl<'de> Deserialize<'de> for AnyModel {
|
||||
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_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 == "plugin" => Plugin(fv(value).unwrap()),
|
||||
Some(m) if m == "settings" => Settings(fv(value).unwrap()),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
use serde_json::Value;
|
||||
|
||||
pub enum AnyRequest {
|
||||
HttpRequest(HttpRequest),
|
||||
@@ -8,6 +9,36 @@ pub enum AnyRequest {
|
||||
WebsocketRequest(WebsocketRequest),
|
||||
}
|
||||
|
||||
/// Run an expression against whichever request this is, bound as `$request`.
|
||||
macro_rules! with_request {
|
||||
($self:expr, |$request:ident| $body:expr) => {
|
||||
match $self {
|
||||
AnyRequest::HttpRequest($request) => $body,
|
||||
AnyRequest::GrpcRequest($request) => $body,
|
||||
AnyRequest::WebsocketRequest($request) => $body,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl AnyRequest {
|
||||
pub fn id(&self) -> &str {
|
||||
with_request!(self, |request| &request.id)
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
with_request!(self, |request| &request.workspace_id)
|
||||
}
|
||||
|
||||
/// The model name, eg. `http_request`.
|
||||
pub fn model_type(&self) -> &str {
|
||||
with_request!(self, |request| &request.model)
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> Result<Value> {
|
||||
Ok(with_request!(self, |request| serde_json::to_value(request)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
|
||||
if let Ok(http_request) = self.get_http_request(id) {
|
||||
|
||||
@@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(m.id.as_str())?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(m.id.as_str())?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ mod grpc_requests;
|
||||
mod http_requests;
|
||||
mod http_response_events;
|
||||
mod http_responses;
|
||||
mod import_source_resources;
|
||||
mod import_sources;
|
||||
mod key_values;
|
||||
mod model_changes;
|
||||
mod model_versions;
|
||||
mod plugin_key_values;
|
||||
mod plugins;
|
||||
mod settings;
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
GrpcRequest, HttpRequest, ModelVersion, ModelVersionIden, ModelVersionReason, UpsertModelInfo,
|
||||
WebsocketRequest,
|
||||
};
|
||||
use crate::queries::any_request::AnyRequest;
|
||||
use crate::util::UpdateSource;
|
||||
use crate::versions::{apply_version_document, content_hash, version_document};
|
||||
use log::warn;
|
||||
use sea_query::{Expr, ExprTrait, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
/// Unreferenced versions older than this are dropped.
|
||||
const RETENTION_DAYS: i64 = 30;
|
||||
|
||||
/// How many unreferenced versions a request keeps, newest first.
|
||||
const RETENTION_COUNT: i64 = 50;
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn get_model_version(&self, id: &str) -> Result<ModelVersion> {
|
||||
self.find_one(ModelVersionIden::Id, id)
|
||||
}
|
||||
|
||||
/// Every version of one model, newest first.
|
||||
pub fn list_model_versions(&self, model_id: &str) -> Result<Vec<ModelVersion>> {
|
||||
self.find_many(ModelVersionIden::ModelId, model_id, None)
|
||||
}
|
||||
|
||||
/// Capture a request's current content, or return the version that already
|
||||
/// holds it.
|
||||
///
|
||||
/// The single entry point for creating versions. Callers do not check
|
||||
/// whether anything changed first — that is what content addressing is for,
|
||||
/// and it is why a send, a window blur and an idle timer can all call this
|
||||
/// on the same unedited request and leave one row behind.
|
||||
pub fn snapshot_request(
|
||||
&self,
|
||||
request: &AnyRequest,
|
||||
reason: ModelVersionReason,
|
||||
) -> Result<ModelVersion> {
|
||||
let document = version_document(&request.to_value()?)?;
|
||||
let content_hash = content_hash(&document)?;
|
||||
|
||||
if let Some(existing) = self.find_version_by_hash(request.id(), &content_hash) {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let version = self.upsert_untracked(&ModelVersion {
|
||||
workspace_id: request.workspace_id().to_string(),
|
||||
model_type: request.model_type().to_string(),
|
||||
model_id: request.id().to_string(),
|
||||
content_hash: content_hash.clone(),
|
||||
document,
|
||||
reason,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let version = match version {
|
||||
Ok(version) => version,
|
||||
// Two sends of the same request can both miss the lookup above and
|
||||
// race to insert. The unique index settles it, and the loser wants
|
||||
// exactly what the winner wrote.
|
||||
Err(err) => match self.find_version_by_hash(request.id(), &content_hash) {
|
||||
Some(existing) => return Ok(existing),
|
||||
None => return Err(err),
|
||||
},
|
||||
};
|
||||
|
||||
self.prune_model_versions(request.id())?;
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
pub fn snapshot_request_by_id(
|
||||
&self,
|
||||
request_id: &str,
|
||||
reason: ModelVersionReason,
|
||||
) -> Result<ModelVersion> {
|
||||
self.snapshot_request(&self.get_any_request(request_id)?, reason)
|
||||
}
|
||||
|
||||
/// What every send calls: capture the request, and don't make a fuss.
|
||||
///
|
||||
/// A send is not worth failing over history that couldn't be written, and
|
||||
/// a request with no id is ephemeral and has nothing to version. Either way
|
||||
/// the response just has no version to offer.
|
||||
pub fn snapshot_request_for_send(&self, request: &AnyRequest) -> Option<String> {
|
||||
if request.id().is_empty() {
|
||||
return None;
|
||||
}
|
||||
match self.snapshot_request(request, ModelVersionReason::Send) {
|
||||
Ok(version) => Some(version.id),
|
||||
Err(err) => {
|
||||
warn!("Failed to snapshot request before send: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a version's content back over the live request.
|
||||
///
|
||||
/// Anything the live request has picked up since its last version is
|
||||
/// captured first, so a restore is never the thing that loses an edit. The
|
||||
/// content being written already has a version — the one being restored —
|
||||
/// so this leaves no new row behind.
|
||||
pub fn restore_request_version(
|
||||
&self,
|
||||
version_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<AnyRequest> {
|
||||
let version = self.get_model_version(version_id)?;
|
||||
let live = self.get_any_request(&version.model_id)?;
|
||||
self.snapshot_request(&live, ModelVersionReason::Restore)?;
|
||||
|
||||
let restored = apply_version_document(&live.to_value()?, &version.document);
|
||||
Ok(match live {
|
||||
AnyRequest::HttpRequest(_) => AnyRequest::HttpRequest(
|
||||
self.upsert_http_request(&serde_json::from_value::<HttpRequest>(restored)?, source)?,
|
||||
),
|
||||
AnyRequest::GrpcRequest(_) => AnyRequest::GrpcRequest(
|
||||
self.upsert_grpc_request(&serde_json::from_value::<GrpcRequest>(restored)?, source)?,
|
||||
),
|
||||
AnyRequest::WebsocketRequest(_) => AnyRequest::WebsocketRequest(
|
||||
self.upsert_websocket_request(
|
||||
&serde_json::from_value::<WebsocketRequest>(restored)?,
|
||||
source,
|
||||
)?,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a request's content has moved on from a given version.
|
||||
pub fn request_matches_version(&self, version: &ModelVersion) -> Result<bool> {
|
||||
let live = self.get_any_request(&version.model_id)?;
|
||||
let hash = content_hash(&version_document(&live.to_value()?)?)?;
|
||||
Ok(hash == version.content_hash)
|
||||
}
|
||||
|
||||
pub fn delete_model_versions_for_model(&self, model_id: &str) -> Result<usize> {
|
||||
self.delete_many_untracked::<ModelVersion>(ModelVersionIden::ModelId, model_id)
|
||||
}
|
||||
|
||||
/// Drop the versions a request no longer needs.
|
||||
///
|
||||
/// A version referenced by a response outlives retention entirely — the
|
||||
/// point of the feature is that an old response can still show what sent
|
||||
/// it. Everything else is history the user has not asked to keep, and
|
||||
/// survives only while it is both recent and among the newest few.
|
||||
pub fn prune_model_versions(&self, model_id: &str) -> Result<usize> {
|
||||
let cutoff = format!("-{RETENTION_DAYS} days");
|
||||
let sql = r#"
|
||||
DELETE FROM model_versions
|
||||
WHERE model_id = ?1
|
||||
AND id NOT IN (
|
||||
SELECT version_id FROM http_responses WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
UNION
|
||||
SELECT version_id FROM grpc_connections WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
UNION
|
||||
SELECT version_id FROM websocket_connections WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
)
|
||||
AND (
|
||||
created_at < datetime('now', ?2)
|
||||
OR id NOT IN (
|
||||
SELECT id FROM model_versions WHERE model_id = ?1
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?3
|
||||
)
|
||||
)
|
||||
"#;
|
||||
Ok(self.conn().execute(sql, rusqlite::params![model_id, cutoff, RETENTION_COUNT])?)
|
||||
}
|
||||
|
||||
fn find_version_by_hash(&self, model_id: &str, content_hash: &str) -> Option<ModelVersion> {
|
||||
let (sql, params) = Query::select()
|
||||
.from(ModelVersionIden::Table)
|
||||
.column(sea_query::Asterisk)
|
||||
.cond_where(
|
||||
Expr::col(ModelVersionIden::ModelId)
|
||||
.eq(model_id)
|
||||
.and(Expr::col(ModelVersionIden::ContentHash).eq(content_hash)),
|
||||
)
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
let mut stmt = self.conn().prepare(sql.as_str()).ok()?;
|
||||
stmt.query_row(&*params.as_params(), ModelVersion::from_row).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpResponse, Workspace};
|
||||
|
||||
fn source() -> UpdateSource {
|
||||
UpdateSource::Background
|
||||
}
|
||||
|
||||
fn seed(db: &ClientDb) -> (Workspace, HttpRequest) {
|
||||
let workspace = db
|
||||
.upsert_workspace(&Workspace { name: "Versions".to_string(), ..Default::default() }, &source())
|
||||
.expect("Failed to upsert workspace");
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Original".to_string(),
|
||||
url: "https://example.com/one".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
(workspace, request)
|
||||
}
|
||||
|
||||
fn snapshot(db: &ClientDb, request_id: &str, reason: ModelVersionReason) -> ModelVersion {
|
||||
db.snapshot_request_by_id(request_id, reason).expect("Failed to snapshot")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshotting_unchanged_content_reuses_the_same_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let first = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
let second = snapshot(&db, &request.id, ModelVersionReason::Idle);
|
||||
let third = snapshot(&db, &request.id, ModelVersionReason::Switch);
|
||||
|
||||
assert_eq!(first.id, second.id);
|
||||
assert_eq!(first.id, third.id);
|
||||
// The first capture's reason is the one that sticks; a version is its content
|
||||
assert_eq!(second.reason, ModelVersionReason::Send);
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookkeeping_writes_do_not_mint_a_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let first = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
|
||||
let folder = db
|
||||
.upsert_folder(
|
||||
&crate::models::Folder {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
folder_id: Some(folder.id),
|
||||
sort_priority: 42.0,
|
||||
..db.get_http_request(&request.id).unwrap()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(snapshot(&db, &request.id, ModelVersionReason::Idle).id, first.id);
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_content_mints_a_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
snapshot(&db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_writes_the_old_content_back_without_a_new_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let original = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
url: "https://example.com/two".to_string(),
|
||||
name: "Edited".to_string(),
|
||||
..request.clone()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
let edited = snapshot(&db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
db.restore_request_version(&original.id, &source()).expect("Failed to restore");
|
||||
|
||||
let live = db.get_http_request(&request.id).unwrap();
|
||||
assert_eq!(live.url, "https://example.com/one");
|
||||
assert_eq!(live.name, "Original");
|
||||
assert_eq!(live.id, request.id);
|
||||
|
||||
// The restored content already had a version, and the edit it replaced
|
||||
// still has its own, so nothing new appears
|
||||
let versions = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert!(versions.iter().any(|v| v.id == original.id));
|
||||
assert!(versions.iter().any(|v| v.id == edited.id));
|
||||
}
|
||||
|
||||
/// The case restore exists to be safe for: an edit that was never captured.
|
||||
#[test]
|
||||
fn restoring_captures_uncaptured_edits_first() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let original = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/unsaved".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.restore_request_version(&original.id, &source()).expect("Failed to restore");
|
||||
|
||||
let versions = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(versions.len(), 2);
|
||||
let rescued = versions.iter().find(|v| v.id != original.id).unwrap();
|
||||
assert_eq!(rescued.reason, ModelVersionReason::Restore);
|
||||
assert_eq!(rescued.document.get("url").unwrap(), "https://example.com/unsaved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_matches_version_tracks_the_live_content() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let version = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
assert!(db.request_matches_version(&version).unwrap());
|
||||
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!db.request_matches_version(&version).unwrap());
|
||||
}
|
||||
|
||||
/// Write `count` distinct versions by walking the request's URL forward.
|
||||
fn make_versions(db: &ClientDb, request: &HttpRequest, count: usize) -> Vec<ModelVersion> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: format!("https://example.com/{i}"), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
snapshot(db, &request.id, ModelVersionReason::Idle)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreferenced_versions_are_pruned_to_the_newest_fifty() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let versions = make_versions(&db, &request, RETENTION_COUNT as usize + 10);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(kept.len(), RETENTION_COUNT as usize);
|
||||
// The oldest went first
|
||||
assert!(!kept.iter().any(|v| v.id == versions[0].id));
|
||||
assert!(kept.iter().any(|v| v.id == versions.last().unwrap().id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_referenced_version_survives_retention() {
|
||||
let (query_manager, blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (workspace, request) = seed(&db);
|
||||
|
||||
let pinned = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
version_id: Some(pinned.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
&blobs,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
make_versions(&db, &request, RETENTION_COUNT as usize + 10);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert!(
|
||||
kept.iter().any(|v| v.id == pinned.id),
|
||||
"a version a response points at must outlive retention",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreferenced_versions_expire_after_thirty_days() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let old = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.conn()
|
||||
.execute(
|
||||
"UPDATE model_versions SET created_at = datetime('now', '-31 days') WHERE id = ?1",
|
||||
rusqlite::params![old.id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Any later capture prunes
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
let fresh = snapshot(&db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].id, fresh.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_request_deletes_its_versions() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
make_versions(&db, &request, 3);
|
||||
assert!(!db.list_model_versions(&request.id).unwrap().is_empty());
|
||||
|
||||
db.delete_http_request_by_id(&request.id, &source()).unwrap();
|
||||
assert!(db.list_model_versions(&request.id).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(websocket_request.id.as_str())?;
|
||||
self.delete(websocket_request, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ use crate::models::{
|
||||
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
|
||||
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
|
||||
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden,
|
||||
ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, WebsocketConnection,
|
||||
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
|
||||
ImportSourceIden, ModelVersion, ModelVersionIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
|
||||
WebsocketConnection,
|
||||
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
|
||||
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
|
||||
};
|
||||
@@ -85,6 +86,11 @@ impl<'a> ClientDb<'a> {
|
||||
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<Environment>(EnvironmentIden::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::<ModelVersion>(ModelVersionIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||
self.delete(workspace, source)
|
||||
|
||||
@@ -85,6 +85,137 @@ pub struct BatchUpsertResult {
|
||||
pub websocket_requests: Vec<WebsocketRequest>,
|
||||
}
|
||||
|
||||
/// Where a staged import will be committed.
|
||||
///
|
||||
/// The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
/// the exact destination that confirmation will use.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportDestination {
|
||||
NewWorkspace,
|
||||
ExistingWorkspace {
|
||||
#[serde(rename = "workspaceId")]
|
||||
workspace_id: String,
|
||||
#[serde(rename = "folderId")]
|
||||
#[ts(optional)]
|
||||
folder_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub struct ImportPlanWarning {
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 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(
|
||||
db: &ClientDb,
|
||||
yaak_version: &str,
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Content addressing for model versions.
|
||||
//!
|
||||
//! A version's identity is its *content*, so the two functions here — what
|
||||
//! counts as content, and how content becomes a hash — are the whole of it.
|
||||
//! Everything else about versioning (when to capture, what to keep, how to
|
||||
//! restore) is built on top and stays in `queries::model_versions`.
|
||||
|
||||
use crate::error::Result;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Keys that describe a model's place in the workspace rather than what the
|
||||
/// user typed into it.
|
||||
///
|
||||
/// Dropping them is what makes a version stable: moving a request into a
|
||||
/// folder, dragging it up the sidebar, or simply saving it again all rewrite
|
||||
/// these and nothing else, and none of them should mint a version or show up
|
||||
/// in a diff. It is also why one rule covers HTTP, gRPC and WebSocket — the
|
||||
/// three differ only in the content fields, which are all kept.
|
||||
const BOOKKEEPING_KEYS: &[&str] =
|
||||
&["model", "id", "createdAt", "updatedAt", "workspaceId", "folderId", "sortPriority"];
|
||||
|
||||
/// The editable content of a model, as the object a version stores.
|
||||
pub fn version_document<T: Serialize>(model: &T) -> Result<Value> {
|
||||
let mut value = serde_json::to_value(model)?;
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
for key in BOOKKEEPING_KEYS {
|
||||
object.remove(*key);
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// The hash a version is addressed by.
|
||||
pub fn content_hash(document: &Value) -> Result<String> {
|
||||
let mut canonical = String::new();
|
||||
write_canonical(document, &mut canonical);
|
||||
Ok(hex::encode(Sha256::digest(canonical.as_bytes())))
|
||||
}
|
||||
|
||||
/// Serialize with object keys in sorted order.
|
||||
///
|
||||
/// Plain `to_string` would not do: whether `serde_json::Map` preserves
|
||||
/// insertion order or sorts is a workspace-wide feature decision, and a
|
||||
/// document read back from SQLite has whatever order it was written in. Sorting
|
||||
/// here makes the hash depend on the content and nothing else, in every build.
|
||||
fn write_canonical(value: &Value, out: &mut String) {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut keys = map.keys().collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
out.push('{');
|
||||
for (i, key) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write_canonical(&Value::String(key.clone()), out);
|
||||
out.push(':');
|
||||
write_canonical(&map[key], out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Value::Array(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
write_canonical(item, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
scalar => out.push_str(&scalar.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay a version's document back over a live model.
|
||||
///
|
||||
/// Keys the document carries win; keys it doesn't mention keep whatever the
|
||||
/// live model has. That covers both halves of a restore: bookkeeping (id,
|
||||
/// folder, sort order) survives because the document never held it, and a field
|
||||
/// added to the model after the version was captured survives because the
|
||||
/// version predates it.
|
||||
pub fn apply_version_document(live: &Value, document: &Value) -> Value {
|
||||
let mut merged = live.as_object().cloned().unwrap_or_else(Map::new);
|
||||
if let Some(document) = document.as_object() {
|
||||
for (key, value) in document {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(merged)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{HttpRequest, HttpRequestHeader};
|
||||
use chrono::Utc;
|
||||
|
||||
fn request() -> HttpRequest {
|
||||
HttpRequest {
|
||||
id: "rq_1".to_string(),
|
||||
workspace_id: "wk_1".to_string(),
|
||||
folder_id: Some("fl_1".to_string()),
|
||||
name: "Get user".to_string(),
|
||||
url: "https://example.com/users/1".to_string(),
|
||||
method: "GET".to_string(),
|
||||
sort_priority: 1.0,
|
||||
headers: vec![HttpRequestHeader {
|
||||
name: "Accept".to_string(),
|
||||
value: "application/json".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_of(request: &HttpRequest) -> String {
|
||||
content_hash(&version_document(request).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_holds_content_and_drops_bookkeeping() {
|
||||
let document = version_document(&request()).unwrap();
|
||||
let object = document.as_object().unwrap();
|
||||
|
||||
for key in BOOKKEEPING_KEYS {
|
||||
assert!(!object.contains_key(*key), "document should not carry {key}");
|
||||
}
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/users/1");
|
||||
assert_eq!(object.get("name").unwrap(), "Get user");
|
||||
assert_eq!(object.get("method").unwrap(), "GET");
|
||||
assert!(object.contains_key("headers"));
|
||||
assert!(object.contains_key("body"));
|
||||
assert!(object.contains_key("authentication"));
|
||||
assert!(object.contains_key("description"));
|
||||
assert!(object.contains_key("settingFollowRedirects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookkeeping_never_changes_the_hash() {
|
||||
let base = hash_of(&request());
|
||||
|
||||
let moved = HttpRequest { folder_id: Some("fl_2".to_string()), ..request() };
|
||||
assert_eq!(hash_of(&moved), base, "folder");
|
||||
|
||||
let resorted = HttpRequest { sort_priority: 99.5, ..request() };
|
||||
assert_eq!(hash_of(&resorted), base, "sort priority");
|
||||
|
||||
let touched =
|
||||
HttpRequest { updated_at: Utc::now().naive_utc(), created_at: Utc::now().naive_utc(), ..request() };
|
||||
assert_eq!(hash_of(&touched), base, "timestamps");
|
||||
|
||||
let renamed_id = HttpRequest { id: "rq_2".to_string(), ..request() };
|
||||
assert_eq!(hash_of(&renamed_id), base, "id");
|
||||
|
||||
let moved_workspace = HttpRequest { workspace_id: "wk_2".to_string(), ..request() };
|
||||
assert_eq!(hash_of(&moved_workspace), base, "workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_content_changes_the_hash() {
|
||||
let base = hash_of(&request());
|
||||
|
||||
assert_ne!(hash_of(&HttpRequest { url: "https://example.com/users/2".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { method: "POST".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { name: "Get other user".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { description: "Notes".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { headers: vec![], ..request() }), base);
|
||||
assert_ne!(
|
||||
hash_of(&HttpRequest { body_type: Some("application/json".into()), ..request() }),
|
||||
base
|
||||
);
|
||||
}
|
||||
|
||||
/// The hash has to survive a round trip through SQLite, which stores the
|
||||
/// document as text and hands back whatever order it was written in. It
|
||||
/// also has to survive `serde_json`'s `preserve_order` feature being on in
|
||||
/// one build of the workspace and off in another.
|
||||
#[test]
|
||||
fn key_order_does_not_change_the_hash() {
|
||||
let a: Value = serde_json::from_str(r#"{"url":"a","method":"GET"}"#).unwrap();
|
||||
let b: Value = serde_json::from_str(r#"{"method":"GET","url":"a"}"#).unwrap();
|
||||
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_order_does_not_change_the_hash_when_nested() {
|
||||
let a: Value =
|
||||
serde_json::from_str(r#"{"body":{"text":"x","type":"json"},"headers":[{"a":1,"b":2}]}"#)
|
||||
.unwrap();
|
||||
let b: Value =
|
||||
serde_json::from_str(r#"{"headers":[{"b":2,"a":1}],"body":{"type":"json","text":"x"}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
/// Sorting keys must not make different documents collide.
|
||||
#[test]
|
||||
fn array_order_still_changes_the_hash() {
|
||||
let a: Value = serde_json::from_str(r#"{"headers":[{"n":"a"},{"n":"b"}]}"#).unwrap();
|
||||
let b: Value = serde_json::from_str(r#"{"headers":[{"n":"b"},{"n":"a"}]}"#).unwrap();
|
||||
assert_ne!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_document_keeps_the_live_model_identity() {
|
||||
let live = serde_json::to_value(request()).unwrap();
|
||||
let document = version_document(&HttpRequest {
|
||||
url: "https://example.com/users/2".to_string(),
|
||||
..request()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let merged = apply_version_document(&live, &document);
|
||||
let object = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/users/2");
|
||||
assert_eq!(object.get("id").unwrap(), "rq_1");
|
||||
assert_eq!(object.get("folderId").unwrap(), "fl_1");
|
||||
assert_eq!(object.get("sortPriority").unwrap(), 1.0);
|
||||
assert_eq!(object.get("model").unwrap(), "http_request");
|
||||
}
|
||||
|
||||
/// A version captured before a field existed must not blank that field out.
|
||||
#[test]
|
||||
fn applying_an_older_document_leaves_unknown_fields_alone() {
|
||||
let live = serde_json::to_value(request()).unwrap();
|
||||
let document = serde_json::json!({ "url": "https://example.com/old" });
|
||||
|
||||
let merged = apply_version_document(&live, &document);
|
||||
let object = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/old");
|
||||
assert_eq!(object.get("method").unwrap(), "GET");
|
||||
assert_eq!(object.get("name").unwrap(), "Get user");
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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 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, };
|
||||
|
||||
|
||||
+25
@@ -11,6 +11,7 @@ export type AnyModel =
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
@@ -137,6 +138,10 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -241,6 +246,10 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -318,6 +327,18 @@ export type HttpUrlParameter = {
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type ImportSource = {
|
||||
model: "import_source";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
importer: string;
|
||||
origin: string;
|
||||
originLabel: string;
|
||||
lastImportedAt: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
@@ -417,6 +438,10 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::models::{
|
||||
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,
|
||||
@@ -247,7 +247,16 @@ pub struct ImportRequest {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct ImportResponse {
|
||||
/// Display name of the importer that recognized the input.
|
||||
pub importer: String,
|
||||
pub resources: ImportResources,
|
||||
|
||||
/// 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)]
|
||||
|
||||
@@ -187,24 +187,25 @@ impl PluginManager {
|
||||
}
|
||||
|
||||
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
|
||||
let db = query_manager.connect();
|
||||
for dir in &bundled_dirs {
|
||||
if db.get_plugin_by_directory(dir).is_none() {
|
||||
db.upsert_plugin(
|
||||
&Plugin {
|
||||
directory: dir.clone(),
|
||||
enabled: true,
|
||||
url: None,
|
||||
source: PluginSource::Bundled,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)?;
|
||||
// Scope the db connection so the future stays Send across the await below
|
||||
let plugins = {
|
||||
let db = query_manager.connect();
|
||||
for dir in &bundled_dirs {
|
||||
if db.get_plugin_by_directory(dir).is_none() {
|
||||
db.upsert_plugin(
|
||||
&Plugin {
|
||||
directory: dir.clone(),
|
||||
enabled: true,
|
||||
url: None,
|
||||
source: PluginSource::Bundled,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let plugins = db.list_plugins()?;
|
||||
drop(db);
|
||||
db.list_plugins()?
|
||||
};
|
||||
|
||||
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
|
||||
if !init_errors.is_empty() {
|
||||
@@ -1104,8 +1105,19 @@ impl PluginManager {
|
||||
.await?;
|
||||
|
||||
// TODO: Don't just return the first valid response
|
||||
let result = reply_events.into_iter().find_map(|e| match e.payload {
|
||||
InternalEventPayload::ImportResponse(resp) => Some(resp),
|
||||
let result = reply_events.into_iter().find_map(|e| match e {
|
||||
InternalEvent {
|
||||
plugin_name,
|
||||
payload: InternalEventPayload::ImportResponse(mut resp),
|
||||
..
|
||||
} => {
|
||||
// Older plugin runtimes do not include the importer's display name. The plugin
|
||||
// package name is still enough to identify the detected format in that case.
|
||||
if resp.importer.is_empty() {
|
||||
resp.importer = plugin_name;
|
||||
}
|
||||
Some(resp)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ impl TryFrom<AnyModel> for SyncModel {
|
||||
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
|
||||
AnyModel::HttpResponse(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::Plugin(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::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]
|
||||
fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> {
|
||||
let raw = r#"
|
||||
|
||||
@@ -32,12 +32,13 @@ use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::cookies::apply_cookie_changes;
|
||||
use yaak_models::models::{
|
||||
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
|
||||
HttpSendSettings,
|
||||
HttpSendSettings, ModelVersionReason, RequestVersionComparison,
|
||||
};
|
||||
use yaak_models::models_ops;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::render_http_request;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_models::versions::version_document;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback};
|
||||
|
||||
/// Names inside the VFS, not paths on any disk. Two files because the desktop
|
||||
@@ -218,6 +219,19 @@ struct UpsertIntrospectionReq {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SnapshotRequestReq {
|
||||
request_id: String,
|
||||
reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VersionIdReq {
|
||||
version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResponseIdReq {
|
||||
@@ -319,6 +333,37 @@ fn dispatch(
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
"models_snapshot_request" => {
|
||||
let req: SnapshotRequestReq = from_js(payload)?;
|
||||
to_json(
|
||||
host.queries
|
||||
.connect()
|
||||
.snapshot_request_by_id(&req.request_id, req.reason)
|
||||
.map_err(js_error)?,
|
||||
)
|
||||
}
|
||||
|
||||
"models_request_version" => {
|
||||
let req: VersionIdReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let version = db.get_model_version(&req.version_id).map_err(js_error)?;
|
||||
let request = db.get_any_request(&version.model_id).map_err(js_error)?;
|
||||
let current_document =
|
||||
version_document(&request.to_value().map_err(js_error)?).map_err(js_error)?;
|
||||
let differs = !db.request_matches_version(&version).map_err(js_error)?;
|
||||
to_json(RequestVersionComparison { version, current_document, differs })
|
||||
}
|
||||
|
||||
"models_restore_request_version" => {
|
||||
let req: VersionIdReq = from_js(payload)?;
|
||||
let restored = host
|
||||
.queries
|
||||
.connect()
|
||||
.restore_request_version(&req.version_id, source)
|
||||
.map_err(js_error)?;
|
||||
to_json(restored.id().to_string())
|
||||
}
|
||||
|
||||
"models_get_settings" => to_json(host.queries.connect().get_settings()),
|
||||
|
||||
"models_get_graphql_introspection" => {
|
||||
|
||||
@@ -9,6 +9,7 @@ async-trait = "0.1"
|
||||
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
||||
log = { workspace = true }
|
||||
md5 = "0.8.0"
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||
@@ -21,5 +22,6 @@ yaak-templates = { workspace = true }
|
||||
yaak-tls = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
+2131
-81
File diff suppressed because it is too large
Load Diff
+104
-3
@@ -24,9 +24,10 @@ use yaak_http::types::{
|
||||
use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::models::{
|
||||
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
|
||||
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting,
|
||||
ProxySettingAuth, ResolvedHttpRequestSettings,
|
||||
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState,
|
||||
ProxySetting, ProxySettingAuth, ResolvedHttpRequestSettings,
|
||||
};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::render_http_request;
|
||||
use yaak_models::util::{UpdateSource, generate_prefixed_id};
|
||||
@@ -283,6 +284,9 @@ pub struct HttpSendInputs {
|
||||
/// Cookies the send starts with. The store is shared, so reading it back after the send
|
||||
/// returns (or fails) yields the cookies the transaction collected.
|
||||
pub cookie_store: Option<CookieStore>,
|
||||
/// The version holding the request's content as it was when this send was resolved,
|
||||
/// which the response will point at. `None` for an ephemeral request with no id.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a send writes its response. Without it, the send keeps everything in memory: no
|
||||
@@ -434,6 +438,13 @@ pub fn resolve_send_inputs(
|
||||
client_certificates: settings.client_certificates,
|
||||
},
|
||||
cookie_store: cookies.map(CookieStore::from_cookies),
|
||||
// Captured here rather than deeper in the send because this is the last place that
|
||||
// still holds the *stored* request: further down it has been resolved against its
|
||||
// folder and workspace and then rendered, and neither of those is what a restore
|
||||
// should put back. Every host reaches sending through this function — the desktop,
|
||||
// the CLI, plugin-triggered sends — so every response gets a version without each
|
||||
// of them remembering to ask for one.
|
||||
version_id: db.snapshot_request_for_send(&AnyRequest::HttpRequest(request.clone())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -581,7 +592,8 @@ pub async fn send_http_request_by_id<T: TemplateCallback>(
|
||||
pub async fn send_http_request<T: TemplateCallback>(
|
||||
params: SendHttpRequestParams<'_, T>,
|
||||
) -> Result<SendHttpRequestResult> {
|
||||
let HttpSendInputs { request, environment_chain, runtime_config, cookie_store } = params.inputs;
|
||||
let HttpSendInputs { request, environment_chain, runtime_config, cookie_store, version_id } =
|
||||
params.inputs;
|
||||
let (request, auth_context_id) = request.into_parts();
|
||||
let storage = params.storage;
|
||||
let send_options = runtime_config.send_options();
|
||||
@@ -619,6 +631,7 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
let mut response = params.existing_response.unwrap_or_default();
|
||||
response.request_id = request.id.clone();
|
||||
response.workspace_id = request.workspace_id.clone();
|
||||
response.version_id = version_id;
|
||||
response.request_content_length = request_content_length;
|
||||
response.request_headers = sendable_request
|
||||
.headers
|
||||
@@ -1345,6 +1358,7 @@ mod tests {
|
||||
client_certificates: Vec::new(),
|
||||
},
|
||||
cookie_store: Some(CookieStore::new()),
|
||||
version_id: None,
|
||||
},
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: None,
|
||||
@@ -1414,6 +1428,7 @@ mod tests {
|
||||
client_certificates: Vec::new(),
|
||||
},
|
||||
cookie_store: Some(CookieStore::new()),
|
||||
version_id: None,
|
||||
},
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: None,
|
||||
@@ -1466,6 +1481,92 @@ mod tests {
|
||||
(query_manager, cookie_jar, temp_dir)
|
||||
}
|
||||
|
||||
/// The whole point of the feature, end to end: a stored send must leave a
|
||||
/// response that can name the request behind it, and repeated sends of an
|
||||
/// unchanged request must all name the same one.
|
||||
#[tokio::test]
|
||||
async fn a_stored_send_links_the_request_version_that_produced_it() {
|
||||
let (query_manager, blob_manager, temp_dir) = seed_send_storage();
|
||||
let request = query_manager
|
||||
.connect()
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: "wk_test".to_string(),
|
||||
url: "http://localhost/test".to_string(),
|
||||
name: "Original".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to seed request");
|
||||
|
||||
let first = stored_send(&query_manager, &blob_manager, temp_dir.path(), &request).await;
|
||||
let second = stored_send(&query_manager, &blob_manager, temp_dir.path(), &request).await;
|
||||
|
||||
let version_id = first.version_id.clone().expect("a stored send must record a version");
|
||||
assert_eq!(second.version_id, Some(version_id.clone()), "an unchanged request is one version");
|
||||
|
||||
let db = query_manager.connect();
|
||||
let version = db.get_model_version(&version_id).expect("Failed to load version");
|
||||
assert_eq!(version.model_id, request.id);
|
||||
assert_eq!(version.document.get("url").unwrap(), "http://localhost/test");
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
|
||||
// Editing after the fact is what the response pane has to be able to notice
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_http_request(&HttpRequest { name: "Edited".to_string(), ..request.clone() }, &UpdateSource::Sync)
|
||||
.expect("Failed to edit request");
|
||||
assert!(!db.request_matches_version(&version).expect("Failed to compare"));
|
||||
}
|
||||
|
||||
async fn stored_send(
|
||||
query_manager: &QueryManager,
|
||||
blob_manager: &BlobManager,
|
||||
response_dir: &std::path::Path,
|
||||
request: &HttpRequest,
|
||||
) -> HttpResponse {
|
||||
let executor = StubExecutor { body: b"hello world" };
|
||||
let inputs = resolve_send_inputs(query_manager, request, None, None)
|
||||
.expect("Failed to resolve send inputs");
|
||||
send_http_request(SendHttpRequestParams {
|
||||
inputs,
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: Some(ResponseStorage {
|
||||
query_manager,
|
||||
blob_manager,
|
||||
update_source: UpdateSource::Sync,
|
||||
response_dir,
|
||||
}),
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
cancelled_rx: None,
|
||||
existing_response: None,
|
||||
prepare_sendable_request: None,
|
||||
executor: &executor,
|
||||
})
|
||||
.await
|
||||
.expect("send should succeed")
|
||||
.response
|
||||
}
|
||||
|
||||
fn seed_send_storage() -> (QueryManager, BlobManager, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
&temp_dir.path().join("db.sqlite"),
|
||||
&temp_dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.expect("Failed to initialize DB");
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(
|
||||
&Workspace { id: "wk_test".to_string(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to seed workspace");
|
||||
(query_manager, blob_manager, temp_dir)
|
||||
}
|
||||
|
||||
fn cookie(name: &str) -> Cookie {
|
||||
Cookie {
|
||||
name: name.to_string(),
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@
|
||||
"lint:vp": "vp lint",
|
||||
"lint:workspaces": "npm run --workspaces --if-present lint",
|
||||
"replace-version": "node scripts/replace-version.cjs",
|
||||
"format": "vp fmt --ignore-path .oxfmtignore",
|
||||
"format": "vp fmt",
|
||||
"tauri": "tauri",
|
||||
"client:tauri-before-build": "npm run bootstrap",
|
||||
"client:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-client",
|
||||
|
||||
@@ -63,6 +63,10 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
db.rpc("models_get_graphql_introspection", payload),
|
||||
models_upsert_graphql_introspection: (payload, db) =>
|
||||
db.rpc("models_upsert_graphql_introspection", payload),
|
||||
models_snapshot_request: (payload, db) => db.rpc("models_snapshot_request", payload),
|
||||
models_request_version: (payload, db) => db.rpc("models_request_version", payload),
|
||||
models_restore_request_version: (payload, db) =>
|
||||
db.rpc("models_restore_request_version", payload),
|
||||
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
|
||||
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
|
||||
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
|
||||
@@ -76,7 +80,12 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
cmd_send_http_request: (payload, db) => {
|
||||
const requestId = str(payload, "requestId");
|
||||
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
|
||||
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
|
||||
return sendHttpRequest(
|
||||
db,
|
||||
requestId,
|
||||
str(payload, "environmentId"),
|
||||
str(payload, "cookieJarId"),
|
||||
);
|
||||
},
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
@@ -262,11 +271,20 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: [
|
||||
"WebSocket requests aren't available in the browser yet",
|
||||
"websocket",
|
||||
],
|
||||
|
||||
// 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_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_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"],
|
||||
@@ -295,8 +313,14 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_function_config: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_template_tokens_to_string: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
HttpResponse,
|
||||
HttpResponseEventData,
|
||||
HttpSendSettings,
|
||||
ModelVersion,
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { Frame, SendRequest } from "@yaakapp-internal/web";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
@@ -71,7 +72,13 @@ export async function sendHttpRequest(
|
||||
// a failure to render or to reach the server lands in the response pane as
|
||||
// that response's error rather than as a toast that names no request.
|
||||
const workspaceId = await workspaceIdOfRequest(db, requestId);
|
||||
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
|
||||
const versionId = await snapshotRequestVersion(db, requestId);
|
||||
const response = new ResponseWriter(db, {
|
||||
model: "http_response",
|
||||
requestId,
|
||||
workspaceId,
|
||||
versionId,
|
||||
});
|
||||
await response.create();
|
||||
|
||||
const cancel = new AbortController();
|
||||
@@ -88,6 +95,29 @@ export async function sendHttpRequest(
|
||||
return response.current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture what is about to be sent, so the response can offer it back later.
|
||||
* The desktop does this inside its send pipeline; this host's pipeline is here,
|
||||
* so this is where it goes. Versions are content-addressed, so repeated sends
|
||||
* of an unchanged request all point at the same one.
|
||||
*/
|
||||
async function snapshotRequestVersion(
|
||||
db: WorkerConnection,
|
||||
requestId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const version = await db.rpc<ModelVersion>("models_snapshot_request", {
|
||||
requestId,
|
||||
reason: "send",
|
||||
});
|
||||
return version.id;
|
||||
} catch (err) {
|
||||
// History is not worth failing a send over
|
||||
console.warn("Failed to snapshot request version", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSend(
|
||||
db: WorkerConnection,
|
||||
response: ResponseWriter,
|
||||
@@ -315,8 +345,16 @@ class TimelineWriter {
|
||||
* yaak-models), so an edit made while the send was in flight survives rather
|
||||
* than being written over by the send's stale snapshot.
|
||||
*/
|
||||
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> {
|
||||
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies });
|
||||
async function persistCookies(
|
||||
db: WorkerConnection,
|
||||
jar: CookieJar,
|
||||
cookies: Cookie[],
|
||||
): Promise<void> {
|
||||
await db.rpc("web_persist_send_cookies", {
|
||||
cookieJarId: jar.id,
|
||||
before: jar.cookies,
|
||||
after: cookies,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
-1
@@ -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 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, };
|
||||
|
||||
|
||||
@@ -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 { Context } from "./Context";
|
||||
|
||||
@@ -14,9 +14,12 @@ export type PartialImportResources = {
|
||||
websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>;
|
||||
};
|
||||
|
||||
export type ImportPluginResponse = null | {
|
||||
resources: PartialImportResources;
|
||||
};
|
||||
/** `importer` is omitted because the host fills it in from the plugin's own name. */
|
||||
export type ImportPluginResponse =
|
||||
| null
|
||||
| (Omit<ImportResponse, "importer" | "resources"> & {
|
||||
resources: PartialImportResources;
|
||||
});
|
||||
|
||||
export type ImporterPlugin = {
|
||||
name: string;
|
||||
|
||||
@@ -167,7 +167,9 @@ export class PluginInstance {
|
||||
if (reply != null) {
|
||||
const replyPayload: InternalEventPayload = {
|
||||
type: "import_response",
|
||||
importer: this.#mod.importer.name,
|
||||
resources: reply.resources as ImportResources,
|
||||
sourceKeys: reply.sourceKeys ?? null,
|
||||
};
|
||||
this.#sendPayload(context, replyPayload, replyId);
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { forwardRef } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { LoadingIcon } from "./LoadingIcon";
|
||||
|
||||
type ButtonVariant = "border" | "solid";
|
||||
type ButtonVariant = "border" | "solid" | "input";
|
||||
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
|
||||
|
||||
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
|
||||
@@ -88,6 +88,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
resolvedColor !== "custom" &&
|
||||
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
|
||||
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
|
||||
// Chrome of a form input rather than a button: no hover state, and colors resolve from the
|
||||
// surrounding x-theme-input rather than a button theme.
|
||||
variant === "input" && "border-border text-text",
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -959,6 +959,10 @@ describe("importer-curl", () => {
|
||||
{ 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>> = {};
|
||||
|
||||
@@ -15,6 +15,21 @@ export function convertId(id: string): string {
|
||||
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) {
|
||||
const { headers } = importHeaders(obj);
|
||||
const { body, bodyType } = importHttpBody(obj.body);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
/* oxlint-disable no-explicit-any */
|
||||
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) {
|
||||
if (!Array.isArray(parsed.resources)) return null;
|
||||
|
||||
const keys = createSourceKeys();
|
||||
const resources: PartialImportResources = {
|
||||
environments: [],
|
||||
folders: [],
|
||||
@@ -20,7 +28,7 @@ export function convertInsomniaV4(parsed: any) {
|
||||
);
|
||||
for (const w of workspacesToImport) {
|
||||
resources.workspaces.push({
|
||||
id: convertId(w._id),
|
||||
id: keys.own(w._id),
|
||||
createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined,
|
||||
model: "workspace",
|
||||
@@ -31,7 +39,7 @@ export function convertInsomniaV4(parsed: any) {
|
||||
(r: any) => isJSObject(r) && r._type === "environment",
|
||||
);
|
||||
resources.environments.push(
|
||||
...environmentsToImport.map((r: any) => importEnvironment(r, w._id)),
|
||||
...environmentsToImport.map((r: any) => importEnvironment(r, w._id, keys)),
|
||||
);
|
||||
|
||||
const nextFolder = (parentId: string) => {
|
||||
@@ -40,12 +48,12 @@ export function convertInsomniaV4(parsed: any) {
|
||||
if (!isJSObject(child)) continue;
|
||||
|
||||
if (child._type === "request_group") {
|
||||
resources.folders.push(importFolder(child, w._id));
|
||||
resources.folders.push(importFolder(child, w._id, keys));
|
||||
nextFolder(child._id);
|
||||
} 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") {
|
||||
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.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 authentication = {};
|
||||
if (r.authentication.type === "bearer") {
|
||||
@@ -80,7 +92,7 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
|
||||
}
|
||||
|
||||
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,
|
||||
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
||||
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 service = parts[0] ?? null;
|
||||
const method = parts[1] ?? null;
|
||||
|
||||
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,
|
||||
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
|
||||
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 {
|
||||
id: convertId(f._id),
|
||||
id: keys.own(f._id),
|
||||
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined,
|
||||
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
|
||||
@@ -147,11 +167,12 @@ function importFolder(f: any, workspaceId: string): PartialImportResources["fold
|
||||
function importEnvironment(
|
||||
e: any,
|
||||
workspaceId: string,
|
||||
keys: SourceKeys,
|
||||
isParentOg?: boolean,
|
||||
): PartialImportResources["environments"][0] {
|
||||
const isParent = isParentOg ?? e.parentId === workspaceId;
|
||||
return {
|
||||
id: convertId(e._id),
|
||||
id: keys.own(e._id),
|
||||
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined,
|
||||
workspaceId: convertId(workspaceId),
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { PartialImportResources } from "@yaakapp/api";
|
||||
import {
|
||||
convertId,
|
||||
convertTemplateSyntax,
|
||||
createSourceKeys,
|
||||
importHeaders,
|
||||
importHttpBodyAndHeaders,
|
||||
isJSObject,
|
||||
type SourceKeys,
|
||||
} from "./common";
|
||||
|
||||
export function convertInsomniaV5(parsed: any) {
|
||||
@@ -18,6 +20,7 @@ export function convertInsomniaV5(parsed: any) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keys = createSourceKeys();
|
||||
const resources: PartialImportResources = {
|
||||
environments: [],
|
||||
folders: [],
|
||||
@@ -30,7 +33,7 @@ export function convertInsomniaV5(parsed: any) {
|
||||
// Import workspaces
|
||||
const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>;
|
||||
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,
|
||||
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined,
|
||||
model: "workspace",
|
||||
@@ -42,8 +45,10 @@ export function convertInsomniaV5(parsed: any) {
|
||||
|
||||
// Import environments
|
||||
resources.environments.push(
|
||||
importEnvironment(parsed.environments, meta.id, true),
|
||||
...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)),
|
||||
importEnvironment(parsed.environments, meta.id, keys, true),
|
||||
...(parsed.environments.subEnvironments ?? []).map((r: any) =>
|
||||
importEnvironment(r, meta.id, keys),
|
||||
),
|
||||
);
|
||||
|
||||
// Import folders
|
||||
@@ -52,16 +57,16 @@ export function convertInsomniaV5(parsed: any) {
|
||||
if (!isJSObject(child)) continue;
|
||||
|
||||
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);
|
||||
if (environment) resources.environments.push(environment);
|
||||
nextFolder(child.children, child.meta.id);
|
||||
} 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) {
|
||||
resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId));
|
||||
resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId, keys));
|
||||
} 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.workspaces = resources.workspaces.filter(Boolean);
|
||||
|
||||
return { resources: convertTemplateSyntax(resources) };
|
||||
return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
|
||||
}
|
||||
|
||||
function importHttpRequest(
|
||||
r: any,
|
||||
workspaceId: string,
|
||||
parentId: string,
|
||||
keys: SourceKeys,
|
||||
): PartialImportResources["httpRequests"][0] {
|
||||
const id = r.meta?.id ?? r._id;
|
||||
const created = r.meta?.created ?? r.created;
|
||||
@@ -89,7 +95,7 @@ function importHttpRequest(
|
||||
const sortKey = r.meta?.sortKey ?? r.sortKey;
|
||||
|
||||
return {
|
||||
id: convertId(id),
|
||||
id: keys.own(id),
|
||||
workspaceId: convertId(workspaceId),
|
||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||
@@ -114,6 +120,7 @@ function importGrpcRequest(
|
||||
r: any,
|
||||
workspaceId: string,
|
||||
parentId: string,
|
||||
keys: SourceKeys,
|
||||
): PartialImportResources["grpcRequests"][0] {
|
||||
const id = r.meta?.id ?? r._id;
|
||||
const created = r.meta?.created ?? r.created;
|
||||
@@ -126,7 +133,7 @@ function importGrpcRequest(
|
||||
|
||||
return {
|
||||
model: "grpc_request",
|
||||
id: convertId(id),
|
||||
id: keys.own(id),
|
||||
workspaceId: convertId(workspaceId),
|
||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||
@@ -152,6 +159,7 @@ function importWebsocketRequest(
|
||||
r: any,
|
||||
workspaceId: string,
|
||||
parentId: string,
|
||||
keys: SourceKeys,
|
||||
): PartialImportResources["websocketRequests"][0] {
|
||||
const id = r.meta?.id ?? r._id;
|
||||
const created = r.meta?.created ?? r.created;
|
||||
@@ -160,7 +168,7 @@ function importWebsocketRequest(
|
||||
|
||||
return {
|
||||
model: "websocket_request",
|
||||
id: convertId(id),
|
||||
id: keys.own(id),
|
||||
workspaceId: convertId(workspaceId),
|
||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||
@@ -198,6 +206,7 @@ function importFolder(
|
||||
f: any,
|
||||
workspaceId: string,
|
||||
parentId: string,
|
||||
keys: SourceKeys,
|
||||
): {
|
||||
folder: PartialImportResources["folders"][0];
|
||||
environment: PartialImportResources["environments"][0] | null;
|
||||
@@ -210,7 +219,7 @@ function importFolder(
|
||||
let environment: PartialImportResources["environments"][0] | null = null;
|
||||
if (Object.keys(f.environment ?? {}).length > 0) {
|
||||
environment = {
|
||||
id: convertId(`${id}folder`),
|
||||
id: keys.own(`${id}folder`),
|
||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||
workspaceId: convertId(workspaceId),
|
||||
@@ -230,7 +239,7 @@ function importFolder(
|
||||
return {
|
||||
folder: {
|
||||
model: "folder",
|
||||
id: convertId(id),
|
||||
id: keys.own(id),
|
||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||
folderId: parentId === workspaceId ? null : convertId(parentId),
|
||||
@@ -248,6 +257,7 @@ function importFolder(
|
||||
function importEnvironment(
|
||||
e: any,
|
||||
workspaceId: string,
|
||||
keys: SourceKeys,
|
||||
isParent?: boolean,
|
||||
): PartialImportResources["environments"][0] {
|
||||
const id = e.meta?.id ?? e._id;
|
||||
@@ -256,7 +266,7 @@ function importEnvironment(
|
||||
const sortKey = e.meta?.sortKey ?? e.sortKey;
|
||||
|
||||
return {
|
||||
id: convertId(id),
|
||||
id: keys.own(id),
|
||||
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
|
||||
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
|
||||
workspaceId: convertId(workspaceId),
|
||||
|
||||
@@ -132,5 +132,13 @@
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"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));
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -110,6 +110,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
|
||||
const folderIdsByTag = new Map<string, string>();
|
||||
const routeLabels = new Map<string, string>();
|
||||
const sourceKeys: Record<string, string> = {};
|
||||
for (const tag of toArray(spec.tags)) {
|
||||
const tagRecord = toRecord(tag);
|
||||
const name = stringAt(tagRecord, "name");
|
||||
@@ -126,6 +127,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
};
|
||||
resources.folders.push(folder);
|
||||
folderIdsByTag.set(name, folder.id);
|
||||
sourceKeys[folder.id] = tagSourceKey(name);
|
||||
}
|
||||
|
||||
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
|
||||
@@ -139,6 +141,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
importState,
|
||||
operation,
|
||||
resources,
|
||||
sourceKeys,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
@@ -160,6 +163,11 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
authenticationVariables,
|
||||
});
|
||||
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
|
||||
sourceKeys[request.id] = operationSourceKey(
|
||||
stringAt(operation, "operationId"),
|
||||
method,
|
||||
rawPath,
|
||||
);
|
||||
resources.httpRequests.push(request);
|
||||
}
|
||||
}
|
||||
@@ -241,6 +249,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
websocketRequests: [],
|
||||
workspaces: resources.workspaces,
|
||||
}) as PartialImportResources,
|
||||
sourceKeys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -681,12 +690,14 @@ function findOrCreateFolderId({
|
||||
importState,
|
||||
operation,
|
||||
resources,
|
||||
sourceKeys,
|
||||
workspaceId,
|
||||
}: {
|
||||
folderIdsByTag: Map<string, string>;
|
||||
importState: ImportState;
|
||||
operation: UnknownRecord;
|
||||
resources: ImportResources;
|
||||
sourceKeys: Record<string, string>;
|
||||
workspaceId: string;
|
||||
}): string | null {
|
||||
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
|
||||
@@ -705,9 +716,20 @@ function findOrCreateFolderId({
|
||||
};
|
||||
resources.folders.push(folder);
|
||||
folderIdsByTag.set(tag, folder.id);
|
||||
sourceKeys[folder.id] = tagSourceKey(tag);
|
||||
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
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
|
||||
test("Keys operations by operationId, unchanged by a rename", async () => {
|
||||
const spec = (summary: string) =>
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Keys", version: "1" },
|
||||
paths: {
|
||||
"/pets": {
|
||||
get: { operationId: "listPets", summary, tags: ["pets"], responses: {} },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const before = await convertOpenApi(spec("List pets"));
|
||||
const after = await convertOpenApi(spec("Fetch every pet"));
|
||||
|
||||
expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("op:listPets");
|
||||
expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("op:listPets");
|
||||
expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("tag:pets");
|
||||
});
|
||||
|
||||
test("Falls back to the route when an operation has no operationId", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.0",
|
||||
info: { title: "Keys", version: "1" },
|
||||
paths: { "/pets/{id}": { delete: { summary: "Remove", responses: {} } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.sourceKeys?.[imported.resources.httpRequests[0]!.id]).toBe(
|
||||
"route:DELETE /pets/{id}",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,12 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
||||
|
||||
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 = {
|
||||
workspaces: [],
|
||||
environments: [],
|
||||
@@ -63,6 +69,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
||||
description: importDescription(info.description),
|
||||
...globalAuth,
|
||||
};
|
||||
trackSourceKey(workspace.id, info, "collection");
|
||||
exportResources.workspaces.push(workspace);
|
||||
|
||||
// Create the base environment
|
||||
@@ -92,6 +99,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
||||
name: v.name,
|
||||
folderId,
|
||||
};
|
||||
trackSourceKey(folder.id, v, "item");
|
||||
exportResources.folders.push(folder);
|
||||
for (const child of v.item) {
|
||||
importItem(child, folder.id);
|
||||
@@ -142,6 +150,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
||||
headers,
|
||||
...requestAuth,
|
||||
};
|
||||
trackSourceKey(request.id, v, "item");
|
||||
exportResources.httpRequests.push(request);
|
||||
} else {
|
||||
console.log("Unknown item", v, folderId);
|
||||
@@ -156,7 +165,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
||||
convertTemplateSyntax(exportResources),
|
||||
) as PartialImportResources;
|
||||
|
||||
return { resources };
|
||||
return { resources, sourceKeys };
|
||||
}
|
||||
|
||||
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
|
||||
|
||||
@@ -300,5 +300,8 @@
|
||||
}
|
||||
],
|
||||
"folders": []
|
||||
},
|
||||
"sourceKeys": {
|
||||
"GENERATE_ID::WORKSPACE_0": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,5 +88,8 @@
|
||||
"folderId": "GENERATE_ID::FOLDER_0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceKeys": {
|
||||
"GENERATE_ID::WORKSPACE_1": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user