Compare commits

...
Author SHA1 Message Date
Gregory Schier 646f5a09b0 fix(models): let a snapshot race resolve to the version that won
Two sends of the same request can both miss the content-hash lookup and race to
insert. The unique index settles it; the loser wants exactly what the winner
wrote, not an error that costs its response a version link.
2026-09-05 21:24:41 -07:00
Gregory Schier e47088b507 docs: update the request versioning plan with what shipped 2026-09-05 21:23:39 -07:00
Gregory Schier 3ad6172bb1 test(send): cover the version link end to end
Drives the real HTTP pipeline against a stub executor and a real database, so
the assertion is the one the feature actually rests on: a stored send leaves a
response naming the request behind it, two sends of an unchanged request name
the same version, and editing afterwards is something the response pane can
detect.
2026-09-05 21:23:04 -07:00
Gregory Schier 7bd159b0b1 feat(client): offer the request a response was sent from
When the selected response's version no longer matches the live request, the
response header grows a "Request Changed" control with View Diff and Restore.
It follows the GraphQL editor's shape — a state-labelled dropdown button rather
than a new icon in the action row — because the label is the whole point: it
has to say *that* something is different before it offers to do anything about
it. Hidden when the two agree, and for responses recorded before versioning
existed, which have no version and never will.

The diff reuses the Git dialog's DiffViewer over YAML renderings of the two
documents, so a request diff reads like a commit diff.

Restore flushes pending edits first (the backend restores from the database, so
a debounced keystroke would otherwise land on top of it) and calls
wasUpdatedExternally afterwards, because the write came from this window and
the store's echo suppression would leave open editors showing the old content.

EditSessionTracker turns the boundaries only the frontend can see — switching
requests, losing focus, closing, falling idle for 60s — into snapshot calls. It
tracks no content of its own: content addressing already decides whether a
boundary had anything behind it, which is what keeps this a timer and two
assignments instead of a change-tracking system.
2026-09-05 21:20:20 -07:00
Gregory Schier b08b3277da feat(rpc): expose snapshot, compare and restore for request versions
Three commands, all host-independent so the browser build answers them from
the same model layer as the desktop:

- models_snapshot_request, for the edit-session boundaries only the frontend
  can see. It takes a reason and nothing else — the caller does not decide
  whether anything changed, because content addressing already has.
- models_request_version, which returns a version and the live request's
  content together so they are guaranteed comparable, plus the same
  content-hash verdict the backend uses rather than a second opinion formed in
  TypeScript.
- models_restore_request_version.

The browser host also snapshots before its own send, since its send pipeline
is in TypeScript rather than in the shared crate.

Bindings regenerated with CI's `cargo test --all --features
yaak-app-client/wry`, which also drops a stale ImportSourceResource from the
rpc-schema copy and adds a missing ImportSource to the plugins copy.
2026-09-05 21:16:29 -07:00
Gregory Schier 19a43e3785 feat(send): link every response to the request version that produced it
HTTP snapshots in resolve_send_inputs, the last point in the pipeline that
still holds the stored request — below it the request has been resolved against
its folder and workspace and then rendered, and neither is what a restore
should put back. Every host reaches sending through that function, so the
desktop, the CLI and plugin-triggered sends all get versions without each
knowing about them. gRPC and WebSocket connect do the same at their connection
upserts.

snapshot_request_for_send swallows its own errors: a send is not worth failing
over history that couldn't be written, and an ephemeral request has no id to
version. Either way the response just has no version to offer.
2026-09-05 21:05:34 -07:00
Gregory Schier 77fe1367a0 feat(models): add content-addressed request versions
One table versions HTTP, gRPC and WebSocket requests alike. A version stores
the model's editable content — the serialized model minus model/id/timestamps/
workspace/folder/sortPriority — and is addressed by the sha256 of that
document, with (model_id, content_hash) unique.

Content addressing is what makes the trigger side simple: snapshot_request can
be called by a send, a window blur and an idle timer on the same unedited
request and leave one row behind, so no caller has to reason about whether
anything changed. Dropping bookkeeping keys is what makes it stable — moving a
request between folders or re-sorting it rewrites those fields and nothing
else, and neither should mint a version or show up in a diff. The same rule
covers all three request types because they differ only in content fields.

Responses and gRPC/WebSocket connections gain a nullable version_id.

Versions are local history, so ModelVersion is deliberately absent from
AnyModel: no model-change rows, no frontend store bucket, no sync, no export.
Retention keeps anything a response points at, plus the newest 50 per request
within 30 days; request and workspace deletes cascade.
2026-09-05 21:01:14 -07:00
Gregory Schier 862fb6d65a docs: plan for request versioning 2026-09-05 20:55:59 -07:00
Gregory Schier d2d4b80a09 feat(import): merge re-imports from a linked source instead of duplicating (#618) 2026-09-01 11:12:07 -07:00
Gregory Schier d461c982ec Don't block window creation on plugin runtime boot (#616) 2026-09-01 09:14:50 -07:00
Gregory SchierandClaude Fable 5 8eebee460e fix(pair-editor): remount the editor when its stateKey changes (#617)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 08:58:55 -07:00
Gregory SchierandClaude Opus 5 81a2a5d955 feat(import): add stable per-resource source keys to the importer contract (#614)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:11:54 -07:00
Gregory SchierandClaude Opus 5 e63a87718c fix(tooling): keep the formatter off generated files in every entry point (#615)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:40:42 -07:00
Gregory Schier 222ea9cb83 fix(editor): opt editors out of macOS Writing Tools suggestions
A "Write with Siri" pill appears over empty editors on the macOS 27 beta,
because Codemirror's contentDOM is a contenteditable like any other. The
`writingsuggestions` attribute opts an editable element out, and setting
it here covers every editor at once.

If the pill turns out to be separate from writing suggestions, the
remaining lever is WKWebView's writingToolsBehavior, which would disable
Writing Tools for the whole window.
2026-08-31 10:55:05 -07:00
Gregory Schier 360c098b09 fix(grpc): remove the dead Refresh item from the method picker
It has never had an onSelect. Introduced inert in #193 and carried
through the codebase split, so clicking it only ever closed the dropdown.
Reload Schema in the message editor's schema dropdown does the job it
looked like it was for.
2026-08-31 10:54:40 -07:00
Gregory Schier fce1039bac feat(grpc): generate an example message from the method schema (#613) 2026-08-31 10:44:31 -07:00
Gregory Schier f18f93d613 Stage imports before committing (#571) 2026-08-31 10:20:44 -07:00
Gregory SchierandClaude Opus 5 661a384bed Faster codemirror search match counting (#612)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:09:50 -07:00
105 changed files with 7304 additions and 591 deletions
-3
View File
@@ -1,3 +0,0 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
Generated
+2
View File
@@ -11223,8 +11223,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"chrono",
"log 0.4.29", "log 0.4.29",
"md5 0.8.0", "md5 0.8.0",
"rusqlite",
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
+101
View File
@@ -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.
+180 -63
View File
@@ -1,9 +1,8 @@
import { linter } from "@codemirror/lint"; import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view"; import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc"; import { jsoncLanguage } from "@shopify/lang-jsonc";
import type { GrpcRequest } from "@yaakapp-internal/models"; import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { import {
handleRefresh, handleRefresh,
jsonCompletion, jsonCompletion,
@@ -11,12 +10,20 @@ import {
stateExtensions, stateExtensions,
updateSchema, updateSchema,
} from "codemirror-json-schema"; } from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc"; import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert"; import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor"; import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog"; import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
protoFiles: string[]; protoFiles: string[];
}; };
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({ export function GrpcEditor({
services, services,
reflectionError, reflectionError,
@@ -42,21 +54,16 @@ export function GrpcEditor({
setEditorView(h); setEditorView(h);
}, []); }, []);
// Find the schema for the selected service and method and update the editor // Find the schema for the selected service and method
useEffect(() => { const methodSchema = useMemo<MethodSchema>(() => {
if ( if (services === null || request.service === null || request.method === null) {
editorView == null || return { type: "none" };
services === null ||
request.service === null ||
request.method === null
) {
return;
} }
const s = services.find((s) => s.name === request.service); const s = services.find((s) => s.name === request.service);
if (s == null) { if (s == null) {
console.log("Failed to find service", { service: request.service, services }); return {
showAlert({ type: "error",
id: "grpc-find-service-error", id: "grpc-find-service-error",
title: "Couldn't Find Service", title: "Couldn't Find Service",
body: ( body: (
@@ -64,14 +71,14 @@ export function GrpcEditor({
Failed to find service <InlineCode>{request.service}</InlineCode> in schema Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</> </>
), ),
}); log: ["Failed to find service", { service: request.service, services }],
return; };
} }
const schema = s.methods.find((m) => m.name === request.method)?.schema; const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (request.method != null && schema == null) { if (schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods }); return {
showAlert({ type: "error",
id: "grpc-find-schema-error", id: "grpc-find-schema-error",
title: "Couldn't Find Method", title: "Couldn't Find Method",
body: ( body: (
@@ -80,18 +87,15 @@ export function GrpcEditor({
<InlineCode>{request.service}</InlineCode> in schema <InlineCode>{request.service}</InlineCode> in schema
</> </>
), ),
}); log: ["Failed to find method", { method: request.method, methods: s.methods }],
return; };
}
if (schema == null) {
return;
} }
try { try {
updateSchema(editorView, JSON.parse(schema)); return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
} catch (err) { } catch (err) {
showAlert({ return {
type: "error",
id: "grpc-parse-schema-error", id: "grpc-parse-schema-error",
title: "Failed to Parse Schema", title: "Failed to Parse Schema",
body: ( body: (
@@ -103,9 +107,22 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError> <FormattedError>{String(err)}</FormattedError>
</VStack> </VStack>
), ),
}); log: ["Failed to parse schema", err],
};
} }
}, [editorView, services, request.method, request.service]); }, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
const extraExtensions = useMemo( const extraExtensions = useMemo(
() => [ () => [
@@ -124,45 +141,145 @@ export function GrpcEditor({
const reflectionUnavailable = reflectionError?.match(/unimplemented/i); const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError; reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo( const actions = useMemo(
() => [ () => [
<div key="reflection" className={classNames(services == null && "opacity-100!")}> // Matches the GraphQL editor: one always-visible control labelled by schema state,
<Button // with everything schema-related behind it.
size="xs" <div key="schema" className="opacity-100!">
color={ <Dropdown
reflectionLoading items={[
? "secondary" {
: reflectionUnavailable // Hidden for servers without reflection, which isn't an error
? "info" hidden: !reflectionError,
: reflectionError type: "content",
? "danger" label: (
: "secondary" <Banner color="danger">
} <p className="mb-1">Reflection failed</p>
isLoading={reflectionLoading} <Button
onClick={() => { size="xs"
showDialog({ color="danger"
title: "Configure Schema", variant="border"
size: "md", onClick={handleShowReflectionError}
id: "reflection-failed", >
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />, View Error
}); </Button>
}} </Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
> >
{reflectionLoading <Button
? "Inspecting Schema" size="sm"
: reflectionUnavailable variant="border"
? "Select Proto Files" title="Schema"
: reflectionError forDropdown
? "Server Error" isLoading={reflectionLoading}
: protoFiles.length > 0 color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
? pluralizeCount("File", protoFiles.length) >
: services != null && protoFiles.length === 0 {reflectionLoading
? "Schema Detected" ? "Inspecting Schema"
: "Select Schema"} : reflectionUnavailable
</Button> ? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
</div>, </div>,
], ],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services], [
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
); );
return ( return (
@@ -194,13 +194,6 @@ export function GrpcRequestPane({
type: "default", type: "default",
shortLabel: o.label, shortLabel: o.label,
}))} }))}
itemsAfter={[
{
label: "Refresh",
type: "default",
leftSlot: <Icon size="sm" icon="refresh" />,
},
]}
> >
<Button <Button
size="sm" size="sm"
@@ -30,6 +30,7 @@ import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary"; import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline"; import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown"; import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestVersionDropdown } from "./RequestVersionDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer"; import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies"; import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders"; import { ResponseHeaders } from "./ResponseHeaders";
@@ -263,13 +264,14 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
) : ( ) : (
<span /> <span />
)} )}
<div className="justify-self-end shrink-0"> <HStack space={1} className="justify-self-end shrink-0">
<RequestVersionDropdown response={activeResponse} />
<RecentHttpResponsesDropdown <RecentHttpResponsesDropdown
responses={responses} responses={responses}
activeResponse={activeResponse} activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId} onPinnedResponseId={setPinnedResponseId}
/> />
</div> </HStack>
</div> </div>
)} )}
</HStack> </HStack>
+588 -25
View File
@@ -1,15 +1,38 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useRef, useState } from "react"; import { formatDistanceToNowStrict } from "date-fns";
import { useLocalStorage } from "react-use"; import { useEffect, useMemo, useRef, useState } from "react";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner"; import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props { interface Props {
importFile: (filePath: string) => Promise<void>; currentWorkspace: Workspace | null;
importUrl: (url: string) => Promise<void>; workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
} }
/** /**
@@ -31,10 +54,74 @@ function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path; return path.split(/[/\\]/).at(-1) || path;
} }
export function ImportDataDialog({ importFile, importUrl }: Props) { /**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
* patching it in with effects after the first paint.
*/
export function ImportDataDialog(props: Props) {
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
const { currentWorkspace, listSources } = props;
useEffect(() => {
let cancelled = false;
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
load
.then((sources) => {
if (!cancelled) setInitialSources(sources);
})
.catch(() => {
if (!cancelled) setInitialSources([]);
});
return () => {
cancelled = true;
};
}, [currentWorkspace, listSources]);
if (initialSources == null) return null;
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
listSources,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
);
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import // A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null); const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0); const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false); const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -65,25 +152,270 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
}); });
}, [isHovering, setSource]); }, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
setOriginSources([]);
return;
}
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
})
.catch(() => setOriginSources([]));
}, 300);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [trimmedSource, filePath, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
return workspaces.find((w) => w.id === ids[0]) ?? null;
}, [originSources, workspaces]);
// A file linked to the current workspace defaults back into it, so re-importing doesn't
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
// silently targeting a workspace that is neither new nor current is too surprising. An
// explicit choice always wins.
const destinationKind =
destinationChoice ??
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
const destinationWorkspaceId =
destinationKind === "current"
? (currentWorkspace?.id ?? null)
: destinationKind === "other"
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => { const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false }); const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return; if (selected == null) return;
selectSource(selected); selectSource(selected);
}; };
const handleImport = async () => { // The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
if (filePath != null) { const nextPlan =
await importFile(filePath); filePath != null
} else { ? await planFile(filePath, destination())
await importUrl(trimmedSource); : await planUrl(trimmedSource, destination());
} setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
onError(err);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit({ ...plan, items });
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
// updates toggle together, while removals only ever cascade beneath a removed folder.
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
const targets = new Set(
collectItems(node)
.filter((i) => togglesWith(node.data, i))
.map((i) => i.modelId),
);
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
setItems((prev) =>
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
);
};
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
// new folder can't exist, and a removed folder takes its contents with it.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null || parent.model !== "folder") break;
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
disabled.add(item.modelId);
}
if (parent.action === "delete" && parent.selected && item.action === "delete") {
disabled.add(item.modelId);
}
parentId = parent.parentId;
}
}
return disabled;
}, [items]);
if (plan != null) {
const unchanged = items.filter((i) => i.action === "unchanged");
const footerNote =
unchanged.length > 0
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
: "";
const changeCount = items.filter((item) => {
if (disabledIds.has(item.modelId)) return false;
if (item.action === "conflict") return item.resolution === "take_source";
if (item.action === "unchanged") return false;
return item.selected;
}).length;
const destinationLabel = (() => {
if (plan.destination.type === "new_workspace") return "New workspace";
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name;
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
const planned = plan.resources.workspaces[0];
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
return {
key: existing?.id ?? planned?.id ?? "workspace",
data: {
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
model: "workspace",
modelId: existing?.id ?? planned?.id ?? "workspace",
name: existing?.name ?? planned?.name ?? "New workspace",
selected: true,
},
children: itemTree,
};
})();
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
</div>
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
setPlan(null);
setItems([]);
}}
>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Apply"}
</Button>
</HStack>
</VStack>
);
}
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return ( return (
<VStack ref={ref} space={4} className="pb-4"> <VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" /> <CommercialUseBanner source="data-import" title="Importing work data?" />
@@ -94,7 +426,9 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
className={classNames( className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6", "w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center", "flex flex-col items-center gap-1 text-center",
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text", isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)} )}
> >
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" /> <Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
@@ -115,25 +449,254 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
</div> </div>
</button> </button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}> <VStack space={2}>
<PlainInput <Select
label="Or enter a file path or URL" name="import-destination-kind"
label="Import location"
size="sm" size="sm"
placeholder="https://example.com/openapi.json" value={destinationKind}
defaultValue={source ?? ""} onChange={setDestinationChoice}
forceUpdateKey={String(forceUpdateKey)} options={[
onChange={setSource} { value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/> />
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
{lastImported != null && destinationWorkspaceId != null ? (
<div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
} else {
setDestinationChoice("other");
setOtherWorkspaceId(linkedWorkspace.id);
}
}}
>
{linkedWorkspace.name}
</button>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button <Button
color="primary" color="primary"
disabled={trimmedSource === "" || isLoading} disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading} isLoading={isLoading}
size="sm" onClick={handlePreview}
onClick={handleImport}
> >
{isLoading ? "Importing" : "Import"} {isLoading ? "Analyzing" : "Preview Import"}
</Button> </Button>
</VStack> </HStack>
</VStack> </VStack>
); );
} }
function ImportTreeRow({
item,
onResolveConflict,
}: {
item: ImportPlanItem;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
return (
<>
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
<Icon
color="secondary"
icon={
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0 flex items-center gap-1.5">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
{ value: "keep_mine", label: "Keep mine" },
{ value: "take_source", label: "Take source" },
]}
/>
<IconTooltip content={actionHelp(item)} iconSize="sm" />
</div>
) : (
actionLabel(item) && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
)}
>
{actionLabel(item)}
<IconTooltip content={actionHelp(item)} iconSize="xs" />
</InlineCode>
)
)}
</>
);
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "new";
case "update":
return "updated";
case "delete":
return "removed";
case "keep_local":
return "edited";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "Added since the last import";
case "update":
return "Changed since the last import";
case "delete":
return "Deleted since the last import";
case "keep_local":
return "Local edits made since the last import. Importing will revert them if checked";
case "conflict":
return "Changed both here and in the file since the last import";
default:
return null;
}
}
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = [];
for (const item of items) {
if (item.parentId != null && byId.has(item.parentId)) {
const siblings = childrenOf.get(item.parentId) ?? [];
siblings.push(item);
childrenOf.set(item.parentId, siblings);
} else {
roots.push(item);
}
}
const foldersFirst = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
key: item.modelId,
data: item,
children: seen.has(item.modelId)
? []
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
return foldersFirst(roots).map((r) => toNode(r, new Set()));
}
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
return [node.data, ...node.children.flatMap(collectItems)];
}
/**
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
* (deletions, reverting local edits) never ride along with a parent toggle.
*/
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
if (item.model === "workspace") return false;
if (root.action === "delete") return item.action === "delete";
if (item.action === "keep_local") {
return root.modelId === item.modelId && item.model !== "folder";
}
return item.action === "create" || item.action === "update";
}
function nodeCheckedStatus(
node: CheckboxTreeNode<ImportPlanItem>,
): boolean | "indeterminate" | "hidden" {
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true;
if (selected === 0) return false;
return "indeterminate";
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
@@ -0,0 +1,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));
}
+7 -5
View File
@@ -10,11 +10,13 @@ export interface DialogProps {
children: ReactNode; children: ReactNode;
open: boolean; open: boolean;
onClose?: () => void; onClose?: () => void;
disableBackdropClose?: boolean; /** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode; title?: ReactNode;
description?: ReactNode; description?: ReactNode;
className?: string; className?: string;
size?: DialogSize; size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean; hideX?: boolean;
noPadding?: boolean; noPadding?: boolean;
noScroll?: boolean; noScroll?: boolean;
@@ -27,7 +29,7 @@ export function Dialog({
size = "full", size = "full",
open, open,
onClose, onClose,
disableBackdropClose, disableClose,
title, title,
description, description,
hideX, hideX,
@@ -42,7 +44,7 @@ export function Dialog({
); );
return ( return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog"> <Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div <div
role="dialog" role="dialog"
className={classNames( className={classNames(
@@ -58,7 +60,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple // NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed. // dialogs and can be intercepted by children if needed.
if (e.key === "Escape") { if (e.key === "Escape") {
onClose?.(); if (!disableClose) onClose?.();
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
} }
@@ -110,7 +112,7 @@ export function Dialog({
</div> </div>
{/*Put close at the end so that it's the last thing to be tabbed to*/} {/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && ( {!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1"> <div className="ml-auto absolute right-1 top-1">
<IconButton <IconButton
className="opacity-70 hover:opacity-100" className="opacity-70 hover:opacity-100"
@@ -601,6 +601,8 @@ function getExtensions({
EditorView.contentAttributes.of({ EditorView.contentAttributes.of({
autocapitalize: "off", autocapitalize: "off",
autocorrect: "off", autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}), }),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: () => {
@@ -0,0 +1,202 @@
import { SearchQuery } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { describe, expect, test } from "vite-plus/test";
import {
currentMatch,
literalSearch,
MAX_COUNT,
MatchCounter,
normalizeDoc,
normalizeSearch,
scanNormalized,
scanQuery,
} from "./searchMatchCount";
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
const stateOf = (doc: string) => EditorState.create({ doc });
/**
* The matches the counter finds, having checked them against the search panel's own cursor.
*
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
*/
function matchesOf(doc: string, config: QueryConfig) {
const state = stateOf(doc);
const query = new SearchQuery(config);
const matches = new MatchCounter().matches(state, query);
expect(matches).toEqual(scanQuery(state, query));
return matches;
}
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
describe("counting", () => {
test("counts every match, whatever the case", () => {
expect(countOf("one Two three two", { search: "two" })).toBe(2);
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
});
test("skips matches overlapping an earlier one", () => {
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
expect(countOf("ababa", { search: "aba" })).toBe(1);
});
test("treats a query as text, not as a pattern", () => {
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
});
test("unquotes escapes unless the query is literal", () => {
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
});
test("counts regexp and whole word queries through the cursor", () => {
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
});
test("stops counting at the cap", () => {
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
});
test("reports where the matches are", () => {
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
{ from: 0, to: 2 },
{ from: 4, to: 6 },
]);
});
test("finds nothing to match with an empty needle", () => {
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
});
});
describe("normalization", () => {
test("finds what a character decomposes into", () => {
// The é is one character holding an `e`, and the match covers the whole of it
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
expect(countOf("one two", { search: "one two" })).toBe(1);
expect(countOf("full width", { search: "full" })).toBe(1);
});
test("matches a decomposed query against composed text, and the reverse", () => {
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
});
test("keeps offsets straight after an expansion", () => {
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
});
test("normalizes the query whole, the document by character", () => {
expect(normalizeSearch("CAFÉ", false)).toBe("café");
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
});
test("leaves a document that normalizes to itself untouched", () => {
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
expect(text).toBe("plain 日本 🎉 text");
expect(expansions).toEqual([]);
});
});
describe("current match", () => {
const matches = [
{ from: 0, to: 2 },
{ from: 4, to: 6 },
{ from: 8, to: 10 },
];
test("counts from one, and reports 0 off a match", () => {
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
});
test("moving the selection doesn't scan again", () => {
const state = stateOf("a1 b2 c3");
const query = new SearchQuery({ search: "\\d", regexp: true });
const counter = new MatchCounter();
const found = counter.matches(state, query);
// The document a selection-only transaction leaves behind is the one already scanned
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
expect(counter.matches(moved, query)).toBe(found);
expect(currentMatch(found, moved.selection.main)).toBe(2);
});
});
/**
* The mapping from normalized offsets back to document offsets is the part of this that can go
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
*/
describe("against the cursor, on awkward text", () => {
const ALPHABET = [
..."abcABC .\\\n".split(""),
"é",
"é",
"fi",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"",
"①",
"́",
];
/** Seeded, so a failure is the same failure next run */
function random(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
for (const caseSensitive of [false, true]) {
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
const next = random(caseSensitive ? 20260831 : 7);
for (let round = 0; round < 400; round++) {
const doc = Array.from(
{ length: 2 + Math.floor(next() * 60) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
// Half the queries are lifted out of the document, so matches are actually found
const start = Math.floor(next() * doc.length);
const search =
next() < 0.5
? doc.slice(start, start + 1 + Math.floor(next() * 3))
: Array.from(
{ length: 1 + Math.floor(next() * 2) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
if (search === "") continue;
const state = stateOf(doc);
const query = new SearchQuery({ search, caseSensitive });
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
}
});
}
});
@@ -1,7 +1,232 @@
import { getSearchQuery, searchPanelOpen } from "@codemirror/search"; import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state"; import type { EditorState, Extension, Text } from "@codemirror/state";
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
/** Matches are counted no further than this, since an exact total stops being useful long before */
export const MAX_COUNT = 9999;
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
export interface Match {
from: number;
to: number;
}
/** A character whose normalized form is a different length, shifting every offset past it */
interface Expansion {
normFrom: number;
normTo: number;
docFrom: number;
docTo: number;
}
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
export interface NormalizedDoc {
text: string;
expansions: Expansion[];
}
/**
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
* case-sensitive — in a single pass rather than one call per code point.
*
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
* makes counting matches in a large response slow. Doing it a character at a time still matters
* for the result, since it keeps NFKD from reordering marks across characters, so the
* granularity stays and only the repeated work goes: each distinct character is normalized once
* and the answer reused, and ASCII runs never reach ICU at all.
*/
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
const rewritten = new Map<string, string>();
const expansions: Expansion[] = [];
let shift = 0;
const normalized = text.replace(
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
(chunk: string, at: number) => {
// An ASCII run only ever folds case, which can't change its length
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
let out = rewritten.get(chunk);
if (out === undefined) {
out = chunk.normalize("NFKD");
if (!caseSensitive) out = out.toLowerCase();
rewritten.set(chunk, out);
}
if (out.length !== chunk.length) {
expansions.push({
normFrom: at + shift,
normTo: at + shift + out.length,
docFrom: at,
docTo: at + chunk.length,
});
shift += out.length - chunk.length;
}
return out;
},
);
return { text: normalized, expansions };
}
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
export function normalizeSearch(search: string, caseSensitive: boolean): string {
const normalized = search.normalize("NFKD");
return caseSensitive ? normalized : normalized.toLowerCase();
}
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
const matches: Match[] = [];
if (needle === "") return matches;
const { text } = doc;
let pos = text.indexOf(needle);
while (pos >= 0) {
let end = pos + needle.length;
// However the query was cut, a match ends on a whole code point, as the cursor's do
if (isLowSurrogate(text.charCodeAt(end))) end++;
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
if (matches.length > MAX_COUNT) break;
pos = text.indexOf(needle, resumeAfter(doc, end));
}
return matches;
}
/** The same through the query's own cursor, which handles regexps and whole words */
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
const matches: Match[] = [];
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
matches.push({ from: result.value.from, to: result.value.to });
if (matches.length > MAX_COUNT) break;
}
return matches;
}
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
/** The last character expansion beginning at or before `offset`, if there is one */
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
let low = 0;
let high = expansions.length - 1;
let found: Expansion | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
if (expansions[mid]!.normFrom <= offset) {
found = expansions[mid]!;
low = mid + 1;
} else {
high = mid - 1;
}
}
return found;
}
function docStart(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
// A match starting inside a character's expansion starts at the character
return offset < expansion.normTo
? expansion.docFrom
: offset - (expansion.normTo - expansion.docTo);
}
/**
* Where scanning picks up after a match ending at `offset`.
*
* The cursor moves through the document a character at a time, so once a match ends inside a
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
* ever counts as one match of ".".
*/
function resumeAfter(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
? expansion.normTo
: offset;
}
function docEnd(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
if (offset <= expansion.normFrom) return expansion.docFrom;
// A match ending inside a character's expansion covers the whole character
return offset < expansion.normTo
? expansion.docTo
: offset - (expansion.normTo - expansion.docTo);
}
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
let index = 0;
for (const match of matches) {
index++;
if (match.from <= selection.from && match.to >= selection.to) return index;
}
return 0;
}
/** The text a plain query looks for, or null when only the cursor can answer it */
export function literalSearch(query: SearchQuery): string | null {
if (query.regexp || query.wholeWord || query.test != null) return null;
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
return query.literal
? query.search
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
);
}
/**
* Finds the matches for the search panel, keeping the normalized document and the matches it
* last found, so neither moving the selection nor typing another character starts over.
*/
export class MatchCounter {
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
matches(state: EditorState, query: SearchQuery): Match[] {
const last = this.last;
if (last != null && last.doc === state.doc && last.query.eq(query)) {
return last.matches;
}
const matches = this.scan(state, query);
this.last = { doc: state.doc, query, matches };
return matches;
}
private scan(state: EditorState, query: SearchQuery): Match[] {
const search = literalSearch(query);
if (search == null) return scanQuery(state, query);
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
}
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
const cached = this.doc;
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
return cached.normalized;
}
const normalized = normalizeDoc(doc.toString(), caseSensitive);
this.doc = { doc, caseSensitive, normalized };
return normalized;
}
}
/** /**
* A CodeMirror extension that displays the total number of search matches * A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel. * inside the built-in search panel.
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
return ViewPlugin.fromClass( return ViewPlugin.fromClass(
class { class {
private countEl: HTMLElement | null = null; private countEl: HTMLElement | null = null;
private counter = new MatchCounter();
constructor(private view: EditorView) { constructor(private view: EditorView) {
this.updateCount(); this.updateCount();
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
} }
this.ensureCountEl(); this.ensureCountEl();
if (this.countEl == null) return;
if (!query.search) { if (!query.search) {
if (this.countEl) { this.countEl.textContent = "0/0";
this.countEl.textContent = "0/0";
}
return; return;
} }
const selection = state.selection.main; const matches = this.counter.matches(state, query);
let count = 0; if (matches.length > MAX_COUNT) {
let currentIndex = 0; this.countEl.textContent = `${MAX_COUNT}+`;
const MAX_COUNT = 9999; } else if (matches.length === 0) {
const cursor = query.getCursor(state); this.countEl.textContent = "0/0";
for (let result = cursor.next(); !result.done; result = cursor.next()) { } else {
count++; const current = currentMatch(matches, state.selection.main);
const match = result.value; this.countEl.textContent = `${current}/${matches.length}`;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
} }
} }
@@ -113,7 +113,15 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
/** Max number of pairs to show before prompting the user to reveal the rest */ /** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30; const MAX_INITIAL_PAIRS = 30;
export function PairEditor({ // Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
// fast path below would swap in the new data without rebuilding the row editors, leaving any
// still-focused input showing the old owner's text.
export function PairEditor(props: PairEditorProps) {
return <PairEditorInner key={props.stateKey} {...props} />;
}
function PairEditorInner({
allowFileValues, allowFileValues,
allowMultilineValues, allowMultilineValues,
className, className,
+6 -2
View File
@@ -119,9 +119,13 @@ export function Select<T extends string>({
)} )}
> >
<Button <Button
className="w-full text-sm font-mono" className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start" justify="start"
variant="border" variant="input"
size={size} size={size}
leftSlot={leftSlot} leftSlot={leftSlot}
disabled={disabled} disabled={disabled}
@@ -21,6 +21,8 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox"; import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
import { CheckboxTree } from "../core/CheckboxTree";
import { DiffViewer } from "../core/Editor/DiffViewer"; import { DiffViewer } from "../core/Editor/DiffViewer";
import { Input } from "../core/Input"; import { Input } from "../core/Input";
import { Separator } from "../core/Separator"; import { Separator } from "../core/Separator";
@@ -43,10 +45,7 @@ interface CommitTreeNode {
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const callbacks = useGitCallbacks(syncDir); const callbacks = useGitCallbacks(syncDir);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit( const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
syncDir,
callbacks,
);
const [isPushing, setIsPushing] = useState(false); const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null); const [commitError, setCommitError] = useState<string | null>(null);
const [message, setMessage] = useState<string>(""); const [message, setMessage] = useState<string>("");
@@ -143,6 +142,15 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return next(workspace, []); return next(workspace, []);
}, [workspace, internalEntries]); }, [workspace, internalEntries]);
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
key: n.status.relaPath + n.status.status + n.status.staged,
data: n,
children: n.children.map(toTreeNode),
});
return tree == null ? null : toTreeNode(tree);
}, [tree]);
const checkNode = useCallback( const checkNode = useCallback(
(treeNode: CommitTreeNode) => { (treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode); const checked = nodeCheckedStatus(treeNode);
@@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
[restore], [restore],
); );
if (tree == null) { if (tree == null || treeNode == null) {
return null; return null;
} }
@@ -221,12 +229,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
style={innerStyle} style={innerStyle}
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu" className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
> >
<TreeNodeChildren <CheckboxTree
node={tree} node={treeNode}
depth={0} checked={(n) => nodeCheckedStatus(n.data)}
onCheck={checkNode} onCheck={(n) => checkNode(n.data)}
onSelect={handleSelectChild} checkboxTitle={(n) =>
selectedPath={selectedEntry?.relaPath ?? null} nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
}
isRelevant={(n) => n.data.status.status !== "current"}
canSelectRow={(n) => n.data.status.status !== "current"}
onSelectRow={(n) => handleSelectChild(n.data.status)}
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
renderRow={(n) => <CommitTreeRow node={n.data} />}
/> />
{externalEntries.find((e) => e.status !== "current") && ( {externalEntries.find((e) => e.status !== "current") && (
<> <>
@@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div> </div>
)} )}
secondSlot={({ style: innerStyle }) => ( secondSlot={({ style: innerStyle }) => (
<div <div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
style={innerStyle}
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
>
<Input <Input
className="text-base! font-sans rounded-md" className="text-base! font-sans rounded-md"
placeholder="Commit message..." placeholder="Commit message..."
@@ -301,96 +312,39 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
); );
} }
function TreeNodeChildren({ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
node,
depth,
onCheck,
onSelect,
selectedPath,
}: {
node: CommitTreeNode | null;
depth: number;
onCheck: (node: CommitTreeNode, checked: boolean) => void;
onSelect: (entry: GitStatusEntry) => void;
selectedPath: string | null;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node);
const isSelected = selectedPath === node.status.relaPath;
return ( return (
<div <>
className={classNames( {node.model.model !== "http_request" &&
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative", node.model.model !== "grpc_request" &&
)} node.model.model !== "websocket_request" ? (
> <Icon
<div color="secondary"
className={classNames( icon={
"relative flex gap-1 w-full h-xs items-center", node.model.model === "folder"
isSelected ? "text-text" : "text-text-subtle", ? "folder"
)} : node.model.model === "environment"
> ? "variable"
{isSelected && ( : "house"
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" /> }
)}
<Checkbox
checked={checked}
title={checked ? "Unstage change" : "Stage change"}
hideLabel
onChange={(checked) => onCheck(node, checked)}
/> />
<button ) : (
type="button" <span aria-hidden className="w-4" />
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")} )}
onClick={() => node.status.status !== "current" && onSelect(node.status)} <div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
> >
{node.model.model !== "http_request" && {node.status.status}
node.model.model !== "grpc_request" && </InlineCode>
node.model.model !== "websocket_request" ? ( )}
<Icon </>
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
)}
</button>
</div>
{node.children.map((childNode) => {
return (
<TreeNodeChildren
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
node={childNode}
depth={depth + 1}
onCheck={onCheck}
onSelect={onSelect}
selectedPath={selectedPath}
/>
);
})}
</div>
); );
} }
@@ -495,15 +449,6 @@ function setCheckedAndChildren(
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage }); if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
} }
function isNodeRelevant(node: CommitTreeNode): boolean {
if (node.status.status !== "current") {
return true;
}
// Recursively check children
return node.children.some((c) => isNodeRelevant(c));
}
function DiffPanel({ function DiffPanel({
entry, entry,
onDiscardChanges, onDiscardChanges,
@@ -526,13 +471,11 @@ function DiffPanel({
size="2xs" size="2xs"
variant="border" variant="border"
onClick={() => onDiscardChanges(entry)} onClick={() => onDiscardChanges(entry)}
>Discard Changes</Button> >
Discard Changes
</Button>
</div> </div>
<DiffViewer <DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
original={prevYaml ?? ""}
modified={nextYaml ?? ""}
className="flex-1 min-h-0"
/>
</div> </div>
); );
} }
+1 -3
View File
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
showDialog({ showDialog({
id: "git-diverged", id: "git-diverged",
title: "Branches Diverged", title: "Branches Diverged",
hideX: true,
size: "sm", size: "sm",
disableBackdropClose: true, disableClose: true,
onClose: () => resolve("cancel"),
render: ({ hide }) => render: ({ hide }) =>
DivergedDialog({ DivergedDialog({
remote, remote,
@@ -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! }),
});
}
+1 -2
View File
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({ showDialog({
id, id,
title, title,
hideX: true,
size, size,
disableBackdropClose: true, // Prevent accidental dismisses disableClose: true,
render: ({ hide }) => Alert({ onHide: hide, body }), render: ({ hide }) => Alert({ onHide: hide, body }),
}); });
} }
+1 -2
View File
@@ -18,9 +18,8 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => { return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({ showDialog({
...extraProps, ...extraProps,
hideX: true,
size, size,
disableBackdropClose: true, // Prevent accidental dismisses disableClose: true,
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }), render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
}); });
}); });
@@ -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");
}
}
+49 -17
View File
@@ -1,14 +1,34 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models"; import {
type BatchUpsertResult,
type ImportDestination,
type ImportPlan,
type ImportSource,
workspacesAtom,
} from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui"; import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button"; import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog"; import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation"; import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert"; import { showAlert } from "./alert";
import { showDialog } from "./dialog"; import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize"; import { pluralizeCount } from "./pluralize";
import { router } from "./router"; import { router } from "./router";
import { rpc } from "./rpc"; import { rpc } from "./rpc";
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
// fetches) every time the dialog container re-renders.
const planFile = (filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
const planUrl = (url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination });
const listSources = (workspaceId: string) =>
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
export const importData = createFastMutation({ export const importData = createFastMutation({
mutationKey: ["import_data"], mutationKey: ["import_data"],
onError: (err: string) => { onError: (err: string) => {
@@ -21,29 +41,41 @@ export const importData = createFastMutation({
}, },
mutationFn: async () => { mutationFn: async () => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({ showDialog({
id: "import", id: "import",
title: "Import Data", title: "Import Data",
size: "sm", size: "lg",
disableClose: true,
render: ({ hide }) => { render: ({ hide }) => {
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => { const cancel = () => {
try { hide();
await finishImport(await runImport()); resolve();
resolve(); };
} catch (err) { const fail = (err: unknown) => {
reject(err); hide();
} finally { reject(err);
hide(); };
} const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
}; };
return ( return (
<ImportDataDialog <ImportDataDialog
importFile={(filePath) => currentWorkspace={currentWorkspace}
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath })) workspaces={workspaces}
} selectedFolder={selectedFolder}
importUrl={(url) => planFile={planFile}
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url })) planUrl={planUrl}
} listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
cancel={cancel}
onError={fail}
/> />
); );
}, },
@@ -0,0 +1,251 @@
import { describe, expect, test } from "vite-plus/test";
import type { JsonSchema } from "./jsonSchemaExample";
import { buildExampleFromSchema } from "./jsonSchemaExample";
describe("buildExampleFromSchema", () => {
test("fills scalar fields with placeholders", () => {
const schema: JsonSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number", format: "int32" },
active: { type: "boolean" },
data: { type: "string", format: "byte" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
name: "",
age: 0,
active: false,
data: "",
});
});
test("encodes 64-bit integers as strings", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string", format: "int64" },
count: { type: "string", format: "uint64" },
offset: { type: "string", format: "sfixed64" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
});
test("fills date-time with a parseable timestamp", () => {
const schema: JsonSchema = {
type: "object",
properties: { createdAt: { type: "string", format: "date-time" } },
};
const example = buildExampleFromSchema(schema) as { createdAt: string };
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
});
test("fills a duration with a value that parses", () => {
const schema: JsonSchema = {
type: "object",
properties: { timeout: { type: "string", format: "duration" } },
};
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
});
test("expands nested messages through $defs", () => {
const schema: JsonSchema = {
type: "object",
properties: { user: { $ref: "#/$defs/example.User" } },
$defs: {
"example.User": {
type: "object",
properties: {
name: { type: "string" },
address: { $ref: "#/$defs/example.Address" },
},
},
"example.Address": {
type: "object",
properties: { city: { type: "string" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
user: { name: "", address: { city: "" } },
});
});
test("gives repeated fields a single placeholder item", () => {
const schema: JsonSchema = {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
unknown: { type: "array" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
tags: [""],
users: [{ name: "" }],
unknown: [],
});
});
test("uses the first value of an enum", () => {
const schema: JsonSchema = {
type: "object",
properties: {
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
empty: { type: "string", enum: [] },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
});
test("gives maps a single placeholder entry", () => {
const schema: JsonSchema = {
type: "object",
properties: {
labels: { type: "object", additionalProperties: { type: "string" } },
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
labels: { key: "" },
users: { key: { name: "" } },
});
});
test("stops at the root self-reference", () => {
const schema: JsonSchema = {
type: "object",
properties: {
value: { type: "string" },
children: { type: "array", items: { $ref: "#" } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
});
test("stops at a cycle between messages", () => {
const schema: JsonSchema = {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
$defs: {
"example.Node": {
type: "object",
properties: {
name: { type: "string" },
parent: { $ref: "#/$defs/example.Node" },
leaf: { $ref: "#/$defs/example.Leaf" },
},
},
"example.Leaf": {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
node: { name: "", parent: {}, leaf: { node: {} } },
});
});
test("expands the same message twice when it is not on the same path", () => {
const schema: JsonSchema = {
type: "object",
properties: {
from: { $ref: "#/$defs/example.User" },
to: { $ref: "#/$defs/example.User" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
});
test("fills every branch of a flattened oneof", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
image: { $ref: "#/$defs/example.Image" },
},
$defs: {
"example.Image": { type: "object", properties: { url: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
id: "",
text: "",
image: { url: "" },
});
});
test("stops expanding once the node budget runs out", () => {
// Every level references the next one twice, so an unbounded walk would build 2^depth
// nodes without ever repeating a ref on the same path.
const depth = 16;
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
for (let i = 0; i < depth; i++) {
$defs[`d${i}`] = {
type: "object",
properties: {
a: { $ref: `#/$defs/d${i + 1}` },
b: { $ref: `#/$defs/d${i + 1}` },
},
};
}
const example = buildExampleFromSchema({
type: "object",
properties: { root: { $ref: "#/$defs/d0" } },
$defs,
});
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
expect(countNodes(example)).toBeLessThan(10_000);
});
test("handles messages without a known type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
empty: {},
struct: { type: "object" },
missing: { $ref: "#/$defs/example.Nope" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
});
});
function countNodes(value: unknown): number {
if (Array.isArray(value)) {
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
}
if (value !== null && typeof value === "object") {
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
}
return 1;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
*/
export type JsonSchema = {
type?: string;
format?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
additionalProperties?: JsonSchema;
enum?: unknown[];
$defs?: Record<string, JsonSchema>;
$ref?: string;
};
const DEFS_PREFIX = "#/$defs/";
const ROOT_REF = "#";
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
// Refs on sibling branches each expand their own subtree, so a schema that references the
// same messages repeatedly can produce exponentially many nodes without ever cycling.
const MAX_NODES = 5000;
type Budget = { remaining: number };
/** Build a sample message with placeholder values for every field in the schema */
export function buildExampleFromSchema(schema: JsonSchema): unknown {
// The root is already being built, so a `#` ref anywhere below it is a cycle
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
}
function buildValue(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
return null;
}
budget.remaining -= 1;
if (typeof schema.$ref === "string") {
if (refPath.has(schema.$ref)) {
return {};
}
const resolved = resolveRef(schema.$ref, root);
if (resolved == null) {
return {};
}
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
}
if (Array.isArray(schema.enum)) {
return schema.enum[0] ?? "";
}
switch (schema.type) {
case "object":
return buildObject(schema, root, refPath, budget);
case "array":
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
case "string":
return buildString(schema.format);
case "number":
return 0;
case "boolean":
return false;
default:
return null;
}
}
function buildObject(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema.properties != null && typeof schema.properties === "object") {
const example: Record<string, unknown> = {};
for (const [name, propertySchema] of Object.entries(schema.properties)) {
example[name] = buildValue(propertySchema, root, refPath, budget);
}
return example;
}
// Maps have no properties, only a value schema
if (schema.additionalProperties != null) {
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
}
return {};
}
function buildString(format: string | undefined): string {
if (format === "date-time") {
return new Date().toISOString();
}
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
if (format === "duration") {
return "0s";
}
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
return "0";
}
return "";
}
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
if (ref === ROOT_REF) {
return root;
}
if (!ref.startsWith(DEFS_PREFIX)) {
return null;
}
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
}
+1 -6
View File
@@ -25,13 +25,8 @@ export async function showPromptForm({
id, id,
title, title,
description, description,
hideX: true,
size: size ?? "sm", size: size ?? "sm",
disableBackdropClose: true, // Prevent accidental dismisses disableClose: true,
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
render: ({ hide }) => render: ({ hide }) =>
Prompt({ Prompt({
onCancel: () => { onCancel: () => {
+50
View File
@@ -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",
});
})(),
);
}
+2
View File
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
import { initGit } from "./init/git"; import { initGit } from "./init/git";
import { initSync } from "./init/sync"; import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners"; import { initGlobalListeners } from "./lib/initGlobalListeners";
import { initRequestVersionSnapshots } from "./lib/requestVersions";
import { jotaiStore } from "./lib/jotai"; import { jotaiStore } from "./lib/jotai";
import { router } from "./lib/router"; import { router } from "./lib/router";
@@ -36,6 +37,7 @@ initGit();
initSync(); initSync();
initModelStore(jotaiStore); initModelStore(jotaiStore);
initGlobalListeners(); initGlobalListeners();
initRequestVersionSnapshots();
await changeModelStoreWorkspace(null); // Load global models await changeModelStoreWorkspace(null); // Load global models
console.log("Creating React root"); console.log("Creating React root");
@@ -5,16 +5,20 @@ use std::fs;
use std::io::ErrorKind; use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams}; use yaak::export::{self, ExportDataParams};
use yaak::import; use yaak::import;
use yaak_core::WorkspaceContext; use yaak_models::util::{
use yaak_models::util::BatchUpsertResult; BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
};
use yaak_plugins::events::{ImportResources, PluginContext}; use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>; type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 { pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
match import(ctx, args).await { match import(ctx, args).await {
Ok(result) => { Ok((result, items)) => {
println!("Imported {}", format_counts(&result)); println!("Imported {}", format_counts(&result));
if let Some(skipped) = format_skipped(&items) {
println!("Skipped {skipped}");
}
0 0
} }
Err(error) => { Err(error) => {
@@ -37,7 +41,10 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
} }
} }
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> { async fn import(
ctx: &CliContext,
args: ImportArgs,
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>)> {
if let Some(workspace_id) = args.workspace_id.as_deref() { if let Some(workspace_id) = args.workspace_id.as_deref() {
ctx.db() ctx.db()
.get_workspace(workspace_id) .get_workspace(workspace_id)
@@ -51,6 +58,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.import_data(&plugin_context, &file_contents) .import_data(&plugin_context, &file_contents)
.await .await
.map_err(|e| format!("Failed to import data: {e}"))?; .map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
let resources = import_result.resources; let resources = import_result.resources;
let workspace_id = args.workspace_id; let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) { if workspace_id.is_none() && resources_need_current_workspace(&resources) {
@@ -59,15 +67,58 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.to_string(), .to_string(),
); );
} }
let workspace_context = WorkspaceContext { let destination = match workspace_id {
workspace_id, Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
environment_id: None, None => ImportDestination::NewWorkspace,
cookie_jar_id: None,
request_id: None,
}; };
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources) let plan = import::plan_import_resources(
ctx.query_manager(),
importer,
destination,
resources,
import_result.source_keys,
Some(file_origin(&args.file)),
)
.map_err(|e| format!("Failed to plan import: {e}"))?;
let items = plan.items.clone();
let imported = import::commit_import_plan(ctx.query_manager(), plan)
.map_err(|e| format!("Failed to import data: {e}"))?; .map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported) Ok((imported, items))
}
fn file_origin(path: &std::path::Path) -> ImportOrigin {
let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let label = canonical
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
}
/// Summarize what the default selection left untouched during a merging re-import.
fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
let count = |action: ImportPlanAction| items.iter().filter(|i| i.action == action).count();
let plural = |n: usize| if n == 1 { "" } else { "s" };
let mut parts = Vec::new();
let deletions = count(ImportPlanAction::Delete);
if deletions > 0 {
parts.push(format!("{deletions} removed from source (not deleted locally)"));
}
let conflicts = count(ImportPlanAction::Conflict);
if conflicts > 0 {
parts.push(format!("{conflicts} conflict{} (kept local changes)", plural(conflicts)));
}
let keep_local = count(ImportPlanAction::KeepLocal);
if keep_local > 0 {
parts.push(format!("{keep_local} with local edits"));
}
let unchanged = count(ImportPlanAction::Unchanged);
if unchanged > 0 {
parts.push(format!("{unchanged} unchanged"));
}
if parts.is_empty() { None } else { Some(parts.join(", ")) }
} }
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> { fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
@@ -81,14 +81,21 @@ fn import_reads_yaak_workspace_file() {
let query_manager = query_manager(data_dir); let query_manager = query_manager(data_dir);
let db = query_manager.connect(); let db = query_manager.connect();
assert_eq!( let workspaces = db.list_workspaces().expect("list imported workspaces");
db.get_workspace("wrk_import").expect("workspace imported").name, let workspace = workspaces
"Imported Workspace" .iter()
); .find(|workspace| workspace.name == "Imported Workspace")
assert_eq!( .expect("workspace imported");
db.get_http_request("req_import").expect("request imported").url, assert_ne!(workspace.id, "wrk_import");
"https://example.com"
); let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
let request = requests
.iter()
.find(|request| request.name == "Imported Request")
.expect("request imported");
assert_ne!(request.id, "req_import");
assert_eq!(request.workspace_id, workspace.id);
assert_eq!(request.url, "https://example.com");
} }
fn write_postman_environment_fixture(path: &std::path::Path) { fn write_postman_environment_fixture(path: &std::path::Path) {
@@ -160,3 +167,93 @@ fn import_postman_environment_uses_workspace_id() {
environments.iter().find(|e| e.name == "Local").expect("postman environment imported"); environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
assert_eq!(imported_environment.workspace_id, workspace_id); assert_eq!(imported_environment.workspace_id, workspace_id);
} }
fn write_linked_fixture(path: &std::path::Path, requests: &[(&str, &str, &str)]) {
let requests = requests
.iter()
.map(|(id, name, url)| {
format!(
r#"{{ "model": "http_request", "id": "{id}", "workspaceId": "wrk_link",
"name": "{name}", "method": "GET", "url": "{url}" }}"#
)
})
.collect::<Vec<_>>()
.join(",");
std::fs::write(
path,
format!(
r#"{{
"yaakVersion": "test",
"yaakSchema": 4,
"resources": {{
"workspaces": [{{ "model": "workspace", "id": "wrk_link", "name": "Linked Workspace" }}],
"httpRequests": [{requests}]
}}
}}"#
),
)
.expect("write linked fixture");
}
#[test]
fn re_import_merges_into_linked_workspace() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("linked.json");
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a"),
("req_b", "Request B", "https://example.com/b"),
],
);
cli_cmd(data_dir)
.args(["import", import_path.to_str().expect("import path is utf-8")])
.assert()
.success()
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
db.list_workspaces()
.expect("list workspaces")
.into_iter()
.find(|w| w.name == "Linked Workspace")
.expect("workspace imported")
.id
};
// The source doc changes A, drops B, and adds C. The default selection applies the
// update and the create but leaves the removal as an offer.
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a-v2"),
("req_c", "Request C", "https://example.com/c"),
],
);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Imported 2 HTTP requests"))
.stdout(contains("Skipped 1 removed from source"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let requests = db.list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 3, "merge must not duplicate: {requests:?}");
assert_eq!(
requests.iter().find(|r| r.name == "Request A").expect("request A").url,
"https://example.com/a-v2"
);
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
assert!(requests.iter().any(|r| r.name == "Request C"));
}
+2 -3
View File
@@ -3,11 +3,10 @@ use std::collections::BTreeMap;
use crate::PluginContextExt; use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use KeyAndValueRef::{Ascii, Binary}; use KeyAndValueRef::{Ascii, Binary};
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Runtime, WebviewWindow};
use yaak_grpc::{KeyAndValueRef, MetadataMap}; use yaak_grpc::{KeyAndValueRef, MetadataMap};
use yaak_models::models::GrpcRequest; use yaak_models::models::GrpcRequest;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader}; use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
use yaak_plugins::manager::PluginManager;
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> { pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
let mut entries = BTreeMap::new(); let mut entries = BTreeMap::new();
@@ -26,7 +25,7 @@ pub(crate) async fn build_metadata<R: Runtime>(
request: &GrpcRequest, request: &GrpcRequest,
authentication_context_id: &str, authentication_context_id: &str,
) -> Result<BTreeMap<String, String>> { ) -> Result<BTreeMap<String, String>> {
let plugin_manager = window.state::<PluginManager>(); let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let mut metadata = BTreeMap::new(); let mut metadata = BTreeMap::new();
// Add the rest of metadata // Add the rest of metadata
@@ -14,7 +14,6 @@ use yaak_http::manager::HttpConnectionManager;
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState}; use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext; use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
/// Context for managing response state during HTTP transactions. /// Context for managing response state during HTTP transactions.
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only). /// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
@@ -149,7 +148,7 @@ async fn send_http_request_inner<R: Runtime>(
response_ctx: &mut ResponseContext<R>, response_ctx: &mut ResponseContext<R>,
) -> Result<SentHttpRequest> { ) -> Result<SentHttpRequest> {
let app_handle = window.app_handle().clone(); let app_handle = window.app_handle().clone();
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let connection_manager = app_handle.state::<HttpConnectionManager>(); let connection_manager = app_handle.state::<HttpConnectionManager>();
let environment_id = environment.map(|e| e.id); let environment_id = environment.map(|e| e.id);
+53 -22
View File
@@ -4,53 +4,84 @@ use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string; use std::fs::read_to_string;
use std::io::ErrorKind; use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams}; use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client}; use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_core::WorkspaceContext; use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>( pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
file_path: &str, file_path: &str,
origin: Option<ImportOrigin>,
) -> Result<BatchUpsertResult> { ) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?; let contents = read_import_file(file_path)?;
import_contents(window, &contents).await let plan =
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
commit_import(window, plan)
} }
pub(crate) async fn import_url<R: Runtime>( pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
}
pub(crate) async fn plan_import_url<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
url: &str, url: &str,
) -> Result<BatchUpsertResult> { destination: ImportDestination,
let contents = fetch_import_url(window, url).await?; ) -> Result<ImportPlan> {
import_contents(window, &contents).await let url = normalize_import_url(url)?;
let contents = fetch_import_url(window, &url).await?;
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
} }
async fn import_contents<R: Runtime>( async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>, window: &WebviewWindow<R>,
contents: &str, contents: &str,
) -> Result<BatchUpsertResult> { destination: ImportDestination,
let plugin_manager = window.state::<PluginManager>(); origin: Option<ImportOrigin>,
) -> Result<ImportPlan> {
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let query_manager = window.db_manager(); let query_manager = window.db_manager();
let plugin_context = window.plugin_context(); let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
Ok(import::import_data(ImportDataParams { Ok(import::plan_import_data(PlanImportDataParams {
query_manager: &query_manager, query_manager: &query_manager,
plugin_manager: &plugin_manager, plugin_manager: &plugin_manager,
plugin_context: &plugin_context, plugin_context: &plugin_context,
workspace_context, destination,
contents, contents,
origin,
}) })
.await?) .await?)
} }
/// Canonicalize so re-importing the same file through a different spelling of its path still
/// matches the linked source.
pub(crate) fn file_origin(file_path: &str) -> ImportOrigin {
let path = std::path::Path::new(file_path);
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let label = canonical
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.to_string());
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
}
pub(crate) fn url_origin(url: &str) -> ImportOrigin {
ImportOrigin { origin: url.to_string(), label: url.to_string() }
}
pub(crate) fn commit_import<R: Runtime>(
window: &WebviewWindow<R>,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same /// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk. /// pipeline as a file on disk.
/// ///
@@ -79,7 +110,7 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}"))) .map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
} }
fn normalize_import_url(url: &str) -> Result<String> { pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
let url = url.trim(); let url = url.trim();
if url.is_empty() { if url.is_empty() {
return Err(Error::GenericError("Import URL must not be empty".to_string())); return Err(Error::GenericError("Import URL must not be empty".to_string()));
+37 -14
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map}; use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request; use crate::http_request::send_http_request;
use crate::import::{import_data, import_url}; use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt}; use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier; use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template}; use crate::render::{render_grpc_request, render_template};
@@ -40,12 +40,12 @@ use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace, GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
}; };
use yaak_models::util::{BatchUpsertResult, UpdateSource}; use yaak_models::queries::any_request::AnyRequest;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_plugins::events::{ use yaak_plugins::events::{
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext, Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest, RenderPurpose, ShowToastRequest,
}; };
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse}; use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
use yaak_sse::sse::ServerSentEvent; use yaak_sse::sse::ServerSentEvent;
@@ -250,7 +250,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
let resolved_settings = let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?; app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let req = render_grpc_request( let req = render_grpc_request(
&resolved_request, &resolved_request,
@@ -310,7 +310,7 @@ async fn cmd_grpc_go<R: Runtime>(
let resolved_settings = let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?; app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_grpc_request( let request = render_grpc_request(
&resolved_request, &resolved_request,
@@ -331,6 +331,12 @@ async fn cmd_grpc_go<R: Runtime>(
let settings = app_handle.db().get_settings(); let settings = app_handle.db().get_settings();
let client_cert = find_client_certificate(&request.url, &settings.client_certificates); 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( let conn = app_handle.db().upsert_grpc_connection(
&GrpcConnection { &GrpcConnection {
workspace_id: request.workspace_id.clone(), workspace_id: request.workspace_id.clone(),
@@ -339,6 +345,7 @@ async fn cmd_grpc_go<R: Runtime>(
elapsed: 0, elapsed: 0,
state: GrpcConnectionState::Initialized, state: GrpcConnectionState::Initialized,
url: request.url.clone(), url: request.url.clone(),
version_id,
..Default::default() ..Default::default()
}, },
&UpdateSource::from_window_label(window.label()), &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>( async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response_id: &str, response_id: &str,
filter: Option<&str>, filter: Option<&str>,
) -> YaakResult<FilterResponse> { ) -> YaakResult<FilterResponse> {
@@ -977,7 +983,8 @@ async fn cmd_http_response_body<R: Runtime>(
.ok_or(GenericError("Failed to find response body".to_string()))?; .ok_or(GenericError("Failed to find response body".to_string()))?;
match filter { match filter {
Some(filter) if !filter.is_empty() => Ok(plugin_manager Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window)
.await?
.filter_data(&window.plugin_context(), filter, &body, content_type) .filter_data(&window.plugin_context(), filter, &body, content_type)
.await?), .await?),
_ => Ok(FilterResponse { content: body, error: None }), _ => Ok(FilterResponse { content: body, error: None }),
@@ -1014,15 +1021,24 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>( async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
file_path: &str, file_path: &str,
) -> YaakResult<BatchUpsertResult> { destination: ImportDestination,
import_data(&window, file_path).await ) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
} }
async fn cmd_import_url<R: Runtime>( async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
url: &str, url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
}
async fn cmd_commit_import<R: Runtime>(
window: WebviewWindow<R>,
plan: ImportPlan,
) -> YaakResult<BatchUpsertResult> { ) -> YaakResult<BatchUpsertResult> {
import_url(&window, url).await commit_import(&window, plan)
} }
@@ -1441,7 +1457,10 @@ fn safe_uri(endpoint: &str) -> String {
fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) { fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
let app_handle = app_handle.clone(); let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let plugin_manager: State<'_, PluginManager> = app_handle.state(); let plugin_manager = match plugins_ext::plugin_manager(&app_handle).await {
Ok(pm) => pm,
Err(_) => return, // The runtime failed to boot; there are no events
};
let (rx_id, mut rx) = plugin_manager.subscribe("app").await; let (rx_id, mut rx) = plugin_manager.subscribe("app").await;
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
@@ -1482,9 +1501,13 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
} }
}; };
let plugin_manager: State<'_, PluginManager> = app_handle.state(); match plugins_ext::plugin_manager(&app_handle).await {
if let Err(e) = plugin_manager.reply(&event, &ev).await { Ok(pm) => {
warn!("Failed to reply to plugin manager: {:?}", e) if let Err(e) = pm.reply(&event, &ev).await {
warn!("Failed to reply to plugin manager: {:?}", e)
}
}
Err(e) => warn!("Failed to get plugin manager for reply: {e:?}"),
} }
}); });
} }
@@ -32,7 +32,6 @@ use yaak_plugins::events::{
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent, ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
WorkspaceInfo, WorkspaceInfo,
}; };
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle; use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -205,7 +204,7 @@ async fn handle_host_plugin_request<R: Runtime>(
req.grpc_request.folder_id.as_deref(), req.grpc_request.folder_id.as_deref(),
environment_id.as_deref(), environment_id.as_deref(),
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(
plugin_manager, plugin_manager,
@@ -231,7 +230,7 @@ async fn handle_host_plugin_request<R: Runtime>(
req.http_request.folder_id.as_deref(), req.http_request.folder_id.as_deref(),
environment_id.as_deref(), environment_id.as_deref(),
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(
plugin_manager, plugin_manager,
@@ -267,7 +266,7 @@ async fn handle_host_plugin_request<R: Runtime>(
folder_id.as_deref(), folder_id.as_deref(),
environment_id.as_deref(), environment_id.as_deref(),
)?; )?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new( let cb = PluginTemplateCallback::new(
plugin_manager, plugin_manager,
+77 -18
View File
@@ -31,10 +31,43 @@ use yaak_plugins::api::{
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest}; use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
use yaak_plugins::install::{delete_and_uninstall, download_and_install}; use yaak_plugins::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::plugin_meta::get_plugin_meta; use yaak_plugins::plugin_meta::get_plugin_meta;
static EXITING: AtomicBool = AtomicBool::new(false); static EXITING: AtomicBool = AtomicBool::new(false);
// ============================================================================
// Plugin Manager Handle
// ============================================================================
/// The plugin runtime boots in the background so startup doesn't wait on it.
/// This handle is the only way to reach the manager: [`PluginManagerHandle::get`]
/// resolves once boot completes, so callers can never observe a
/// partially-initialized runtime.
#[derive(Clone)]
pub struct PluginManagerHandle {
rx: tokio::sync::watch::Receiver<Option<std::result::Result<PluginManager, String>>>,
}
impl PluginManagerHandle {
pub async fn get(&self) -> yaak_plugins::error::Result<PluginManager> {
let mut rx = self.rx.clone();
let result = rx
.wait_for(|v| v.is_some())
.await
.map_err(|_| PluginErr("Plugin runtime boot task died".to_string()))?;
result.clone().unwrap().map_err(PluginErr)
}
}
/// Wait for the plugin runtime to finish booting and return the manager.
pub async fn plugin_manager<R: Runtime>(
manager: &impl Manager<R>,
) -> yaak_plugins::error::Result<PluginManager> {
let handle = manager.state::<PluginManagerHandle>().inner().clone();
handle.get().await
}
// ============================================================================ // ============================================================================
// Plugin Updater // Plugin Updater
// ============================================================================ // ============================================================================
@@ -146,7 +179,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
name: &str, name: &str,
version: Option<String>, version: Option<String>,
) -> Result<()> { ) -> Result<()> {
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(plugin_manager(&window).await?);
let app_version = window.app_handle().package_info().version.to_string(); let app_version = window.app_handle().package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?; let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
let query_manager = window.state::<yaak_models::query_manager::QueryManager>(); let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
@@ -167,6 +200,9 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
directory: &str, directory: &str,
) -> Result<Plugin> { ) -> Result<Plugin> {
// Resolve the manager before writing the row so startup's plugin snapshot
// can't include it and boot it a second time
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let plugin = window.db().upsert_plugin( let plugin = window.db().upsert_plugin(
&Plugin { &Plugin {
directory: directory.into(), directory: directory.into(),
@@ -178,7 +214,6 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
)?; )?;
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?; plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
Ok(plugin) Ok(plugin)
@@ -188,7 +223,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
plugin_id: &str, plugin_id: &str,
window: WebviewWindow<R>, window: WebviewWindow<R>,
) -> Result<Plugin> { ) -> Result<Plugin> {
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(plugin_manager(&window).await?);
let query_manager = window.state::<yaak_models::query_manager::QueryManager>(); let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
let plugin_context = window.plugin_context(); let plugin_context = window.plugin_context();
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?) Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
@@ -217,7 +252,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
return Ok(Vec::new()); return Ok(Vec::new());
} }
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(plugin_manager(&window).await?);
let query_manager = window.state::<yaak_models::query_manager::QueryManager>(); let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
let plugin_context = window.plugin_context(); let plugin_context = window.plugin_context();
@@ -300,20 +335,38 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
let query_manager = let query_manager =
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone(); app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
// Create plugin manager asynchronously // Boot the plugin runtime in the background so the window shows
// immediately. Everything that needs plugins resolves the handle,
// which waits for this task to finish.
let (tx, rx) = tokio::sync::watch::channel(None);
app_handle.manage(PluginManagerHandle { rx });
let app_handle_clone = app_handle.clone(); let app_handle_clone = app_handle.clone();
tauri::async_runtime::block_on(async move { tauri::async_runtime::spawn(async move {
let manager = PluginManager::new( let result = tokio::time::timeout(
vendored_plugin_dir, Duration::from_secs(60),
installed_plugin_dir, PluginManager::new(
node_bin_path, vendored_plugin_dir,
plugin_runtime_main, installed_plugin_dir,
&query_manager, node_bin_path,
&PluginContext::new_empty(), plugin_runtime_main,
dev_mode, &query_manager,
&PluginContext::new_empty(),
dev_mode,
),
) )
.await .await
.expect("Failed to start plugin runtime"); .unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
"Timed out starting the plugin runtime".to_string(),
)));
let manager = match result {
Ok(manager) => manager,
Err(e) => {
error!("Failed to start plugin runtime: {e:?}");
let _ = tx.send(Some(Err(e.to_string())));
return;
}
};
// Surface unexpected runtime crashes to the user // Surface unexpected runtime crashes to the user
let mut crash_rx = manager.runtime_crash_rx(); let mut crash_rx = manager.runtime_crash_rx();
@@ -339,7 +392,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
} }
}); });
app_handle_clone.manage(manager); let _ = tx.send(Some(Ok(manager)));
}); });
let plugin_updater = PluginUpdater::new(); let plugin_updater = PluginUpdater::new();
@@ -355,8 +408,14 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
api.prevent_exit(); api.prevent_exit();
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
info!("Exiting plugin runtime due to app exit"); info!("Exiting plugin runtime due to app exit");
let manager: State<PluginManager> = app.state(); // Bound the wait in case the exit comes while boot is still
manager.terminate().await; // in flight
let get_manager = plugin_manager(app);
if let Ok(Ok(manager)) =
tokio::time::timeout(Duration::from_secs(5), get_manager).await
{
manager.terminate().await;
}
app.exit(0); app.exit(0);
}); });
} }
+87 -42
View File
@@ -37,10 +37,11 @@ use yaak_grpc::ServiceDefinition;
use yaak_models::blob_manager::BlobManager; use yaak_models::blob_manager::BlobManager;
use yaak_models::models::{ use yaak_models::models::{
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, HttpResponseEvent, ImportSource, ModelVersion, Plugin, RequestVersionComparison, Settings,
WebsocketConnection, WebsocketEvent, WorkspaceMeta,
}; };
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::util::BatchUpsertResult; use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::events::{ use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
@@ -109,10 +110,11 @@ impl<R: Runtime> Host for ClientCtx<R> {
} }
impl<R: Runtime> ClientCtx<R> { impl<R: Runtime> ClientCtx<R> {
/// The plugin runtime this window talks to. Only the `PluginHost` impl /// The plugin runtime this window talks to, once it finishes booting.
/// below uses it; everything else goes through the trait. /// Only the `PluginHost` impl below uses it; everything else goes through
fn pm(&self) -> State<'_, PluginManager> { /// the trait.
self.window.state::<PluginManager>() async fn pm(&self) -> yaak_plugins::error::Result<PluginManager> {
crate::plugins_ext::plugin_manager(&self.window).await
} }
} }
@@ -122,35 +124,40 @@ impl<R: Runtime> ClientCtx<R> {
/// providing them. /// providing them.
impl<R: Runtime> PluginHost for ClientCtx<R> { impl<R: Runtime> PluginHost for ClientCtx<R> {
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> { async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
let handle = self.pm().get_plugin_by_dir(directory).await?; let handle = self.pm().await.ok()?.get_plugin_by_dir(directory).await?;
Some(handle.info()) Some(handle.info())
} }
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> { async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
self.pm().take_init_errors().await match self.pm().await {
Ok(pm) => pm.take_init_errors().await,
Err(_) => Vec::new(),
}
} }
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> { async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
self.pm().resolve_plugins_for_runtime_from_db(plugins).await match self.pm().await {
Ok(pm) => pm.resolve_plugins_for_runtime_from_db(plugins).await,
Err(_) => plugins,
}
} }
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback { async fn template_callback(
PluginTemplateCallback::new( &self,
Arc::new((*self.pm()).clone()), purpose: RenderPurpose,
) -> yaak_commands::Result<impl TemplateCallback> {
Ok(PluginTemplateCallback::new(
Arc::new(self.pm().await?),
Arc::new(self.encryption_manager().clone()), Arc::new(self.encryption_manager().clone()),
&self.plugin_context(), &self.plugin_context(),
purpose, purpose,
) ))
} }
async fn template_function_summaries( async fn template_function_summaries(
&self, &self,
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> { ) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
Ok(self Ok(self.pm().await?.get_template_function_summaries(&self.plugin_context()).await?)
.window
.state::<PluginManager>()
.get_template_function_summaries(&self.plugin_context())
.await?)
} }
async fn template_function_config( async fn template_function_config(
@@ -160,81 +167,81 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
model_id: &str, model_id: &str,
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> { ) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
Ok(self Ok(self
.window .pm()
.state::<PluginManager>() .await?
.get_template_function_config(&self.plugin_context(), function_name, values, model_id) .get_template_function_config(&self.plugin_context(), function_name, values, model_id)
.await?) .await?)
} }
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> { async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
Ok(self.pm().get_themes(&self.plugin_context()).await?) Ok(self.pm().await?.get_themes(&self.plugin_context()).await?)
} }
async fn http_request_actions( async fn http_request_actions(
&self, &self,
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> { ) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
Ok(self.pm().get_http_request_actions(&self.plugin_context()).await?) Ok(self.pm().await?.get_http_request_actions(&self.plugin_context()).await?)
} }
async fn websocket_request_actions( async fn websocket_request_actions(
&self, &self,
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> { ) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
Ok(self.pm().get_websocket_request_actions(&self.plugin_context()).await?) Ok(self.pm().await?.get_websocket_request_actions(&self.plugin_context()).await?)
} }
async fn grpc_request_actions( async fn grpc_request_actions(
&self, &self,
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> { ) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
Ok(self.pm().get_grpc_request_actions(&self.plugin_context()).await?) Ok(self.pm().await?.get_grpc_request_actions(&self.plugin_context()).await?)
} }
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> { async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
Ok(self.pm().get_workspace_actions(&self.plugin_context()).await?) Ok(self.pm().await?.get_workspace_actions(&self.plugin_context()).await?)
} }
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> { async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
Ok(self.pm().get_folder_actions(&self.plugin_context()).await?) Ok(self.pm().await?.get_folder_actions(&self.plugin_context()).await?)
} }
async fn call_http_request_action( async fn call_http_request_action(
&self, &self,
req: CallHttpRequestActionRequest, req: CallHttpRequestActionRequest,
) -> yaak_commands::Result<()> { ) -> yaak_commands::Result<()> {
Ok(self.pm().call_http_request_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.call_http_request_action(&self.plugin_context(), req).await?)
} }
async fn call_grpc_request_action( async fn call_grpc_request_action(
&self, &self,
req: CallGrpcRequestActionRequest, req: CallGrpcRequestActionRequest,
) -> yaak_commands::Result<()> { ) -> yaak_commands::Result<()> {
Ok(self.pm().call_grpc_request_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.call_grpc_request_action(&self.plugin_context(), req).await?)
} }
async fn call_websocket_request_action( async fn call_websocket_request_action(
&self, &self,
req: CallWebsocketRequestActionRequest, req: CallWebsocketRequestActionRequest,
) -> yaak_commands::Result<()> { ) -> yaak_commands::Result<()> {
Ok(self.pm().call_websocket_request_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.call_websocket_request_action(&self.plugin_context(), req).await?)
} }
async fn call_workspace_action( async fn call_workspace_action(
&self, &self,
req: CallWorkspaceActionRequest, req: CallWorkspaceActionRequest,
) -> yaak_commands::Result<()> { ) -> yaak_commands::Result<()> {
Ok(self.pm().call_workspace_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?)
} }
async fn call_folder_action( async fn call_folder_action(
&self, &self,
req: CallFolderActionRequest, req: CallFolderActionRequest,
) -> yaak_commands::Result<()> { ) -> yaak_commands::Result<()> {
Ok(self.pm().call_folder_action(&self.plugin_context(), req).await?) Ok(self.pm().await?.call_folder_action(&self.plugin_context(), req).await?)
} }
async fn http_authentication_summaries( async fn http_authentication_summaries(
&self, &self,
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> { ) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
let results = self.pm().get_http_authentication_summaries(&self.plugin_context()).await?; let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
Ok(results.into_iter().map(|(_, a)| a).collect()) Ok(results.into_iter().map(|(_, a)| a).collect())
} }
@@ -246,6 +253,7 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> { ) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
Ok(self Ok(self
.pm() .pm()
.await?
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id) .get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
.await?) .await?)
} }
@@ -259,6 +267,7 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
) -> yaak_commands::Result<()> { ) -> yaak_commands::Result<()> {
Ok(self Ok(self
.pm() .pm()
.await?
.call_http_authentication_action( .call_http_authentication_action(
&self.plugin_context(), &self.plugin_context(),
auth_name, auth_name,
@@ -270,15 +279,18 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
} }
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> { async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
Ok(self.pm().import_data(&self.plugin_context(), content).await?) Ok(self.pm().await?.import_data(&self.plugin_context(), content).await?)
} }
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> { async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await match self.pm().await {
Ok(pm) => pm.initialize_all_plugins(plugins, &self.plugin_context()).await,
Err(e) => vec![("*".to_string(), e.to_string())],
}
} }
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> { async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
let plugin_manager = Arc::new((*self.pm()).clone()); let plugin_manager = Arc::new(self.pm().await?);
let encryption_manager = Arc::new(self.encryption_manager().clone()); let encryption_manager = Arc::new(self.encryption_manager().clone());
Ok(encrypt_secure_template_function( Ok(encrypt_secure_template_function(
plugin_manager, plugin_manager,
@@ -422,7 +434,7 @@ async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphq
} }
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> { async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?) Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?)
} }
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> { async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
@@ -441,12 +453,34 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?) Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
} }
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> { async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?) Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
} }
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> { async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?) Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
}
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
}
async fn cmd_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
use crate::models_ext::QueryManagerExt;
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
}
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
use crate::models_ext::QueryManagerExt;
let origin = match (req.file_path, req.url) {
(Some(file_path), _) => crate::import::file_origin(&file_path).origin,
(None, Some(url)) => match crate::import::normalize_import_url(&url) {
Ok(url) => crate::import::url_origin(&url).origin,
Err(_) => return Ok(Vec::new()),
},
(None, None) => return Ok(Vec::new()),
};
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
} }
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> { async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
@@ -619,6 +653,18 @@ async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq
Ok(yaak_commands::models::models_duplicate(ctx, req).await?) 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>> { 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?) 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> { async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?) Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
} }
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> { async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
@@ -843,4 +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>> { async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?) Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
} }
+3 -3
View File
@@ -14,7 +14,6 @@ use tokio::task::block_in_place;
use tokio::time::sleep; use tokio::time::sleep;
use ts_rs::TS; use ts_rs::TS;
use yaak_models::util::generate_id; use yaak_models::util::generate_id;
use yaak_plugins::manager::PluginManager;
use url::Url; use url::Url;
use yaak_api::get_system_proxy_url; use yaak_api::get_system_proxy_url;
@@ -98,8 +97,9 @@ impl YaakUpdater {
block_in_place(|| { block_in_place(|| {
tauri::async_runtime::block_on(async move { tauri::async_runtime::block_on(async move {
info!("Shutting down plugin manager before update"); info!("Shutting down plugin manager before update");
let plugin_manager = w.state::<PluginManager>(); if let Ok(plugin_manager) = crate::plugins_ext::plugin_manager(&w).await {
plugin_manager.terminate().await; plugin_manager.terminate().await;
}
}); });
}); });
}) })
@@ -1,6 +1,6 @@
use crate::PluginContextExt; use crate::PluginContextExt;
use crate::error::Result; use crate::error::Result;
use crate::import::import_data; use crate::import::{file_origin, import_data, url_origin};
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use log::{info, warn}; use log::{info, warn};
use std::collections::HashMap; use std::collections::HashMap;
@@ -12,7 +12,6 @@ use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::generate_id; use yaak_models::util::generate_id;
use yaak_plugins::events::{Color, ShowToastRequest}; use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install; use yaak_plugins::install::download_and_install;
use yaak_plugins::manager::PluginManager;
pub(crate) async fn handle_deep_link<R: Runtime>( pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
@@ -44,7 +43,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
return Ok(()); return Ok(());
} }
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(window).await?);
let query_manager = app_handle.db_manager(); let query_manager = app_handle.db_manager();
let app_version = app_handle.package_info().version.to_string(); let app_version = app_handle.package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?; let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
@@ -70,6 +69,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
} }
"import-data" => { "import-data" => {
let mut file_path = query_map.get("path").map(|s| s.to_owned()); let mut file_path = query_map.get("path").map(|s| s.to_owned());
let mut origin = None;
let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string()); let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string());
_ = window.set_focus(); _ = window.set_focus();
@@ -99,6 +99,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
.to_string(); .to_string();
fs::write(&p, json)?; fs::write(&p, json)?;
file_path = Some(p); file_path = Some(p);
origin = Some(url_origin(file_url));
} }
let file_path = match file_path { let file_path = match file_path {
@@ -117,7 +118,8 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
} }
}; };
let results = import_data(window, &file_path).await?; let origin = origin.unwrap_or_else(|| file_origin(&file_path));
let results = import_data(window, &file_path, Some(origin)).await?;
window.emit( window.emit(
"show_toast", "show_toast",
ShowToastRequest { ShowToastRequest {
+10 -4
View File
@@ -20,9 +20,9 @@ use yaak_models::models::{
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent, HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
WebsocketEventType, WebsocketEventType,
}; };
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose}; use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback; use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::strip_json_comments::maybe_strip_json_comments; use yaak_templates::strip_json_comments::maybe_strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions}; use yaak_templates::{RenderErrorBehavior, RenderOptions};
@@ -77,7 +77,7 @@ async fn send_websocket_message<R: Runtime>(
)?; )?;
let (resolved_request, _auth_context_id) = let (resolved_request, _auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?; resolve_websocket_request(&window.db(), &unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request( let request = render_websocket_request(
&resolved_request, &resolved_request,
@@ -142,7 +142,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
cookie_jar_id: Option<&str>, cookie_jar_id: Option<&str>,
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
_plugin_manager: State<'_, PluginManager>,
ws_manager: State<'_, Mutex<WebsocketManager>>, ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(request_id)?; let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
@@ -156,7 +155,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
let settings = app_handle.db().get_settings(); let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) = let (resolved_request, auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?; resolve_websocket_request(&window.db(), &unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request( let request = render_websocket_request(
&resolved_request, &resolved_request,
@@ -171,10 +170,17 @@ pub async fn cmd_ws_connect<R: Runtime>(
) )
.await?; .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( let connection = app_handle.db().upsert_websocket_connection(
&WebsocketConnection { &WebsocketConnection {
workspace_id: request.workspace_id.clone(), workspace_id: request.workspace_id.clone(),
request_id: request_id.to_string(), request_id: request_id.to_string(),
version_id,
..Default::default() ..Default::default()
}, },
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
+64
View File
@@ -11,6 +11,7 @@ export type AnyModel =
| HttpRequest | HttpRequest
| HttpResponse | HttpResponse
| HttpResponseEvent | HttpResponseEvent
| ImportSource
| KeyValue | KeyValue
| Plugin | Plugin
| Settings | Settings
@@ -137,6 +138,10 @@ export type GrpcConnection = {
state: GrpcConnectionState; state: GrpcConnectionState;
trailers: { [key in string]?: string }; trailers: { [key in string]?: string };
url: string; url: string;
/**
* The request version this connection was opened from, when one was captured.
*/
versionId: string | null;
}; };
export type GrpcConnectionState = "initialized" | "connected" | "closed"; export type GrpcConnectionState = "initialized" | "connected" | "closed";
@@ -241,6 +246,10 @@ export type HttpResponse = {
state: HttpResponseState; state: HttpResponseState;
url: string; url: string;
version: string | null; version: string | null;
/**
* The request version this response was sent from, when one was captured.
*/
versionId: string | null;
}; };
export type HttpResponseEvent = { export type HttpResponseEvent = {
@@ -318,6 +327,18 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2"; export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
@@ -334,6 +355,28 @@ export type KeyValue = {
value: string; 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 = { export type Plugin = {
model: "plugin"; model: "plugin";
id: string; id: string;
@@ -361,6 +404,23 @@ export type ProxySetting =
export type ProxySettingAuth = { user: string; password: string }; 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 = { export type Settings = {
model: "settings"; model: "settings";
id: string; id: string;
@@ -425,6 +485,10 @@ export type WebsocketConnection = {
state: WebsocketConnectionState; state: WebsocketConnectionState;
status: number; status: number;
url: string; url: string;
/**
* The request version this connection was opened from, when one was captured.
*/
versionId: string | null;
}; };
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed"; export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
File diff suppressed because one or more lines are too long
+44
View File
@@ -2,3 +2,47 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportConflictResolution = "keep_mine" | "take_source";
/**
* Where a staged import will be committed.
*
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
/**
* Where an import's contents came from, used to link the committed workspace back to it.
*/
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string, label: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string },
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
export type ImportPlanWarning = { title: string, detail: string, };
/**
* The model types an import plan can contain.
*/
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
+59 -4
View File
@@ -21,9 +21,10 @@ use yaak_git::{
use yaak_grpc::ServiceDefinition; use yaak_grpc::ServiceDefinition;
use yaak_models::models::{ use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, HttpResponseEvent, ImportSource, 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::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{ use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
@@ -229,6 +230,7 @@ pub struct CmdGetHttpResponseEventsReq {
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq { pub struct CmdImportDataReq {
pub file_path: String, pub file_path: String,
pub destination: ImportDestination,
} }
#[derive(Debug, Deserialize, TS)] #[derive(Debug, Deserialize, TS)]
@@ -236,6 +238,31 @@ pub struct CmdImportDataReq {
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq { pub struct CmdImportUrlReq {
pub url: String, pub url: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCommitImportReq {
pub plan: ImportPlan,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdListImportSourcesReq {
pub workspace_id: String,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportSourcesForOriginReq {
#[ts(optional)]
pub file_path: Option<String>,
#[ts(optional)]
pub url: Option<String>,
} }
#[derive(Debug, Deserialize, TS)] #[derive(Debug, Deserialize, TS)]
@@ -507,6 +534,28 @@ pub struct ModelsDuplicateReq {
pub model_id: String, 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)] #[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")] #[ts(export, export_to = "gen_rpc.ts")]
@@ -909,8 +958,11 @@ macro_rules! with_commands {
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>, cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>, cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>, cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult, cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult, cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>, cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>, cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (), cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
@@ -951,6 +1003,9 @@ macro_rules! with_commands {
models_upsert(ModelsUpsertReq) -> String, models_upsert(ModelsUpsertReq) -> String,
models_delete(ModelsDeleteReq) -> String, models_delete(ModelsDeleteReq) -> String,
models_duplicate(ModelsDuplicateReq) -> 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_websocket_events(ModelsWebsocketEventsReq) -> Vec<WebsocketEvent>,
models_grpc_events(ModelsGrpcEventsReq) -> Vec<GrpcEvent>, models_grpc_events(ModelsGrpcEventsReq) -> Vec<GrpcEvent>,
models_get_settings(ModelsGetSettingsReq) -> Settings, models_get_settings(ModelsGetSettingsReq) -> Settings,
+6 -1
View File
@@ -121,7 +121,12 @@ pub trait PluginHost: Host {
/// a render — the variables come from the environment chain, which is an /// a render — the variables come from the environment chain, which is an
/// ordinary database read — so handing back the callback keeps the rest of /// ordinary database read — so handing back the callback keeps the rest of
/// rendering shared instead of pushing whole commands behind this trait. /// rendering shared instead of pushing whole commands behind this trait.
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback; /// Async so hosts that finish booting their plugin runtime in the
/// background can wait for it here.
fn template_callback(
&self,
purpose: RenderPurpose,
) -> impl Future<Output = crate::Result<impl TemplateCallback>>;
/// Every template function the installed plugins expose, for the /// Every template function the installed plugins expose, for the
/// autocomplete menu. /// autocomplete menu.
+39 -2
View File
@@ -4,9 +4,10 @@
use crate::error::Result; use crate::error::Result;
use crate::host::{Host, PluginHost}; use crate::host::{Host, PluginHost};
use yaak_models::models::{ use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent, AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, ModelVersion,
WorkspaceMeta, RequestVersionComparison, Settings, WebsocketEvent, WorkspaceMeta,
}; };
use yaak_models::versions::version_document;
use yaak_models::queries::workspaces::default_headers; use yaak_models::queries::workspaces::default_headers;
use yaak_rpc_schema::*; 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>( pub async fn models_websocket_events<H: Host>(
host: H, host: H,
req: ModelsWebsocketEventsReq, req: ModelsWebsocketEventsReq,
+1 -1
View File
@@ -66,7 +66,7 @@ pub(crate) async fn render_form_values<H: PluginHost>(
let environment_chain = let environment_chain =
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?; host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
let cb = host.template_callback(purpose); let cb = host.template_callback(purpose).await?;
let rendered = let rendered =
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?; render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
+2 -2
View File
@@ -21,7 +21,7 @@ pub async fn cmd_render_template<H: PluginHost>(
) -> Result<String> { ) -> Result<String> {
let environment_chain = let environment_chain =
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?; host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)); let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)).await?;
let options = RenderOptions { let options = RenderOptions {
// A preview that throws would show the user an error where they expect // A preview that throws would show the user an error where they expect
// to see the value so far, so callers rendering *into the UI* ask for // to see the value so far, so callers rendering *into the UI* ask for
@@ -41,7 +41,7 @@ pub async fn cmd_template_tokens_to_string<H: PluginHost>(
host: H, host: H,
req: CmdTemplateTokensToStringReq, req: CmdTemplateTokensToStringReq,
) -> Result<String> { ) -> Result<String> {
let cb = host.template_callback(RenderPurpose::Preview); let cb = host.template_callback(RenderPurpose::Preview).await?;
Ok(transform_args(req.tokens, &cb)?.to_string()) Ok(transform_args(req.tokens, &cb)?.to_string())
} }
+5 -2
View File
@@ -250,8 +250,11 @@ impl PluginHost for SingleThreadedHost {
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into())) Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
} }
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback { async fn template_callback(
NoTemplateFunctions &self,
_purpose: RenderPurpose,
) -> yaak_commands::Result<impl TemplateCallback> {
Ok(NoTemplateFunctions)
} }
async fn template_function_summaries( async fn template_function_summaries(
+1 -1
View File
@@ -210,7 +210,7 @@ fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEn
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/ // [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
"google.protobuf.FieldMask" => JsonSchemaEntry::string(), "google.protobuf.FieldMask" => JsonSchemaEntry::string(),
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"), "google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
"google.protobuf.Duration" => JsonSchemaEntry::string(), "google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
"google.protobuf.StringValue" => JsonSchemaEntry::string(), "google.protobuf.StringValue" => JsonSchemaEntry::string(),
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"), "google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"), "google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
+75
View File
@@ -12,6 +12,7 @@ export type AnyModel =
| HttpRequest | HttpRequest
| HttpResponse | HttpResponse
| HttpResponseEvent | HttpResponseEvent
| ImportSource
| KeyValue | KeyValue
| Plugin | Plugin
| Settings | Settings
@@ -138,6 +139,10 @@ export type GrpcConnection = {
state: GrpcConnectionState; state: GrpcConnectionState;
trailers: { [key in string]?: string }; trailers: { [key in string]?: string };
url: string; url: string;
/**
* The request version this connection was opened from, when one was captured.
*/
versionId: string | null;
}; };
export type GrpcConnectionState = "initialized" | "connected" | "closed"; export type GrpcConnectionState = "initialized" | "connected" | "closed";
@@ -242,6 +247,10 @@ export type HttpResponse = {
state: HttpResponseState; state: HttpResponseState;
url: string; url: string;
version: string | null; version: string | null;
/**
* The request version this response was sent from, when one was captured.
*/
versionId: string | null;
}; };
export type HttpResponseEvent = { export type HttpResponseEvent = {
@@ -336,6 +345,29 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2"; export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type ImportSourceResource = {
model: "import_source_resource";
createdAt: string;
updatedAt: string;
importSourceId: string;
sourceKey: string;
modelType: string;
modelId: string;
snapshot: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
@@ -358,6 +390,28 @@ export type ModelPayload = {
change: ModelChangeEvent; 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 = { export type ParentAuthentication = {
authentication: Record<string, any>; authentication: Record<string, any>;
authenticationType: string | null; authenticationType: string | null;
@@ -401,6 +455,23 @@ export type ProxySetting =
export type ProxySettingAuth = { user: string; password: string }; 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 = { export type Settings = {
model: "settings"; model: "settings";
id: string; id: string;
@@ -464,6 +535,10 @@ export type WebsocketConnection = {
state: WebsocketConnectionState; state: WebsocketConnectionState;
status: number; status: number;
url: string; url: string;
/**
* The request version this connection was opened from, when one was captured.
*/
versionId: string | null;
}; };
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed"; export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
+44
View File
@@ -2,3 +2,47 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportConflictResolution = "keep_mine" | "take_source";
/**
* Where a staged import will be committed.
*
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
/**
* Where an import's contents came from, used to link the committed workspace back to it.
*/
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string, label: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string },
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
export type ImportPlanWarning = { title: string, detail: string, };
/**
* The model types an import plan can contain.
*/
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
+1
View File
@@ -12,6 +12,7 @@ export function newStoreData(): ModelStoreData {
http_request: {}, http_request: {},
http_response: {}, http_response: {},
http_response_event: {}, http_response_event: {},
import_source: {},
key_value: {}, key_value: {},
plugin: {}, plugin: {},
settings: {}, settings: {},
@@ -0,0 +1,25 @@
CREATE TABLE import_sources
(
id TEXT NOT NULL PRIMARY KEY,
model TEXT DEFAULT 'import_source' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
workspace_id TEXT NOT NULL,
importer TEXT NOT NULL,
origin TEXT NOT NULL,
origin_label TEXT NOT NULL,
last_imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE import_source_resources
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT NOT NULL,
snapshot TEXT NOT NULL,
PRIMARY KEY (import_source_id, source_key)
);
@@ -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;
+14
View File
@@ -118,6 +118,20 @@ impl<'a> ClientDb<'a> {
Ok(m.clone()) 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<()> { fn record_model_change(&self, payload: &ModelPayload) -> Result<()> {
let payload_json = serde_json::to_string(payload)?; let payload_json = serde_json::to_string(payload)?;
let source_json = serde_json::to_string(&payload.update_source)?; let source_json = serde_json::to_string(&payload.update_source)?;
+1
View File
@@ -21,6 +21,7 @@ pub mod queries;
pub mod query_manager; pub mod query_manager;
pub mod render; pub mod render;
pub mod util; pub mod util;
pub mod versions;
/// Per-connection setup, applied by every pool on every connection it opens. /// Per-connection setup, applied by every pool on every connection it opens.
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> { fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
+282
View File
@@ -1539,6 +1539,8 @@ pub struct WebsocketConnection {
pub state: WebsocketConnectionState, pub state: WebsocketConnectionState,
pub status: i32, pub status: i32,
pub url: String, pub url: String,
/// The request version this connection was opened from, when one was captured.
pub version_id: Option<String>,
} }
impl UpsertModelInfo for WebsocketConnection { impl UpsertModelInfo for WebsocketConnection {
@@ -1578,6 +1580,7 @@ impl UpsertModelInfo for WebsocketConnection {
(State, serde_json::to_value(&self.state)?.as_str().into()), (State, serde_json::to_value(&self.state)?.as_str().into()),
(Status, self.status.into()), (Status, self.status.into()),
(Url, self.url.into()), (Url, self.url.into()),
(VersionId, self.version_id.into()),
]) ])
} }
@@ -1590,6 +1593,7 @@ impl UpsertModelInfo for WebsocketConnection {
WebsocketConnectionIden::State, WebsocketConnectionIden::State,
WebsocketConnectionIden::Status, WebsocketConnectionIden::Status,
WebsocketConnectionIden::Url, WebsocketConnectionIden::Url,
WebsocketConnectionIden::VersionId,
] ]
} }
@@ -1612,6 +1616,7 @@ impl UpsertModelInfo for WebsocketConnection {
error: row.get("error")?, error: row.get("error")?,
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(), state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
status: row.get("status")?, status: row.get("status")?,
version_id: row.get("version_id").unwrap_or_default(),
}) })
} }
} }
@@ -1965,6 +1970,8 @@ pub struct HttpResponse {
pub state: HttpResponseState, pub state: HttpResponseState,
pub url: String, pub url: String,
pub version: Option<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 { impl UpsertModelInfo for HttpResponse {
@@ -2014,6 +2021,7 @@ impl UpsertModelInfo for HttpResponse {
(Url, self.url.into()), (Url, self.url.into()),
(Version, self.version.into()), (Version, self.version.into()),
(RequestContentLength, self.request_content_length.into()), (RequestContentLength, self.request_content_length.into()),
(VersionId, self.version_id.into()),
]) ])
} }
@@ -2036,6 +2044,7 @@ impl UpsertModelInfo for HttpResponse {
HttpResponseIden::StatusReason, HttpResponseIden::StatusReason,
HttpResponseIden::Url, HttpResponseIden::Url,
HttpResponseIden::Version, HttpResponseIden::Version,
HttpResponseIden::VersionId,
] ]
} }
@@ -2071,6 +2080,7 @@ impl UpsertModelInfo for HttpResponse {
r.get::<_, String>("request_headers").unwrap_or_default().as_str(), r.get::<_, String>("request_headers").unwrap_or_default().as_str(),
) )
.unwrap_or_default(), .unwrap_or_default(),
version_id: r.get("version_id").unwrap_or_default(),
}) })
} }
} }
@@ -2516,6 +2526,8 @@ pub struct GrpcConnection {
pub state: GrpcConnectionState, pub state: GrpcConnectionState,
pub trailers: BTreeMap<String, String>, pub trailers: BTreeMap<String, String>,
pub url: 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 { impl UpsertModelInfo for GrpcConnection {
@@ -2557,6 +2569,7 @@ impl UpsertModelInfo for GrpcConnection {
(Error, self.error.as_ref().map(|s| s.as_str()).into()), (Error, self.error.as_ref().map(|s| s.as_str()).into()),
(Trailers, serde_json::to_string(&self.trailers)?.into()), (Trailers, serde_json::to_string(&self.trailers)?.into()),
(Url, self.url.into()), (Url, self.url.into()),
(VersionId, self.version_id.into()),
]) ])
} }
@@ -2571,6 +2584,7 @@ impl UpsertModelInfo for GrpcConnection {
GrpcConnectionIden::Error, GrpcConnectionIden::Error,
GrpcConnectionIden::Trailers, GrpcConnectionIden::Trailers,
GrpcConnectionIden::Url, GrpcConnectionIden::Url,
GrpcConnectionIden::VersionId,
] ]
} }
@@ -2595,6 +2609,7 @@ impl UpsertModelInfo for GrpcConnection {
url: row.get("url")?, url: row.get("url")?,
error: row.get("error")?, error: row.get("error")?,
trailers: serde_json::from_str(trailers.as_str()).unwrap_or_default(), 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 /// Only used as a `from_row` fallback for an unparseable settings column. The
/// value a *new* model gets comes from that model's `Default` impl. /// value a *new* model gets comes from that model's `Default` impl.
fn default_request_message_size_setting() -> InheritedIntSetting { fn default_request_message_size_setting() -> InheritedIntSetting {
@@ -3093,6 +3373,7 @@ define_any_model! {
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
HttpResponseEvent, HttpResponseEvent,
ImportSource,
KeyValue, KeyValue,
Plugin, Plugin,
Settings, 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_request" => HttpRequest(fv(value).unwrap()),
Some(m) if m == "http_response" => HttpResponse(fv(value).unwrap()), Some(m) if m == "http_response" => HttpResponse(fv(value).unwrap()),
Some(m) if m == "http_response_event" => HttpResponseEvent(fv(value).unwrap()), Some(m) if m == "http_response_event" => HttpResponseEvent(fv(value).unwrap()),
Some(m) if m == "import_source" => ImportSource(fv(value).unwrap()),
Some(m) if m == "key_value" => KeyValue(fv(value).unwrap()), Some(m) if m == "key_value" => KeyValue(fv(value).unwrap()),
Some(m) if m == "plugin" => Plugin(fv(value).unwrap()), Some(m) if m == "plugin" => Plugin(fv(value).unwrap()),
Some(m) if m == "settings" => Settings(fv(value).unwrap()), Some(m) if m == "settings" => Settings(fv(value).unwrap()),
@@ -1,6 +1,7 @@
use crate::client_db::ClientDb; use crate::client_db::ClientDb;
use crate::error::Result; use crate::error::Result;
use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest}; use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};
use serde_json::Value;
pub enum AnyRequest { pub enum AnyRequest {
HttpRequest(HttpRequest), HttpRequest(HttpRequest),
@@ -8,6 +9,36 @@ pub enum AnyRequest {
WebsocketRequest(WebsocketRequest), 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> { impl<'a> ClientDb<'a> {
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> { pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
if let Ok(http_request) = self.get_http_request(id) { if let Ok(http_request) = self.get_http_request(id) {
@@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> {
source: &UpdateSource, source: &UpdateSource,
) -> Result<GrpcRequest> { ) -> Result<GrpcRequest> {
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?; 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) self.delete(m, source)
} }
@@ -24,6 +24,7 @@ impl<'a> ClientDb<'a> {
source: &UpdateSource, source: &UpdateSource,
) -> Result<HttpRequest> { ) -> Result<HttpRequest> {
self.delete_all_http_responses_for_request(m.id.as_str(), source)?; 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) 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)
}
}
+3
View File
@@ -11,8 +11,11 @@ mod grpc_requests;
mod http_requests; mod http_requests;
mod http_response_events; mod http_response_events;
mod http_responses; mod http_responses;
mod import_source_resources;
mod import_sources;
mod key_values; mod key_values;
mod model_changes; mod model_changes;
mod model_versions;
mod plugin_key_values; mod plugin_key_values;
mod plugins; mod plugins;
mod settings; 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, source: &UpdateSource,
) -> Result<WebsocketRequest> { ) -> Result<WebsocketRequest> {
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?; 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) self.delete(websocket_request, source)
} }
+8 -2
View File
@@ -6,8 +6,9 @@ use crate::models::{
AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden, AnyModel, CookieJar, CookieJarIden, Environment, EnvironmentIden, Folder, FolderIden,
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent, GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden, GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden, WebsocketConnection, ImportSourceIden, ModelVersion, ModelVersionIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
WebsocketConnection,
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
}; };
@@ -85,6 +86,11 @@ impl<'a> ClientDb<'a> {
self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?; self.delete_many_untracked::<Folder>(FolderIden::WorkspaceId, wid)?;
self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?; self.delete_many_untracked::<Environment>(EnvironmentIden::WorkspaceId, wid)?;
self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?; self.delete_many_untracked::<CookieJar>(CookieJarIden::WorkspaceId, wid)?;
for import_source in self.list_import_sources(wid)? {
self.delete_import_source_resources(&import_source.id)?;
}
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
self.delete_many_untracked::<ModelVersion>(ModelVersionIden::WorkspaceId, wid)?;
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?; self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?; self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
self.delete(workspace, source) self.delete(workspace, source)
+131
View File
@@ -85,6 +85,137 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>, pub websocket_requests: Vec<WebsocketRequest>,
} }
/// Where a staged import will be committed.
///
/// The destination workspace and optional folder IDs are captured in the plan so the preview describes
/// the exact destination that confirmation will use.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportDestination {
NewWorkspace,
ExistingWorkspace {
#[serde(rename = "workspaceId")]
workspace_id: String,
#[serde(rename = "folderId")]
#[ts(optional)]
folder_id: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
}
/// Where an import's contents came from, used to link the committed workspace back to it.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportOrigin {
/// The absolute file path or URL the contents were read from.
pub origin: String,
pub label: String,
}
/// The model types an import plan can contain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportResourceType {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
}
impl ImportResourceType {
pub fn as_str(&self) -> &'static str {
match self {
ImportResourceType::Environment => "environment",
ImportResourceType::Folder => "folder",
ImportResourceType::GrpcRequest => "grpc_request",
ImportResourceType::HttpRequest => "http_request",
ImportResourceType::WebsocketRequest => "websocket_request",
ImportResourceType::Workspace => "workspace",
}
}
pub fn from_str(value: &str) -> Option<Self> {
match value {
"environment" => Some(ImportResourceType::Environment),
"folder" => Some(ImportResourceType::Folder),
"grpc_request" => Some(ImportResourceType::GrpcRequest),
"http_request" => Some(ImportResourceType::HttpRequest),
"websocket_request" => Some(ImportResourceType::WebsocketRequest),
"workspace" => Some(ImportResourceType::Workspace),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanAction {
Create,
Update,
Delete,
Unchanged,
KeepLocal,
Conflict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportConflictResolution {
KeepMine,
TakeSource,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanItem {
pub action: ImportPlanAction,
pub model: ImportResourceType,
pub model_id: String,
pub name: String,
/// Planned parent folder ID for incoming resources; current parent for deletions.
#[ts(optional)]
pub parent_id: Option<String>,
pub selected: bool,
#[ts(optional)]
pub resolution: Option<ImportConflictResolution>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlan {
pub importer: String,
pub destination: ImportDestination,
pub resources: BatchUpsertResult,
pub warnings: Vec<ImportPlanWarning>,
/// Stable source key for every model in `resources`, keyed by its planned ID.
pub source_keys: BTreeMap<String, String>,
/// One entry per plannable resource; commit applies only the selected ones.
#[serde(default)]
pub items: Vec<ImportPlanItem>,
#[serde(default)]
#[ts(optional)]
pub origin: Option<ImportOrigin>,
}
pub fn get_workspace_export_resources( pub fn get_workspace_export_resources(
db: &ClientDb, db: &ClientDb,
yaak_version: &str, yaak_version: &str,
+240
View File
@@ -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
View File
@@ -474,7 +474,18 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, }; export type ImportResponse = {
/**
* Display name of the importer that recognized the input.
*/
importer: string, resources: ImportResources,
/**
* Identifies the same source element across re-parses, keyed by the IDs in `resources`.
*
* Must come from the document, never from anything the user can rename in Yaak. Only set
* for formats that carry their own identifiers; the host derives the rest.
*/
sourceKeys?: { [key in string]?: string }, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, }; export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
+25
View File
@@ -11,6 +11,7 @@ export type AnyModel =
| HttpRequest | HttpRequest
| HttpResponse | HttpResponse
| HttpResponseEvent | HttpResponseEvent
| ImportSource
| KeyValue | KeyValue
| Plugin | Plugin
| Settings | Settings
@@ -137,6 +138,10 @@ export type GrpcConnection = {
state: GrpcConnectionState; state: GrpcConnectionState;
trailers: { [key in string]?: string }; trailers: { [key in string]?: string };
url: string; url: string;
/**
* The request version this connection was opened from, when one was captured.
*/
versionId: string | null;
}; };
export type GrpcConnectionState = "initialized" | "connected" | "closed"; export type GrpcConnectionState = "initialized" | "connected" | "closed";
@@ -241,6 +246,10 @@ export type HttpResponse = {
state: HttpResponseState; state: HttpResponseState;
url: string; url: string;
version: string | null; version: string | null;
/**
* The request version this response was sent from, when one was captured.
*/
versionId: string | null;
}; };
export type HttpResponseEvent = { export type HttpResponseEvent = {
@@ -318,6 +327,18 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2"; export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
@@ -417,6 +438,10 @@ export type WebsocketConnection = {
state: WebsocketConnectionState; state: WebsocketConnectionState;
status: number; status: number;
url: string; url: string;
/**
* The request version this connection was opened from, when one was captured.
*/
versionId: string | null;
}; };
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed"; export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
+10 -1
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use ts_rs::TS; use ts_rs::TS;
use yaak_models::models::{ use yaak_models::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest, AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,
@@ -247,7 +247,16 @@ pub struct ImportRequest {
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")] #[ts(export, export_to = "gen_events.ts")]
pub struct ImportResponse { pub struct ImportResponse {
/// Display name of the importer that recognized the input.
pub importer: String,
pub resources: ImportResources, pub resources: ImportResources,
/// Identifies the same source element across re-parses, keyed by the IDs in `resources`.
///
/// Must come from the document, never from anything the user can rename in Yaak. Only set
/// for formats that carry their own identifiers; the host derives the rest.
#[ts(optional)]
pub source_keys: Option<BTreeMap<String, String>>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
+31 -19
View File
@@ -187,24 +187,25 @@ impl PluginManager {
} }
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?; let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
let db = query_manager.connect(); // Scope the db connection so the future stays Send across the await below
for dir in &bundled_dirs { let plugins = {
if db.get_plugin_by_directory(dir).is_none() { let db = query_manager.connect();
db.upsert_plugin( for dir in &bundled_dirs {
&Plugin { if db.get_plugin_by_directory(dir).is_none() {
directory: dir.clone(), db.upsert_plugin(
enabled: true, &Plugin {
url: None, directory: dir.clone(),
source: PluginSource::Bundled, enabled: true,
..Default::default() url: None,
}, source: PluginSource::Bundled,
&UpdateSource::Background, ..Default::default()
)?; },
&UpdateSource::Background,
)?;
}
} }
} db.list_plugins()?
};
let plugins = db.list_plugins()?;
drop(db);
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await; let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
if !init_errors.is_empty() { if !init_errors.is_empty() {
@@ -1104,8 +1105,19 @@ impl PluginManager {
.await?; .await?;
// TODO: Don't just return the first valid response // TODO: Don't just return the first valid response
let result = reply_events.into_iter().find_map(|e| match e.payload { let result = reply_events.into_iter().find_map(|e| match e {
InternalEventPayload::ImportResponse(resp) => Some(resp), InternalEvent {
plugin_name,
payload: InternalEventPayload::ImportResponse(mut resp),
..
} => {
// Older plugin runtimes do not include the importer's display name. The plugin
// package name is still enough to identify the detected format in that case.
if resp.importer.is_empty() {
resp.importer = plugin_name;
}
Some(resp)
}
_ => None, _ => None,
}); });
+9
View File
@@ -209,6 +209,7 @@ impl TryFrom<AnyModel> for SyncModel {
AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)), AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),
AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)), AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)),
AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)), AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)),
AnyModel::ImportSource(m) => return Err(UnknownModel(m.model)),
AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)), AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)),
AnyModel::Plugin(m) => return Err(UnknownModel(m.model)), AnyModel::Plugin(m) => return Err(UnknownModel(m.model)),
AnyModel::Settings(m) => return Err(UnknownModel(m.model)), AnyModel::Settings(m) => return Err(UnknownModel(m.model)),
@@ -226,6 +227,14 @@ mod migration_tests {
use crate::error::Result; use crate::error::Result;
use crate::models::SyncModel; use crate::models::SyncModel;
#[test]
fn import_sources_are_excluded_from_sync() {
let model = yaak_models::models::AnyModel::ImportSource(
yaak_models::models::ImportSource::default(),
);
assert!(SyncModel::try_from(model).is_err());
}
#[test] #[test]
fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> { fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> {
let raw = r#" let raw = r#"
+46 -1
View File
@@ -32,12 +32,13 @@ use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::cookies::apply_cookie_changes; use yaak_models::cookies::apply_cookie_changes;
use yaak_models::models::{ use yaak_models::models::{
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData, AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
HttpSendSettings, HttpSendSettings, ModelVersionReason, RequestVersionComparison,
}; };
use yaak_models::models_ops; use yaak_models::models_ops;
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::render::render_http_request; use yaak_models::render::render_http_request;
use yaak_models::util::{ModelPayload, UpdateSource}; use yaak_models::util::{ModelPayload, UpdateSource};
use yaak_models::versions::version_document;
use yaak_templates::{RenderOptions, TemplateCallback}; use yaak_templates::{RenderOptions, TemplateCallback};
/// Names inside the VFS, not paths on any disk. Two files because the desktop /// Names inside the VFS, not paths on any disk. Two files because the desktop
@@ -218,6 +219,19 @@ struct UpsertIntrospectionReq {
content: Option<String>, 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)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ResponseIdReq { struct ResponseIdReq {
@@ -319,6 +333,37 @@ fn dispatch(
to_json(id) 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_settings" => to_json(host.queries.connect().get_settings()),
"models_get_graphql_introspection" => { "models_get_graphql_introspection" => {
+2
View File
@@ -9,6 +9,7 @@ async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true } log = { workspace = true }
md5 = "0.8.0" md5 = "0.8.0"
chrono = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt"] } tokio = { workspace = true, features = ["sync", "rt"] }
@@ -21,5 +22,6 @@ yaak-templates = { workspace = true }
yaak-tls = { workspace = true } yaak-tls = { workspace = true }
[dev-dependencies] [dev-dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
tempfile = "3" tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+2131 -81
View File
File diff suppressed because it is too large Load Diff
+104 -3
View File
@@ -24,9 +24,10 @@ use yaak_http::types::{
use yaak_models::blob_manager::{BlobManager, BodyChunk}; use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::models::{ use yaak_models::models::{
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse, ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting, HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState,
ProxySettingAuth, ResolvedHttpRequestSettings, ProxySetting, ProxySettingAuth, ResolvedHttpRequestSettings,
}; };
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManager; use yaak_models::query_manager::QueryManager;
use yaak_models::render::render_http_request; use yaak_models::render::render_http_request;
use yaak_models::util::{UpdateSource, generate_prefixed_id}; 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 /// 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. /// returns (or fails) yields the cookies the transaction collected.
pub cookie_store: Option<CookieStore>, 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 /// 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, client_certificates: settings.client_certificates,
}, },
cookie_store: cookies.map(CookieStore::from_cookies), 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>( pub async fn send_http_request<T: TemplateCallback>(
params: SendHttpRequestParams<'_, T>, params: SendHttpRequestParams<'_, T>,
) -> Result<SendHttpRequestResult> { ) -> 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 (request, auth_context_id) = request.into_parts();
let storage = params.storage; let storage = params.storage;
let send_options = runtime_config.send_options(); 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(); let mut response = params.existing_response.unwrap_or_default();
response.request_id = request.id.clone(); response.request_id = request.id.clone();
response.workspace_id = request.workspace_id.clone(); response.workspace_id = request.workspace_id.clone();
response.version_id = version_id;
response.request_content_length = request_content_length; response.request_content_length = request_content_length;
response.request_headers = sendable_request response.request_headers = sendable_request
.headers .headers
@@ -1345,6 +1358,7 @@ mod tests {
client_certificates: Vec::new(), client_certificates: Vec::new(),
}, },
cookie_store: Some(CookieStore::new()), cookie_store: Some(CookieStore::new()),
version_id: None,
}, },
template_callback: &NoopTemplateCallback, template_callback: &NoopTemplateCallback,
storage: None, storage: None,
@@ -1414,6 +1428,7 @@ mod tests {
client_certificates: Vec::new(), client_certificates: Vec::new(),
}, },
cookie_store: Some(CookieStore::new()), cookie_store: Some(CookieStore::new()),
version_id: None,
}, },
template_callback: &NoopTemplateCallback, template_callback: &NoopTemplateCallback,
storage: None, storage: None,
@@ -1466,6 +1481,92 @@ mod tests {
(query_manager, cookie_jar, temp_dir) (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 { fn cookie(name: &str) -> Cookie {
Cookie { Cookie {
name: name.to_string(), name: name.to_string(),
+1 -1
View File
@@ -100,7 +100,7 @@
"lint:vp": "vp lint", "lint:vp": "vp lint",
"lint:workspaces": "npm run --workspaces --if-present lint", "lint:workspaces": "npm run --workspaces --if-present lint",
"replace-version": "node scripts/replace-version.cjs", "replace-version": "node scripts/replace-version.cjs",
"format": "vp fmt --ignore-path .oxfmtignore", "format": "vp fmt",
"tauri": "tauri", "tauri": "tauri",
"client:tauri-before-build": "npm run bootstrap", "client:tauri-before-build": "npm run bootstrap",
"client:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-client", "client:tauri-before-dev": "node scripts/run-workspaces-dev.mjs apps/yaak-client",
+29 -5
View File
@@ -63,6 +63,10 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
db.rpc("models_get_graphql_introspection", payload), db.rpc("models_get_graphql_introspection", payload),
models_upsert_graphql_introspection: (payload, db) => models_upsert_graphql_introspection: (payload, db) =>
db.rpc("models_upsert_graphql_introspection", payload), 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_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
models_websocket_events: (payload, db) => db.rpc("models_websocket_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), 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) => { cmd_send_http_request: (payload, db) => {
const requestId = str(payload, "requestId"); const requestId = str(payload, "requestId");
if (requestId == null) throw new Error("cmd_send_http_request needs a 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 ---------------------------------- */ /* -------------------------------- 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_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_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_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. // Anything that needs files the page can't reach.
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"], cmd_import_data: [
"Importing from a file needs a filesystem, which a browser tab has no",
"localFiles",
],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null], cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"], cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"], cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"], cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
@@ -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_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_updates: ["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_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_function_config: [
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"], "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_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_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"], cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
+41 -3
View File
@@ -30,6 +30,7 @@ import type {
HttpResponse, HttpResponse,
HttpResponseEventData, HttpResponseEventData,
HttpSendSettings, HttpSendSettings,
ModelVersion,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/web"; import type { Frame, SendRequest } from "@yaakapp-internal/web";
import type { WorkerConnection } from "./connection"; 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 // 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. // that response's error rather than as a toast that names no request.
const workspaceId = await workspaceIdOfRequest(db, requestId); 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(); await response.create();
const cancel = new AbortController(); const cancel = new AbortController();
@@ -88,6 +95,29 @@ export async function sendHttpRequest(
return response.current(); 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( async function runSend(
db: WorkerConnection, db: WorkerConnection,
response: ResponseWriter, response: ResponseWriter,
@@ -315,8 +345,16 @@ class TimelineWriter {
* yaak-models), so an edit made while the send was in flight survives rather * yaak-models), so an edit made while the send was in flight survives rather
* than being written over by the send's stale snapshot. * than being written over by the send's stale snapshot.
*/ */
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> { async function persistCookies(
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies }); 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
View File
@@ -474,7 +474,18 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, }; export type ImportResponse = {
/**
* Display name of the importer that recognized the input.
*/
importer: string, resources: ImportResources,
/**
* Identifies the same source element across re-parses, keyed by the IDs in `resources`.
*
* Must come from the document, never from anything the user can rename in Yaak. Only set
* for formats that carry their own identifiers; the host derives the rest.
*/
sourceKeys?: { [key in string]?: string }, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, }; export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
@@ -1,4 +1,4 @@
import type { ImportResources } from "../bindings/gen_events"; import type { ImportResources, ImportResponse } from "../bindings/gen_events";
import type { AtLeast, MaybePromise } from "../helpers"; import type { AtLeast, MaybePromise } from "../helpers";
import type { Context } from "./Context"; import type { Context } from "./Context";
@@ -14,9 +14,12 @@ export type PartialImportResources = {
websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>; websocketRequests: Array<AtLeast<ImportResources["websocketRequests"][0], CommonFields>>;
}; };
export type ImportPluginResponse = null | { /** `importer` is omitted because the host fills it in from the plugin's own name. */
resources: PartialImportResources; export type ImportPluginResponse =
}; | null
| (Omit<ImportResponse, "importer" | "resources"> & {
resources: PartialImportResources;
});
export type ImporterPlugin = { export type ImporterPlugin = {
name: string; name: string;
@@ -167,7 +167,9 @@ export class PluginInstance {
if (reply != null) { if (reply != null) {
const replyPayload: InternalEventPayload = { const replyPayload: InternalEventPayload = {
type: "import_response", type: "import_response",
importer: this.#mod.importer.name,
resources: reply.resources as ImportResources, resources: reply.resources as ImportResources,
sourceKeys: reply.sourceKeys ?? null,
}; };
this.#sendPayload(context, replyPayload, replyId); this.#sendPayload(context, replyPayload, replyId);
return; return;
+4 -1
View File
@@ -5,7 +5,7 @@ import { forwardRef } from "react";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon"; import { LoadingIcon } from "./LoadingIcon";
type ButtonVariant = "border" | "solid"; type ButtonVariant = "border" | "solid" | "input";
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto"; type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & { export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
@@ -88,6 +88,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
resolvedColor !== "custom" && resolvedColor !== "custom" &&
"border-border-subtle text-text-subtle enabled:hocus:border-border " + "border-border-subtle text-text-subtle enabled:hocus:border-border " +
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler", "enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
// Chrome of a form input rather than a button: no hover state, and colors resolve from the
// surrounding x-theme-input rather than a button theme.
variant === "input" && "border-border text-text",
)} )}
disabled={isDisabled} disabled={isDisabled}
onClick={onClick} onClick={onClick}
@@ -959,6 +959,10 @@ describe("importer-curl", () => {
{ enabled: true, name: "q", value: "a=b" }, { enabled: true, name: "q", value: "a=b" },
]); ]);
}); });
test("Emits no source keys", () => {
expect(convertCurl("curl https://yaak.app")).not.toHaveProperty("sourceKeys");
});
}); });
const idCount: Partial<Record<string, number>> = {}; const idCount: Partial<Record<string, number>> = {};
+15
View File
@@ -15,6 +15,21 @@ export function convertId(id: string): string {
return `GENERATE_ID::${id}`; return `GENERATE_ID::${id}`;
} }
export function createSourceKeys() {
const keys: Record<string, string> = {};
return {
/** Convert a resource's own document ID, keeping it as that resource's source key. */
own(id: string): string {
const converted = convertId(id);
keys[converted] = id;
return converted;
},
all: (): Record<string, string> => keys,
};
}
export type SourceKeys = ReturnType<typeof createSourceKeys>;
export function importHttpBodyAndHeaders(obj: any) { export function importHttpBodyAndHeaders(obj: any) {
const { headers } = importHeaders(obj); const { headers } = importHeaders(obj);
const { body, bodyType } = importHttpBody(obj.body); const { body, bodyType } = importHttpBody(obj.body);
+35 -14
View File
@@ -1,10 +1,18 @@
/* oxlint-disable no-explicit-any */ /* oxlint-disable no-explicit-any */
import type { PartialImportResources } from "@yaakapp/api"; import type { PartialImportResources } from "@yaakapp/api";
import { convertId, convertTemplateSyntax, importHttpBodyAndHeaders, isJSObject } from "./common"; import {
convertId,
convertTemplateSyntax,
createSourceKeys,
importHttpBodyAndHeaders,
isJSObject,
type SourceKeys,
} from "./common";
export function convertInsomniaV4(parsed: any) { export function convertInsomniaV4(parsed: any) {
if (!Array.isArray(parsed.resources)) return null; if (!Array.isArray(parsed.resources)) return null;
const keys = createSourceKeys();
const resources: PartialImportResources = { const resources: PartialImportResources = {
environments: [], environments: [],
folders: [], folders: [],
@@ -20,7 +28,7 @@ export function convertInsomniaV4(parsed: any) {
); );
for (const w of workspacesToImport) { for (const w of workspacesToImport) {
resources.workspaces.push({ resources.workspaces.push({
id: convertId(w._id), id: keys.own(w._id),
createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined, createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined,
updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined, updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined,
model: "workspace", model: "workspace",
@@ -31,7 +39,7 @@ export function convertInsomniaV4(parsed: any) {
(r: any) => isJSObject(r) && r._type === "environment", (r: any) => isJSObject(r) && r._type === "environment",
); );
resources.environments.push( resources.environments.push(
...environmentsToImport.map((r: any) => importEnvironment(r, w._id)), ...environmentsToImport.map((r: any) => importEnvironment(r, w._id, keys)),
); );
const nextFolder = (parentId: string) => { const nextFolder = (parentId: string) => {
@@ -40,12 +48,12 @@ export function convertInsomniaV4(parsed: any) {
if (!isJSObject(child)) continue; if (!isJSObject(child)) continue;
if (child._type === "request_group") { if (child._type === "request_group") {
resources.folders.push(importFolder(child, w._id)); resources.folders.push(importFolder(child, w._id, keys));
nextFolder(child._id); nextFolder(child._id);
} else if (child._type === "request") { } else if (child._type === "request") {
resources.httpRequests.push(importHttpRequest(child, w._id)); resources.httpRequests.push(importHttpRequest(child, w._id, keys));
} else if (child._type === "grpc_request") { } else if (child._type === "grpc_request") {
resources.grpcRequests.push(importGrpcRequest(child, w._id)); resources.grpcRequests.push(importGrpcRequest(child, w._id, keys));
} }
} }
}; };
@@ -60,10 +68,14 @@ export function convertInsomniaV4(parsed: any) {
resources.environments = resources.environments.filter(Boolean); resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean); resources.workspaces = resources.workspaces.filter(Boolean);
return { resources: convertTemplateSyntax(resources) }; return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
} }
function importHttpRequest(r: any, workspaceId: string): PartialImportResources["httpRequests"][0] { function importHttpRequest(
r: any,
workspaceId: string,
keys: SourceKeys,
): PartialImportResources["httpRequests"][0] {
let authenticationType: string | null = null; let authenticationType: string | null = null;
let authentication = {}; let authentication = {};
if (r.authentication.type === "bearer") { if (r.authentication.type === "bearer") {
@@ -80,7 +92,7 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
} }
return { return {
id: convertId(r.meta?.id ?? r._id), id: keys.own(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined, createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined, updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
@@ -102,13 +114,17 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
}; };
} }
function importGrpcRequest(r: any, workspaceId: string): PartialImportResources["grpcRequests"][0] { function importGrpcRequest(
r: any,
workspaceId: string,
keys: SourceKeys,
): PartialImportResources["grpcRequests"][0] {
const parts = r.protoMethodName.split("/").filter((p: any) => p !== ""); const parts = r.protoMethodName.split("/").filter((p: any) => p !== "");
const service = parts[0] ?? null; const service = parts[0] ?? null;
const method = parts[1] ?? null; const method = parts[1] ?? null;
return { return {
id: convertId(r.meta?.id ?? r._id), id: keys.own(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined, createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined, updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
@@ -131,9 +147,13 @@ function importGrpcRequest(r: any, workspaceId: string): PartialImportResources[
}; };
} }
function importFolder(f: any, workspaceId: string): PartialImportResources["folders"][0] { function importFolder(
f: any,
workspaceId: string,
keys: SourceKeys,
): PartialImportResources["folders"][0] {
return { return {
id: convertId(f._id), id: keys.own(f._id),
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined, createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined,
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined, updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined,
folderId: f.parentId === workspaceId ? null : convertId(f.parentId), folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
@@ -147,11 +167,12 @@ function importFolder(f: any, workspaceId: string): PartialImportResources["fold
function importEnvironment( function importEnvironment(
e: any, e: any,
workspaceId: string, workspaceId: string,
keys: SourceKeys,
isParentOg?: boolean, isParentOg?: boolean,
): PartialImportResources["environments"][0] { ): PartialImportResources["environments"][0] {
const isParent = isParentOg ?? e.parentId === workspaceId; const isParent = isParentOg ?? e.parentId === workspaceId;
return { return {
id: convertId(e._id), id: keys.own(e._id),
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined, createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined,
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined, updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
+24 -14
View File
@@ -3,9 +3,11 @@ import type { PartialImportResources } from "@yaakapp/api";
import { import {
convertId, convertId,
convertTemplateSyntax, convertTemplateSyntax,
createSourceKeys,
importHeaders, importHeaders,
importHttpBodyAndHeaders, importHttpBodyAndHeaders,
isJSObject, isJSObject,
type SourceKeys,
} from "./common"; } from "./common";
export function convertInsomniaV5(parsed: any) { export function convertInsomniaV5(parsed: any) {
@@ -18,6 +20,7 @@ export function convertInsomniaV5(parsed: any) {
return null; return null;
} }
const keys = createSourceKeys();
const resources: PartialImportResources = { const resources: PartialImportResources = {
environments: [], environments: [],
folders: [], folders: [],
@@ -30,7 +33,7 @@ export function convertInsomniaV5(parsed: any) {
// Import workspaces // Import workspaces
const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>; const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>;
resources.workspaces.push({ resources.workspaces.push({
id: convertId(meta.id ?? "collection"), id: keys.own(meta.id ?? "collection"),
createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : undefined, createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : undefined,
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined, updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined,
model: "workspace", model: "workspace",
@@ -42,8 +45,10 @@ export function convertInsomniaV5(parsed: any) {
// Import environments // Import environments
resources.environments.push( resources.environments.push(
importEnvironment(parsed.environments, meta.id, true), importEnvironment(parsed.environments, meta.id, keys, true),
...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)), ...(parsed.environments.subEnvironments ?? []).map((r: any) =>
importEnvironment(r, meta.id, keys),
),
); );
// Import folders // Import folders
@@ -52,16 +57,16 @@ export function convertInsomniaV5(parsed: any) {
if (!isJSObject(child)) continue; if (!isJSObject(child)) continue;
if (Array.isArray(child.children)) { if (Array.isArray(child.children)) {
const { folder, environment } = importFolder(child, meta.id, parentId); const { folder, environment } = importFolder(child, meta.id, parentId, keys);
resources.folders.push(folder); resources.folders.push(folder);
if (environment) resources.environments.push(environment); if (environment) resources.environments.push(environment);
nextFolder(child.children, child.meta.id); nextFolder(child.children, child.meta.id);
} else if (child.method) { } else if (child.method) {
resources.httpRequests.push(importHttpRequest(child, meta.id, parentId)); resources.httpRequests.push(importHttpRequest(child, meta.id, parentId, keys));
} else if (child.protoFileId) { } else if (child.protoFileId) {
resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId)); resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId, keys));
} else if (child.url) { } else if (child.url) {
resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId)); resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId, keys));
} }
} }
}; };
@@ -75,13 +80,14 @@ export function convertInsomniaV5(parsed: any) {
resources.environments = resources.environments.filter(Boolean); resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean); resources.workspaces = resources.workspaces.filter(Boolean);
return { resources: convertTemplateSyntax(resources) }; return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
} }
function importHttpRequest( function importHttpRequest(
r: any, r: any,
workspaceId: string, workspaceId: string,
parentId: string, parentId: string,
keys: SourceKeys,
): PartialImportResources["httpRequests"][0] { ): PartialImportResources["httpRequests"][0] {
const id = r.meta?.id ?? r._id; const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created; const created = r.meta?.created ?? r.created;
@@ -89,7 +95,7 @@ function importHttpRequest(
const sortKey = r.meta?.sortKey ?? r.sortKey; const sortKey = r.meta?.sortKey ?? r.sortKey;
return { return {
id: convertId(id), id: keys.own(id),
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
@@ -114,6 +120,7 @@ function importGrpcRequest(
r: any, r: any,
workspaceId: string, workspaceId: string,
parentId: string, parentId: string,
keys: SourceKeys,
): PartialImportResources["grpcRequests"][0] { ): PartialImportResources["grpcRequests"][0] {
const id = r.meta?.id ?? r._id; const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created; const created = r.meta?.created ?? r.created;
@@ -126,7 +133,7 @@ function importGrpcRequest(
return { return {
model: "grpc_request", model: "grpc_request",
id: convertId(id), id: keys.own(id),
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
@@ -152,6 +159,7 @@ function importWebsocketRequest(
r: any, r: any,
workspaceId: string, workspaceId: string,
parentId: string, parentId: string,
keys: SourceKeys,
): PartialImportResources["websocketRequests"][0] { ): PartialImportResources["websocketRequests"][0] {
const id = r.meta?.id ?? r._id; const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created; const created = r.meta?.created ?? r.created;
@@ -160,7 +168,7 @@ function importWebsocketRequest(
return { return {
model: "websocket_request", model: "websocket_request",
id: convertId(id), id: keys.own(id),
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
@@ -198,6 +206,7 @@ function importFolder(
f: any, f: any,
workspaceId: string, workspaceId: string,
parentId: string, parentId: string,
keys: SourceKeys,
): { ): {
folder: PartialImportResources["folders"][0]; folder: PartialImportResources["folders"][0];
environment: PartialImportResources["environments"][0] | null; environment: PartialImportResources["environments"][0] | null;
@@ -210,7 +219,7 @@ function importFolder(
let environment: PartialImportResources["environments"][0] | null = null; let environment: PartialImportResources["environments"][0] | null = null;
if (Object.keys(f.environment ?? {}).length > 0) { if (Object.keys(f.environment ?? {}).length > 0) {
environment = { environment = {
id: convertId(`${id}folder`), id: keys.own(`${id}folder`),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
@@ -230,7 +239,7 @@ function importFolder(
return { return {
folder: { folder: {
model: "folder", model: "folder",
id: convertId(id), id: keys.own(id),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId), folderId: parentId === workspaceId ? null : convertId(parentId),
@@ -248,6 +257,7 @@ function importFolder(
function importEnvironment( function importEnvironment(
e: any, e: any,
workspaceId: string, workspaceId: string,
keys: SourceKeys,
isParent?: boolean, isParent?: boolean,
): PartialImportResources["environments"][0] { ): PartialImportResources["environments"][0] {
const id = e.meta?.id ?? e._id; const id = e.meta?.id ?? e._id;
@@ -256,7 +266,7 @@ function importEnvironment(
const sortKey = e.meta?.sortKey ?? e.sortKey; const sortKey = e.meta?.sortKey ?? e.sortKey;
return { return {
id: convertId(id), id: keys.own(id),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined, createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined, updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId), workspaceId: convertId(workspaceId),
@@ -132,5 +132,13 @@
"name": "Dummy" "name": "Dummy"
} }
] ]
},
"sourceKeys": {
"GENERATE_ID::env_16c0dec5b77c414ae0e419b8f10c3701300c5900": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"GENERATE_ID::env_799ae3d723ef44af91b4817e5d057e6d": "env_799ae3d723ef44af91b4817e5d057e6d",
"GENERATE_ID::env_030fbfdbb274426ebd78e2e6518f8553": "env_030fbfdbb274426ebd78e2e6518f8553",
"GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3": "fld_859d1df78261463480b6a3a1419517e3",
"GENERATE_ID::req_84cd9ae4bd034dd8bb730e856a665cbb": "req_84cd9ae4bd034dd8bb730e856a665cbb",
"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019": "wrk_d4d92f7c0ee947b89159243506687019"
} }
} }
@@ -116,5 +116,13 @@
"headers": [] "headers": []
} }
] ]
},
"sourceKeys": {
"GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce": "env_e46dc73e8ccda30ca132153e8f11183bd08119ce",
"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7folder": "fld_296933ea4ea84783a775d199997e9be7folder",
"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7": "fld_296933ea4ea84783a775d199997e9be7",
"GENERATE_ID::req_9a80320365ac4509ade406359dbc6a71": "req_9a80320365ac4509ade406359dbc6a71",
"GENERATE_ID::req_e3f8cdbd58784a539dd4c1e127d73451": "req_e3f8cdbd58784a539dd4c1e127d73451",
"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c": "wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
} }
} }
@@ -189,5 +189,15 @@
"headers": [] "headers": []
} }
] ]
},
"sourceKeys": {
"GENERATE_ID::env_20945044d3c8497ca8b717bef750987e": "env_20945044d3c8497ca8b717bef750987e",
"GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2": "env_6f7728bb7fc04d558d668e954d756ea2",
"GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243": "env_976a8b6eb5d44fb6a20150f65c32d243",
"GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c": "fld_42eb2e2bb22b4cedacbd3d057634e80c",
"GENERATE_ID::greq_06d659324df94504a4d64632be7106b3": "greq_06d659324df94504a4d64632be7106b3",
"GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785": "req_d72fff2a6b104b91a2ebe9de9edd2785",
"GENERATE_ID::ws-req_5d1a4c7c79494743962e5176f6add270": "ws-req_5d1a4c7c79494743962e5176f6add270",
"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53": "wrk_c1eacfa750a04f3ea9985ef28043fa53"
} }
} }
@@ -24,6 +24,38 @@ describe("importer-yaak", () => {
expect(result).toEqual(parseJsonOrYaml(expected)); expect(result).toEqual(parseJsonOrYaml(expected));
}); });
} }
test("Keys resources by their Insomnia _id, unchanged by a rename", () => {
const collection = (requestName: string) =>
YAML.stringify({
type: "collection.insomnia.rest/5.0",
name: "Keys",
meta: { id: "wrk_1" },
environments: { meta: { id: "env_1" }, name: "Base", data: {} },
collection: [
{
meta: { id: "fld_1" },
name: "Folder",
children: [
{
meta: { id: "req_1" },
name: requestName,
method: "GET",
url: "https://yaak.app",
},
],
},
],
});
const before = convertInsomnia(collection("Original"));
const after = convertInsomnia(collection("Renamed"));
expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("req_1");
expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("req_1");
expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("fld_1");
expect(before?.sourceKeys?.[before.resources.workspaces[0]!.id]).toBe("wrk_1");
});
}); });
function parseJsonOrYaml(text: string): unknown { function parseJsonOrYaml(text: string): unknown {
+22
View File
@@ -110,6 +110,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
const folderIdsByTag = new Map<string, string>(); const folderIdsByTag = new Map<string, string>();
const routeLabels = new Map<string, string>(); const routeLabels = new Map<string, string>();
const sourceKeys: Record<string, string> = {};
for (const tag of toArray(spec.tags)) { for (const tag of toArray(spec.tags)) {
const tagRecord = toRecord(tag); const tagRecord = toRecord(tag);
const name = stringAt(tagRecord, "name"); const name = stringAt(tagRecord, "name");
@@ -126,6 +127,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
}; };
resources.folders.push(folder); resources.folders.push(folder);
folderIdsByTag.set(name, folder.id); folderIdsByTag.set(name, folder.id);
sourceKeys[folder.id] = tagSourceKey(name);
} }
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) { for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
@@ -139,6 +141,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
importState, importState,
operation, operation,
resources, resources,
sourceKeys,
workspaceId: workspace.id, workspaceId: workspace.id,
}); });
@@ -160,6 +163,11 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
authenticationVariables, authenticationVariables,
}); });
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`); routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
sourceKeys[request.id] = operationSourceKey(
stringAt(operation, "operationId"),
method,
rawPath,
);
resources.httpRequests.push(request); resources.httpRequests.push(request);
} }
} }
@@ -241,6 +249,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
websocketRequests: [], websocketRequests: [],
workspaces: resources.workspaces, workspaces: resources.workspaces,
}) as PartialImportResources, }) as PartialImportResources,
sourceKeys,
}; };
} }
@@ -681,12 +690,14 @@ function findOrCreateFolderId({
importState, importState,
operation, operation,
resources, resources,
sourceKeys,
workspaceId, workspaceId,
}: { }: {
folderIdsByTag: Map<string, string>; folderIdsByTag: Map<string, string>;
importState: ImportState; importState: ImportState;
operation: UnknownRecord; operation: UnknownRecord;
resources: ImportResources; resources: ImportResources;
sourceKeys: Record<string, string>;
workspaceId: string; workspaceId: string;
}): string | null { }): string | null {
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string"); const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
@@ -705,9 +716,20 @@ function findOrCreateFolderId({
}; };
resources.folders.push(folder); resources.folders.push(folder);
folderIdsByTag.set(tag, folder.id); folderIdsByTag.set(tag, folder.id);
sourceKeys[folder.id] = tagSourceKey(tag);
return folder.id; return folder.id;
} }
function operationSourceKey(operationId: string | undefined, method: string, path: string): string {
return operationId != null && operationId !== ""
? `op:${operationId}`
: `route:${method.toUpperCase()} ${path}`;
}
function tagSourceKey(tag: string): string {
return `tag:${tag}`;
}
/** /**
* Yaak's `:name` placeholders only substitute when they span a whole path * Yaak's `:name` placeholders only substitute when they span a whole path
* segment and hold a single plain value. Templates elsewhere in a segment * segment and hold a single plain value. Templates elsewhere in a segment
@@ -308,6 +308,16 @@ License: CC0 1.0 (https://github.com/APIs-guru/openapi-directory#licenses)",
}, },
], ],
}, },
"sourceKeys": {
"GENERATE_ID::FOLDER_0": "tag:APIs",
"GENERATE_ID::HTTP_REQUEST_0": "op:listAPIs",
"GENERATE_ID::HTTP_REQUEST_1": "op:getMetrics",
"GENERATE_ID::HTTP_REQUEST_2": "op:getProviders",
"GENERATE_ID::HTTP_REQUEST_3": "op:getAPI",
"GENERATE_ID::HTTP_REQUEST_4": "op:getServiceAPI",
"GENERATE_ID::HTTP_REQUEST_5": "op:getProvider",
"GENERATE_ID::HTTP_REQUEST_6": "op:getServices",
},
} }
`; `;
@@ -2600,6 +2610,97 @@ Contact: me@kennethreitz.org",
}, },
], ],
}, },
"sourceKeys": {
"GENERATE_ID::FOLDER_0": "tag:HTTP Methods",
"GENERATE_ID::FOLDER_1": "tag:Auth",
"GENERATE_ID::FOLDER_10": "tag:Anything",
"GENERATE_ID::FOLDER_2": "tag:Status codes",
"GENERATE_ID::FOLDER_3": "tag:Request inspection",
"GENERATE_ID::FOLDER_4": "tag:Response inspection",
"GENERATE_ID::FOLDER_5": "tag:Response formats",
"GENERATE_ID::FOLDER_6": "tag:Dynamic data",
"GENERATE_ID::FOLDER_7": "tag:Cookies",
"GENERATE_ID::FOLDER_8": "tag:Images",
"GENERATE_ID::FOLDER_9": "tag:Redirects",
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /absolute-redirect/{n}",
"GENERATE_ID::HTTP_REQUEST_1": "route:DELETE /anything",
"GENERATE_ID::HTTP_REQUEST_10": "route:POST /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_11": "route:PUT /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_12": "route:TRACE /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_13": "route:GET /base64/{value}",
"GENERATE_ID::HTTP_REQUEST_14": "route:GET /basic-auth/{user}/{passwd}",
"GENERATE_ID::HTTP_REQUEST_15": "route:GET /bearer",
"GENERATE_ID::HTTP_REQUEST_16": "route:GET /brotli",
"GENERATE_ID::HTTP_REQUEST_17": "route:GET /bytes/{n}",
"GENERATE_ID::HTTP_REQUEST_18": "route:GET /cache",
"GENERATE_ID::HTTP_REQUEST_19": "route:GET /cache/{value}",
"GENERATE_ID::HTTP_REQUEST_2": "route:GET /anything",
"GENERATE_ID::HTTP_REQUEST_20": "route:GET /cookies",
"GENERATE_ID::HTTP_REQUEST_21": "route:GET /cookies/delete",
"GENERATE_ID::HTTP_REQUEST_22": "route:GET /cookies/set",
"GENERATE_ID::HTTP_REQUEST_23": "route:GET /cookies/set/{name}/{value}",
"GENERATE_ID::HTTP_REQUEST_24": "route:GET /deflate",
"GENERATE_ID::HTTP_REQUEST_25": "route:DELETE /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_26": "route:GET /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_27": "route:PATCH /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_28": "route:POST /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_29": "route:PUT /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_3": "route:PATCH /anything",
"GENERATE_ID::HTTP_REQUEST_30": "route:TRACE /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_31": "route:DELETE /delete",
"GENERATE_ID::HTTP_REQUEST_32": "route:GET /deny",
"GENERATE_ID::HTTP_REQUEST_33": "route:GET /digest-auth/{qop}/{user}/{passwd}",
"GENERATE_ID::HTTP_REQUEST_34": "route:GET /digest-auth/{qop}/{user}/{passwd}/{algorithm}",
"GENERATE_ID::HTTP_REQUEST_35": "route:GET /digest-auth/{qop}/{user}/{passwd}/{algorithm}/{stale_after}",
"GENERATE_ID::HTTP_REQUEST_36": "route:GET /drip",
"GENERATE_ID::HTTP_REQUEST_37": "route:GET /encoding/utf8",
"GENERATE_ID::HTTP_REQUEST_38": "route:GET /etag/{etag}",
"GENERATE_ID::HTTP_REQUEST_39": "route:GET /get",
"GENERATE_ID::HTTP_REQUEST_4": "route:POST /anything",
"GENERATE_ID::HTTP_REQUEST_40": "route:GET /gzip",
"GENERATE_ID::HTTP_REQUEST_41": "route:GET /headers",
"GENERATE_ID::HTTP_REQUEST_42": "route:GET /hidden-basic-auth/{user}/{passwd}",
"GENERATE_ID::HTTP_REQUEST_43": "route:GET /html",
"GENERATE_ID::HTTP_REQUEST_44": "route:GET /image",
"GENERATE_ID::HTTP_REQUEST_45": "route:GET /image/jpeg",
"GENERATE_ID::HTTP_REQUEST_46": "route:GET /image/png",
"GENERATE_ID::HTTP_REQUEST_47": "route:GET /image/svg",
"GENERATE_ID::HTTP_REQUEST_48": "route:GET /image/webp",
"GENERATE_ID::HTTP_REQUEST_49": "route:GET /ip",
"GENERATE_ID::HTTP_REQUEST_5": "route:PUT /anything",
"GENERATE_ID::HTTP_REQUEST_50": "route:GET /json",
"GENERATE_ID::HTTP_REQUEST_51": "route:GET /links/{n}/{offset}",
"GENERATE_ID::HTTP_REQUEST_52": "route:PATCH /patch",
"GENERATE_ID::HTTP_REQUEST_53": "route:POST /post",
"GENERATE_ID::HTTP_REQUEST_54": "route:PUT /put",
"GENERATE_ID::HTTP_REQUEST_55": "route:GET /range/{numbytes}",
"GENERATE_ID::HTTP_REQUEST_56": "route:DELETE /redirect-to",
"GENERATE_ID::HTTP_REQUEST_57": "route:GET /redirect-to",
"GENERATE_ID::HTTP_REQUEST_58": "route:PATCH /redirect-to",
"GENERATE_ID::HTTP_REQUEST_59": "route:POST /redirect-to",
"GENERATE_ID::HTTP_REQUEST_6": "route:TRACE /anything",
"GENERATE_ID::HTTP_REQUEST_60": "route:PUT /redirect-to",
"GENERATE_ID::HTTP_REQUEST_61": "route:TRACE /redirect-to",
"GENERATE_ID::HTTP_REQUEST_62": "route:GET /redirect/{n}",
"GENERATE_ID::HTTP_REQUEST_63": "route:GET /relative-redirect/{n}",
"GENERATE_ID::HTTP_REQUEST_64": "route:GET /response-headers",
"GENERATE_ID::HTTP_REQUEST_65": "route:POST /response-headers",
"GENERATE_ID::HTTP_REQUEST_66": "route:GET /robots.txt",
"GENERATE_ID::HTTP_REQUEST_67": "route:DELETE /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_68": "route:GET /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_69": "route:PATCH /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_7": "route:DELETE /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_70": "route:POST /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_71": "route:PUT /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_72": "route:TRACE /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_73": "route:GET /stream-bytes/{n}",
"GENERATE_ID::HTTP_REQUEST_74": "route:GET /stream/{n}",
"GENERATE_ID::HTTP_REQUEST_75": "route:GET /user-agent",
"GENERATE_ID::HTTP_REQUEST_76": "route:GET /uuid",
"GENERATE_ID::HTTP_REQUEST_77": "route:GET /xml",
"GENERATE_ID::HTTP_REQUEST_8": "route:GET /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_9": "route:PATCH /anything/{anything}",
},
} }
`; `;
@@ -2734,6 +2835,10 @@ License: Apache 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html)",
}, },
], ],
}, },
"sourceKeys": {
"GENERATE_ID::FOLDER_0": "tag:request tag",
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /apod",
},
} }
`; `;
@@ -2834,5 +2939,9 @@ Responses:
}, },
], ],
}, },
"sourceKeys": {
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /info.0.json",
"GENERATE_ID::HTTP_REQUEST_1": "route:GET /{comicId}/info.0.json",
},
} }
`; `;
@@ -2382,4 +2382,38 @@ describe("importer-openapi", () => {
expect(imported).toMatchSnapshot(); expect(imported).toMatchSnapshot();
}); });
} }
test("Keys operations by operationId, unchanged by a rename", async () => {
const spec = (summary: string) =>
JSON.stringify({
openapi: "3.0.0",
info: { title: "Keys", version: "1" },
paths: {
"/pets": {
get: { operationId: "listPets", summary, tags: ["pets"], responses: {} },
},
},
});
const before = await convertOpenApi(spec("List pets"));
const after = await convertOpenApi(spec("Fetch every pet"));
expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("op:listPets");
expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("op:listPets");
expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("tag:pets");
});
test("Falls back to the route when an operation has no operationId", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Keys", version: "1" },
paths: { "/pets/{id}": { delete: { summary: "Remove", responses: {} } } },
}),
);
expect(imported?.sourceKeys?.[imported.resources.httpRequests[0]!.id]).toBe(
"route:DELETE /pets/{id}",
);
});
}); });
+10 -1
View File
@@ -49,6 +49,12 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
const globalAuth = importAuth(root.auth); const globalAuth = importAuth(root.auth);
const sourceKeys: Record<string, string> = {};
const trackSourceKey = (modelId: string, v: Record<string, unknown>, prefix: string) => {
const id = v.id ?? v._postman_id;
if (typeof id === "string" && id !== "") sourceKeys[modelId] = `${prefix}:${id}`;
};
const exportResources: ExportResources = { const exportResources: ExportResources = {
workspaces: [], workspaces: [],
environments: [], environments: [],
@@ -63,6 +69,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
description: importDescription(info.description), description: importDescription(info.description),
...globalAuth, ...globalAuth,
}; };
trackSourceKey(workspace.id, info, "collection");
exportResources.workspaces.push(workspace); exportResources.workspaces.push(workspace);
// Create the base environment // Create the base environment
@@ -92,6 +99,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
name: v.name, name: v.name,
folderId, folderId,
}; };
trackSourceKey(folder.id, v, "item");
exportResources.folders.push(folder); exportResources.folders.push(folder);
for (const child of v.item) { for (const child of v.item) {
importItem(child, folder.id); importItem(child, folder.id);
@@ -142,6 +150,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
headers, headers,
...requestAuth, ...requestAuth,
}; };
trackSourceKey(request.id, v, "item");
exportResources.httpRequests.push(request); exportResources.httpRequests.push(request);
} else { } else {
console.log("Unknown item", v, folderId); console.log("Unknown item", v, folderId);
@@ -156,7 +165,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
convertTemplateSyntax(exportResources), convertTemplateSyntax(exportResources),
) as PartialImportResources; ) as PartialImportResources;
return { resources }; return { resources, sourceKeys };
} }
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> { function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
@@ -300,5 +300,8 @@
} }
], ],
"folders": [] "folders": []
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_0": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
} }
} }
@@ -88,5 +88,8 @@
"folderId": "GENERATE_ID::FOLDER_0" "folderId": "GENERATE_ID::FOLDER_0"
} }
] ]
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_1": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
} }
} }

Some files were not shown because too many files have changed in this diff Show More