mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-06 18:07:18 +02:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
646f5a09b0 | ||
|
|
e47088b507 | ||
|
|
3ad6172bb1 | ||
|
|
7bd159b0b1 | ||
|
|
b08b3277da | ||
|
|
19a43e3785 | ||
|
|
77fe1367a0 | ||
|
|
862fb6d65a | ||
|
|
d2d4b80a09 | ||
|
|
d461c982ec | ||
|
|
8eebee460e | ||
|
|
81a2a5d955 | ||
|
|
e63a87718c | ||
|
|
222ea9cb83 | ||
|
|
360c098b09 | ||
|
|
fce1039bac | ||
|
|
f18f93d613 | ||
|
|
661a384bed | ||
|
|
50cccf1d25 | ||
|
|
0aae516f3a | ||
|
|
b7b8ae5f94 | ||
|
|
6777003b70 | ||
|
|
426c6d5eb6 | ||
|
|
068afe325e | ||
|
|
cef1129d4b | ||
|
|
9a7bcf73bb | ||
|
|
aa76d501f0 | ||
|
|
ce8cb5e7bc |
@@ -1,3 +0,0 @@
|
|||||||
**/bindings/**
|
|
||||||
**/routeTree.gen.ts
|
|
||||||
crates/yaak-templates/pkg/**
|
|
||||||
Generated
+5
@@ -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",
|
||||||
@@ -11723,7 +11725,10 @@ name = "yaak-system-appearance"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dark-light",
|
"dark-light",
|
||||||
|
"dispatch2",
|
||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation 0.3.1",
|
||||||
"tauri",
|
"tauri",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
|||||||
)}
|
)}
|
||||||
// If no environments, the button simply opens the dialog.
|
// If no environments, the button simply opens the dialog.
|
||||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
onClick={
|
||||||
|
subEnvironments.length === 0
|
||||||
|
? (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
editEnvironment(null);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import type {
|
|||||||
Folder,
|
Folder,
|
||||||
GrpcRequest,
|
GrpcRequest,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
|
HttpVersion,
|
||||||
InheritedBoolSetting,
|
InheritedBoolSetting,
|
||||||
|
InheritedHttpVersionSetting,
|
||||||
InheritedIntSetting,
|
InheritedIntSetting,
|
||||||
WebsocketRequest,
|
WebsocketRequest,
|
||||||
Workspace,
|
Workspace,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
modelSupportsSetting,
|
modelSupportsSetting,
|
||||||
type RequestSettingDefinition,
|
type RequestSettingDefinition,
|
||||||
SETTING_FOLLOW_REDIRECTS,
|
SETTING_FOLLOW_REDIRECTS,
|
||||||
|
SETTING_HTTP_VERSION,
|
||||||
SETTING_REQUEST_MESSAGE_SIZE,
|
SETTING_REQUEST_MESSAGE_SIZE,
|
||||||
SETTING_REQUEST_TIMEOUT,
|
SETTING_REQUEST_TIMEOUT,
|
||||||
SETTING_SEND_COOKIES,
|
SETTING_SEND_COOKIES,
|
||||||
@@ -21,6 +24,7 @@ import {
|
|||||||
} from "../lib/requestSettings";
|
} from "../lib/requestSettings";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
import { Select } from "./core/Select";
|
||||||
import {
|
import {
|
||||||
SettingOverrideRow,
|
SettingOverrideRow,
|
||||||
SettingRow,
|
SettingRow,
|
||||||
@@ -38,37 +42,21 @@ interface Props {
|
|||||||
model: ModelWithSettings;
|
model: ModelWithSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelWithSettings =
|
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||||
type ModelWithTlsSettings =
|
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||||
| Workspace
|
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
||||||
| Folder
|
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type ModelWithCookieSettings =
|
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest;
|
|
||||||
type ModelWithMessageSizeSettings =
|
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||||
type IntegerSetting = number | InheritedIntSetting;
|
type IntegerSetting = number | InheritedIntSetting;
|
||||||
|
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
|
||||||
type CookieSettingsPatch = {
|
type CookieSettingsPatch = {
|
||||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||||
};
|
};
|
||||||
type HttpSettingsPatch = {
|
type HttpSettingsPatch = {
|
||||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||||
|
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
|
||||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||||
};
|
};
|
||||||
type TlsSettingsPatch = {
|
type TlsSettingsPatch = {
|
||||||
@@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = {
|
|||||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModelSettingsEditor({
|
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
||||||
model,
|
|
||||||
showSectionTitles = false,
|
|
||||||
}: Props) {
|
|
||||||
const ancestors = useModelAncestors(model);
|
const ancestors = useModelAncestors(model);
|
||||||
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||||
@@ -154,12 +139,26 @@ export function ModelSettingsEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{supportsHttpSettings && (
|
||||||
|
<HttpVersionSettingRow
|
||||||
|
settingDefinition={SETTING_HTTP_VERSION}
|
||||||
|
setting={model.settingHttpVersion}
|
||||||
|
inheritedValue={resolveInheritedValue(
|
||||||
|
ancestors,
|
||||||
|
SETTING_HTTP_VERSION.modelKey,
|
||||||
|
model.settingHttpVersion,
|
||||||
|
)}
|
||||||
|
onChange={(settingHttpVersion) =>
|
||||||
|
patchHttpSettings(model, {
|
||||||
|
settingHttpVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
{supportsCookieSettings && (
|
{supportsCookieSettings && (
|
||||||
<SettingsSection
|
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
|
||||||
>
|
|
||||||
<BooleanSettingRow
|
<BooleanSettingRow
|
||||||
settingDefinition={SETTING_SEND_COOKIES}
|
settingDefinition={SETTING_SEND_COOKIES}
|
||||||
setting={model.settingSendCookies}
|
setting={model.settingSendCookies}
|
||||||
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||||
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
|
||||||
|
|
||||||
if (modelSupportsCookieSettings(model)) {
|
if (modelSupportsCookieSettings(model)) {
|
||||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||||
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
|||||||
settings.push(model.settingValidateCertificates);
|
settings.push(model.settingValidateCertificates);
|
||||||
|
|
||||||
if (modelSupportsHttpSettings(model)) {
|
if (modelSupportsHttpSettings(model)) {
|
||||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
settings.push(
|
||||||
|
model.settingFollowRedirects,
|
||||||
|
model.settingRequestTimeout,
|
||||||
|
model.settingHttpVersion,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelSupportsMessageSizeSettings(model)) {
|
if (modelSupportsMessageSizeSettings(model)) {
|
||||||
settings.push(model.settingRequestMessageSize);
|
settings.push(model.settingRequestMessageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings.filter(
|
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
.length;
|
||||||
).length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCookieSettings(
|
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||||
model: ModelWithCookieSettings,
|
|
||||||
patch: Partial<CookieSettingsPatch>,
|
|
||||||
) {
|
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -232,10 +231,7 @@ function patchCookieSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchHttpSettings(
|
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||||
model: ModelWithHttpSettings,
|
|
||||||
patch: Partial<HttpSettingsPatch>,
|
|
||||||
) {
|
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -246,10 +242,7 @@ function patchHttpSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchTlsSettings(
|
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||||
model: ModelWithTlsSettings,
|
|
||||||
patch: Partial<TlsSettingsPatch>,
|
|
||||||
) {
|
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsHttpSettings(
|
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithHttpSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsCookieSettings(
|
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithCookieSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsTlsSettings(
|
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithTlsSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,11 +304,7 @@ function BooleanSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
@@ -352,6 +335,63 @@ function BooleanSettingRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
|
||||||
|
{ label: "Automatic", value: "auto" },
|
||||||
|
{ label: "HTTP/1.1", value: "http1" },
|
||||||
|
{ label: "HTTP/2", value: "http2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function HttpVersionSettingRow({
|
||||||
|
inheritedValue,
|
||||||
|
setting,
|
||||||
|
settingDefinition,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
inheritedValue: HttpVersion;
|
||||||
|
setting: HttpVersionSetting;
|
||||||
|
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
|
||||||
|
onChange: (setting: HttpVersionSetting) => void;
|
||||||
|
}) {
|
||||||
|
const inherited = isInheritedSetting(setting);
|
||||||
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
|
|
||||||
|
if (!inherited) {
|
||||||
|
return (
|
||||||
|
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||||
|
<Select
|
||||||
|
hideLabel
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
|
label={settingDefinition.title}
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
|
options={HTTP_VERSION_OPTIONS}
|
||||||
|
onChange={(value) => onChange(value)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingOverrideRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
overridden={overridden}
|
||||||
|
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
hideLabel
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
|
label={settingDefinition.title}
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
|
options={HTTP_VERSION_OPTIONS}
|
||||||
|
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||||
|
/>
|
||||||
|
</SettingOverrideRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function IntegerSettingRow({
|
function IntegerSettingRow({
|
||||||
inheritedValue,
|
inheritedValue,
|
||||||
setting,
|
setting,
|
||||||
@@ -365,18 +405,11 @@ function IntegerSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
<SettingRow
|
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
>
|
|
||||||
<NumberUnitInput
|
<NumberUnitInput
|
||||||
name={settingDefinition.modelKey}
|
name={settingDefinition.modelKey}
|
||||||
label={settingDefinition.title}
|
label={settingDefinition.title}
|
||||||
@@ -429,20 +462,13 @@ function MessageSizeSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
const displayValue = formatMegabytes(value);
|
const displayValue = formatMegabytes(value);
|
||||||
const placeholder = "0";
|
const placeholder = "0";
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
<SettingRow
|
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
>
|
|
||||||
<MessageSizeInput
|
<MessageSizeInput
|
||||||
name={settingDefinition.modelKey}
|
name={settingDefinition.modelKey}
|
||||||
label={settingDefinition.title}
|
label={settingDefinition.title}
|
||||||
@@ -567,13 +593,18 @@ function resolveInheritedValue(
|
|||||||
key: BooleanWorkspaceSettingKey,
|
key: BooleanWorkspaceSettingKey,
|
||||||
fallback: BooleanSetting,
|
fallback: BooleanSetting,
|
||||||
): boolean;
|
): boolean;
|
||||||
|
function resolveInheritedValue(
|
||||||
|
ancestors: (Folder | Workspace)[],
|
||||||
|
key: "settingHttpVersion",
|
||||||
|
fallback: HttpVersionSetting,
|
||||||
|
): HttpVersion;
|
||||||
function resolveInheritedValue(
|
function resolveInheritedValue(
|
||||||
ancestors: (Folder | Workspace)[],
|
ancestors: (Folder | Workspace)[],
|
||||||
key: keyof WorkspaceSettings,
|
key: keyof WorkspaceSettings,
|
||||||
fallback: BooleanSetting | IntegerSetting,
|
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
||||||
) {
|
) {
|
||||||
for (const ancestor of ancestors) {
|
for (const ancestor of ancestors) {
|
||||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
||||||
if (isInheritedSetting(setting)) {
|
if (isInheritedSetting(setting)) {
|
||||||
if (setting.enabled === true) {
|
if (setting.enabled === true) {
|
||||||
return setting.value;
|
return setting.value;
|
||||||
@@ -589,6 +620,7 @@ function resolveInheritedValue(
|
|||||||
type WorkspaceSettings = Pick<
|
type WorkspaceSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
|
| "settingHttpVersion"
|
||||||
| "settingRequestMessageSize"
|
| "settingRequestMessageSize"
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
|
|||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<
|
type BooleanWorkspaceSettingKey = Exclude<
|
||||||
keyof WorkspaceSettings,
|
keyof WorkspaceSettings,
|
||||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function formatMegabytes(bytes: number) {
|
function formatMegabytes(bytes: number) {
|
||||||
const megabytes = bytes / BYTES_PER_MB;
|
const megabytes = bytes / BYTES_PER_MB;
|
||||||
return Number.isInteger(megabytes)
|
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||||
? `${megabytes}`
|
|
||||||
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMegabytes(value: string) {
|
function parseMegabytes(value: string) {
|
||||||
@@ -626,9 +656,5 @@ function isValidInteger(value: string) {
|
|||||||
function isValidMegabytes(value: string) {
|
function isValidMegabytes(value: string) {
|
||||||
if (value === "") return true;
|
if (value === "") return true;
|
||||||
const megabytes = Number(value);
|
const megabytes = Number(value);
|
||||||
return (
|
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
|
||||||
Number.isFinite(megabytes) &&
|
|
||||||
megabytes >= 0 &&
|
|
||||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models";
|
||||||
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
import { useRequestVersion } from "../hooks/useRequestVersion";
|
||||||
|
import { showDialog } from "../lib/dialog";
|
||||||
|
import { restoreRequestVersion } from "../lib/restoreRequestVersion";
|
||||||
|
import { Button } from "./core/Button";
|
||||||
|
import { DiffViewer } from "./core/Editor/DiffViewer";
|
||||||
|
import { Dropdown } from "./core/Dropdown";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
response: Pick<HttpResponse, "requestId" | "versionId">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers the request a response was sent from, when that is no longer the
|
||||||
|
* request you have.
|
||||||
|
*
|
||||||
|
* Hidden while the two agree, which is the overwhelmingly common case and the
|
||||||
|
* one where there is nothing to say. Responses recorded before versioning
|
||||||
|
* existed have no version and stay quiet forever.
|
||||||
|
*/
|
||||||
|
export function RequestVersionDropdown({ response }: Props) {
|
||||||
|
const { data: comparison } = useRequestVersion(response.versionId, response.requestId);
|
||||||
|
if (comparison == null || !comparison.differs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "View Diff",
|
||||||
|
leftSlot: <Icon icon="git_branch" />,
|
||||||
|
onSelect: () => showRequestVersionDiff(comparison),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Restore This Version",
|
||||||
|
leftSlot: <Icon icon="history" />,
|
||||||
|
onSelect: () => restoreRequestVersion(comparison.version),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
color="notice"
|
||||||
|
className="font-sans"
|
||||||
|
title="This request has changed since this response was sent"
|
||||||
|
forDropdown
|
||||||
|
>
|
||||||
|
Request Changed
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRequestVersionDiff(comparison: RequestVersionComparison) {
|
||||||
|
showDialog({
|
||||||
|
id: "request-version-diff",
|
||||||
|
title: "Request Changes Since This Response",
|
||||||
|
size: "full",
|
||||||
|
noPadding: true,
|
||||||
|
render: () => (
|
||||||
|
<div className="h-full flex flex-col px-4 pb-4">
|
||||||
|
<DiffViewer
|
||||||
|
original={toYaml(comparison.version.document)}
|
||||||
|
modified={toYaml(comparison.currentDocument)}
|
||||||
|
className="flex-1 min-h-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches how the Git dialog renders a model for diffing. */
|
||||||
|
function toYaml(document: unknown): string {
|
||||||
|
return stringify(document, { indent: 2, lineWidth: 0 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { CheckboxProps } from "./Checkbox";
|
||||||
|
import { Checkbox } from "./Checkbox";
|
||||||
|
|
||||||
|
export interface CheckboxTreeNode<T> {
|
||||||
|
key: string;
|
||||||
|
data: T;
|
||||||
|
children: CheckboxTreeNode<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
node: CheckboxTreeNode<T>;
|
||||||
|
depth?: number;
|
||||||
|
/** Return "hidden" to render row alignment space instead of a checkbox */
|
||||||
|
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
|
||||||
|
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
|
||||||
|
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
|
||||||
|
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
||||||
|
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
||||||
|
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
|
||||||
|
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
|
||||||
|
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
||||||
|
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
||||||
|
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxTree<T>(props: Props<T>) {
|
||||||
|
const { node, depth = 0 } = props;
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||||
|
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||||
|
|
||||||
|
const checked = props.checked(node);
|
||||||
|
const selected = props.isRowSelected?.(node) ?? false;
|
||||||
|
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
|
||||||
|
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
|
||||||
|
const rowContent = (
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
|
||||||
|
{props.renderRow(node)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"relative flex gap-1 w-full h-xs items-center",
|
||||||
|
selected ? "text-text" : "text-text-subtle",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && (
|
||||||
|
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||||
|
)}
|
||||||
|
{hasVisibleChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={collapsed ? "Expand" : "Collapse"}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
className="shrink-0 text-text-subtlest hocus:text-text"
|
||||||
|
onClick={() => setCollapsed((v) => !v)}
|
||||||
|
>
|
||||||
|
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden className="w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
{checked === "hidden" ? (
|
||||||
|
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
title={props.checkboxTitle?.(node) ?? "Toggle"}
|
||||||
|
hideLabel
|
||||||
|
disabled={props.isCheckboxDisabled?.(node)}
|
||||||
|
onChange={(checked) => props.onCheck(node, checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 min-w-0 flex text-left"
|
||||||
|
onClick={() => props.onSelectRow?.(node)}
|
||||||
|
>
|
||||||
|
{rowContent}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
rowContent
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed &&
|
||||||
|
node.children.map((child) => (
|
||||||
|
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRelevantNode<T>(
|
||||||
|
node: CheckboxTreeNode<T>,
|
||||||
|
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
|
||||||
|
): boolean {
|
||||||
|
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
|
||||||
|
}
|
||||||
@@ -10,11 +10,13 @@ export interface DialogProps {
|
|||||||
children: ReactNode;
|
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",
|
||||||
|
"…",
|
||||||
|
" ",
|
||||||
|
"İ",
|
||||||
|
"Σ",
|
||||||
|
"ς",
|
||||||
|
"日",
|
||||||
|
"🎉",
|
||||||
|
"Ⅻ",
|
||||||
|
"f",
|
||||||
|
"①",
|
||||||
|
"́",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Seeded, so a failure is the same failure next run */
|
||||||
|
function random(seed: number) {
|
||||||
|
let state = seed;
|
||||||
|
return () => {
|
||||||
|
state = (state * 1664525 + 1013904223) >>> 0;
|
||||||
|
return state / 2 ** 32;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const caseSensitive of [false, true]) {
|
||||||
|
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
|
||||||
|
const next = random(caseSensitive ? 20260831 : 7);
|
||||||
|
|
||||||
|
for (let round = 0; round < 400; round++) {
|
||||||
|
const doc = Array.from(
|
||||||
|
{ length: 2 + Math.floor(next() * 60) },
|
||||||
|
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
// Half the queries are lifted out of the document, so matches are actually found
|
||||||
|
const start = Math.floor(next() * doc.length);
|
||||||
|
const search =
|
||||||
|
next() < 0.5
|
||||||
|
? doc.slice(start, start + 1 + Math.floor(next() * 3))
|
||||||
|
: Array.from(
|
||||||
|
{ length: 1 + Math.floor(next() * 2) },
|
||||||
|
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||||
|
).join("");
|
||||||
|
if (search === "") continue;
|
||||||
|
|
||||||
|
const state = stateOf(doc);
|
||||||
|
const query = new SearchQuery({ search, caseSensitive });
|
||||||
|
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
|
||||||
|
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,232 @@
|
|||||||
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
|
import { 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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! }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
UpdateResponse,
|
UpdateResponse,
|
||||||
YaakNotification,
|
YaakNotification,
|
||||||
} from "@yaakapp-internal/tauri-client";
|
} from "@yaakapp-internal/tauri-client";
|
||||||
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { openSettings } from "../commands/openSettings";
|
import { openSettings } from "../commands/openSettings";
|
||||||
import { Button } from "../components/core/Button";
|
import { Button } from "../components/core/Button";
|
||||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||||
@@ -180,9 +180,65 @@ function showUpdateInstalledToast(version: string) {
|
|||||||
|
|
||||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||||
const UPDATE_TOAST_ID = "update-info";
|
const UPDATE_TOAST_ID = "update-info";
|
||||||
const { version, replyEventId, downloaded } = updateInfo;
|
const { version, replyEventId, downloaded, install } = updateInfo;
|
||||||
|
|
||||||
jotaiStore.set(updateAvailableAtom, { version, downloaded });
|
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
|
||||||
|
|
||||||
|
const whatsNewButton = (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="info"
|
||||||
|
variant="border"
|
||||||
|
rightSlot={<Icon icon="external_link" />}
|
||||||
|
onClick={async () => {
|
||||||
|
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
What's New
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (install !== "integrated") {
|
||||||
|
// Nothing to reply to here; the backend only told us so we can say how to update
|
||||||
|
const flatpak = install === "flatpak";
|
||||||
|
showToast({
|
||||||
|
id: UPDATE_TOAST_ID,
|
||||||
|
color: "info",
|
||||||
|
timeout: null,
|
||||||
|
message: (
|
||||||
|
<VStack>
|
||||||
|
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||||
|
<p className="text-text-subtle text-sm">
|
||||||
|
{flatpak ? (
|
||||||
|
<>
|
||||||
|
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Download the new version to upgrade."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
action: () => (
|
||||||
|
<HStack space={1.5}>
|
||||||
|
{!flatpak && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="info"
|
||||||
|
rightSlot={<Icon icon="external_link" />}
|
||||||
|
onClick={async () => {
|
||||||
|
await platform.openUrl("https://yaak.app/download");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{whatsNewButton}
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||||
@@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
|||||||
>
|
>
|
||||||
{downloaded ? "Install Now" : "Download and Install"}
|
{downloaded ? "Install Now" : "Download and Install"}
|
||||||
</ButtonInfiniteLoading>
|
</ButtonInfiniteLoading>
|
||||||
<Button
|
{whatsNewButton}
|
||||||
size="xs"
|
|
||||||
color="info"
|
|
||||||
variant="border"
|
|
||||||
rightSlot={<Icon icon="external_link" />}
|
|
||||||
onClick={async () => {
|
|
||||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
What's New
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import type { JsonSchema } from "./jsonSchemaExample";
|
||||||
|
import { buildExampleFromSchema } from "./jsonSchemaExample";
|
||||||
|
|
||||||
|
describe("buildExampleFromSchema", () => {
|
||||||
|
test("fills scalar fields with placeholders", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
age: { type: "number", format: "int32" },
|
||||||
|
active: { type: "boolean" },
|
||||||
|
data: { type: "string", format: "byte" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({
|
||||||
|
name: "",
|
||||||
|
age: 0,
|
||||||
|
active: false,
|
||||||
|
data: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encodes 64-bit integers as strings", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", format: "int64" },
|
||||||
|
count: { type: "string", format: "uint64" },
|
||||||
|
offset: { type: "string", format: "sfixed64" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fills date-time with a parseable timestamp", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: { createdAt: { type: "string", format: "date-time" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const example = buildExampleFromSchema(schema) as { createdAt: string };
|
||||||
|
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fills a duration with a value that parses", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: { timeout: { type: "string", format: "duration" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expands nested messages through $defs", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: { user: { $ref: "#/$defs/example.User" } },
|
||||||
|
$defs: {
|
||||||
|
"example.User": {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
address: { $ref: "#/$defs/example.Address" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"example.Address": {
|
||||||
|
type: "object",
|
||||||
|
properties: { city: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({
|
||||||
|
user: { name: "", address: { city: "" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gives repeated fields a single placeholder item", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tags: { type: "array", items: { type: "string" } },
|
||||||
|
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
|
||||||
|
unknown: { type: "array" },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({
|
||||||
|
tags: [""],
|
||||||
|
users: [{ name: "" }],
|
||||||
|
unknown: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the first value of an enum", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
|
||||||
|
empty: { type: "string", enum: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gives maps a single placeholder entry", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
labels: { type: "object", additionalProperties: { type: "string" } },
|
||||||
|
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({
|
||||||
|
labels: { key: "" },
|
||||||
|
users: { key: { name: "" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops at the root self-reference", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: { type: "string" },
|
||||||
|
children: { type: "array", items: { $ref: "#" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops at a cycle between messages", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||||
|
$defs: {
|
||||||
|
"example.Node": {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
parent: { $ref: "#/$defs/example.Node" },
|
||||||
|
leaf: { $ref: "#/$defs/example.Leaf" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"example.Leaf": {
|
||||||
|
type: "object",
|
||||||
|
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({
|
||||||
|
node: { name: "", parent: {}, leaf: { node: {} } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expands the same message twice when it is not on the same path", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
from: { $ref: "#/$defs/example.User" },
|
||||||
|
to: { $ref: "#/$defs/example.User" },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fills every branch of a flattened oneof", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
text: { type: "string" },
|
||||||
|
image: { $ref: "#/$defs/example.Image" },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
"example.Image": { type: "object", properties: { url: { type: "string" } } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({
|
||||||
|
id: "",
|
||||||
|
text: "",
|
||||||
|
image: { url: "" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops expanding once the node budget runs out", () => {
|
||||||
|
// Every level references the next one twice, so an unbounded walk would build 2^depth
|
||||||
|
// nodes without ever repeating a ref on the same path.
|
||||||
|
const depth = 16;
|
||||||
|
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
|
||||||
|
for (let i = 0; i < depth; i++) {
|
||||||
|
$defs[`d${i}`] = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
a: { $ref: `#/$defs/d${i + 1}` },
|
||||||
|
b: { $ref: `#/$defs/d${i + 1}` },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const example = buildExampleFromSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: { root: { $ref: "#/$defs/d0" } },
|
||||||
|
$defs,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
|
||||||
|
expect(countNodes(example)).toBeLessThan(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles messages without a known type", () => {
|
||||||
|
const schema: JsonSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
empty: {},
|
||||||
|
struct: { type: "object" },
|
||||||
|
missing: { $ref: "#/$defs/example.Nope" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function countNodes(value: unknown): number {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
|
||||||
|
}
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
|
||||||
|
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
|
||||||
|
*/
|
||||||
|
export type JsonSchema = {
|
||||||
|
type?: string;
|
||||||
|
format?: string;
|
||||||
|
properties?: Record<string, JsonSchema>;
|
||||||
|
items?: JsonSchema;
|
||||||
|
additionalProperties?: JsonSchema;
|
||||||
|
enum?: unknown[];
|
||||||
|
$defs?: Record<string, JsonSchema>;
|
||||||
|
$ref?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFS_PREFIX = "#/$defs/";
|
||||||
|
const ROOT_REF = "#";
|
||||||
|
|
||||||
|
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
|
||||||
|
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
|
||||||
|
|
||||||
|
// Refs on sibling branches each expand their own subtree, so a schema that references the
|
||||||
|
// same messages repeatedly can produce exponentially many nodes without ever cycling.
|
||||||
|
const MAX_NODES = 5000;
|
||||||
|
|
||||||
|
type Budget = { remaining: number };
|
||||||
|
|
||||||
|
/** Build a sample message with placeholder values for every field in the schema */
|
||||||
|
export function buildExampleFromSchema(schema: JsonSchema): unknown {
|
||||||
|
// The root is already being built, so a `#` ref anywhere below it is a cycle
|
||||||
|
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildValue(
|
||||||
|
schema: JsonSchema,
|
||||||
|
root: JsonSchema,
|
||||||
|
refPath: Set<string>,
|
||||||
|
budget: Budget,
|
||||||
|
): unknown {
|
||||||
|
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.remaining -= 1;
|
||||||
|
|
||||||
|
if (typeof schema.$ref === "string") {
|
||||||
|
if (refPath.has(schema.$ref)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const resolved = resolveRef(schema.$ref, root);
|
||||||
|
if (resolved == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(schema.enum)) {
|
||||||
|
return schema.enum[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (schema.type) {
|
||||||
|
case "object":
|
||||||
|
return buildObject(schema, root, refPath, budget);
|
||||||
|
case "array":
|
||||||
|
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
|
||||||
|
case "string":
|
||||||
|
return buildString(schema.format);
|
||||||
|
case "number":
|
||||||
|
return 0;
|
||||||
|
case "boolean":
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildObject(
|
||||||
|
schema: JsonSchema,
|
||||||
|
root: JsonSchema,
|
||||||
|
refPath: Set<string>,
|
||||||
|
budget: Budget,
|
||||||
|
): unknown {
|
||||||
|
if (schema.properties != null && typeof schema.properties === "object") {
|
||||||
|
const example: Record<string, unknown> = {};
|
||||||
|
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
||||||
|
example[name] = buildValue(propertySchema, root, refPath, budget);
|
||||||
|
}
|
||||||
|
return example;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps have no properties, only a value schema
|
||||||
|
if (schema.additionalProperties != null) {
|
||||||
|
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildString(format: string | undefined): string {
|
||||||
|
if (format === "date-time") {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
|
||||||
|
if (format === "duration") {
|
||||||
|
return "0s";
|
||||||
|
}
|
||||||
|
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
|
||||||
|
if (ref === ROOT_REF) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
if (!ref.startsWith(DEFS_PREFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
|
||||||
|
}
|
||||||
@@ -25,13 +25,8 @@ export async function showPromptForm({
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
hideX: true,
|
|
||||||
size: size ?? "sm",
|
size: size ?? "sm",
|
||||||
disableBackdropClose: true, // Prevent accidental dismisses
|
disableClose: true,
|
||||||
onClose: () => {
|
|
||||||
// Click backdrop, close, or escape
|
|
||||||
resolve(null);
|
|
||||||
},
|
|
||||||
render: ({ hide }) =>
|
render: ({ hide }) =>
|
||||||
Prompt({
|
Prompt({
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
|
|||||||
type WorkspaceRequestSettings = Pick<
|
type WorkspaceRequestSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
|
| "settingHttpVersion"
|
||||||
| "settingRequestMessageSize"
|
| "settingRequestMessageSize"
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
@@ -18,9 +19,7 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
|||||||
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
||||||
}[ModelType];
|
}[ModelType];
|
||||||
|
|
||||||
export type RequestSettingDefinition<
|
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
||||||
K extends RequestSettingKey = RequestSettingKey,
|
|
||||||
> = {
|
|
||||||
defaultValue: WorkspaceRequestSettings[K];
|
defaultValue: WorkspaceRequestSettings[K];
|
||||||
description: string;
|
description: string;
|
||||||
modelKey: K;
|
modelKey: K;
|
||||||
@@ -46,8 +45,7 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
|||||||
|
|
||||||
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
||||||
defaultValue: 64 * 1024 * 1024,
|
defaultValue: 64 * 1024 * 1024,
|
||||||
description:
|
description: "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||||
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
|
||||||
modelKey: "settingRequestMessageSize",
|
modelKey: "settingRequestMessageSize",
|
||||||
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
||||||
title: "Message Size Limit",
|
title: "Message Size Limit",
|
||||||
@@ -57,13 +55,7 @@ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description: "When disabled, skip validation of server certificates.",
|
description: "When disabled, skip validation of server certificates.",
|
||||||
modelKey: "settingValidateCertificates",
|
modelKey: "settingValidateCertificates",
|
||||||
models: [
|
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
||||||
"workspace",
|
|
||||||
"folder",
|
|
||||||
"http_request",
|
|
||||||
"websocket_request",
|
|
||||||
"grpc_request",
|
|
||||||
],
|
|
||||||
title: "Validate TLS certificates",
|
title: "Validate TLS certificates",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,10 +67,17 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
|||||||
title: "Follow redirects",
|
title: "Follow redirects",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SETTING_HTTP_VERSION = defineRequestSetting({
|
||||||
|
defaultValue: "auto",
|
||||||
|
description: "Force HTTP/1.1 or HTTP/2 for servers that don't negotiate the version correctly.",
|
||||||
|
modelKey: "settingHttpVersion",
|
||||||
|
models: ["workspace", "folder", "http_request"],
|
||||||
|
title: "HTTP version",
|
||||||
|
});
|
||||||
|
|
||||||
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description:
|
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||||
"Attach matching cookies from the active cookie jar to outgoing requests.",
|
|
||||||
modelKey: "settingSendCookies",
|
modelKey: "settingSendCookies",
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||||
title: "Automatically send cookies",
|
title: "Automatically send cookies",
|
||||||
@@ -86,8 +85,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
|
|||||||
|
|
||||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description:
|
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||||
"Save cookies from Set-Cookie response headers to the active cookie jar.",
|
|
||||||
modelKey: "settingStoreCookies",
|
modelKey: "settingStoreCookies",
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||||
title: "Automatically store cookies",
|
title: "Automatically store cookies",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { flushAllPendingPatches } from "@yaakapp-internal/models";
|
||||||
|
import type { ModelPayload, ModelVersion, ModelVersionReason } from "@yaakapp-internal/models";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { EditSessionTracker } from "./editSessionTracker";
|
||||||
|
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||||
|
import { jotaiStore } from "./jotai";
|
||||||
|
import { rpc } from "./rpc";
|
||||||
|
|
||||||
|
const REQUEST_MODELS = ["http_request", "grpc_request", "websocket_request"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the backend to capture a request's current content.
|
||||||
|
*
|
||||||
|
* Quiet by design: a version that fails to write is not worth a toast, because
|
||||||
|
* every caller below is reacting to the user leaving rather than asking for
|
||||||
|
* anything.
|
||||||
|
*/
|
||||||
|
export function snapshotRequestVersion(requestId: string, reason: ModelVersionReason) {
|
||||||
|
// Edits reach the database on a debounce, so flush before asking for a
|
||||||
|
// version of what is in it
|
||||||
|
flushAllPendingPatches();
|
||||||
|
rpc<ModelVersion>("models_snapshot_request", { requestId, reason }).catch((err: unknown) => {
|
||||||
|
console.warn("Failed to snapshot request version", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initRequestVersionSnapshots() {
|
||||||
|
const tracker = new EditSessionTracker(snapshotRequestVersion);
|
||||||
|
|
||||||
|
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||||
|
for (const payload of payloads) {
|
||||||
|
if (payload.change.type !== "upsert") continue;
|
||||||
|
if (!REQUEST_MODELS.includes(payload.model.model)) continue;
|
||||||
|
tracker.noteEdit(payload.model.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
|
||||||
|
jotaiStore.sub(activeRequestIdAtom, () => {
|
||||||
|
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
|
||||||
|
});
|
||||||
|
|
||||||
|
platform.window.onFocusChanged((focused) => {
|
||||||
|
if (!focused) tracker.noteBoundary();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closing is the last boundary there is. Nothing can be awaited here, but the
|
||||||
|
// write is already on its way and the backend outlives the window.
|
||||||
|
window.addEventListener("beforeunload", () => tracker.noteBoundary());
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||||
|
import type { ModelVersion } from "@yaakapp-internal/models";
|
||||||
|
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||||
|
import { fireAndForget } from "./fireAndForget";
|
||||||
|
import { rpc } from "./rpc";
|
||||||
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put an old version's content back into the live request.
|
||||||
|
*
|
||||||
|
* The backend captures whatever the request currently holds before
|
||||||
|
* overwriting it, so this is not a destructive action even when the last edit
|
||||||
|
* was never versioned — but the request is still rewritten under the user's
|
||||||
|
* cursor, so it is announced.
|
||||||
|
*/
|
||||||
|
export function restoreRequestVersion(version: ModelVersion) {
|
||||||
|
fireAndForget(
|
||||||
|
(async () => {
|
||||||
|
// The backend restores from the database, so anything still sitting in a
|
||||||
|
// debounce has to land first — otherwise it would overwrite the restore
|
||||||
|
await flushAllModelWrites();
|
||||||
|
const requestId = await rpc<string>("models_restore_request_version", {
|
||||||
|
versionId: version.id,
|
||||||
|
});
|
||||||
|
// The write came from this window, so the store's echo suppression would
|
||||||
|
// otherwise leave open editors showing what was there before
|
||||||
|
wasUpdatedExternally(requestId);
|
||||||
|
showToast({
|
||||||
|
id: "request-version-restored",
|
||||||
|
color: "success",
|
||||||
|
message: "Restored the request that produced this response",
|
||||||
|
});
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { initGit } from "./init/git";
|
import { 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"));
|
||||||
|
}
|
||||||
|
|||||||
+111
-24
@@ -1,48 +1,135 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
export type Cookie = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: CookieDomain;
|
||||||
|
expires: CookieExpires;
|
||||||
|
path: string;
|
||||||
|
secure: boolean;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: CookieSameSite | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||||
|
|
||||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||||
|
|
||||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||||
|
|
||||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
|
export type HttpRequest = {
|
||||||
/**
|
model: "http_request";
|
||||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
body: Record<string, any>;
|
||||||
|
bodyType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
method: string;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
|
*/
|
||||||
|
urlParameters: Array<HttpUrlParameter>;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializable representation of HTTP response events for DB storage.
|
* Serializable representation of HTTP response events for DB storage.
|
||||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||||
*/
|
*/
|
||||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
export type HttpResponseEventData =
|
||||||
|
| {
|
||||||
|
type: "setting";
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
source_model?: string;
|
||||||
|
source_id?: string;
|
||||||
|
source_name?: string;
|
||||||
|
}
|
||||||
|
| { type: "info"; message: string }
|
||||||
|
| {
|
||||||
|
type: "redirect";
|
||||||
|
url: string;
|
||||||
|
status: number;
|
||||||
|
behavior: string;
|
||||||
|
dropped_body: boolean;
|
||||||
|
dropped_headers: Array<string>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "send_url";
|
||||||
|
method: string;
|
||||||
|
scheme: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
path: string;
|
||||||
|
query: string;
|
||||||
|
fragment: string;
|
||||||
|
}
|
||||||
|
| { type: "receive_url"; version: string; status: string }
|
||||||
|
| { type: "header_up"; name: string; value: string }
|
||||||
|
| { type: "header_down"; name: string; value: string }
|
||||||
|
| { type: "chunk_sent"; bytes: number }
|
||||||
|
| { type: "chunk_received"; bytes: number }
|
||||||
|
| {
|
||||||
|
type: "dns_resolved";
|
||||||
|
hostname: string;
|
||||||
|
addresses: Array<string>;
|
||||||
|
duration: bigint;
|
||||||
|
overridden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string; value: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||||
* crosses from a tab to the Yaak server, and what the server reads.
|
* crosses from a tab to the Yaak server, and what the server reads.
|
||||||
*/
|
*/
|
||||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
export type HttpSendSettings = {
|
||||||
/**
|
validateCertificates: boolean;
|
||||||
* Milliseconds. Zero or negative means no timeout.
|
followRedirects: boolean;
|
||||||
*/
|
/**
|
||||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
|
* Milliseconds. Zero or negative means no timeout.
|
||||||
|
*/
|
||||||
|
timeoutMs: number;
|
||||||
|
sendCookies: boolean;
|
||||||
|
storeCookies: boolean;
|
||||||
|
httpVersion: HttpVersion;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpUrlParameter = { enabled?: boolean,
|
export type HttpUrlParameter = {
|
||||||
/**
|
enabled?: boolean;
|
||||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
/**
|
||||||
* Other entries are appended as query parameters
|
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||||
*/
|
* Other entries are appended as query parameters
|
||||||
name: string, value: string, id?: string, };
|
*/
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|
||||||
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ impl PreparedSend {
|
|||||||
let (client, resolver) = HttpConnectionOptions {
|
let (client, resolver) = HttpConnectionOptions {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
validate_certificates: self.settings.validate_certificates,
|
validate_certificates: self.settings.validate_certificates,
|
||||||
|
http_version: self.settings.http_version,
|
||||||
// The proxy connects directly. Going through a system proxy would move DNS, and
|
// The proxy connects directly. Going through a system proxy would move DNS, and
|
||||||
// therefore the address check, somewhere this process can't see.
|
// therefore the address check, somewhere this process can't see.
|
||||||
proxy: HttpConnectionProxySetting::Disabled,
|
proxy: HttpConnectionProxySetting::Disabled,
|
||||||
|
|||||||
+28
-6
@@ -1,15 +1,37 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string };
|
||||||
|
|
||||||
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
|
||||||
|
|
||||||
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
|
export type UpdateInfo = {
|
||||||
|
replyEventId: string;
|
||||||
|
version: string;
|
||||||
|
downloaded: boolean;
|
||||||
|
/**
|
||||||
|
* How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||||
|
* itself and the user is told how to update instead.
|
||||||
|
*/
|
||||||
|
install: UpdateInstall;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };
|
/**
|
||||||
|
* How an update can be applied to this install.
|
||||||
|
*/
|
||||||
|
export type UpdateInstall = "integrated" | "flatpak" | "manual";
|
||||||
|
|
||||||
|
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
|
||||||
|
|
||||||
export type UpdateResponseAction = "install" | "skip";
|
export type UpdateResponseAction = "install" | "skip";
|
||||||
|
|
||||||
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
export type YaakNotification = {
|
||||||
|
timestamp: string;
|
||||||
|
timeout: number | null;
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
message: string;
|
||||||
|
color: string | null;
|
||||||
|
action: YaakNotificationAction | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type YaakNotificationAction = { label: string, url: string, };
|
export type YaakNotificationAction = { label: string; url: string };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1367,6 +1383,16 @@ pub fn run() {
|
|||||||
debug!("Launched Yaak {:?}", info);
|
debug!("Launched Yaak {:?}", info);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
RunEvent::WindowEvent { event: WindowEvent::ThemeChanged(_), .. } => {
|
||||||
|
// On macOS this is how OS appearance changes arrive: tao observes
|
||||||
|
// AppleInterfaceThemeChangedNotification and emits it for every window
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
|
if let Some(state) =
|
||||||
|
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
|
||||||
|
{
|
||||||
|
yaak_system_appearance::emit_change(app_handle, &state);
|
||||||
|
}
|
||||||
|
}
|
||||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
if let Some(state) =
|
if let Some(state) =
|
||||||
@@ -1431,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 {
|
||||||
@@ -1472,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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -76,14 +75,6 @@ impl YaakUpdater {
|
|||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
update_trigger: UpdateTrigger,
|
update_trigger: UpdateTrigger,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
// Only AppImage supports updates on Linux, so skip if it's not
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
if std::env::var("APPIMAGE").is_err() {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = window.db().get_settings();
|
let settings = window.db().get_settings();
|
||||||
let update_key = format!("{:x}", md5::compute(settings.id));
|
let update_key = format!("{:x}", md5::compute(settings.id));
|
||||||
self.last_check = Some(Instant::now());
|
self.last_check = Some(Instant::now());
|
||||||
@@ -106,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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -130,6 +122,18 @@ impl YaakUpdater {
|
|||||||
Some(update) => {
|
Some(update) => {
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
|
// Only hand the artifact to the updater plugin when this install can
|
||||||
|
// apply it itself; otherwise tell the user how to update instead
|
||||||
|
let install = update_install_method(&update);
|
||||||
|
if install != UpdateInstall::Integrated {
|
||||||
|
info!(
|
||||||
|
"{} available, but this install updates via {install:?}",
|
||||||
|
update.version
|
||||||
|
);
|
||||||
|
notify_external_update(&w, &update, install);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Force native updater if specified (useful if a release broke the UI)
|
// Force native updater if specified (useful if a release broke the UI)
|
||||||
let native_install_mode =
|
let native_install_mode =
|
||||||
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
||||||
@@ -207,6 +211,23 @@ struct UpdateInfo {
|
|||||||
reply_event_id: String,
|
reply_event_id: String,
|
||||||
version: String,
|
version: String,
|
||||||
downloaded: bool,
|
downloaded: bool,
|
||||||
|
/// How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||||
|
/// itself and the user is told how to update instead.
|
||||||
|
install: UpdateInstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an update can be applied to this install.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export, export_to = "index.ts")]
|
||||||
|
enum UpdateInstall {
|
||||||
|
/// The app downloads and installs it itself
|
||||||
|
#[default]
|
||||||
|
Integrated,
|
||||||
|
/// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark)
|
||||||
|
Flatpak,
|
||||||
|
/// Nothing can install it in-app (distro package, Nix, unknown); download by hand
|
||||||
|
Manual,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||||
@@ -272,8 +293,12 @@ async fn start_integrated_update<R: Runtime>(
|
|||||||
let _guard = Unlisten { win: window, id: event_id };
|
let _guard = Unlisten { win: window, id: event_id };
|
||||||
|
|
||||||
// 2) Emit the event now that listener is in place
|
// 2) Emit the event now that listener is in place
|
||||||
let info =
|
let info = UpdateInfo {
|
||||||
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };
|
version: update.version.to_string(),
|
||||||
|
downloaded,
|
||||||
|
install: UpdateInstall::Integrated,
|
||||||
|
reply_event_id: reply_id,
|
||||||
|
};
|
||||||
window
|
window
|
||||||
.emit_to(window.label(), "update_available", &info)
|
.emit_to(window.label(), "update_available", &info)
|
||||||
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
||||||
@@ -306,6 +331,24 @@ async fn start_integrated_update<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the frontend about an update this install can't apply itself, so the user can be
|
||||||
|
/// told how to get it. Unlike the integrated flow, there is nothing to reply to.
|
||||||
|
fn notify_external_update<R: Runtime>(
|
||||||
|
window: &WebviewWindow<R>,
|
||||||
|
update: &Update,
|
||||||
|
install: UpdateInstall,
|
||||||
|
) {
|
||||||
|
let info = UpdateInfo {
|
||||||
|
version: update.version.to_string(),
|
||||||
|
downloaded: false,
|
||||||
|
install,
|
||||||
|
reply_event_id: generate_id(),
|
||||||
|
};
|
||||||
|
if let Err(e) = window.emit_to(window.label(), "update_available", &info) {
|
||||||
|
warn!("Failed to emit update_available: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||||
// If the frontend doesn't respond, fallback to native dialogs
|
// If the frontend doesn't respond, fallback to native dialogs
|
||||||
let confirmed = window
|
let confirmed = window
|
||||||
@@ -376,7 +419,137 @@ fn detect_install_mode() -> Option<&'static str> {
|
|||||||
return Some("nsis");
|
return Some("nsis");
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
None
|
if !cfg!(target_os = "linux") {
|
||||||
|
None
|
||||||
|
} else if is_flatpak() {
|
||||||
|
Some("flatpak")
|
||||||
|
} else {
|
||||||
|
linux_installer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatpak installs (e.g. FlatPark) are updated by flatpak from their remote; the in-app
|
||||||
|
/// updater can't write inside the sandbox and must not try.
|
||||||
|
fn is_flatpak() -> bool {
|
||||||
|
std::env::var_os("FLATPAK_ID").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How Yaak was installed on Linux, as far as the updater plugin can install into it.
|
||||||
|
///
|
||||||
|
/// The bundle type is stamped into the binary by the bundler, but that only says how the
|
||||||
|
/// binary was *packaged*: third-party packages (AUR, Nix, ...) repackage the .deb, and
|
||||||
|
/// letting dpkg/rpm replace those would stomp on another package manager's files. So a
|
||||||
|
/// deb/rpm install also has to be one the package manager actually owns. The AppImage
|
||||||
|
/// updater needs `$APPIMAGE` since that's the file it replaces.
|
||||||
|
fn linux_installer() -> Option<&'static str> {
|
||||||
|
use tauri::utils::{config::BundleType, platform::bundle_type};
|
||||||
|
match bundle_type() {
|
||||||
|
Some(BundleType::Deb) if package_manager_owns_exe("dpkg", "-S") => Some("deb"),
|
||||||
|
Some(BundleType::Rpm) if package_manager_owns_exe("rpm", "-qf") => Some("rpm"),
|
||||||
|
Some(BundleType::Deb) | Some(BundleType::Rpm) => None,
|
||||||
|
_ if std::env::var_os("APPIMAGE").is_some() => Some("appimage"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `cmd query_arg <current exe>` succeeds, i.e. that package manager knows the
|
||||||
|
/// running executable as one of its files. False when the tool isn't installed at all.
|
||||||
|
fn package_manager_owns_exe(cmd: &str, query_arg: &str) -> bool {
|
||||||
|
let Ok(exe) = std::env::current_exe() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
std::process::Command::new(cmd)
|
||||||
|
.arg(query_arg)
|
||||||
|
.arg(&exe)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the artifact the server returned can be applied to this install. On Linux the
|
||||||
|
/// server may hand back a different package format than the one installed, Flatpak can't
|
||||||
|
/// be written from inside the sandbox, and unknown install methods (distro packages,
|
||||||
|
/// Nix, ...) can't be updated in-app at all.
|
||||||
|
fn update_install_method(update: &Update) -> UpdateInstall {
|
||||||
|
// Dev-only override to preview the non-integrated flows on any OS:
|
||||||
|
// YAAK_SIMULATE_INSTALL=flatpak|manual
|
||||||
|
if is_dev() {
|
||||||
|
match std::env::var("YAAK_SIMULATE_INSTALL").as_deref() {
|
||||||
|
Ok("flatpak") => return UpdateInstall::Flatpak,
|
||||||
|
Ok("manual") => return UpdateInstall::Manual,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg!(target_os = "linux") {
|
||||||
|
return UpdateInstall::Integrated;
|
||||||
|
}
|
||||||
|
if is_flatpak() {
|
||||||
|
return UpdateInstall::Flatpak;
|
||||||
|
}
|
||||||
|
if artifact_matches_installer(linux_installer(), update.download_url.path()) {
|
||||||
|
UpdateInstall::Integrated
|
||||||
|
} else {
|
||||||
|
UpdateInstall::Manual
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the artifact at `url_path` is in the package format `installer` can install.
|
||||||
|
fn artifact_matches_installer(installer: Option<&str>, url_path: &str) -> bool {
|
||||||
|
let path = url_path.to_ascii_lowercase();
|
||||||
|
match installer {
|
||||||
|
Some("deb") => path.ends_with(".deb"),
|
||||||
|
Some("rpm") => path.ends_with(".rpm"),
|
||||||
|
Some("appimage") => path.ends_with(".appimage") || path.ends_with(".appimage.tar.gz"),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::artifact_matches_installer;
|
||||||
|
|
||||||
|
const BASE: &str = "/mountain-loop/yaak/releases/download/v2026.6.0/";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_package_format_is_installable() {
|
||||||
|
let cases = [
|
||||||
|
("deb", "yaak_2026.6.0_amd64.deb"),
|
||||||
|
("deb", "yaak_2026.6.0_arm64.deb"),
|
||||||
|
("rpm", "yaak-2026.6.0-1.x86_64.rpm"),
|
||||||
|
("rpm", "yaak-2026.6.0-1.aarch64.rpm"),
|
||||||
|
("appimage", "yaak_2026.6.0_amd64.AppImage"),
|
||||||
|
("appimage", "yaak_2026.6.0_amd64.AppImage.tar.gz"),
|
||||||
|
];
|
||||||
|
for (installer, asset) in cases {
|
||||||
|
assert!(
|
||||||
|
artifact_matches_installer(Some(installer), &format!("{BASE}{asset}")),
|
||||||
|
"{installer} should install {asset}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mismatched_package_format_is_not_installable() {
|
||||||
|
// What the server returns for every Linux install today
|
||||||
|
let appimage = format!("{BASE}yaak_2026.6.0_amd64.AppImage");
|
||||||
|
assert!(!artifact_matches_installer(Some("deb"), &appimage));
|
||||||
|
assert!(!artifact_matches_installer(Some("rpm"), &appimage));
|
||||||
|
|
||||||
|
let deb = format!("{BASE}yaak_2026.6.0_amd64.deb");
|
||||||
|
assert!(!artifact_matches_installer(Some("rpm"), &deb));
|
||||||
|
assert!(!artifact_matches_installer(Some("appimage"), &deb));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_installer_is_never_installable() {
|
||||||
|
for asset in ["yaak_2026.6.0_amd64.deb", "yaak_2026.6.0_amd64.AppImage"] {
|
||||||
|
assert!(!artifact_matches_installer(None, &format!("{BASE}{asset}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn install_update_maybe_download<R: Runtime>(
|
pub async fn install_update_maybe_download<R: Runtime>(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dark-light = "2.0.0"
|
dark-light = "2.0.0"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
dispatch2 = "0.3.0"
|
||||||
|
objc2-app-kit = { version = "0.3.1", features = ["NSAppearance", "NSApplication", "NSResponder"] }
|
||||||
|
objc2-foundation = { version = "0.3.1", features = ["NSArray", "NSString", "NSUserDefaults"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
tauri = { workspace = true }
|
tauri = { workspace = true }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Runtime};
|
|||||||
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
||||||
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -47,14 +47,12 @@ pub fn initialization_script(appearance: Appearance) -> String {
|
|||||||
|
|
||||||
/// Detect the appearance the OS prefers, independent of any appearance that has
|
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||||
/// been forced onto app windows (which is what the webview itself reports).
|
/// been forced onto app windows (which is what the webview itself reports).
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn system_appearance() -> Option<Appearance> {
|
pub fn system_appearance() -> Option<Appearance> {
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
if let Some(appearance) = gsettings_system_appearance() {
|
if let Some(appearance) = gsettings_system_appearance() {
|
||||||
return Some(appearance);
|
return Some(appearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On macOS this reads AppleInterfaceStyle from the global user defaults
|
|
||||||
match dark_light::detect() {
|
match dark_light::detect() {
|
||||||
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
|
||||||
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
|
||||||
@@ -66,11 +64,69 @@ pub fn system_appearance() -> Option<Appearance> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect the appearance the OS prefers, independent of any appearance that has
|
||||||
|
/// been forced onto app windows (which is what the webview itself reports).
|
||||||
|
///
|
||||||
|
/// This asks AppKit for the application's effective appearance, the same source tauri
|
||||||
|
/// uses for `window.theme()`, instead of reading `AppleInterfaceStyle` from the user
|
||||||
|
/// defaults: macOS 27 no longer reliably writes that key when dark mode is on, so anything
|
||||||
|
/// reading it sees light mode. Appearances forced per window (yaak-mac-window) don't reach
|
||||||
|
/// `NSApp`, so this is the OS preference.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn system_appearance() -> Option<Appearance> {
|
||||||
|
use objc2_app_kit::{NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication};
|
||||||
|
use objc2_foundation::NSArray;
|
||||||
|
|
||||||
|
// AppKit is main-thread only. Every caller runs there today; this keeps it correct if
|
||||||
|
// one ever doesn't.
|
||||||
|
dispatch2::run_on_main(|mtm| {
|
||||||
|
let app = NSApplication::sharedApplication(mtm);
|
||||||
|
|
||||||
|
// An appearance forced on the whole app (tauri's `set_theme` does this) would make
|
||||||
|
// the effective appearance report the override instead of the OS preference. Nothing
|
||||||
|
// in Yaak does that, but fall back to the user defaults if something ever does.
|
||||||
|
//
|
||||||
|
// SAFETY: Called on the main thread with the shared application
|
||||||
|
if unsafe { app.appearance() }.is_some() {
|
||||||
|
return defaults_appearance();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: The appearance names are AppKit constants that live for the whole process
|
||||||
|
let (dark, light) = unsafe { (NSAppearanceNameDarkAqua, NSAppearanceNameAqua) };
|
||||||
|
let names = NSArray::from_slice(&[dark, light]);
|
||||||
|
let best = app.effectiveAppearance().bestMatchFromAppearancesWithNames(&names)?;
|
||||||
|
|
||||||
|
// SAFETY: Both are valid strings
|
||||||
|
let is_dark = unsafe { best.isEqualToString(dark) };
|
||||||
|
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The appearance macOS persists to the global user defaults. Absent means light, except
|
||||||
|
/// on macOS 27, which stopped reliably writing the key. Only used when the effective
|
||||||
|
/// appearance is forced and can't be trusted.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn defaults_appearance() -> Option<Appearance> {
|
||||||
|
use objc2_foundation::{NSUserDefaults, ns_string};
|
||||||
|
|
||||||
|
// SAFETY: The standard defaults are a process-wide singleton and the key is a valid string
|
||||||
|
let style = unsafe {
|
||||||
|
NSUserDefaults::standardUserDefaults().stringForKey(ns_string!("AppleInterfaceStyle"))
|
||||||
|
};
|
||||||
|
|
||||||
|
// SAFETY: Both are valid strings
|
||||||
|
let is_dark = style.is_some_and(|style| unsafe { style.isEqualToString(ns_string!("Dark")) });
|
||||||
|
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
pub fn system_appearance() -> Option<Appearance> {
|
pub fn system_appearance() -> Option<Appearance> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start tracking the OS appearance. Linux polls for changes. macOS gets them from tauri's
|
||||||
|
/// `WindowEvent::ThemeChanged` (tao observes `AppleInterfaceThemeChangedNotification`), which
|
||||||
|
/// the app forwards to [`emit_change`], so no thread is needed there.
|
||||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||||
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
|
||||||
let last_appearance = system_appearance();
|
let last_appearance = system_appearance();
|
||||||
@@ -80,13 +136,19 @@ pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
|
||||||
let thread_state = state.clone();
|
|
||||||
let _ = std::thread::spawn(move || {
|
#[cfg(target_os = "linux")]
|
||||||
loop {
|
{
|
||||||
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
let thread_state = state.clone();
|
||||||
emit_change(&app_handle, &thread_state);
|
let _ = std::thread::spawn(move || {
|
||||||
}
|
loop {
|
||||||
});
|
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
|
||||||
|
emit_change(&app_handle, &thread_state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let _ = app_handle;
|
||||||
|
|
||||||
Some(state)
|
Some(state)
|
||||||
}
|
}
|
||||||
|
|||||||
+518
-72
@@ -1,127 +1,573 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
|
export type AnyModel =
|
||||||
|
| CookieJar
|
||||||
|
| Environment
|
||||||
|
| Folder
|
||||||
|
| GraphQlIntrospection
|
||||||
|
| GrpcConnection
|
||||||
|
| GrpcEvent
|
||||||
|
| GrpcRequest
|
||||||
|
| HttpRequest
|
||||||
|
| HttpResponse
|
||||||
|
| HttpResponseEvent
|
||||||
|
| ImportSource
|
||||||
|
| KeyValue
|
||||||
|
| Plugin
|
||||||
|
| Settings
|
||||||
|
| SyncState
|
||||||
|
| WebsocketConnection
|
||||||
|
| WebsocketEvent
|
||||||
|
| WebsocketRequest
|
||||||
|
| Workspace
|
||||||
|
| WorkspaceMeta;
|
||||||
|
|
||||||
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
|
export type ClientCertificate = {
|
||||||
|
host: string;
|
||||||
|
port: number | null;
|
||||||
|
crtFile: string | null;
|
||||||
|
keyFile: string | null;
|
||||||
|
pfxFile: string | null;
|
||||||
|
passphrase: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
export type Cookie = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: CookieDomain;
|
||||||
|
expires: CookieExpires;
|
||||||
|
path: string;
|
||||||
|
secure: boolean;
|
||||||
|
httpOnly: boolean;
|
||||||
|
sameSite: CookieSameSite | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||||
|
|
||||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||||
|
|
||||||
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
|
export type CookieJar = {
|
||||||
|
model: "cookie_jar";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
cookies: Array<Cookie>;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||||
|
|
||||||
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
|
export type DnsOverride = {
|
||||||
|
hostname: string;
|
||||||
|
ipv4: Array<string>;
|
||||||
|
ipv6: Array<string>;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
||||||
|
|
||||||
export type EncryptedKey = { encryptedKey: string, };
|
export type EncryptedKey = { encryptedKey: string };
|
||||||
|
|
||||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
|
export type Environment = {
|
||||||
/**
|
model: "environment";
|
||||||
* Variables defined in this environment scope.
|
id: string;
|
||||||
* Child environments override parent variables by name.
|
workspaceId: string;
|
||||||
*/
|
createdAt: string;
|
||||||
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
|
updatedAt: string;
|
||||||
|
name: string;
|
||||||
|
public: boolean;
|
||||||
|
parentModel: string;
|
||||||
|
parentId: string | null;
|
||||||
|
/**
|
||||||
|
* Variables defined in this environment scope.
|
||||||
|
* Child environments override parent variables by name.
|
||||||
|
*/
|
||||||
|
variables: Array<EnvironmentVariable>;
|
||||||
|
color: string | null;
|
||||||
|
sortPriority: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
|
|
||||||
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, };
|
export type Folder = {
|
||||||
|
model: "folder";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
|
export type GraphQlIntrospection = {
|
||||||
|
model: "graphql_introspection";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
content: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
|
export type GrpcConnection = {
|
||||||
|
model: "grpc_connection";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
elapsed: number;
|
||||||
|
error: string | null;
|
||||||
|
method: string;
|
||||||
|
service: string;
|
||||||
|
status: number;
|
||||||
|
state: GrpcConnectionState;
|
||||||
|
trailers: { [key in string]?: string };
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* The request version this connection was opened from, when one was captured.
|
||||||
|
*/
|
||||||
|
versionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||||
|
|
||||||
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
|
export type GrpcEvent = {
|
||||||
|
model: "grpc_event";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
connectionId: string;
|
||||||
|
content: string;
|
||||||
|
error: string | null;
|
||||||
|
eventType: GrpcEventType;
|
||||||
|
metadata: { [key in string]?: string };
|
||||||
|
status: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
|
export type GrpcEventType =
|
||||||
|
| "info"
|
||||||
|
| "error"
|
||||||
|
| "client_message"
|
||||||
|
| "server_message"
|
||||||
|
| "connection_start"
|
||||||
|
| "connection_end";
|
||||||
|
|
||||||
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
|
export type GrpcRequest = {
|
||||||
/**
|
model: "grpc_request";
|
||||||
* Server URL (http for plaintext or https for secure)
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authenticationType: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
description: string;
|
||||||
|
message: string;
|
||||||
|
metadata: Array<HttpRequestHeader>;
|
||||||
|
method: string | null;
|
||||||
|
name: string;
|
||||||
|
service: string | null;
|
||||||
|
sortPriority: number;
|
||||||
|
/**
|
||||||
|
* Server URL (http for plaintext or https for secure)
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
|
export type HttpRequest = {
|
||||||
/**
|
model: "http_request";
|
||||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
body: Record<string, any>;
|
||||||
|
bodyType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
method: string;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
|
*/
|
||||||
|
urlParameters: Array<HttpUrlParameter>;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
|
|
||||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
export type HttpResponse = {
|
||||||
|
model: "http_response";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
contentLength: number | null;
|
||||||
|
contentLengthCompressed: number | null;
|
||||||
|
elapsed: number;
|
||||||
|
elapsedHeaders: number;
|
||||||
|
elapsedDns: number;
|
||||||
|
error: string | null;
|
||||||
|
headers: Array<HttpResponseHeader>;
|
||||||
|
remoteAddr: string | null;
|
||||||
|
requestContentLength: number | null;
|
||||||
|
requestHeaders: Array<HttpResponseHeader>;
|
||||||
|
status: number;
|
||||||
|
statusReason: string | null;
|
||||||
|
state: HttpResponseState;
|
||||||
|
url: string;
|
||||||
|
version: string | null;
|
||||||
|
/**
|
||||||
|
* The request version this response was sent from, when one was captured.
|
||||||
|
*/
|
||||||
|
versionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
export type HttpResponseEvent = {
|
||||||
|
model: "http_response_event";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
responseId: string;
|
||||||
|
event: HttpResponseEventData;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializable representation of HTTP response events for DB storage.
|
* Serializable representation of HTTP response events for DB storage.
|
||||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||||
*/
|
*/
|
||||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
export type HttpResponseEventData =
|
||||||
|
| {
|
||||||
|
type: "setting";
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
source_model?: string;
|
||||||
|
source_id?: string;
|
||||||
|
source_name?: string;
|
||||||
|
}
|
||||||
|
| { type: "info"; message: string }
|
||||||
|
| {
|
||||||
|
type: "redirect";
|
||||||
|
url: string;
|
||||||
|
status: number;
|
||||||
|
behavior: string;
|
||||||
|
dropped_body: boolean;
|
||||||
|
dropped_headers: Array<string>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "send_url";
|
||||||
|
method: string;
|
||||||
|
scheme: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
path: string;
|
||||||
|
query: string;
|
||||||
|
fragment: string;
|
||||||
|
}
|
||||||
|
| { type: "receive_url"; version: string; status: string }
|
||||||
|
| { type: "header_up"; name: string; value: string }
|
||||||
|
| { type: "header_down"; name: string; value: string }
|
||||||
|
| { type: "chunk_sent"; bytes: number }
|
||||||
|
| { type: "chunk_received"; bytes: number }
|
||||||
|
| {
|
||||||
|
type: "dns_resolved";
|
||||||
|
hostname: string;
|
||||||
|
addresses: Array<string>;
|
||||||
|
duration: bigint;
|
||||||
|
overridden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type HttpResponseHeader = { name: string, value: string, };
|
export type HttpResponseHeader = { name: string; value: string };
|
||||||
|
|
||||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||||
|
|
||||||
|
export type HttpUrlParameter = {
|
||||||
|
enabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||||
|
* Other entries are appended as query parameters
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
|
export type ImportSource = {
|
||||||
|
model: "import_source";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
importer: string;
|
||||||
|
origin: string;
|
||||||
|
originLabel: string;
|
||||||
|
lastImportedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|
||||||
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
|
|
||||||
|
export type KeyValue = {
|
||||||
|
model: "key_value";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
key: string;
|
||||||
|
namespace: 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;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
* Why a version was captured. Not a UI label — the frontend decides how to
|
||||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
* phrase these — but it is what makes a history readable when debugging.
|
||||||
* crosses from a tab to the Yaak server, and what the server reads.
|
|
||||||
*/
|
*/
|
||||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual";
|
||||||
/**
|
|
||||||
* Milliseconds. Zero or negative means no timeout.
|
|
||||||
*/
|
|
||||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
|
|
||||||
|
|
||||||
export type HttpUrlParameter = { enabled?: boolean,
|
export type Plugin = {
|
||||||
/**
|
model: "plugin";
|
||||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
id: string;
|
||||||
* Other entries are appended as query parameters
|
createdAt: string;
|
||||||
*/
|
updatedAt: string;
|
||||||
name: string, value: string, id?: string, };
|
checkedAt: string | null;
|
||||||
|
directory: string;
|
||||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
enabled: boolean;
|
||||||
|
url: string | null;
|
||||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
source: PluginSource;
|
||||||
|
};
|
||||||
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
|
|
||||||
|
|
||||||
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
|
|
||||||
|
|
||||||
export type PluginSource = "bundled" | "filesystem" | "registry";
|
export type PluginSource = "bundled" | "filesystem" | "registry";
|
||||||
|
|
||||||
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
|
export type ProxySetting =
|
||||||
|
| {
|
||||||
|
type: "enabled";
|
||||||
|
http: string;
|
||||||
|
https: string;
|
||||||
|
auth: ProxySettingAuth | null;
|
||||||
|
bypass: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
| { type: "disabled" };
|
||||||
|
|
||||||
export type ProxySettingAuth = { user: string, password: string, };
|
export type ProxySettingAuth = { user: string; password: string };
|
||||||
|
|
||||||
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<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 SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
|
export type Settings = {
|
||||||
|
model: "settings";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
appearance: string;
|
||||||
|
clientCertificates: Array<ClientCertificate>;
|
||||||
|
coloredMethods: boolean;
|
||||||
|
editorFont: string | null;
|
||||||
|
editorFontSize: number;
|
||||||
|
editorKeymap: EditorKeymap;
|
||||||
|
editorSoftWrap: boolean;
|
||||||
|
hideWindowControls: boolean;
|
||||||
|
useNativeTitlebar: boolean;
|
||||||
|
interfaceFont: string | null;
|
||||||
|
interfaceFontSize: number;
|
||||||
|
interfaceScale: number;
|
||||||
|
openWorkspaceNewWindow: boolean | null;
|
||||||
|
proxy: ProxySetting | null;
|
||||||
|
themeDark: string;
|
||||||
|
themeLight: string;
|
||||||
|
updateChannel: string;
|
||||||
|
hideLicenseBadge: boolean;
|
||||||
|
promptFeedback: boolean;
|
||||||
|
autoupdate: boolean;
|
||||||
|
autoDownloadUpdates: boolean;
|
||||||
|
checkNotifications: boolean;
|
||||||
|
hotkeys: { [key in string]?: Array<string> };
|
||||||
|
};
|
||||||
|
|
||||||
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
|
export type SyncModel =
|
||||||
|
| ({ type: "workspace" } & Workspace)
|
||||||
|
| ({ type: "environment" } & Environment)
|
||||||
|
| ({ type: "folder" } & Folder)
|
||||||
|
| ({ type: "http_request" } & HttpRequest)
|
||||||
|
| ({ type: "grpc_request" } & GrpcRequest)
|
||||||
|
| ({ type: "websocket_request" } & WebsocketRequest);
|
||||||
|
|
||||||
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
|
export type SyncState = {
|
||||||
|
model: "sync_state";
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
flushedAt: string;
|
||||||
|
modelId: string;
|
||||||
|
checksum: string;
|
||||||
|
relPath: string;
|
||||||
|
syncDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebsocketConnection = {
|
||||||
|
model: "websocket_connection";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
elapsed: number;
|
||||||
|
error: string | null;
|
||||||
|
headers: Array<HttpResponseHeader>;
|
||||||
|
state: WebsocketConnectionState;
|
||||||
|
status: number;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* The request version this connection was opened from, when one was captured.
|
||||||
|
*/
|
||||||
|
versionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||||
|
|
||||||
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
|
export type WebsocketEvent = {
|
||||||
|
model: "websocket_event";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
requestId: string;
|
||||||
|
connectionId: string;
|
||||||
|
isServer: boolean;
|
||||||
|
message: Array<number>;
|
||||||
|
messageType: WebsocketEventType;
|
||||||
|
};
|
||||||
|
|
||||||
export type WebsocketEventType = "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
export type WebsocketEventType =
|
||||||
|
| "binary"
|
||||||
|
| "close"
|
||||||
|
| "error"
|
||||||
|
| "frame"
|
||||||
|
| "open"
|
||||||
|
| "ping"
|
||||||
|
| "pong"
|
||||||
|
| "text";
|
||||||
|
|
||||||
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
|
export type WebsocketRequest = {
|
||||||
/**
|
model: "websocket_request";
|
||||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
id: string;
|
||||||
*/
|
createdAt: string;
|
||||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
message: string;
|
||||||
|
name: string;
|
||||||
|
sortPriority: number;
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
|
*/
|
||||||
|
urlParameters: Array<HttpUrlParameter>;
|
||||||
|
settingSendCookies: InheritedBoolSetting;
|
||||||
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
};
|
||||||
|
|
||||||
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, };
|
export type Workspace = {
|
||||||
|
model: "workspace";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
authentication: Record<string, any>;
|
||||||
|
authenticationType: string | null;
|
||||||
|
description: string;
|
||||||
|
headers: Array<HttpRequestHeader>;
|
||||||
|
name: string;
|
||||||
|
encryptionKeyChallenge: string | null;
|
||||||
|
settingValidateCertificates: boolean;
|
||||||
|
settingFollowRedirects: boolean;
|
||||||
|
settingRequestTimeout: number;
|
||||||
|
settingRequestMessageSize: number;
|
||||||
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
|
settingSendCookies: boolean;
|
||||||
|
settingStoreCookies: boolean;
|
||||||
|
settingHttpVersion: HttpVersion;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
|
export type WorkspaceMeta = {
|
||||||
|
model: "workspace_meta";
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
encryptionKey: EncryptedKey | null;
|
||||||
|
settingSyncDir: string | null;
|
||||||
|
};
|
||||||
|
|||||||
+17
-5
File diff suppressed because one or more lines are too long
+44
@@ -2,3 +2,47 @@
|
|||||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
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";
|
||||||
|
|||||||
@@ -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,14 +6,11 @@
|
|||||||
//! own environment chain before a plugin sees them, or an auth plugin receives
|
//! own environment chain before a plugin sees them, or an auth plugin receives
|
||||||
//! `${[ api_key ]}` where it expected a key.
|
//! `${[ api_key ]}` where it expected a key.
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::Result;
|
||||||
use crate::host::PluginHost;
|
use crate::host::PluginHost;
|
||||||
use crate::render::render_json_value;
|
use crate::render::render_form_values;
|
||||||
use std::collections::HashMap;
|
|
||||||
use yaak_models::models::AnyModel;
|
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
|
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, RenderPurpose,
|
||||||
RenderPurpose,
|
|
||||||
};
|
};
|
||||||
use yaak_rpc_schema::*;
|
use yaak_rpc_schema::*;
|
||||||
use yaak_templates::RenderOptions;
|
use yaak_templates::RenderOptions;
|
||||||
@@ -31,7 +28,7 @@ pub async fn cmd_get_http_authentication_config<H: PluginHost>(
|
|||||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||||
// A config form is being displayed, so a template that cannot resolve
|
// A config form is being displayed, so a template that cannot resolve
|
||||||
// should show as blank rather than refuse to open the form.
|
// should show as blank rather than refuse to open the form.
|
||||||
let values = render_auth_values(
|
let values = render_form_values(
|
||||||
&host,
|
&host,
|
||||||
&req.model,
|
&req.model,
|
||||||
req.environment_id.as_deref(),
|
req.environment_id.as_deref(),
|
||||||
@@ -50,7 +47,7 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// An action actually uses these values, so an unresolvable template is an
|
// An action actually uses these values, so an unresolvable template is an
|
||||||
// error rather than an empty string that would silently authenticate wrong.
|
// error rather than an empty string that would silently authenticate wrong.
|
||||||
let values = render_auth_values(
|
let values = render_form_values(
|
||||||
&host,
|
&host,
|
||||||
&req.model,
|
&req.model,
|
||||||
req.environment_id.as_deref(),
|
req.environment_id.as_deref(),
|
||||||
@@ -63,40 +60,3 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
|||||||
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the form's values against the environment chain the model sits in.
|
|
||||||
///
|
|
||||||
/// The chain depends on where the model lives — a request inherits through its
|
|
||||||
/// folder, a workspace has only its own — so the model is what decides which
|
|
||||||
/// variables are in scope.
|
|
||||||
async fn render_auth_values<H: PluginHost>(
|
|
||||||
host: &H,
|
|
||||||
model: &AnyModel,
|
|
||||||
environment_id: Option<&str>,
|
|
||||||
values: HashMap<String, JsonPrimitive>,
|
|
||||||
purpose: RenderPurpose,
|
|
||||||
options: &RenderOptions,
|
|
||||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
|
||||||
let (workspace_id, folder_id) = match model {
|
|
||||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
|
||||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
|
||||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
|
||||||
other => {
|
|
||||||
return Err(Error::Generic(format!(
|
|
||||||
"Cannot resolve authentication for a {}",
|
|
||||||
other.model()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let environment_chain =
|
|
||||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
|
||||||
|
|
||||||
let cb = host.template_callback(purpose);
|
|
||||||
let rendered =
|
|
||||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
|
||||||
|
|
||||||
Ok(serde_json::from_value(rendered)?)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -130,6 +135,7 @@ pub trait PluginHost: Host {
|
|||||||
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
||||||
|
|
||||||
/// The form a template function wants to show for the given values.
|
/// The form a template function wants to show for the given values.
|
||||||
|
/// `values` arrive already rendered.
|
||||||
fn template_function_config(
|
fn template_function_config(
|
||||||
&self,
|
&self,
|
||||||
function_name: &str,
|
function_name: &str,
|
||||||
|
|||||||
@@ -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,12 +1,19 @@
|
|||||||
//! Rendering a template against an environment chain.
|
//! Rendering a template against an environment chain.
|
||||||
//!
|
//!
|
||||||
//! The variables come from the chain, the functions come from the host's
|
//! The variables come from the chain, the functions come from the host's
|
||||||
//! template callback. Neither of these knows which host it is running under —
|
//! template callback. `render_template` and `render_json_value` know nothing
|
||||||
//! that is the whole point of taking the callback as a parameter.
|
//! about which host they run under — that is the whole point of taking the
|
||||||
|
//! callback as a parameter. `render_form_values` sits one level up: resolving
|
||||||
|
//! the chain a model sits in is an ordinary database read, so it takes the
|
||||||
|
//! host and does that read before rendering.
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::host::PluginHost;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use yaak_models::models::Environment;
|
use std::collections::HashMap;
|
||||||
|
use yaak_models::models::{AnyModel, Environment};
|
||||||
use yaak_models::render::make_vars_hashmap;
|
use yaak_models::render::make_vars_hashmap;
|
||||||
|
use yaak_plugins::events::{JsonPrimitive, RenderPurpose};
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||||
|
|
||||||
pub async fn render_template<T: TemplateCallback>(
|
pub async fn render_template<T: TemplateCallback>(
|
||||||
@@ -28,3 +35,40 @@ pub async fn render_json_value<T: TemplateCallback>(
|
|||||||
let vars = &make_vars_hashmap(environment_chain);
|
let vars = &make_vars_hashmap(environment_chain);
|
||||||
render_json_value_raw(value, vars, cb, opt).await
|
render_json_value_raw(value, vars, cb, opt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render a config form's values against the environment chain the model sits in.
|
||||||
|
///
|
||||||
|
/// The chain depends on where the model lives — a request inherits through its
|
||||||
|
/// folder, a workspace has only its own — so the model is what decides which
|
||||||
|
/// variables are in scope.
|
||||||
|
pub(crate) async fn render_form_values<H: PluginHost>(
|
||||||
|
host: &H,
|
||||||
|
model: &AnyModel,
|
||||||
|
environment_id: Option<&str>,
|
||||||
|
values: HashMap<String, JsonPrimitive>,
|
||||||
|
purpose: RenderPurpose,
|
||||||
|
options: &RenderOptions,
|
||||||
|
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||||
|
let (workspace_id, folder_id) = match model {
|
||||||
|
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||||
|
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||||
|
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||||
|
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||||
|
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||||
|
other => {
|
||||||
|
return Err(Error::Generic(format!(
|
||||||
|
"Cannot resolve environments for a {}",
|
||||||
|
other.model()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let environment_chain =
|
||||||
|
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||||
|
|
||||||
|
let cb = host.template_callback(purpose).await?;
|
||||||
|
let rendered =
|
||||||
|
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||||
|
|
||||||
|
Ok(serde_json::from_value(rendered)?)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::host::PluginHost;
|
use crate::host::PluginHost;
|
||||||
use crate::render::render_template;
|
use crate::render::{render_form_values, render_template};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||||
RenderPurpose,
|
RenderPurpose,
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,19 @@ pub async fn cmd_template_function_config<H: PluginHost>(
|
|||||||
host: H,
|
host: H,
|
||||||
req: CmdTemplateFunctionConfigReq,
|
req: CmdTemplateFunctionConfigReq,
|
||||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||||
host.template_function_config(&req.function_name, req.values, req.model.id()).await
|
// A config form is being displayed, so a template that cannot resolve
|
||||||
|
// should show as blank rather than refuse to open the form.
|
||||||
|
let values = render_form_values(
|
||||||
|
&host,
|
||||||
|
&req.model,
|
||||||
|
req.environment_id.as_deref(),
|
||||||
|
req.values,
|
||||||
|
RenderPurpose::Preview,
|
||||||
|
&RenderOptions::return_empty(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
host.template_function_config(&req.function_name, values, req.model.id()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cmd_get_themes<H: PluginHost>(
|
pub async fn cmd_get_themes<H: PluginHost>(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use yaak_commands::models::{
|
|||||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||||
models_workspace_models,
|
models_workspace_models,
|
||||||
};
|
};
|
||||||
use yaak_commands::templates::cmd_render_template;
|
use yaak_commands::templates::{cmd_render_template, cmd_template_function_config};
|
||||||
use yaak_commands::{Host, PluginHost};
|
use yaak_commands::{Host, PluginHost};
|
||||||
use yaak_core::WorkspaceContext;
|
use yaak_core::WorkspaceContext;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
@@ -172,6 +172,8 @@ struct SingleThreadedHost {
|
|||||||
/// The values the last auth-config call arrived with, so a test can check
|
/// The values the last auth-config call arrived with, so a test can check
|
||||||
/// they were rendered before the host ever saw them.
|
/// they were rendered before the host ever saw them.
|
||||||
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||||
|
/// Same, for the last template-function-config call.
|
||||||
|
fn_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Host for SingleThreadedHost {
|
impl Host for SingleThreadedHost {
|
||||||
@@ -248,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(
|
||||||
@@ -261,9 +266,10 @@ impl PluginHost for SingleThreadedHost {
|
|||||||
async fn template_function_config(
|
async fn template_function_config(
|
||||||
&self,
|
&self,
|
||||||
function_name: &str,
|
function_name: &str,
|
||||||
_values: HashMap<String, JsonPrimitive>,
|
values: HashMap<String, JsonPrimitive>,
|
||||||
_model_id: &str,
|
_model_id: &str,
|
||||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||||
|
*self.fn_values.borrow_mut() = Some(values);
|
||||||
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +382,7 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
|||||||
let host = SingleThreadedHost {
|
let host = SingleThreadedHost {
|
||||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||||
auth_values: Rc::new(RefCell::new(None)),
|
auth_values: Rc::new(RefCell::new(None)),
|
||||||
|
fn_values: Rc::new(RefCell::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
||||||
@@ -448,6 +455,7 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
|||||||
let host = SingleThreadedHost {
|
let host = SingleThreadedHost {
|
||||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||||
auth_values: Rc::new(RefCell::new(None)),
|
auth_values: Rc::new(RefCell::new(None)),
|
||||||
|
fn_values: Rc::new(RefCell::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let workspace = host
|
let workspace = host
|
||||||
@@ -499,3 +507,65 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
|
|||||||
seen.get("password"),
|
seen.get("password"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same contract as auth: template function argument values may contain
|
||||||
|
/// templates (the 1Password token argument defaults to `${[1PASSWORD_TOKEN]}`),
|
||||||
|
/// and the shared handler renders them before the host is called.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn template_function_values_are_rendered_before_the_host_sees_them() {
|
||||||
|
let TestHost { inner } = TestHost::new();
|
||||||
|
let host = SingleThreadedHost {
|
||||||
|
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||||
|
auth_values: Rc::new(RefCell::new(None)),
|
||||||
|
fn_values: Rc::new(RefCell::new(None)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let workspace = host
|
||||||
|
.db()
|
||||||
|
.upsert_workspace(
|
||||||
|
&Workspace { name: "Functions".to_string(), ..Default::default() },
|
||||||
|
&host.update_source(),
|
||||||
|
)
|
||||||
|
.expect("workspace");
|
||||||
|
host.db()
|
||||||
|
.upsert_environment(
|
||||||
|
&Environment {
|
||||||
|
workspace_id: workspace.id.clone(),
|
||||||
|
name: "Env".to_string(),
|
||||||
|
variables: vec![EnvironmentVariable {
|
||||||
|
enabled: true,
|
||||||
|
name: "1PASSWORD_TOKEN".to_string(),
|
||||||
|
value: "ops_abc123".to_string(),
|
||||||
|
id: None,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&host.update_source(),
|
||||||
|
)
|
||||||
|
.expect("environment");
|
||||||
|
let environment =
|
||||||
|
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||||
|
|
||||||
|
let mut values = HashMap::new();
|
||||||
|
values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string()));
|
||||||
|
|
||||||
|
// The host refuses the call itself — it has no plugins — but only after the
|
||||||
|
// handler has rendered and handed over the values, which is what matters.
|
||||||
|
let _ = cmd_template_function_config(
|
||||||
|
host.clone(),
|
||||||
|
yaak_rpc_schema::CmdTemplateFunctionConfigReq {
|
||||||
|
function_name: "1password.item".to_string(),
|
||||||
|
values,
|
||||||
|
model: AnyModel::Workspace(workspace),
|
||||||
|
environment_id: Some(environment.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let seen = host.fn_values.borrow().clone().expect("the host should have been called");
|
||||||
|
assert!(
|
||||||
|
matches!(seen.get("token"), Some(JsonPrimitive::String(v)) if v == "ops_abc123"),
|
||||||
|
"the template should have been rendered before reaching the host, got {:?}",
|
||||||
|
seen.get("token"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Generated
+7
@@ -47,6 +47,7 @@ export type Folder = {
|
|||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GrpcRequest = {
|
export type GrpcRequest = {
|
||||||
@@ -99,6 +100,7 @@ export type HttpRequest = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
@@ -114,8 +116,12 @@ export type HttpUrlParameter = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|
||||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
|
|
||||||
export type SyncModel =
|
export type SyncModel =
|
||||||
@@ -169,4 +175,5 @@ export type Workspace = {
|
|||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
settingHttpVersion: HttpVersion;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::error::Result;
|
|||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use yaak_models::models::DnsOverride;
|
use yaak_models::models::{DnsOverride, HttpVersion};
|
||||||
use yaak_tls::{
|
use yaak_tls::{
|
||||||
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
|
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
|
||||||
};
|
};
|
||||||
@@ -39,6 +39,7 @@ impl ConfiguredClient {
|
|||||||
/// supports TLS 1.0+ for legacy servers.
|
/// supports TLS 1.0+ for legacy servers.
|
||||||
fn build_native_tls_connector(
|
fn build_native_tls_connector(
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
|
http_version: HttpVersion,
|
||||||
) -> Result<native_tls::TlsConnector> {
|
) -> Result<native_tls::TlsConnector> {
|
||||||
let mut builder = native_tls::TlsConnector::builder();
|
let mut builder = native_tls::TlsConnector::builder();
|
||||||
builder.danger_accept_invalid_certs(true);
|
builder.danger_accept_invalid_certs(true);
|
||||||
@@ -46,7 +47,11 @@ fn build_native_tls_connector(
|
|||||||
builder.min_protocol_version(Some(native_tls::Protocol::Tlsv10));
|
builder.min_protocol_version(Some(native_tls::Protocol::Tlsv10));
|
||||||
// reqwest cannot add ALPN to a connector it did not build, so without this
|
// reqwest cannot add ALPN to a connector it did not build, so without this
|
||||||
// the native path would silently negotiate HTTP/1.1 for every request.
|
// the native path would silently negotiate HTTP/1.1 for every request.
|
||||||
builder.request_alpns(&["h2", "http/1.1"]);
|
match http_version {
|
||||||
|
HttpVersion::Auto => builder.request_alpns(&["h2", "http/1.1"]),
|
||||||
|
HttpVersion::Http1 => builder.request_alpns(&["http/1.1"]),
|
||||||
|
HttpVersion::Http2 => builder.request_alpns(&["h2"]),
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(identity) = build_native_tls_identity(client_cert)? {
|
if let Some(identity) = build_native_tls_identity(client_cert)? {
|
||||||
builder.identity(identity);
|
builder.identity(identity);
|
||||||
@@ -100,6 +105,7 @@ pub enum HttpConnectionProxySetting {
|
|||||||
pub struct HttpConnectionOptions {
|
pub struct HttpConnectionOptions {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub validate_certificates: bool,
|
pub validate_certificates: bool,
|
||||||
|
pub http_version: HttpVersion,
|
||||||
pub proxy: HttpConnectionProxySetting,
|
pub proxy: HttpConnectionProxySetting,
|
||||||
pub client_certificate: Option<ClientCertificateConfig>,
|
pub client_certificate: Option<ClientCertificateConfig>,
|
||||||
pub dns_overrides: Vec<DnsOverride>,
|
pub dns_overrides: Vec<DnsOverride>,
|
||||||
@@ -128,14 +134,28 @@ impl HttpConnectionOptions {
|
|||||||
// This is needed so we can emit DNS timing events for each request
|
// This is needed so we can emit DNS timing events for each request
|
||||||
.pool_max_idle_per_host(0);
|
.pool_max_idle_per_host(0);
|
||||||
|
|
||||||
|
match self.http_version {
|
||||||
|
HttpVersion::Auto => {}
|
||||||
|
HttpVersion::Http1 => client = client.http1_only(),
|
||||||
|
HttpVersion::Http2 => client = client.http2_prior_knowledge(),
|
||||||
|
}
|
||||||
|
|
||||||
// Configure TLS
|
// Configure TLS
|
||||||
if self.validate_certificates {
|
if self.validate_certificates {
|
||||||
// Use rustls with platform certificate verification (TLS 1.2+ only)
|
// Use rustls with platform certificate verification (TLS 1.2+ only)
|
||||||
let config = get_tls_config(true, true, self.client_certificate.clone())?;
|
let mut config = get_tls_config(true, true, self.client_certificate.clone())?;
|
||||||
|
// A forced version must also constrain ALPN, or the server may
|
||||||
|
// negotiate a protocol the client then refuses to speak
|
||||||
|
match self.http_version {
|
||||||
|
HttpVersion::Auto => {}
|
||||||
|
HttpVersion::Http1 => config.alpn_protocols = vec![b"http/1.1".to_vec()],
|
||||||
|
HttpVersion::Http2 => config.alpn_protocols = vec![b"h2".to_vec()],
|
||||||
|
}
|
||||||
client = client.use_preconfigured_tls(config);
|
client = client.use_preconfigured_tls(config);
|
||||||
} else {
|
} else {
|
||||||
// Use native TLS for maximum compatibility (supports TLS 1.0+)
|
// Use native TLS for maximum compatibility (supports TLS 1.0+)
|
||||||
let connector = build_native_tls_connector(self.client_certificate.clone())?;
|
let connector =
|
||||||
|
build_native_tls_connector(self.client_certificate.clone(), self.http_version)?;
|
||||||
client = client.use_preconfigured_tls(connector);
|
client = client.use_preconfigured_tls(connector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ impl HttpConnectionManager {
|
|||||||
|
|
||||||
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
|
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
|
||||||
let mut connections = self.connections.write().await;
|
let mut connections = self.connections.write().await;
|
||||||
let id = opt.id.clone();
|
// The key must include any per-request option that changes how the
|
||||||
|
// client is built, or a send after a settings change reuses a client
|
||||||
|
// built with the old value for up to the cache TTL.
|
||||||
|
let id = format!("{}::{}::{}", opt.id, opt.validate_certificates, opt.http_version);
|
||||||
|
|
||||||
// Clean old connections
|
// Clean old connections
|
||||||
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
|
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
|
||||||
|
|||||||
+91
-1
@@ -12,6 +12,7 @@ export type AnyModel =
|
|||||||
| HttpRequest
|
| HttpRequest
|
||||||
| HttpResponse
|
| HttpResponse
|
||||||
| HttpResponseEvent
|
| HttpResponseEvent
|
||||||
|
| ImportSource
|
||||||
| KeyValue
|
| KeyValue
|
||||||
| Plugin
|
| Plugin
|
||||||
| Settings
|
| Settings
|
||||||
@@ -110,6 +111,7 @@ export type Folder = {
|
|||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphQlIntrospection = {
|
export type GraphQlIntrospection = {
|
||||||
@@ -137,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";
|
||||||
@@ -214,6 +220,7 @@ export type HttpRequest = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
@@ -240,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 = {
|
||||||
@@ -318,6 +329,7 @@ export type HttpSendSettings = {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
sendCookies: boolean;
|
sendCookies: boolean;
|
||||||
storeCookies: boolean;
|
storeCookies: boolean;
|
||||||
|
httpVersion: HttpVersion;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpUrlParameter = {
|
export type HttpUrlParameter = {
|
||||||
@@ -331,8 +343,35 @@ export type HttpUrlParameter = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 InheritedIntSetting = { enabled?: boolean; value: number };
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
|
|
||||||
export type KeyValue = {
|
export type KeyValue = {
|
||||||
@@ -351,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;
|
||||||
@@ -394,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;
|
||||||
@@ -457,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";
|
||||||
@@ -475,7 +557,14 @@ export type WebsocketEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WebsocketEventType =
|
export type WebsocketEventType =
|
||||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
| "binary"
|
||||||
|
| "close"
|
||||||
|
| "error"
|
||||||
|
| "frame"
|
||||||
|
| "open"
|
||||||
|
| "ping"
|
||||||
|
| "pong"
|
||||||
|
| "text";
|
||||||
|
|
||||||
export type WebsocketMessageType = "text" | "binary";
|
export type WebsocketMessageType = "text" | "binary";
|
||||||
|
|
||||||
@@ -522,6 +611,7 @@ export type Workspace = {
|
|||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
settingHttpVersion: HttpVersion;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceMeta = {
|
export type WorkspaceMeta = {
|
||||||
|
|||||||
Generated
+44
@@ -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";
|
||||||
|
|||||||
@@ -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,5 @@
|
|||||||
|
ALTER TABLE workspaces ADD COLUMN setting_http_version TEXT DEFAULT 'auto' NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE folders ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE http_requests ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE import_sources
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
model TEXT DEFAULT 'import_source' NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
importer TEXT NOT NULL,
|
||||||
|
origin TEXT NOT NULL,
|
||||||
|
origin_label TEXT NOT NULL,
|
||||||
|
last_imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE import_source_resources
|
||||||
|
(
|
||||||
|
model TEXT DEFAULT 'import_source_resource' NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
import_source_id TEXT NOT NULL,
|
||||||
|
source_key TEXT NOT NULL,
|
||||||
|
model_type TEXT NOT NULL,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
snapshot TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (import_source_id, source_key)
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE model_versions
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
model TEXT DEFAULT 'model_version' NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
model_type TEXT NOT NULL,
|
||||||
|
model_id TEXT NOT NULL,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
document TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Content addressing, enforced by the database rather than by every caller.
|
||||||
|
CREATE UNIQUE INDEX model_versions_content ON model_versions (model_id, content_hash);
|
||||||
|
|
||||||
|
ALTER TABLE http_responses ADD COLUMN version_id TEXT;
|
||||||
|
ALTER TABLE grpc_connections ADD COLUMN version_id TEXT;
|
||||||
|
ALTER TABLE websocket_connections ADD COLUMN version_id TEXT;
|
||||||
@@ -118,6 +118,20 @@ impl<'a> ClientDb<'a> {
|
|||||||
Ok(m.clone())
|
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)?;
|
||||||
|
|||||||
@@ -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<()> {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::HttpRequestIden::{
|
use crate::models::HttpRequestIden::{
|
||||||
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
|
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
|
||||||
Method, Name, SettingFollowRedirects, SettingRequestTimeout, SettingSendCookies,
|
Method, Name, SettingFollowRedirects, SettingHttpVersion, SettingRequestTimeout,
|
||||||
SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt, Url, UrlParameters,
|
SettingSendCookies, SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt,
|
||||||
WorkspaceId,
|
Url, UrlParameters, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crate::util::generate_prefixed_id;
|
use crate::util::generate_prefixed_id;
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
@@ -143,6 +143,7 @@ pub struct ResolvedHttpRequestSettings {
|
|||||||
pub request_message_size: ResolvedSetting<i32>,
|
pub request_message_size: ResolvedSetting<i32>,
|
||||||
pub send_cookies: ResolvedSetting<bool>,
|
pub send_cookies: ResolvedSetting<bool>,
|
||||||
pub store_cookies: ResolvedSetting<bool>,
|
pub store_cookies: ResolvedSetting<bool>,
|
||||||
|
pub http_version: ResolvedSetting<HttpVersion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ResolvedHttpRequestSettings {
|
impl Default for ResolvedHttpRequestSettings {
|
||||||
@@ -154,6 +155,7 @@ impl Default for ResolvedHttpRequestSettings {
|
|||||||
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
|
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
|
||||||
send_cookies: ResolvedSetting::default_source(true),
|
send_cookies: ResolvedSetting::default_source(true),
|
||||||
store_cookies: ResolvedSetting::default_source(true),
|
store_cookies: ResolvedSetting::default_source(true),
|
||||||
|
http_version: ResolvedSetting::default_source(HttpVersion::Auto),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +193,7 @@ impl ResolvedHttpRequestSettings {
|
|||||||
event("timeout", timeout, &self.request_timeout),
|
event("timeout", timeout, &self.request_timeout),
|
||||||
event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies),
|
event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies),
|
||||||
event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies),
|
event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies),
|
||||||
|
event("http_version", self.http_version.value.to_string(), &self.http_version),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +211,8 @@ pub struct HttpSendSettings {
|
|||||||
pub timeout_ms: i32,
|
pub timeout_ms: i32,
|
||||||
pub send_cookies: bool,
|
pub send_cookies: bool,
|
||||||
pub store_cookies: bool,
|
pub store_cookies: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub http_version: HttpVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
|
impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
|
||||||
@@ -218,6 +223,7 @@ impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
|
|||||||
timeout_ms: s.request_timeout.value,
|
timeout_ms: s.request_timeout.value,
|
||||||
send_cookies: s.send_cookies.value,
|
send_cookies: s.send_cookies.value,
|
||||||
store_cookies: s.store_cookies.value,
|
store_cookies: s.store_cookies.value,
|
||||||
|
http_version: s.http_version.value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,6 +261,49 @@ impl Default for InheritedIntSetting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
|
pub enum HttpVersion {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Http1,
|
||||||
|
Http2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for HttpVersion {
|
||||||
|
type Err = crate::error::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self> {
|
||||||
|
match s {
|
||||||
|
"http1" => Ok(Self::Http1),
|
||||||
|
"http2" => Ok(Self::Http2),
|
||||||
|
_ => Ok(Self::Auto),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for HttpVersion {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let str = match self {
|
||||||
|
HttpVersion::Auto => "auto",
|
||||||
|
HttpVersion::Http1 => "http1",
|
||||||
|
HttpVersion::Http2 => "http2",
|
||||||
|
};
|
||||||
|
write!(f, "{}", str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema, TS)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
|
pub struct InheritedHttpVersionSetting {
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional, as = "Option<bool>")]
|
||||||
|
pub enabled: bool,
|
||||||
|
pub value: HttpVersion,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[ts(export, export_to = "gen_models.ts")]
|
#[ts(export, export_to = "gen_models.ts")]
|
||||||
@@ -484,6 +533,7 @@ impl Default for Workspace {
|
|||||||
setting_dns_overrides: Vec::new(),
|
setting_dns_overrides: Vec::new(),
|
||||||
setting_send_cookies: true,
|
setting_send_cookies: true,
|
||||||
setting_store_cookies: true,
|
setting_store_cookies: true,
|
||||||
|
setting_http_version: HttpVersion::Auto,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,6 +566,7 @@ pub struct Workspace {
|
|||||||
pub setting_dns_overrides: Vec<DnsOverride>,
|
pub setting_dns_overrides: Vec<DnsOverride>,
|
||||||
pub setting_send_cookies: bool,
|
pub setting_send_cookies: bool,
|
||||||
pub setting_store_cookies: bool,
|
pub setting_store_cookies: bool,
|
||||||
|
pub setting_http_version: HttpVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for Workspace {
|
impl UpsertModelInfo for Workspace {
|
||||||
@@ -560,6 +611,7 @@ impl UpsertModelInfo for Workspace {
|
|||||||
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
||||||
(SettingSendCookies, self.setting_send_cookies.into()),
|
(SettingSendCookies, self.setting_send_cookies.into()),
|
||||||
(SettingStoreCookies, self.setting_store_cookies.into()),
|
(SettingStoreCookies, self.setting_store_cookies.into()),
|
||||||
|
(SettingHttpVersion, self.setting_http_version.to_string().into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +631,7 @@ impl UpsertModelInfo for Workspace {
|
|||||||
WorkspaceIden::SettingDnsOverrides,
|
WorkspaceIden::SettingDnsOverrides,
|
||||||
WorkspaceIden::SettingSendCookies,
|
WorkspaceIden::SettingSendCookies,
|
||||||
WorkspaceIden::SettingStoreCookies,
|
WorkspaceIden::SettingStoreCookies,
|
||||||
|
WorkspaceIden::SettingHttpVersion,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +642,7 @@ impl UpsertModelInfo for Workspace {
|
|||||||
let headers: String = row.get("headers")?;
|
let headers: String = row.get("headers")?;
|
||||||
let authentication: String = row.get("authentication")?;
|
let authentication: String = row.get("authentication")?;
|
||||||
let setting_dns_overrides: String = row.get("setting_dns_overrides")?;
|
let setting_dns_overrides: String = row.get("setting_dns_overrides")?;
|
||||||
|
let setting_http_version: String = row.get("setting_http_version")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -607,6 +661,7 @@ impl UpsertModelInfo for Workspace {
|
|||||||
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
||||||
setting_send_cookies: row.get("setting_send_cookies")?,
|
setting_send_cookies: row.get("setting_send_cookies")?,
|
||||||
setting_store_cookies: row.get("setting_store_cookies")?,
|
setting_store_cookies: row.get("setting_store_cookies")?,
|
||||||
|
setting_http_version: setting_http_version.parse().unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1078,6 +1133,7 @@ impl Default for Folder {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||||
},
|
},
|
||||||
|
setting_http_version: InheritedHttpVersionSetting::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1108,6 +1164,7 @@ pub struct Folder {
|
|||||||
pub setting_follow_redirects: InheritedBoolSetting,
|
pub setting_follow_redirects: InheritedBoolSetting,
|
||||||
pub setting_request_timeout: InheritedIntSetting,
|
pub setting_request_timeout: InheritedIntSetting,
|
||||||
pub setting_request_message_size: InheritedIntSetting,
|
pub setting_request_message_size: InheritedIntSetting,
|
||||||
|
pub setting_http_version: InheritedHttpVersionSetting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for Folder {
|
impl UpsertModelInfo for Folder {
|
||||||
@@ -1159,6 +1216,7 @@ impl UpsertModelInfo for Folder {
|
|||||||
SettingRequestMessageSize,
|
SettingRequestMessageSize,
|
||||||
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
||||||
),
|
),
|
||||||
|
(SettingHttpVersion, serde_json::to_string(&self.setting_http_version)?.into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,6 +1236,7 @@ impl UpsertModelInfo for Folder {
|
|||||||
FolderIden::SettingFollowRedirects,
|
FolderIden::SettingFollowRedirects,
|
||||||
FolderIden::SettingRequestTimeout,
|
FolderIden::SettingRequestTimeout,
|
||||||
FolderIden::SettingRequestMessageSize,
|
FolderIden::SettingRequestMessageSize,
|
||||||
|
FolderIden::SettingHttpVersion,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,6 +1252,7 @@ impl UpsertModelInfo for Folder {
|
|||||||
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
||||||
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
||||||
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
||||||
|
let setting_http_version: String = row.get("setting_http_version")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -1216,6 +1276,7 @@ impl UpsertModelInfo for Folder {
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
||||||
.unwrap_or_else(|_| default_request_message_size_setting()),
|
.unwrap_or_else(|_| default_request_message_size_setting()),
|
||||||
|
setting_http_version: serde_json::from_str(&setting_http_version).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1283,6 +1344,7 @@ impl Default for HttpRequest {
|
|||||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||||
setting_follow_redirects: InheritedBoolSetting::default(),
|
setting_follow_redirects: InheritedBoolSetting::default(),
|
||||||
setting_request_timeout: InheritedIntSetting::default(),
|
setting_request_timeout: InheritedIntSetting::default(),
|
||||||
|
setting_http_version: InheritedHttpVersionSetting::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1319,6 +1381,7 @@ pub struct HttpRequest {
|
|||||||
pub setting_validate_certificates: InheritedBoolSetting,
|
pub setting_validate_certificates: InheritedBoolSetting,
|
||||||
pub setting_follow_redirects: InheritedBoolSetting,
|
pub setting_follow_redirects: InheritedBoolSetting,
|
||||||
pub setting_request_timeout: InheritedIntSetting,
|
pub setting_request_timeout: InheritedIntSetting,
|
||||||
|
pub setting_http_version: InheritedHttpVersionSetting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for HttpRequest {
|
impl UpsertModelInfo for HttpRequest {
|
||||||
@@ -1370,6 +1433,7 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
),
|
),
|
||||||
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
|
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
|
||||||
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
|
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
|
||||||
|
(SettingHttpVersion, serde_json::to_string(&self.setting_http_version)?.into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1394,6 +1458,7 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
SettingValidateCertificates,
|
SettingValidateCertificates,
|
||||||
SettingFollowRedirects,
|
SettingFollowRedirects,
|
||||||
SettingRequestTimeout,
|
SettingRequestTimeout,
|
||||||
|
SettingHttpVersion,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1407,6 +1472,7 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||||
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
||||||
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
||||||
|
let setting_http_version: String = row.get("setting_http_version")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -1433,6 +1499,7 @@ impl UpsertModelInfo for HttpRequest {
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
|
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
setting_http_version: serde_json::from_str(&setting_http_version).unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1472,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 {
|
||||||
@@ -1511,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()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1523,6 +1593,7 @@ impl UpsertModelInfo for WebsocketConnection {
|
|||||||
WebsocketConnectionIden::State,
|
WebsocketConnectionIden::State,
|
||||||
WebsocketConnectionIden::Status,
|
WebsocketConnectionIden::Status,
|
||||||
WebsocketConnectionIden::Url,
|
WebsocketConnectionIden::Url,
|
||||||
|
WebsocketConnectionIden::VersionId,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1545,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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1898,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 {
|
||||||
@@ -1947,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()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1969,6 +2044,7 @@ impl UpsertModelInfo for HttpResponse {
|
|||||||
HttpResponseIden::StatusReason,
|
HttpResponseIden::StatusReason,
|
||||||
HttpResponseIden::Url,
|
HttpResponseIden::Url,
|
||||||
HttpResponseIden::Version,
|
HttpResponseIden::Version,
|
||||||
|
HttpResponseIden::VersionId,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2004,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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2449,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 {
|
||||||
@@ -2490,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()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2504,6 +2584,7 @@ impl UpsertModelInfo for GrpcConnection {
|
|||||||
GrpcConnectionIden::Error,
|
GrpcConnectionIden::Error,
|
||||||
GrpcConnectionIden::Trailers,
|
GrpcConnectionIden::Trailers,
|
||||||
GrpcConnectionIden::Url,
|
GrpcConnectionIden::Url,
|
||||||
|
GrpcConnectionIden::VersionId,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2528,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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2955,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 {
|
||||||
@@ -3026,6 +3373,7 @@ define_any_model! {
|
|||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
HttpResponseEvent,
|
HttpResponseEvent,
|
||||||
|
ImportSource,
|
||||||
KeyValue,
|
KeyValue,
|
||||||
Plugin,
|
Plugin,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -3058,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) {
|
||||||
|
|||||||
@@ -208,6 +208,14 @@ impl<'a> ClientDb<'a> {
|
|||||||
} else {
|
} else {
|
||||||
parent.store_cookies
|
parent.store_cookies
|
||||||
},
|
},
|
||||||
|
http_version: if folder.setting_http_version.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
folder.setting_http_version.value,
|
||||||
|
AnyModel::Folder(folder.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.http_version
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +154,14 @@ impl<'a> ClientDb<'a> {
|
|||||||
} else {
|
} else {
|
||||||
parent.store_cookies
|
parent.store_cookies
|
||||||
},
|
},
|
||||||
|
http_version: if http_request.setting_http_version.enabled {
|
||||||
|
ResolvedSetting::from_model(
|
||||||
|
http_request.setting_http_version.value,
|
||||||
|
AnyModel::HttpRequest(http_request.clone()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parent.http_version
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +183,10 @@ impl<'a> ClientDb<'a> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::init_in_memory;
|
use crate::init_in_memory;
|
||||||
use crate::models::{HttpRequest, HttpRequestHeader};
|
use crate::models::{
|
||||||
|
Folder, HttpRequest, HttpRequestHeader, HttpVersion, InheritedHttpVersionSetting, Workspace,
|
||||||
|
};
|
||||||
|
use crate::util::UpdateSource;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn request_resolution_preserves_duplicate_request_headers() {
|
fn request_resolution_preserves_duplicate_request_headers() {
|
||||||
@@ -210,4 +222,77 @@ mod tests {
|
|||||||
assert_eq!(cookies[1].value, "optional=1");
|
assert_eq!(cookies[1].value, "optional=1");
|
||||||
assert!(!cookies[1].enabled);
|
assert!(!cookies[1].enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn http_version_resolves_through_the_inheritance_chain() {
|
||||||
|
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||||
|
let db = query_manager.connect();
|
||||||
|
|
||||||
|
let workspace = db
|
||||||
|
.upsert_workspace(
|
||||||
|
&Workspace {
|
||||||
|
name: "Test".to_string(),
|
||||||
|
setting_http_version: HttpVersion::Http2,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&UpdateSource::Background,
|
||||||
|
)
|
||||||
|
.expect("Failed to upsert workspace");
|
||||||
|
|
||||||
|
let folder = db
|
||||||
|
.upsert_folder(
|
||||||
|
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||||
|
&UpdateSource::Background,
|
||||||
|
)
|
||||||
|
.expect("Failed to upsert folder");
|
||||||
|
|
||||||
|
let request = db
|
||||||
|
.upsert_http_request(
|
||||||
|
&HttpRequest {
|
||||||
|
workspace_id: workspace.id.clone(),
|
||||||
|
folder_id: Some(folder.id.clone()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&UpdateSource::Background,
|
||||||
|
)
|
||||||
|
.expect("Failed to upsert request");
|
||||||
|
|
||||||
|
// No overrides, so the workspace base value applies
|
||||||
|
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||||
|
assert_eq!(resolved.http_version.value, HttpVersion::Http2);
|
||||||
|
assert_eq!(resolved.http_version.source_model, "workspace");
|
||||||
|
|
||||||
|
// A folder override beats the workspace base
|
||||||
|
db.upsert_folder(
|
||||||
|
&Folder {
|
||||||
|
setting_http_version: InheritedHttpVersionSetting {
|
||||||
|
enabled: true,
|
||||||
|
value: HttpVersion::Http1,
|
||||||
|
},
|
||||||
|
..folder
|
||||||
|
},
|
||||||
|
&UpdateSource::Background,
|
||||||
|
)
|
||||||
|
.expect("Failed to update folder");
|
||||||
|
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||||
|
assert_eq!(resolved.http_version.value, HttpVersion::Http1);
|
||||||
|
assert_eq!(resolved.http_version.source_model, "folder");
|
||||||
|
|
||||||
|
// A request override beats them both
|
||||||
|
let request = db
|
||||||
|
.upsert_http_request(
|
||||||
|
&HttpRequest {
|
||||||
|
setting_http_version: InheritedHttpVersionSetting {
|
||||||
|
enabled: true,
|
||||||
|
value: HttpVersion::Auto,
|
||||||
|
},
|
||||||
|
..request
|
||||||
|
},
|
||||||
|
&UpdateSource::Background,
|
||||||
|
)
|
||||||
|
.expect("Failed to update request");
|
||||||
|
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
|
||||||
|
assert_eq!(resolved.http_version.value, HttpVersion::Auto);
|
||||||
|
assert_eq!(resolved.http_version.source_model, "http_request");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
use crate::client_db::ClientDb;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{ImportSourceResource, ImportSourceResourceIden};
|
||||||
|
use sea_query::ExprTrait;
|
||||||
|
use sea_query::Keyword::CurrentTimestamp;
|
||||||
|
use sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};
|
||||||
|
use sea_query_rusqlite::RusqliteBinder;
|
||||||
|
|
||||||
|
impl<'a> ClientDb<'a> {
|
||||||
|
pub fn list_import_source_resources(
|
||||||
|
&self,
|
||||||
|
import_source_id: &str,
|
||||||
|
) -> Result<Vec<ImportSourceResource>> {
|
||||||
|
let (sql, params) = Query::select()
|
||||||
|
.from(ImportSourceResourceIden::Table)
|
||||||
|
.column(Asterisk)
|
||||||
|
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
|
||||||
|
.build_rusqlite(SqliteQueryBuilder);
|
||||||
|
let mut stmt = self.conn().prepare(sql.as_str())?;
|
||||||
|
let items = stmt.query_map(&*params.as_params(), |row| row.try_into())?;
|
||||||
|
Ok(items.filter_map(|v| v.ok()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_import_source_resource(
|
||||||
|
&self,
|
||||||
|
resource: &ImportSourceResource,
|
||||||
|
) -> Result<ImportSourceResource> {
|
||||||
|
let (sql, params) = Query::insert()
|
||||||
|
.into_table(ImportSourceResourceIden::Table)
|
||||||
|
.columns([
|
||||||
|
ImportSourceResourceIden::CreatedAt,
|
||||||
|
ImportSourceResourceIden::UpdatedAt,
|
||||||
|
ImportSourceResourceIden::ImportSourceId,
|
||||||
|
ImportSourceResourceIden::SourceKey,
|
||||||
|
ImportSourceResourceIden::ModelType,
|
||||||
|
ImportSourceResourceIden::ModelId,
|
||||||
|
ImportSourceResourceIden::Snapshot,
|
||||||
|
])
|
||||||
|
.values_panic([
|
||||||
|
CurrentTimestamp.into(),
|
||||||
|
CurrentTimestamp.into(),
|
||||||
|
resource.import_source_id.as_str().into(),
|
||||||
|
resource.source_key.as_str().into(),
|
||||||
|
resource.model_type.as_str().into(),
|
||||||
|
resource.model_id.as_str().into(),
|
||||||
|
resource.snapshot.as_str().into(),
|
||||||
|
])
|
||||||
|
.on_conflict(
|
||||||
|
OnConflict::columns([
|
||||||
|
ImportSourceResourceIden::ImportSourceId,
|
||||||
|
ImportSourceResourceIden::SourceKey,
|
||||||
|
])
|
||||||
|
.update_columns([
|
||||||
|
ImportSourceResourceIden::UpdatedAt,
|
||||||
|
ImportSourceResourceIden::ModelType,
|
||||||
|
ImportSourceResourceIden::ModelId,
|
||||||
|
ImportSourceResourceIden::Snapshot,
|
||||||
|
])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.returning_all()
|
||||||
|
.build_rusqlite(SqliteQueryBuilder);
|
||||||
|
|
||||||
|
let mut stmt = self.conn().prepare(sql.as_str())?;
|
||||||
|
let m = stmt.query_row(&*params.as_params(), |row| row.try_into())?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_import_source_resource(
|
||||||
|
&self,
|
||||||
|
import_source_id: &str,
|
||||||
|
source_key: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (sql, params) = Query::delete()
|
||||||
|
.from_table(ImportSourceResourceIden::Table)
|
||||||
|
.cond_where(
|
||||||
|
Cond::all()
|
||||||
|
.add(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
|
||||||
|
.add(Expr::col(ImportSourceResourceIden::SourceKey).eq(source_key)),
|
||||||
|
)
|
||||||
|
.build_rusqlite(SqliteQueryBuilder);
|
||||||
|
self.conn().execute(sql.as_str(), &*params.as_params())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_import_source_resources(&self, import_source_id: &str) -> Result<()> {
|
||||||
|
let (sql, params) = Query::delete()
|
||||||
|
.from_table(ImportSourceResourceIden::Table)
|
||||||
|
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
|
||||||
|
.build_rusqlite(SqliteQueryBuilder);
|
||||||
|
self.conn().execute(sql.as_str(), &*params.as_params())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
use crate::client_db::ClientDb;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{ImportSource, ImportSourceIden};
|
||||||
|
use crate::util::UpdateSource;
|
||||||
|
|
||||||
|
impl<'a> ClientDb<'a> {
|
||||||
|
pub fn get_import_source(&self, id: &str) -> Result<ImportSource> {
|
||||||
|
self.find_one(ImportSourceIden::Id, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_import_sources(&self, workspace_id: &str) -> Result<Vec<ImportSource>> {
|
||||||
|
self.find_many(ImportSourceIden::WorkspaceId, workspace_id, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_import_sources_by_origin(&self, origin: &str) -> Result<Vec<ImportSource>> {
|
||||||
|
self.find_many(ImportSourceIden::Origin, origin, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_import_source(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
importer: &str,
|
||||||
|
origin: &str,
|
||||||
|
) -> Result<Option<ImportSource>> {
|
||||||
|
let sources = self.list_import_sources(workspace_id)?;
|
||||||
|
Ok(sources.into_iter().find(|s| s.importer == importer && s.origin == origin))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_import_source(
|
||||||
|
&self,
|
||||||
|
import_source: &ImportSource,
|
||||||
|
source: &UpdateSource,
|
||||||
|
) -> Result<ImportSource> {
|
||||||
|
self.upsert(import_source, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_import_source(
|
||||||
|
&self,
|
||||||
|
import_source: &ImportSource,
|
||||||
|
source: &UpdateSource,
|
||||||
|
) -> Result<ImportSource> {
|
||||||
|
self.delete_import_source_resources(&import_source.id)?;
|
||||||
|
self.delete(import_source, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,11 @@ mod grpc_requests;
|
|||||||
mod http_requests;
|
mod http_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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -96,8 +102,8 @@ impl<'a> ClientDb<'a> {
|
|||||||
deleted
|
deleted
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = conn
|
let _ =
|
||||||
.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
|
conn.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -177,6 +183,10 @@ impl<'a> ClientDb<'a> {
|
|||||||
workspace.setting_store_cookies,
|
workspace.setting_store_cookies,
|
||||||
AnyModel::Workspace(workspace.clone()),
|
AnyModel::Workspace(workspace.clone()),
|
||||||
),
|
),
|
||||||
|
http_version: ResolvedSetting::from_model(
|
||||||
|
workspace.setting_http_version,
|
||||||
|
AnyModel::Workspace(workspace.clone()),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
//! Content addressing for model versions.
|
||||||
|
//!
|
||||||
|
//! A version's identity is its *content*, so the two functions here — what
|
||||||
|
//! counts as content, and how content becomes a hash — are the whole of it.
|
||||||
|
//! Everything else about versioning (when to capture, what to keep, how to
|
||||||
|
//! restore) is built on top and stays in `queries::model_versions`.
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Keys that describe a model's place in the workspace rather than what the
|
||||||
|
/// user typed into it.
|
||||||
|
///
|
||||||
|
/// Dropping them is what makes a version stable: moving a request into a
|
||||||
|
/// folder, dragging it up the sidebar, or simply saving it again all rewrite
|
||||||
|
/// these and nothing else, and none of them should mint a version or show up
|
||||||
|
/// in a diff. It is also why one rule covers HTTP, gRPC and WebSocket — the
|
||||||
|
/// three differ only in the content fields, which are all kept.
|
||||||
|
const BOOKKEEPING_KEYS: &[&str] =
|
||||||
|
&["model", "id", "createdAt", "updatedAt", "workspaceId", "folderId", "sortPriority"];
|
||||||
|
|
||||||
|
/// The editable content of a model, as the object a version stores.
|
||||||
|
pub fn version_document<T: Serialize>(model: &T) -> Result<Value> {
|
||||||
|
let mut value = serde_json::to_value(model)?;
|
||||||
|
if let Some(object) = value.as_object_mut() {
|
||||||
|
for key in BOOKKEEPING_KEYS {
|
||||||
|
object.remove(*key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash a version is addressed by.
|
||||||
|
pub fn content_hash(document: &Value) -> Result<String> {
|
||||||
|
let mut canonical = String::new();
|
||||||
|
write_canonical(document, &mut canonical);
|
||||||
|
Ok(hex::encode(Sha256::digest(canonical.as_bytes())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize with object keys in sorted order.
|
||||||
|
///
|
||||||
|
/// Plain `to_string` would not do: whether `serde_json::Map` preserves
|
||||||
|
/// insertion order or sorts is a workspace-wide feature decision, and a
|
||||||
|
/// document read back from SQLite has whatever order it was written in. Sorting
|
||||||
|
/// here makes the hash depend on the content and nothing else, in every build.
|
||||||
|
fn write_canonical(value: &Value, out: &mut String) {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
let mut keys = map.keys().collect::<Vec<_>>();
|
||||||
|
keys.sort_unstable();
|
||||||
|
out.push('{');
|
||||||
|
for (i, key) in keys.into_iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
write_canonical(&Value::String(key.clone()), out);
|
||||||
|
out.push(':');
|
||||||
|
write_canonical(&map[key], out);
|
||||||
|
}
|
||||||
|
out.push('}');
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
out.push('[');
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
write_canonical(item, out);
|
||||||
|
}
|
||||||
|
out.push(']');
|
||||||
|
}
|
||||||
|
scalar => out.push_str(&scalar.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay a version's document back over a live model.
|
||||||
|
///
|
||||||
|
/// Keys the document carries win; keys it doesn't mention keep whatever the
|
||||||
|
/// live model has. That covers both halves of a restore: bookkeeping (id,
|
||||||
|
/// folder, sort order) survives because the document never held it, and a field
|
||||||
|
/// added to the model after the version was captured survives because the
|
||||||
|
/// version predates it.
|
||||||
|
pub fn apply_version_document(live: &Value, document: &Value) -> Value {
|
||||||
|
let mut merged = live.as_object().cloned().unwrap_or_else(Map::new);
|
||||||
|
if let Some(document) = document.as_object() {
|
||||||
|
for (key, value) in document {
|
||||||
|
merged.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::{HttpRequest, HttpRequestHeader};
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
fn request() -> HttpRequest {
|
||||||
|
HttpRequest {
|
||||||
|
id: "rq_1".to_string(),
|
||||||
|
workspace_id: "wk_1".to_string(),
|
||||||
|
folder_id: Some("fl_1".to_string()),
|
||||||
|
name: "Get user".to_string(),
|
||||||
|
url: "https://example.com/users/1".to_string(),
|
||||||
|
method: "GET".to_string(),
|
||||||
|
sort_priority: 1.0,
|
||||||
|
headers: vec![HttpRequestHeader {
|
||||||
|
name: "Accept".to_string(),
|
||||||
|
value: "application/json".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_of(request: &HttpRequest) -> String {
|
||||||
|
content_hash(&version_document(request).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_holds_content_and_drops_bookkeeping() {
|
||||||
|
let document = version_document(&request()).unwrap();
|
||||||
|
let object = document.as_object().unwrap();
|
||||||
|
|
||||||
|
for key in BOOKKEEPING_KEYS {
|
||||||
|
assert!(!object.contains_key(*key), "document should not carry {key}");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(object.get("url").unwrap(), "https://example.com/users/1");
|
||||||
|
assert_eq!(object.get("name").unwrap(), "Get user");
|
||||||
|
assert_eq!(object.get("method").unwrap(), "GET");
|
||||||
|
assert!(object.contains_key("headers"));
|
||||||
|
assert!(object.contains_key("body"));
|
||||||
|
assert!(object.contains_key("authentication"));
|
||||||
|
assert!(object.contains_key("description"));
|
||||||
|
assert!(object.contains_key("settingFollowRedirects"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bookkeeping_never_changes_the_hash() {
|
||||||
|
let base = hash_of(&request());
|
||||||
|
|
||||||
|
let moved = HttpRequest { folder_id: Some("fl_2".to_string()), ..request() };
|
||||||
|
assert_eq!(hash_of(&moved), base, "folder");
|
||||||
|
|
||||||
|
let resorted = HttpRequest { sort_priority: 99.5, ..request() };
|
||||||
|
assert_eq!(hash_of(&resorted), base, "sort priority");
|
||||||
|
|
||||||
|
let touched =
|
||||||
|
HttpRequest { updated_at: Utc::now().naive_utc(), created_at: Utc::now().naive_utc(), ..request() };
|
||||||
|
assert_eq!(hash_of(&touched), base, "timestamps");
|
||||||
|
|
||||||
|
let renamed_id = HttpRequest { id: "rq_2".to_string(), ..request() };
|
||||||
|
assert_eq!(hash_of(&renamed_id), base, "id");
|
||||||
|
|
||||||
|
let moved_workspace = HttpRequest { workspace_id: "wk_2".to_string(), ..request() };
|
||||||
|
assert_eq!(hash_of(&moved_workspace), base, "workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editable_content_changes_the_hash() {
|
||||||
|
let base = hash_of(&request());
|
||||||
|
|
||||||
|
assert_ne!(hash_of(&HttpRequest { url: "https://example.com/users/2".into(), ..request() }), base);
|
||||||
|
assert_ne!(hash_of(&HttpRequest { method: "POST".into(), ..request() }), base);
|
||||||
|
assert_ne!(hash_of(&HttpRequest { name: "Get other user".into(), ..request() }), base);
|
||||||
|
assert_ne!(hash_of(&HttpRequest { description: "Notes".into(), ..request() }), base);
|
||||||
|
assert_ne!(hash_of(&HttpRequest { headers: vec![], ..request() }), base);
|
||||||
|
assert_ne!(
|
||||||
|
hash_of(&HttpRequest { body_type: Some("application/json".into()), ..request() }),
|
||||||
|
base
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash has to survive a round trip through SQLite, which stores the
|
||||||
|
/// document as text and hands back whatever order it was written in. It
|
||||||
|
/// also has to survive `serde_json`'s `preserve_order` feature being on in
|
||||||
|
/// one build of the workspace and off in another.
|
||||||
|
#[test]
|
||||||
|
fn key_order_does_not_change_the_hash() {
|
||||||
|
let a: Value = serde_json::from_str(r#"{"url":"a","method":"GET"}"#).unwrap();
|
||||||
|
let b: Value = serde_json::from_str(r#"{"method":"GET","url":"a"}"#).unwrap();
|
||||||
|
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_order_does_not_change_the_hash_when_nested() {
|
||||||
|
let a: Value =
|
||||||
|
serde_json::from_str(r#"{"body":{"text":"x","type":"json"},"headers":[{"a":1,"b":2}]}"#)
|
||||||
|
.unwrap();
|
||||||
|
let b: Value =
|
||||||
|
serde_json::from_str(r#"{"headers":[{"b":2,"a":1}],"body":{"type":"json","text":"x"}}"#)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sorting keys must not make different documents collide.
|
||||||
|
#[test]
|
||||||
|
fn array_order_still_changes_the_hash() {
|
||||||
|
let a: Value = serde_json::from_str(r#"{"headers":[{"n":"a"},{"n":"b"}]}"#).unwrap();
|
||||||
|
let b: Value = serde_json::from_str(r#"{"headers":[{"n":"b"},{"n":"a"}]}"#).unwrap();
|
||||||
|
assert_ne!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn applying_a_document_keeps_the_live_model_identity() {
|
||||||
|
let live = serde_json::to_value(request()).unwrap();
|
||||||
|
let document = version_document(&HttpRequest {
|
||||||
|
url: "https://example.com/users/2".to_string(),
|
||||||
|
..request()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let merged = apply_version_document(&live, &document);
|
||||||
|
let object = merged.as_object().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(object.get("url").unwrap(), "https://example.com/users/2");
|
||||||
|
assert_eq!(object.get("id").unwrap(), "rq_1");
|
||||||
|
assert_eq!(object.get("folderId").unwrap(), "fl_1");
|
||||||
|
assert_eq!(object.get("sortPriority").unwrap(), 1.0);
|
||||||
|
assert_eq!(object.get("model").unwrap(), "http_request");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A version captured before a field existed must not blank that field out.
|
||||||
|
#[test]
|
||||||
|
fn applying_an_older_document_leaves_unknown_fields_alone() {
|
||||||
|
let live = serde_json::to_value(request()).unwrap();
|
||||||
|
let document = serde_json::json!({ "url": "https://example.com/old" });
|
||||||
|
|
||||||
|
let merged = apply_version_document(&live, &document);
|
||||||
|
let object = merged.as_object().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(object.get("url").unwrap(), "https://example.com/old");
|
||||||
|
assert_eq!(object.get("method").unwrap(), "GET");
|
||||||
|
assert_eq!(object.get("name").unwrap(), "Get user");
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -474,7 +474,18 @@ export type ImportRequest = { content: string, };
|
|||||||
|
|
||||||
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
export type 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, };
|
||||||
|
|
||||||
|
|||||||
+41
-1
@@ -11,6 +11,7 @@ export type AnyModel =
|
|||||||
| HttpRequest
|
| HttpRequest
|
||||||
| HttpResponse
|
| HttpResponse
|
||||||
| HttpResponseEvent
|
| HttpResponseEvent
|
||||||
|
| ImportSource
|
||||||
| KeyValue
|
| KeyValue
|
||||||
| Plugin
|
| Plugin
|
||||||
| Settings
|
| Settings
|
||||||
@@ -109,6 +110,7 @@ export type Folder = {
|
|||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphQlIntrospection = {
|
export type GraphQlIntrospection = {
|
||||||
@@ -136,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";
|
||||||
@@ -213,6 +219,7 @@ export type HttpRequest = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
@@ -239,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 = {
|
||||||
@@ -314,8 +325,24 @@ export type HttpUrlParameter = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 InheritedIntSetting = { enabled?: boolean; value: number };
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
|
|
||||||
export type KeyValue = {
|
export type KeyValue = {
|
||||||
@@ -378,6 +405,7 @@ export type Settings = {
|
|||||||
themeLight: string;
|
themeLight: string;
|
||||||
updateChannel: string;
|
updateChannel: string;
|
||||||
hideLicenseBadge: boolean;
|
hideLicenseBadge: boolean;
|
||||||
|
promptFeedback: boolean;
|
||||||
autoupdate: boolean;
|
autoupdate: boolean;
|
||||||
autoDownloadUpdates: boolean;
|
autoDownloadUpdates: boolean;
|
||||||
checkNotifications: boolean;
|
checkNotifications: boolean;
|
||||||
@@ -410,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";
|
||||||
@@ -428,7 +460,14 @@ export type WebsocketEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WebsocketEventType =
|
export type WebsocketEventType =
|
||||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
| "binary"
|
||||||
|
| "close"
|
||||||
|
| "error"
|
||||||
|
| "frame"
|
||||||
|
| "open"
|
||||||
|
| "ping"
|
||||||
|
| "pong"
|
||||||
|
| "text";
|
||||||
|
|
||||||
export type WebsocketRequest = {
|
export type WebsocketRequest = {
|
||||||
model: "websocket_request";
|
model: "websocket_request";
|
||||||
@@ -473,6 +512,7 @@ export type Workspace = {
|
|||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
settingHttpVersion: HttpVersion;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceMeta = {
|
export type WorkspaceMeta = {
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Generated
+7
@@ -47,6 +47,7 @@ export type Folder = {
|
|||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GrpcRequest = {
|
export type GrpcRequest = {
|
||||||
@@ -99,6 +100,7 @@ export type HttpRequest = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
@@ -114,8 +116,12 @@ export type HttpUrlParameter = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|
||||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
|
|
||||||
export type SyncModel =
|
export type SyncModel =
|
||||||
@@ -182,4 +188,5 @@ export type Workspace = {
|
|||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
settingHttpVersion: HttpVersion;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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#"
|
||||||
|
|||||||
@@ -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" => {
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+105
-3
@@ -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};
|
||||||
@@ -190,6 +191,7 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {
|
|||||||
.get_client(&HttpConnectionOptions {
|
.get_client(&HttpConnectionOptions {
|
||||||
id: self.plugin_context_id.clone(),
|
id: self.plugin_context_id.clone(),
|
||||||
validate_certificates: runtime_config.settings.validate_certificates.value,
|
validate_certificates: runtime_config.settings.validate_certificates.value,
|
||||||
|
http_version: runtime_config.settings.http_version.value,
|
||||||
proxy: runtime_config.proxy.clone(),
|
proxy: runtime_config.proxy.clone(),
|
||||||
client_certificate,
|
client_certificate,
|
||||||
dns_overrides: runtime_config.dns_overrides.clone(),
|
dns_overrides: runtime_config.dns_overrides.clone(),
|
||||||
@@ -282,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
|
||||||
@@ -433,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())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,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();
|
||||||
@@ -618,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
|
||||||
@@ -1344,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,
|
||||||
@@ -1413,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,
|
||||||
@@ -1465,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
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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, };
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -109,6 +109,7 @@ export type Folder = {
|
|||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
settingRequestMessageSize: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphQlIntrospection = {
|
export type GraphQlIntrospection = {
|
||||||
@@ -213,6 +214,7 @@ export type HttpRequest = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
|
settingHttpVersion: InheritedHttpVersionSetting;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||||
@@ -314,8 +316,12 @@ export type HttpUrlParameter = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|
||||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||||
|
|
||||||
export type KeyValue = {
|
export type KeyValue = {
|
||||||
@@ -378,6 +384,7 @@ export type Settings = {
|
|||||||
themeLight: string;
|
themeLight: string;
|
||||||
updateChannel: string;
|
updateChannel: string;
|
||||||
hideLicenseBadge: boolean;
|
hideLicenseBadge: boolean;
|
||||||
|
promptFeedback: boolean;
|
||||||
autoupdate: boolean;
|
autoupdate: boolean;
|
||||||
autoDownloadUpdates: boolean;
|
autoDownloadUpdates: boolean;
|
||||||
checkNotifications: boolean;
|
checkNotifications: boolean;
|
||||||
@@ -428,7 +435,14 @@ export type WebsocketEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type WebsocketEventType =
|
export type WebsocketEventType =
|
||||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
| "binary"
|
||||||
|
| "close"
|
||||||
|
| "error"
|
||||||
|
| "frame"
|
||||||
|
| "open"
|
||||||
|
| "ping"
|
||||||
|
| "pong"
|
||||||
|
| "text";
|
||||||
|
|
||||||
export type WebsocketRequest = {
|
export type WebsocketRequest = {
|
||||||
model: "websocket_request";
|
model: "websocket_request";
|
||||||
@@ -473,6 +487,7 @@ export type Workspace = {
|
|||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
settingHttpVersion: HttpVersion;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceMeta = {
|
export type WorkspaceMeta = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user