Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Opus 5 7a6d275fdb feat(auth-oauth2): custom parameters for authorization, token, and refresh requests (#629)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:15:25 -07:00
Gregory Schier 7d0926c961 fix(input): opt plain inputs out of macOS Writing Tools suggestions (#646) 2026-09-13 09:15:15 -07:00
Gregory SchierandClaude Opus 5 522b898620 feat(auth): add HTTP Digest authentication plugin (#628)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:15:06 -07:00
Gregory SchierandClaude Fable 5.1 cb7ed9264d feat(client): show an indicator on the Info tab when a request has a description (#645)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 09:12:42 -07:00
Gregory SchierandClaude Fable 5.1 90cb578e26 Route all database writes through one connection (#642)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 08:12:41 -07:00
Gregory SchierandClaude Fable 5.1 ff7eebf3cd fix(importer-curl): join line continuations that have trailing whitespace (#644)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 23:33:54 -07:00
Ngo Quoc Viet eff0c06cb0 fix(importer-postman): stop swapping the API key name and its value (#643) 2026-09-12 12:52:20 -07:00
dependabot[bot] c6e09a5af3 Bump vite-plus from 0.2.9 to 0.3.0 in the npm-development group (#632)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:56 -07:00
dependabot[bot] b05071ef4b Bump cpy-cli from 5.0.0 to 7.0.0 (#633)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:40 -07:00
dependabot[bot] a44f31eeb2 Bump cpx2 from 8.0.2 to 9.0.0 (#634)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:26 -07:00
dependabot[bot] 1103d05915 Bump zip from 4.0.0 to 7.0.0 (#637)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:15 -07:00
Gregory SchierandClaude Fable 5.1 2f5639a26f chore: cargo fmt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:35:33 -07:00
Gregory SchierandClaude Opus 5 7f8b7bf567 feat(import): remember the user's import selection (#619)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:26:43 -07:00
Gregory Schier 096e83a7bd chore(deps): patch open security advisories across npm deps (#630) 2026-09-10 14:34:00 -07:00
gschier 89ce84cefe Deploying to main from @ mountain-loop/yaak@d5d2a64c32 🚀 2026-09-10 15:37:06 +00:00
Gregory Schier d5d2a64c32 fix(cli): focus the agent skill description on when to use it (#625) 2026-09-09 08:50:26 -07:00
Gregory Schier d2d4b80a09 feat(import): merge re-imports from a linked source instead of duplicating (#618) 2026-09-01 11:12:07 -07:00
Gregory Schier d461c982ec Don't block window creation on plugin runtime boot (#616) 2026-09-01 09:14:50 -07:00
Gregory SchierandClaude Fable 5 8eebee460e fix(pair-editor): remount the editor when its stateKey changes (#617)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 08:58:55 -07:00
Gregory SchierandClaude Opus 5 81a2a5d955 feat(import): add stable per-resource source keys to the importer contract (#614)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:11:54 -07:00
Gregory SchierandClaude Opus 5 e63a87718c fix(tooling): keep the formatter off generated files in every entry point (#615)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:40:42 -07:00
Gregory Schier 222ea9cb83 fix(editor): opt editors out of macOS Writing Tools suggestions
A "Write with Siri" pill appears over empty editors on the macOS 27 beta,
because Codemirror's contentDOM is a contenteditable like any other. The
`writingsuggestions` attribute opts an editable element out, and setting
it here covers every editor at once.

If the pill turns out to be separate from writing suggestions, the
remaining lever is WKWebView's writingToolsBehavior, which would disable
Writing Tools for the whole window.
2026-08-31 10:55:05 -07:00
Gregory Schier 360c098b09 fix(grpc): remove the dead Refresh item from the method picker
It has never had an onSelect. Introduced inert in #193 and carried
through the codebase split, so clicking it only ever closed the dropdown.
Reload Schema in the message editor's schema dropdown does the job it
looked like it was for.
2026-08-31 10:54:40 -07:00
Gregory Schier fce1039bac feat(grpc): generate an example message from the method schema (#613) 2026-08-31 10:44:31 -07:00
Gregory Schier f18f93d613 Stage imports before committing (#571) 2026-08-31 10:20:44 -07:00
Gregory SchierandClaude Opus 5 661a384bed Faster codemirror search match counting (#612)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:09:50 -07:00
185 changed files with 13028 additions and 3471 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
npm-production:
dependency-type: production
update-types:
- minor
- patch
npm-development:
dependency-type: development
update-types:
- minor
- patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
cargo:
patterns:
- "*"
update-types:
- minor
- patch
-3
View File
@@ -1,3 +0,0 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
Generated
+54 -28
View File
@@ -960,15 +960,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2"
version = "0.6.1"
@@ -978,16 +969,6 @@ dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -2114,7 +2095,7 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c169adf067a787e1f1c58ed62906a557de85388bee4b54fb878b722ff606b113"
dependencies = [
"bzip2 0.6.1",
"bzip2",
"clap",
"fs-err",
"indicatif",
@@ -2914,11 +2895,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
]
[[package]]
@@ -4194,6 +4173,15 @@ dependencies = [
"value-bag",
]
[[package]]
name = "lzma-rust2"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619"
dependencies = [
"sha2",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -6174,6 +6162,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -8906,7 +8900,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -11223,9 +11217,12 @@ version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"log 0.4.29",
"md5 0.8.0",
"rusqlite",
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.17",
"tokio",
@@ -11373,7 +11370,7 @@ dependencies = [
"yaak-models",
"yaak-plugins",
"yaak-templates",
"zip",
"zip 7.0.0",
]
[[package]]
@@ -12043,13 +12040,13 @@ dependencies = [
[[package]]
name = "zip"
version = "4.0.0"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"aes",
"arbitrary",
"bzip2 0.5.2",
"bzip2",
"constant_time_eq 0.3.1",
"crc32fast",
"deflate64",
@@ -12060,6 +12057,35 @@ dependencies = [
"liblzma",
"memchr",
"pbkdf2",
"ppmd-rust",
"sha1",
"time",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zip"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq 0.3.1",
"crc32fast",
"deflate64",
"flate2",
"generic-array",
"getrandom 0.3.3",
"hmac",
"indexmap 2.14.0",
"lzma-rust2",
"memchr",
"pbkdf2",
"ppmd-rust",
"sha1",
"time",
"zeroize",
@@ -12075,7 +12101,7 @@ checksum = "aed5f10c571472911e37d8f7601a8dfba52b4f7f73a344015291b82ab292faf6"
dependencies = [
"log 0.4.29",
"thiserror 2.0.17",
"zip",
"zip 4.6.1",
]
[[package]]
+1 -1
View File
@@ -17,7 +17,7 @@
<br>
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/rauchg"><img src="https:&#x2F;&#x2F;github.com&#x2F;rauchg.png" width="80px" alt="User avatar: rauchg" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<a href="https://github.com/PurplProto"><img src="https:&#x2F;&#x2F;github.com&#x2F;PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
+180 -63
View File
@@ -1,9 +1,8 @@
import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import {
handleRefresh,
jsonCompletion,
@@ -11,12 +10,20 @@ import {
stateExtensions,
updateSchema,
} from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
protoFiles: string[];
};
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({
services,
reflectionError,
@@ -42,21 +54,16 @@ export function GrpcEditor({
setEditorView(h);
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorView == null ||
services === null ||
request.service === null ||
request.method === null
) {
return;
// Find the schema for the selected service and method
const methodSchema = useMemo<MethodSchema>(() => {
if (services === null || request.service === null || request.method === null) {
return { type: "none" };
}
const s = services.find((s) => s.name === request.service);
if (s == null) {
console.log("Failed to find service", { service: request.service, services });
showAlert({
return {
type: "error",
id: "grpc-find-service-error",
title: "Couldn't Find Service",
body: (
@@ -64,14 +71,14 @@ export function GrpcEditor({
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
log: ["Failed to find service", { service: request.service, services }],
};
}
const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (request.method != null && schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods });
showAlert({
if (schema == null) {
return {
type: "error",
id: "grpc-find-schema-error",
title: "Couldn't Find Method",
body: (
@@ -80,18 +87,15 @@ export function GrpcEditor({
<InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
}
if (schema == null) {
return;
log: ["Failed to find method", { method: request.method, methods: s.methods }],
};
}
try {
updateSchema(editorView, JSON.parse(schema));
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
} catch (err) {
showAlert({
return {
type: "error",
id: "grpc-parse-schema-error",
title: "Failed to Parse Schema",
body: (
@@ -103,9 +107,22 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
log: ["Failed to parse schema", err],
};
}
}, [editorView, services, request.method, request.service]);
}, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
const extraExtensions = useMemo(
() => [
@@ -124,45 +141,145 @@ export function GrpcEditor({
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo(
() => [
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button
size="xs"
color={
reflectionLoading
? "secondary"
: reflectionUnavailable
? "info"
: reflectionError
? "danger"
: "secondary"
}
isLoading={reflectionLoading}
onClick={() => {
showDialog({
title: "Configure Schema",
size: "md",
id: "reflection-failed",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
}}
// Matches the GraphQL editor: one always-visible control labelled by schema state,
// with everything schema-related behind it.
<div key="schema" className="opacity-100!">
<Dropdown
items={[
{
// Hidden for servers without reflection, which isn't an error
hidden: !reflectionError,
type: "content",
label: (
<Banner color="danger">
<p className="mb-1">Reflection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={handleShowReflectionError}
>
View Error
</Button>
</Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null && protoFiles.length === 0
? "Schema Detected"
: "Select Schema"}
</Button>
<Button
size="sm"
variant="border"
title="Schema"
forDropdown
isLoading={reflectionLoading}
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
</div>,
],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
[
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
);
return (
@@ -133,6 +133,8 @@ export function GrpcRequestPane({
onSend({ message: activeRequest.message });
}, [activeRequest, onSend]);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs: TabItem[] = useMemo(
() => [
{ value: TAB_MESSAGE, label: "Message" },
@@ -146,10 +148,10 @@ export function GrpcRequestPane({
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: activeRequest.description && <CountBadge count={true} />,
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
[authTab, hasDescription, metadataTab, numSettingsOverrides],
);
const handleMetadataChange = useCallback(
@@ -194,13 +196,6 @@ export function GrpcRequestPane({
type: "default",
shortLabel: o.label,
}))}
itemsAfter={[
{
label: "Refresh",
type: "default",
leftSlot: <Icon size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
@@ -165,6 +165,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
: 0;
}
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(
() => [
{
@@ -260,6 +262,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[
@@ -267,6 +270,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
authTab,
contentType,
handleContentTypeChange,
hasDescription,
headersTab,
numParams,
numSettingsOverrides,
+681 -25
View File
@@ -1,15 +1,38 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useRef, useState } from "react";
import { useLocalStorage } from "react-use";
import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useMemo, useRef, useState } from "react";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props {
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
}
/**
@@ -31,10 +54,74 @@ function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
export function ImportDataDialog({ importFile, importUrl }: Props) {
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
* patching it in with effects after the first paint.
*/
export function ImportDataDialog(props: Props) {
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
const { currentWorkspace, listSources } = props;
useEffect(() => {
let cancelled = false;
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
load
.then((sources) => {
if (!cancelled) setInitialSources(sources);
})
.catch(() => {
if (!cancelled) setInitialSources([]);
});
return () => {
cancelled = true;
};
}, [currentWorkspace, listSources]);
if (initialSources == null) return null;
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
listSources,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
);
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
@@ -65,25 +152,272 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
});
}, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
setOriginSources([]);
return;
}
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
})
.catch(() => setOriginSources([]));
}, 300);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [trimmedSource, filePath, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
return workspaces.find((w) => w.id === ids[0]) ?? null;
}, [originSources, workspaces]);
// A file linked to the current workspace defaults back into it, so re-importing doesn't
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
// silently targeting a workspace that is neither new nor current is too surprising. An
// explicit choice always wins.
const destinationKind =
destinationChoice ??
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
const destinationWorkspaceId =
destinationKind === "current"
? (currentWorkspace?.id ?? null)
: destinationKind === "other"
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
const handleImport = async () => {
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
try {
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit({ ...plan, items });
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
const targets = new Set(togglableItems(node).map((i) => i.modelId));
if (checked && node.data.kind === "item") {
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const ancestor of ancestorsOf(node.data.item, byId)) {
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
}
}
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
setItems((prev) =>
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
);
};
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
if (item.action !== "delete") continue;
for (const parent of ancestorsOf(item, byId)) {
if (parent.action === "delete" && parent.selected) {
disabled.add(item.modelId);
}
}
}
return disabled;
}, [items]);
if (plan != null) {
const unchanged = items.filter((i) => i.action === "unchanged");
const footerNote =
unchanged.length > 0
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
: "";
const changeCount = items.filter((item) => {
if (disabledIds.has(item.modelId)) return false;
if (item.action === "conflict") return item.resolution === "take_source";
if (item.action === "unchanged") return false;
return item.selected;
}).length;
// The row's label carries what kind of destination it is, so the value can just be its name
const [destinationLabel, destinationValue] = ((): [string, string] => {
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [pluralize("New workspace", names.length), names.join(", ")];
}
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return [
"Destination",
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<TreeRow> = (() => {
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: {
kind: "destination",
label: existing?.name ?? planned?.name ?? "New workspace",
isNew: planDestination.type === "new_workspace",
},
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={destinationLabel} value={destinationValue} />
</div>
{plan.warnings.map((warning) => (
<Banner
key={`${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "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>
</Banner>
))}
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
setPlan(null);
setItems([]);
}}
>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
);
}
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
@@ -94,7 +428,9 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
@@ -115,25 +451,345 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}>
<PlainInput
label="Or enter a file path or URL"
<Select
name="import-destination-kind"
label="Import location"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
{lastImported != null && destinationWorkspaceId != null ? (
<div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
} else {
setDestinationChoice("other");
setOtherWorkspaceId(linkedWorkspace.id);
}
}}
>
{linkedWorkspace.name}
</button>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}”`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={trimmedSource === "" || isLoading}
disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading}
size="sm"
onClick={handleImport}
onClick={handlePreview}
>
{isLoading ? "Importing" : "Import"}
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</VStack>
</HStack>
</VStack>
);
}
function ImportTreeRow({
row,
onResolveConflict,
}: {
row: TreeRow;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
if (row.kind !== "item") {
return (
<>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" />
)}
</>
);
}
const { item } = row;
const label = actionLabel(item);
return (
<>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
{ value: "keep_mine", label: "Keep mine" },
{ value: "take_source", label: "Take source" },
]}
/>
</div>
) : (
label != null && (
<ActionChip
label={label}
help={actionHelp(item)}
className={classNames(
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)}
/>
)
)}
</>
);
}
function ActionChip({
label,
help,
className,
}: {
label: string;
help: string | null;
className?: string;
}) {
return (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode>
);
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "new";
case "update":
return "updated";
case "delete":
return "removed";
case "keep_local":
return "edited";
case "ignored":
return "ignored";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
const help = (text: string) =>
item.changedFields.length > 0
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
: text;
switch (item.action) {
case "create":
return "Not in this workspace yet";
case "update":
return item.reason === "moved_into_ignored_folder"
? "Moved into a folder that isn't imported. Importing that folder brings it along"
: help("Changed in the source since the last import");
case "delete":
return "Gone from the source since the last import. Checking it deletes it here";
case "keep_local":
return help("Changed here since the last import. Checking it reverts to the source");
case "conflict":
return help("Changed here and in the source since the last import");
case "ignored":
return "Not in this workspace. Imports leave it alone until you check it";
default:
return null;
}
}
function fieldLabel(field: string): string {
return field.replace(/([A-Z])/g, " $1").toLowerCase();
}
/** Every plan item above `item`, nearest first. */
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null) break;
ancestors.push(parent);
parentId = parent.parentId;
}
return ancestors;
}
/**
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
* workspace's environments sit in are headings: they aggregate their children and decide nothing
* themselves.
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string; icon: IconProps["icon"] }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
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 byKind = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "environment"),
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
key: item.modelId,
data: { kind: "item", item },
children: seen.has(item.modelId)
? []
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
// the base one, not its child — so a heading groups them into one thing to turn on and off.
const environments = roots.filter((i) => i.model === "environment");
const others = byKind(roots.filter((i) => i.model !== "environment"));
const nodes = others.map((r) => toNode(r, new Set()));
if (environments.length === 0) return nodes;
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables", icon: "variable" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
];
}
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
return [node.data, ...node.children.flatMap(collectRows)];
}
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
function isMissingFolder(item: ImportPlanItem): boolean {
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
}
/**
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
* along with a parent's.
*/
function togglableItem(row: TreeRow): ImportPlanItem | null {
if (row.kind !== "item") return null;
const { item } = row;
return item.action === "unchanged" || item.action === "conflict" ? null : item;
}
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
return collectRows(node)
.map(togglableItem)
.filter((i) => i != null);
}
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
const covered = togglableItems(node);
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>
);
}
@@ -110,6 +110,8 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(() => {
return [
{
@@ -131,9 +133,10 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
];
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
}, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -0,0 +1,114 @@
import { Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { useState } from "react";
import type { CheckboxProps } from "./Checkbox";
import { Checkbox } from "./Checkbox";
export interface CheckboxTreeNode<T> {
key: string;
data: T;
children: CheckboxTreeNode<T>[];
}
interface Props<T> {
node: CheckboxTreeNode<T>;
depth?: number;
/** Return "hidden" to render row alignment space instead of a checkbox */
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
/** An irrelevant row is hidden unless one of its descendants is relevant */
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
}
export function CheckboxTree<T>(props: Props<T>) {
const { node, depth = 0 } = props;
const [collapsed, setCollapsed] = useState<boolean>(
() => props.isCollapsedByDefault?.(node) ?? false,
);
if (!hasRelevantNode(node, props.isRelevant)) return null;
const checked = props.checked(node);
const selected = props.isRowSelected?.(node) ?? false;
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
const rowContent = (
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
{props.renderRow(node)}
</div>
);
return (
<div
className={classNames(
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
)}
>
<div
className={classNames(
"relative flex gap-1 w-full h-xs items-center",
selected ? "text-text" : "text-text-subtle",
)}
>
{selected && (
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
{hasVisibleChildren ? (
<button
type="button"
aria-label={collapsed ? "Expand" : "Collapse"}
aria-expanded={!collapsed}
className="shrink-0 text-text-subtlest hocus:text-text"
onClick={() => setCollapsed((v) => !v)}
>
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
</button>
) : (
<span aria-hidden className="w-4 shrink-0" />
)}
{checked === "hidden" ? (
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
) : (
<Checkbox
checked={checked}
title={props.checkboxTitle?.(node) ?? "Toggle"}
hideLabel
disabled={props.isCheckboxDisabled?.(node)}
onChange={(checked) => props.onCheck(node, checked)}
/>
)}
{selectable ? (
<button
type="button"
className="flex-1 min-w-0 flex text-left"
onClick={() => props.onSelectRow?.(node)}
>
{rowContent}
</button>
) : (
rowContent
)}
</div>
{!collapsed &&
node.children.map((child) => (
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
))}
</div>
);
}
function hasRelevantNode<T>(
node: CheckboxTreeNode<T>,
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
): boolean {
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
}
+7 -5
View File
@@ -10,11 +10,13 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
disableBackdropClose?: boolean;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
@@ -27,7 +29,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableBackdropClose,
disableClose,
title,
description,
hideX,
@@ -42,7 +44,7 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
@@ -58,7 +60,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
onClose?.();
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -110,7 +112,7 @@ export function Dialog({
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
@@ -601,6 +601,8 @@ function getExtensions({
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}),
EditorView.domEventHandlers({
focus: () => {
@@ -0,0 +1,202 @@
import { SearchQuery } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { describe, expect, test } from "vite-plus/test";
import {
currentMatch,
literalSearch,
MAX_COUNT,
MatchCounter,
normalizeDoc,
normalizeSearch,
scanNormalized,
scanQuery,
} from "./searchMatchCount";
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
const stateOf = (doc: string) => EditorState.create({ doc });
/**
* The matches the counter finds, having checked them against the search panel's own cursor.
*
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
*/
function matchesOf(doc: string, config: QueryConfig) {
const state = stateOf(doc);
const query = new SearchQuery(config);
const matches = new MatchCounter().matches(state, query);
expect(matches).toEqual(scanQuery(state, query));
return matches;
}
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
describe("counting", () => {
test("counts every match, whatever the case", () => {
expect(countOf("one Two three two", { search: "two" })).toBe(2);
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
});
test("skips matches overlapping an earlier one", () => {
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
expect(countOf("ababa", { search: "aba" })).toBe(1);
});
test("treats a query as text, not as a pattern", () => {
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
});
test("unquotes escapes unless the query is literal", () => {
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
});
test("counts regexp and whole word queries through the cursor", () => {
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
});
test("stops counting at the cap", () => {
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
});
test("reports where the matches are", () => {
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
{ from: 0, to: 2 },
{ from: 4, to: 6 },
]);
});
test("finds nothing to match with an empty needle", () => {
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
});
});
describe("normalization", () => {
test("finds what a character decomposes into", () => {
// The é is one character holding an `e`, and the match covers the whole of it
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
expect(countOf("one two", { search: "one two" })).toBe(1);
expect(countOf("full width", { search: "full" })).toBe(1);
});
test("matches a decomposed query against composed text, and the reverse", () => {
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
});
test("keeps offsets straight after an expansion", () => {
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
});
test("normalizes the query whole, the document by character", () => {
expect(normalizeSearch("CAFÉ", false)).toBe("café");
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
});
test("leaves a document that normalizes to itself untouched", () => {
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
expect(text).toBe("plain 日本 🎉 text");
expect(expansions).toEqual([]);
});
});
describe("current match", () => {
const matches = [
{ from: 0, to: 2 },
{ from: 4, to: 6 },
{ from: 8, to: 10 },
];
test("counts from one, and reports 0 off a match", () => {
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
});
test("moving the selection doesn't scan again", () => {
const state = stateOf("a1 b2 c3");
const query = new SearchQuery({ search: "\\d", regexp: true });
const counter = new MatchCounter();
const found = counter.matches(state, query);
// The document a selection-only transaction leaves behind is the one already scanned
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
expect(counter.matches(moved, query)).toBe(found);
expect(currentMatch(found, moved.selection.main)).toBe(2);
});
});
/**
* The mapping from normalized offsets back to document offsets is the part of this that can go
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
*/
describe("against the cursor, on awkward text", () => {
const ALPHABET = [
..."abcABC .\\\n".split(""),
"é",
"é",
"fi",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"f",
"①",
"́",
];
/** Seeded, so a failure is the same failure next run */
function random(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
for (const caseSensitive of [false, true]) {
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
const next = random(caseSensitive ? 20260831 : 7);
for (let round = 0; round < 400; round++) {
const doc = Array.from(
{ length: 2 + Math.floor(next() * 60) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
// Half the queries are lifted out of the document, so matches are actually found
const start = Math.floor(next() * doc.length);
const search =
next() < 0.5
? doc.slice(start, start + 1 + Math.floor(next() * 3))
: Array.from(
{ length: 1 + Math.floor(next() * 2) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
if (search === "") continue;
const state = stateOf(doc);
const query = new SearchQuery({ search, caseSensitive });
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
}
});
}
});
@@ -1,7 +1,232 @@
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
import type { EditorState, Extension, Text } from "@codemirror/state";
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
/** Matches are counted no further than this, since an exact total stops being useful long before */
export const MAX_COUNT = 9999;
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
export interface Match {
from: number;
to: number;
}
/** A character whose normalized form is a different length, shifting every offset past it */
interface Expansion {
normFrom: number;
normTo: number;
docFrom: number;
docTo: number;
}
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
export interface NormalizedDoc {
text: string;
expansions: Expansion[];
}
/**
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
* case-sensitive — in a single pass rather than one call per code point.
*
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
* makes counting matches in a large response slow. Doing it a character at a time still matters
* for the result, since it keeps NFKD from reordering marks across characters, so the
* granularity stays and only the repeated work goes: each distinct character is normalized once
* and the answer reused, and ASCII runs never reach ICU at all.
*/
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
const rewritten = new Map<string, string>();
const expansions: Expansion[] = [];
let shift = 0;
const normalized = text.replace(
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
(chunk: string, at: number) => {
// An ASCII run only ever folds case, which can't change its length
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
let out = rewritten.get(chunk);
if (out === undefined) {
out = chunk.normalize("NFKD");
if (!caseSensitive) out = out.toLowerCase();
rewritten.set(chunk, out);
}
if (out.length !== chunk.length) {
expansions.push({
normFrom: at + shift,
normTo: at + shift + out.length,
docFrom: at,
docTo: at + chunk.length,
});
shift += out.length - chunk.length;
}
return out;
},
);
return { text: normalized, expansions };
}
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
export function normalizeSearch(search: string, caseSensitive: boolean): string {
const normalized = search.normalize("NFKD");
return caseSensitive ? normalized : normalized.toLowerCase();
}
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
const matches: Match[] = [];
if (needle === "") return matches;
const { text } = doc;
let pos = text.indexOf(needle);
while (pos >= 0) {
let end = pos + needle.length;
// However the query was cut, a match ends on a whole code point, as the cursor's do
if (isLowSurrogate(text.charCodeAt(end))) end++;
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
if (matches.length > MAX_COUNT) break;
pos = text.indexOf(needle, resumeAfter(doc, end));
}
return matches;
}
/** The same through the query's own cursor, which handles regexps and whole words */
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
const matches: Match[] = [];
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
matches.push({ from: result.value.from, to: result.value.to });
if (matches.length > MAX_COUNT) break;
}
return matches;
}
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
/** The last character expansion beginning at or before `offset`, if there is one */
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
let low = 0;
let high = expansions.length - 1;
let found: Expansion | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
if (expansions[mid]!.normFrom <= offset) {
found = expansions[mid]!;
low = mid + 1;
} else {
high = mid - 1;
}
}
return found;
}
function docStart(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
// A match starting inside a character's expansion starts at the character
return offset < expansion.normTo
? expansion.docFrom
: offset - (expansion.normTo - expansion.docTo);
}
/**
* Where scanning picks up after a match ending at `offset`.
*
* The cursor moves through the document a character at a time, so once a match ends inside a
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
* ever counts as one match of ".".
*/
function resumeAfter(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
? expansion.normTo
: offset;
}
function docEnd(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
if (offset <= expansion.normFrom) return expansion.docFrom;
// A match ending inside a character's expansion covers the whole character
return offset < expansion.normTo
? expansion.docTo
: offset - (expansion.normTo - expansion.docTo);
}
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
let index = 0;
for (const match of matches) {
index++;
if (match.from <= selection.from && match.to >= selection.to) return index;
}
return 0;
}
/** The text a plain query looks for, or null when only the cursor can answer it */
export function literalSearch(query: SearchQuery): string | null {
if (query.regexp || query.wholeWord || query.test != null) return null;
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
return query.literal
? query.search
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
);
}
/**
* Finds the matches for the search panel, keeping the normalized document and the matches it
* last found, so neither moving the selection nor typing another character starts over.
*/
export class MatchCounter {
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
matches(state: EditorState, query: SearchQuery): Match[] {
const last = this.last;
if (last != null && last.doc === state.doc && last.query.eq(query)) {
return last.matches;
}
const matches = this.scan(state, query);
this.last = { doc: state.doc, query, matches };
return matches;
}
private scan(state: EditorState, query: SearchQuery): Match[] {
const search = literalSearch(query);
if (search == null) return scanQuery(state, query);
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
}
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
const cached = this.doc;
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
return cached.normalized;
}
const normalized = normalizeDoc(doc.toString(), caseSensitive);
this.doc = { doc, caseSensitive, normalized };
return normalized;
}
}
/**
* A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel.
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
return ViewPlugin.fromClass(
class {
private countEl: HTMLElement | null = null;
private counter = new MatchCounter();
constructor(private view: EditorView) {
this.updateCount();
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
}
this.ensureCountEl();
if (this.countEl == null) return;
if (!query.search) {
if (this.countEl) {
this.countEl.textContent = "0/0";
}
this.countEl.textContent = "0/0";
return;
}
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
const matches = this.counter.matches(state, query);
if (matches.length > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (matches.length === 0) {
this.countEl.textContent = "0/0";
} else {
const current = currentMatch(matches, state.selection.main);
this.countEl.textContent = `${current}/${matches.length}`;
}
}
@@ -113,7 +113,15 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30;
export function PairEditor({
// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
// fast path below would swap in the new data without rebuilding the row editors, leaving any
// still-focused input showing the old owner's text.
export function PairEditor(props: PairEditorProps) {
return <PairEditorInner key={props.stateKey} {...props} />;
}
function PairEditorInner({
allowFileValues,
allowMultilineValues,
className,
@@ -205,6 +205,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
writingsuggestions="false"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
@@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { Button } from "./Button";
import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
interface Props<T extends string> {
@@ -36,11 +37,15 @@ export function SegmentedControl<T extends string>({
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
// A visually hidden label has nowhere to show the help, so the last option carries it
const inlineHelp =
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
return (
<div className="w-full grid">
<Label
htmlFor={id.current}
help={help}
help={hideLabel ? undefined : help}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
@@ -78,9 +83,10 @@ export function SegmentedControl<T extends string>({
}
}}
>
{options.map((o) => {
{options.map((o, i) => {
const isSelected = selectedValue === o.value;
const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) {
return (
<Button
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus",
)}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
>
{o.label}
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
)}
title={o.label}
icon={o.icon}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
/>
);
+6 -2
View File
@@ -119,9 +119,13 @@ export function Select<T extends string>({
)}
>
<Button
className="w-full text-sm font-mono"
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start"
variant="border"
variant="input"
size={size}
leftSlot={leftSlot}
disabled={disabled}
@@ -21,6 +21,8 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
import { CheckboxTree } from "../core/CheckboxTree";
import { DiffViewer } from "../core/Editor/DiffViewer";
import { Input } from "../core/Input";
import { Separator } from "../core/Separator";
@@ -43,10 +45,7 @@ interface CommitTreeNode {
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const callbacks = useGitCallbacks(syncDir);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
syncDir,
callbacks,
);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null);
const [message, setMessage] = useState<string>("");
@@ -143,6 +142,15 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return next(workspace, []);
}, [workspace, internalEntries]);
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
key: n.status.relaPath + n.status.status + n.status.staged,
data: n,
children: n.children.map(toTreeNode),
});
return tree == null ? null : toTreeNode(tree);
}, [tree]);
const checkNode = useCallback(
(treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode);
@@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
[restore],
);
if (tree == null) {
if (tree == null || treeNode == null) {
return null;
}
@@ -221,12 +229,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
style={innerStyle}
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
>
<TreeNodeChildren
node={tree}
depth={0}
onCheck={checkNode}
onSelect={handleSelectChild}
selectedPath={selectedEntry?.relaPath ?? null}
<CheckboxTree
node={treeNode}
checked={(n) => nodeCheckedStatus(n.data)}
onCheck={(n) => checkNode(n.data)}
checkboxTitle={(n) =>
nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
}
isRelevant={(n) => n.data.status.status !== "current"}
canSelectRow={(n) => n.data.status.status !== "current"}
onSelectRow={(n) => handleSelectChild(n.data.status)}
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
renderRow={(n) => <CommitTreeRow node={n.data} />}
/>
{externalEntries.find((e) => e.status !== "current") && (
<>
@@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div>
)}
secondSlot={({ style: innerStyle }) => (
<div
style={innerStyle}
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
>
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<Input
className="text-base! font-sans rounded-md"
placeholder="Commit message..."
@@ -301,96 +312,39 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
);
}
function TreeNodeChildren({
node,
depth,
onCheck,
onSelect,
selectedPath,
}: {
node: CommitTreeNode | null;
depth: number;
onCheck: (node: CommitTreeNode, checked: boolean) => void;
onSelect: (entry: GitStatusEntry) => void;
selectedPath: string | null;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node);
const isSelected = selectedPath === node.status.relaPath;
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
return (
<div
className={classNames(
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
)}
>
<div
className={classNames(
"relative flex gap-1 w-full h-xs items-center",
isSelected ? "text-text" : "text-text-subtle",
)}
>
{isSelected && (
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
<Checkbox
checked={checked}
title={checked ? "Unstage change" : "Stage change"}
hideLabel
onChange={(checked) => onCheck(node, checked)}
<>
{node.model.model !== "http_request" &&
node.model.model !== "grpc_request" &&
node.model.model !== "websocket_request" ? (
<Icon
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
/>
<button
type="button"
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
onClick={() => node.status.status !== "current" && onSelect(node.status)}
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.model.model !== "http_request" &&
node.model.model !== "grpc_request" &&
node.model.model !== "websocket_request" ? (
<Icon
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
)}
</button>
</div>
{node.children.map((childNode) => {
return (
<TreeNodeChildren
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
node={childNode}
depth={depth + 1}
onCheck={onCheck}
onSelect={onSelect}
selectedPath={selectedPath}
/>
);
})}
</div>
{node.status.status}
</InlineCode>
)}
</>
);
}
@@ -495,15 +449,6 @@ function setCheckedAndChildren(
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
}
function isNodeRelevant(node: CommitTreeNode): boolean {
if (node.status.status !== "current") {
return true;
}
// Recursively check children
return node.children.some((c) => isNodeRelevant(c));
}
function DiffPanel({
entry,
onDiscardChanges,
@@ -526,13 +471,11 @@ function DiffPanel({
size="2xs"
variant="border"
onClick={() => onDiscardChanges(entry)}
>Discard Changes</Button>
>
Discard Changes
</Button>
</div>
<DiffViewer
original={prevYaml ?? ""}
modified={nextYaml ?? ""}
className="flex-1 min-h-0"
/>
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
</div>
);
}
+1 -3
View File
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
showDialog({
id: "git-diverged",
title: "Branches Diverged",
hideX: true,
size: "sm",
disableBackdropClose: true,
onClose: () => resolve("cancel"),
disableClose: true,
render: ({ hide }) =>
DivergedDialog({
remote,
+1 -2
View File
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({
id,
title,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
disableClose: true,
render: ({ hide }) => Alert({ onHide: hide, body }),
});
}
+1 -2
View File
@@ -18,9 +18,8 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({
...extraProps,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
disableClose: true,
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
});
});
+49 -17
View File
@@ -1,14 +1,34 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import {
type BatchUpsertResult,
type ImportDestination,
type ImportPlan,
type ImportSource,
workspacesAtom,
} from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize";
import { router } from "./router";
import { rpc } from "./rpc";
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
// fetches) every time the dialog container re-renders.
const planFile = (filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
const planUrl = (url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination });
const listSources = (workspaceId: string) =>
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
export const importData = createFastMutation({
mutationKey: ["import_data"],
onError: (err: string) => {
@@ -21,29 +41,41 @@ export const importData = createFastMutation({
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "sm",
size: "lg",
disableClose: true,
render: ({ hide }) => {
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
const cancel = () => {
hide();
resolve();
};
const fail = (err: unknown) => {
hide();
reject(err);
};
const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
};
return (
<ImportDataDialog
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
}
currentWorkspace={currentWorkspace}
workspaces={workspaces}
selectedFolder={selectedFolder}
planFile={planFile}
planUrl={planUrl}
listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
cancel={cancel}
onError={fail}
/>
);
},
@@ -0,0 +1,251 @@
import { describe, expect, test } from "vite-plus/test";
import type { JsonSchema } from "./jsonSchemaExample";
import { buildExampleFromSchema } from "./jsonSchemaExample";
describe("buildExampleFromSchema", () => {
test("fills scalar fields with placeholders", () => {
const schema: JsonSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number", format: "int32" },
active: { type: "boolean" },
data: { type: "string", format: "byte" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
name: "",
age: 0,
active: false,
data: "",
});
});
test("encodes 64-bit integers as strings", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string", format: "int64" },
count: { type: "string", format: "uint64" },
offset: { type: "string", format: "sfixed64" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
});
test("fills date-time with a parseable timestamp", () => {
const schema: JsonSchema = {
type: "object",
properties: { createdAt: { type: "string", format: "date-time" } },
};
const example = buildExampleFromSchema(schema) as { createdAt: string };
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
});
test("fills a duration with a value that parses", () => {
const schema: JsonSchema = {
type: "object",
properties: { timeout: { type: "string", format: "duration" } },
};
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
});
test("expands nested messages through $defs", () => {
const schema: JsonSchema = {
type: "object",
properties: { user: { $ref: "#/$defs/example.User" } },
$defs: {
"example.User": {
type: "object",
properties: {
name: { type: "string" },
address: { $ref: "#/$defs/example.Address" },
},
},
"example.Address": {
type: "object",
properties: { city: { type: "string" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
user: { name: "", address: { city: "" } },
});
});
test("gives repeated fields a single placeholder item", () => {
const schema: JsonSchema = {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
unknown: { type: "array" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
tags: [""],
users: [{ name: "" }],
unknown: [],
});
});
test("uses the first value of an enum", () => {
const schema: JsonSchema = {
type: "object",
properties: {
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
empty: { type: "string", enum: [] },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
});
test("gives maps a single placeholder entry", () => {
const schema: JsonSchema = {
type: "object",
properties: {
labels: { type: "object", additionalProperties: { type: "string" } },
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
labels: { key: "" },
users: { key: { name: "" } },
});
});
test("stops at the root self-reference", () => {
const schema: JsonSchema = {
type: "object",
properties: {
value: { type: "string" },
children: { type: "array", items: { $ref: "#" } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
});
test("stops at a cycle between messages", () => {
const schema: JsonSchema = {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
$defs: {
"example.Node": {
type: "object",
properties: {
name: { type: "string" },
parent: { $ref: "#/$defs/example.Node" },
leaf: { $ref: "#/$defs/example.Leaf" },
},
},
"example.Leaf": {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
node: { name: "", parent: {}, leaf: { node: {} } },
});
});
test("expands the same message twice when it is not on the same path", () => {
const schema: JsonSchema = {
type: "object",
properties: {
from: { $ref: "#/$defs/example.User" },
to: { $ref: "#/$defs/example.User" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
});
test("fills every branch of a flattened oneof", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
image: { $ref: "#/$defs/example.Image" },
},
$defs: {
"example.Image": { type: "object", properties: { url: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
id: "",
text: "",
image: { url: "" },
});
});
test("stops expanding once the node budget runs out", () => {
// Every level references the next one twice, so an unbounded walk would build 2^depth
// nodes without ever repeating a ref on the same path.
const depth = 16;
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
for (let i = 0; i < depth; i++) {
$defs[`d${i}`] = {
type: "object",
properties: {
a: { $ref: `#/$defs/d${i + 1}` },
b: { $ref: `#/$defs/d${i + 1}` },
},
};
}
const example = buildExampleFromSchema({
type: "object",
properties: { root: { $ref: "#/$defs/d0" } },
$defs,
});
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
expect(countNodes(example)).toBeLessThan(10_000);
});
test("handles messages without a known type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
empty: {},
struct: { type: "object" },
missing: { $ref: "#/$defs/example.Nope" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
});
});
function countNodes(value: unknown): number {
if (Array.isArray(value)) {
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
}
if (value !== null && typeof value === "object") {
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
}
return 1;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
*/
export type JsonSchema = {
type?: string;
format?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
additionalProperties?: JsonSchema;
enum?: unknown[];
$defs?: Record<string, JsonSchema>;
$ref?: string;
};
const DEFS_PREFIX = "#/$defs/";
const ROOT_REF = "#";
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
// Refs on sibling branches each expand their own subtree, so a schema that references the
// same messages repeatedly can produce exponentially many nodes without ever cycling.
const MAX_NODES = 5000;
type Budget = { remaining: number };
/** Build a sample message with placeholder values for every field in the schema */
export function buildExampleFromSchema(schema: JsonSchema): unknown {
// The root is already being built, so a `#` ref anywhere below it is a cycle
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
}
function buildValue(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
return null;
}
budget.remaining -= 1;
if (typeof schema.$ref === "string") {
if (refPath.has(schema.$ref)) {
return {};
}
const resolved = resolveRef(schema.$ref, root);
if (resolved == null) {
return {};
}
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
}
if (Array.isArray(schema.enum)) {
return schema.enum[0] ?? "";
}
switch (schema.type) {
case "object":
return buildObject(schema, root, refPath, budget);
case "array":
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
case "string":
return buildString(schema.format);
case "number":
return 0;
case "boolean":
return false;
default:
return null;
}
}
function buildObject(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema.properties != null && typeof schema.properties === "object") {
const example: Record<string, unknown> = {};
for (const [name, propertySchema] of Object.entries(schema.properties)) {
example[name] = buildValue(propertySchema, root, refPath, budget);
}
return example;
}
// Maps have no properties, only a value schema
if (schema.additionalProperties != null) {
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
}
return {};
}
function buildString(format: string | undefined): string {
if (format === "date-time") {
return new Date().toISOString();
}
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
if (format === "duration") {
return "0s";
}
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
return "0";
}
return "";
}
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
if (ref === ROOT_REF) {
return root;
}
if (!ref.startsWith(DEFS_PREFIX)) {
return null;
}
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
}
+1 -6
View File
@@ -25,13 +25,8 @@ export async function showPromptForm({
id,
title,
description,
hideX: true,
size: size ?? "sm",
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
disableClose: true,
render: ({ hide }) =>
Prompt({
onCancel: () => {
+1 -1
View File
@@ -99,6 +99,6 @@
"vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.2.9"
"vite-plus": "^0.3.0"
}
}
+8
View File
@@ -0,0 +1,8 @@
import "react";
declare module "react" {
interface HTMLAttributes<T> {
// Not in @types/react yet; opts editable elements out of macOS Writing Tools
writingsuggestions?: "true" | "false";
}
}
+1 -1
View File
@@ -32,6 +32,6 @@
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
"vite-plus": "^0.2.9"
"vite-plus": "^0.3.0"
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ tokio = { workspace = true, features = [
] }
walkdir = "2"
webbrowser = "1"
zip = "4"
zip = "7"
yaak = { workspace = true }
yaak-api = { workspace = true }
yaak-core = { workspace = true }
+7 -14
View File
@@ -1,19 +1,12 @@
---
name: use-yaak
description: >
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
folders, environments and variables, author HTTP requests, configure
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
send them individually or a whole folder/workspace at once, chain one
request's response into the next, and import existing APIs from OpenAPI,
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
API requests for reuse, to set up API requests for manual testing, to add auth
to a saved request, to turn an OpenAPI or Postman collection into runnable
requests, or to run a saved request suite against staging versus production.
Prefer this over one-off `curl` commands whenever the requests should be
saved, reused, shared, or run as a set.
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
requests for reuse or manual testing, configure auth on saved requests, import
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
saved requests across environments. Prefer over one-off `curl` when requests
should be saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
---
@@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry.
Two consequences worth holding onto. Requests you create are permanent user data
in an app they use, not scratch files, so name them the way the user would and
clean up anything created just to test. And because the app is right there, the
CLI is usually the wrong place to *read* a response in detail; it is the right
CLI is usually the wrong place to _read_ a response in detail; it is the right
place to build, organize, and run requests.
## The CLI describes itself
+13 -10
View File
@@ -49,8 +49,11 @@ fn schema(pretty: bool) -> CommandResult {
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "environment list")?;
let environments = ctx
.db()
.list_environments_ensure_base(&workspace_id)
.query_manager()
.with_tx(|tx| {
tx.ensure_base_environment(&workspace_id)?;
tx.list_environments(&workspace_id)
})
.map_err(|e| format!("Failed to list environments: {e}"))?;
if environments.is_empty() {
@@ -111,8 +114,8 @@ fn create(
}
let created = ctx
.db()
.upsert_environment(&environment, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_environment(&environment, &UpdateSource::Sync))
.map_err(|e| format!("Failed to create environment: {e}"))?;
println!("Created environment: {}", created.id);
@@ -133,8 +136,8 @@ fn create(
};
let created = ctx
.db()
.upsert_environment(&environment, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_environment(&environment, &UpdateSource::Sync))
.map_err(|e| format!("Failed to create environment: {e}"))?;
println!("Created environment: {}", created.id);
@@ -152,8 +155,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let updated = apply_merge_patch(&existing, &patch, &id, "environment update")?;
let saved = ctx
.db()
.upsert_environment(&updated, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_environment(&updated, &UpdateSource::Sync))
.map_err(|e| format!("Failed to update environment: {e}"))?;
println!("Updated environment: {}", saved.id);
@@ -167,8 +170,8 @@ fn delete(ctx: &CliContext, environment_id: &str, yes: bool) -> CommandResult {
}
let deleted = ctx
.db()
.delete_environment_by_id(environment_id, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.delete_environment_by_id(environment_id, &UpdateSource::Sync))
.map_err(|e| format!("Failed to delete environment: {e}"))?;
println!("Deleted environment: {}", deleted.id);
+8 -8
View File
@@ -102,8 +102,8 @@ fn create(
)?;
let created = ctx
.db()
.upsert_folder(&folder, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
.map_err(|e| format!("Failed to create folder: {e}"))?;
println!("Created folder: {}", created.id);
@@ -118,8 +118,8 @@ fn create(
let folder = Folder { workspace_id, name, ..Default::default() };
let created = ctx
.db()
.upsert_folder(&folder, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
.map_err(|e| format!("Failed to create folder: {e}"))?;
println!("Created folder: {}", created.id);
@@ -135,8 +135,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let updated = apply_merge_patch(&existing, &patch, &id, "folder update")?;
let saved = ctx
.db()
.upsert_folder(&updated, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_folder(&updated, &UpdateSource::Sync))
.map_err(|e| format!("Failed to update folder: {e}"))?;
println!("Updated folder: {}", saved.id);
@@ -150,8 +150,8 @@ fn delete(ctx: &CliContext, folder_id: &str, yes: bool) -> CommandResult {
}
let deleted = ctx
.db()
.delete_folder_by_id(folder_id, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.delete_folder_by_id(folder_id, &UpdateSource::Sync))
.map_err(|e| format!("Failed to delete folder: {e}"))?;
println!("Deleted folder: {}", deleted.id);
@@ -5,16 +5,20 @@ use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
};
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
match import(ctx, args).await {
Ok(result) => {
Ok((result, items)) => {
println!("Imported {}", format_counts(&result));
if let Some(skipped) = format_skipped(&items) {
println!("Skipped {skipped}");
}
0
}
Err(error) => {
@@ -37,7 +41,10 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
}
}
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
async fn import(
ctx: &CliContext,
args: ImportArgs,
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>)> {
if let Some(workspace_id) = args.workspace_id.as_deref() {
ctx.db()
.get_workspace(workspace_id)
@@ -51,6 +58,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
@@ -59,15 +67,64 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.to_string(),
);
}
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
let destination = match workspace_id {
Some(workspace_id) => {
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }
}
None => ImportDestination::NewWorkspace,
};
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
let plan = import::plan_import_resources(
ctx.query_manager(),
importer,
destination,
resources,
import_result.source_keys,
Some(file_origin(&args.file)),
)
.map_err(|e| format!("Failed to plan import: {e}"))?;
let items = plan.items.clone();
let imported = import::commit_import_plan(ctx.query_manager(), plan)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
Ok((imported, items))
}
fn file_origin(path: &std::path::Path) -> ImportOrigin {
let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let label = canonical
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
}
/// Summarize what the default selection left untouched during a merging re-import.
fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
let count = |action: ImportPlanAction| items.iter().filter(|i| i.action == action).count();
let plural = |n: usize| if n == 1 { "" } else { "s" };
let mut parts = Vec::new();
let deletions = count(ImportPlanAction::Delete);
if deletions > 0 {
parts.push(format!("{deletions} removed from source (not deleted locally)"));
}
let conflicts = count(ImportPlanAction::Conflict);
if conflicts > 0 {
parts.push(format!("{conflicts} conflict{} (kept local changes)", plural(conflicts)));
}
let keep_local = count(ImportPlanAction::KeepLocal);
if keep_local > 0 {
parts.push(format!("{keep_local} with local edits"));
}
let ignored = count(ImportPlanAction::Ignored);
if ignored > 0 {
parts.push(format!("{ignored} ignored"));
}
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> {
+15 -16
View File
@@ -348,17 +348,19 @@ async fn install_from_directory(context: &CliContext, source: &str) -> CommandRe
ui::info(&format!("Installing plugin from directory {}", plugin_dir.display()));
let plugin = context
.db()
.upsert_plugin(
&Plugin {
directory: plugin_dir_str,
url: None,
enabled: true,
source: PluginSource::Filesystem,
..Default::default()
},
&UpdateSource::Background,
)
.query_manager()
.with_tx(|tx| {
tx.upsert_plugin(
&Plugin {
directory: plugin_dir_str,
url: None,
enabled: true,
source: PluginSource::Filesystem,
..Default::default()
},
&UpdateSource::Background,
)
})
.map_err(|err| format!("Failed to save plugin in database: {err}"))?;
let plugin_context = PluginContext::new(Some("cli".to_string()), None);
@@ -883,11 +885,8 @@ mod tests {
fs::create_dir_all(root.join("build")).expect("create build");
fs::create_dir_all(root.join("vendor")).expect("create vendor");
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
fs::write(
root.join("package.json"),
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
)
.expect("write package.json");
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#)
.expect("write package.json");
copy_build_assets(root).expect("copy assets");
+8 -8
View File
@@ -424,8 +424,8 @@ fn create(
)?;
let created = ctx
.db()
.upsert_http_request(&request, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
.map_err(|e| format!("Failed to create request: {e}"))?;
println!("Created request: {}", created.id);
@@ -443,8 +443,8 @@ fn create(
}
let created = ctx
.db()
.upsert_http_request(&request, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
.map_err(|e| format!("Failed to create request: {e}"))?;
println!("Created request: {}", created.id);
@@ -463,8 +463,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let updated = apply_merge_patch(&existing, &patch, &id, "request update")?;
let saved = ctx
.db()
.upsert_http_request(&updated, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_http_request(&updated, &UpdateSource::Sync))
.map_err(|e| format!("Failed to update request: {e}"))?;
println!("Updated request: {}", saved.id);
@@ -487,8 +487,8 @@ fn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult {
}
let deleted = ctx
.db()
.delete_http_request_by_id(request_id, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.delete_http_request_by_id(request_id, &UpdateSource::Sync))
.map_err(|e| format!("Failed to delete request: {e}"))?;
println!("Deleted request: {}", deleted.id);
Ok(())
+6 -4
View File
@@ -118,8 +118,8 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
return Ok(());
}
let count = ctx
.db()
.delete_all_http_responses_for_request(id, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.delete_all_http_responses_for_request(id, &UpdateSource::Sync))
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for request {id}");
return Ok(());
@@ -131,8 +131,10 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
return Ok(());
}
let count = ctx
.db()
.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| {
tx.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
})
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for workspace {workspace_id}");
Ok(())
+10 -8
View File
@@ -84,8 +84,8 @@ fn create(
.map_err(|e| format!("Failed to parse workspace create JSON: {e}"))?;
let created = ctx
.db()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
.map_err(|e| format!("Failed to create workspace: {e}"))?;
println!("Created workspace: {}", created.id);
return Ok(());
@@ -97,8 +97,8 @@ fn create(
let workspace = Workspace { name, ..Default::default() };
let created = ctx
.db()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
.map_err(|e| format!("Failed to create workspace: {e}"))?;
println!("Created workspace: {}", created.id);
Ok(())
@@ -115,8 +115,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
let updated = apply_merge_patch(&existing, &patch, &id, "workspace update")?;
let saved = ctx
.db()
.upsert_workspace(&updated, &UpdateSource::Sync)
.query_manager()
.with_tx(|tx| tx.upsert_workspace(&updated, &UpdateSource::Sync))
.map_err(|e| format!("Failed to update workspace: {e}"))?;
println!("Updated workspace: {}", saved.id);
@@ -130,8 +130,10 @@ fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {
}
let deleted = ctx
.db()
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
.query_manager()
.with_tx(|tx| {
tx.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
})
.map_err(|e| format!("Failed to delete workspace: {e}"))?;
println!("Deleted workspace: {}", deleted.id);
Ok(())
+3 -5
View File
@@ -51,11 +51,9 @@ impl CliContext {
};
// Guest: the desktop may have this DB open, so only what's safe beside a live session
let _ = yaak_lifecycle::on_launch(
&yaak_lifecycle::Host::guest(),
&query_manager.connect(),
&blob_manager,
);
let _ = query_manager.with_tx(|tx| {
yaak_lifecycle::on_launch(&yaak_lifecycle::Host::guest(), tx, &blob_manager)
});
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
+1 -1
View File
@@ -13,7 +13,6 @@ use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
@@ -24,6 +23,7 @@ use yaak_models::models::Environment;
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,
+5 -10
View File
@@ -40,8 +40,7 @@ pub fn seed_workspace(data_dir: &Path, workspace_id: &str) {
};
query_manager(data_dir)
.connect()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
.expect("Failed to seed workspace");
}
@@ -56,8 +55,7 @@ pub fn seed_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
};
query_manager(data_dir)
.connect()
.upsert_http_request(&request, &UpdateSource::Sync)
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
.expect("Failed to seed request");
}
@@ -70,8 +68,7 @@ pub fn seed_folder(data_dir: &Path, workspace_id: &str, folder_id: &str) {
};
query_manager(data_dir)
.connect()
.upsert_folder(&folder, &UpdateSource::Sync)
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
.expect("Failed to seed folder");
}
@@ -85,8 +82,7 @@ pub fn seed_grpc_request(data_dir: &Path, workspace_id: &str, request_id: &str)
};
query_manager(data_dir)
.connect()
.upsert_grpc_request(&request, &UpdateSource::Sync)
.with_tx(|tx| tx.upsert_grpc_request(&request, &UpdateSource::Sync))
.expect("Failed to seed gRPC request");
}
@@ -100,7 +96,6 @@ pub fn seed_websocket_request(data_dir: &Path, workspace_id: &str, request_id: &
};
query_manager(data_dir)
.connect()
.upsert_websocket_request(&request, &UpdateSource::Sync)
.with_tx(|tx| tx.upsert_websocket_request(&request, &UpdateSource::Sync))
.expect("Failed to seed WebSocket request");
}
@@ -4,6 +4,7 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
use predicates::str::contains;
use serde_json::Value;
use tempfile::TempDir;
use yaak_models::util::UpdateSource;
#[test]
fn export_writes_yaak_workspace_file() {
@@ -81,14 +82,21 @@ fn import_reads_yaak_workspace_file() {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
let workspaces = db.list_workspaces().expect("list imported workspaces");
let workspace = workspaces
.iter()
.find(|workspace| workspace.name == "Imported Workspace")
.expect("workspace imported");
assert_ne!(workspace.id, "wrk_import");
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
let request = requests
.iter()
.find(|request| request.name == "Imported Request")
.expect("request imported");
assert_ne!(request.id, "req_import");
assert_eq!(request.workspace_id, workspace.id);
assert_eq!(request.url, "https://example.com");
}
fn write_postman_environment_fixture(path: &std::path::Path) {
@@ -153,10 +161,164 @@ fn import_postman_environment_uses_workspace_id() {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let environments =
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
let environments = db.list_environments(&workspace_id).expect("list imported environments");
let imported_environment =
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
assert_eq!(imported_environment.workspace_id, workspace_id);
}
fn write_linked_fixture(path: &std::path::Path, requests: &[(&str, &str, &str)]) {
let requests = requests
.iter()
.map(|(id, name, url)| {
format!(
r#"{{ "model": "http_request", "id": "{id}", "workspaceId": "wrk_link",
"name": "{name}", "method": "GET", "url": "{url}" }}"#
)
})
.collect::<Vec<_>>()
.join(",");
std::fs::write(
path,
format!(
r#"{{
"yaakVersion": "test",
"yaakSchema": 4,
"resources": {{
"workspaces": [{{ "model": "workspace", "id": "wrk_link", "name": "Linked Workspace" }}],
"httpRequests": [{requests}]
}}
}}"#
),
)
.expect("write linked fixture");
}
#[test]
fn re_import_merges_into_linked_workspace() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("linked.json");
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a"),
("req_b", "Request B", "https://example.com/b"),
],
);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
])
.assert()
.success()
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
db.list_workspaces()
.expect("list workspaces")
.into_iter()
.find(|w| w.name == "Linked Workspace")
.expect("workspace imported")
.id
};
// The source doc changes A, drops B, and adds C. The default selection applies the
// update and the create but leaves the removal as an offer.
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a-v2"),
("req_c", "Request C", "https://example.com/c"),
],
);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Imported 2 HTTP requests"))
.stdout(contains("Skipped 1 removed from source"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let requests = db.list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 3, "merge must not duplicate: {requests:?}");
assert_eq!(
requests.iter().find(|r| r.name == "Request A").expect("request A").url,
"https://example.com/a-v2"
);
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
assert!(requests.iter().any(|r| r.name == "Request C"));
}
#[test]
fn re_import_leaves_deleted_resources_alone() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("linked.json");
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a"),
("req_b", "Request B", "https://example.com/b"),
],
);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
])
.assert()
.success();
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let workspace_id = db
.list_workspaces()
.expect("list workspaces")
.into_iter()
.find(|w| w.name == "Linked Workspace")
.expect("workspace imported")
.id;
let request_b = db
.list_http_requests(&workspace_id)
.expect("list requests")
.into_iter()
.find(|r| r.name == "Request B")
.expect("request B imported");
drop(db);
query_manager
.with_tx(|tx| tx.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync))
.expect("delete request B");
workspace_id
};
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Skipped 1 ignored"));
let query_manager = query_manager(data_dir);
let requests =
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
assert_eq!(requests[0].name, "Request A");
}
+1 -2
View File
@@ -25,8 +25,7 @@ fn top_level_send_folder_sends_http_requests_and_prints_summary() {
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_http_request(&request, &UpdateSource::Sync)
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
.expect("Failed to seed folder request");
cli_cmd(data_dir)
+2 -5
View File
@@ -145,11 +145,8 @@ async fn cache_control(req: Request, next: Next) -> Response {
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
let value = if hashed_name && !is_html {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
let value =
if hashed_name && !is_html { "public, max-age=31536000, immutable" } else { "no-cache" };
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
res
}
+2 -4
View File
@@ -3,11 +3,10 @@ use std::collections::BTreeMap;
use crate::PluginContextExt;
use crate::error::Result;
use KeyAndValueRef::{Ascii, Binary};
use tauri::{Manager, Runtime, WebviewWindow};
use tauri::{Runtime, WebviewWindow};
use yaak_grpc::{KeyAndValueRef, MetadataMap};
use yaak_models::models::GrpcRequest;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};
use yaak_plugins::manager::PluginManager;
pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {
let mut entries = BTreeMap::new();
@@ -20,13 +19,12 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
entries
}
pub(crate) async fn build_metadata<R: Runtime>(
window: &WebviewWindow<R>,
request: &GrpcRequest,
authentication_context_id: &str,
) -> Result<BTreeMap<String, String>> {
let plugin_manager = window.state::<PluginManager>();
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let mut metadata = BTreeMap::new();
// Add the rest of metadata
@@ -14,7 +14,6 @@ use yaak_http::manager::HttpConnectionManager;
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
/// Context for managing response state during HTTP transactions.
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
@@ -149,7 +148,7 @@ async fn send_http_request_inner<R: Runtime>(
response_ctx: &mut ResponseContext<R>,
) -> Result<SentHttpRequest> {
let app_handle = window.app_handle().clone();
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let connection_manager = app_handle.state::<HttpConnectionManager>();
let environment_id = environment.map(|e| e.id);
@@ -178,4 +177,3 @@ async fn send_http_request_inner<R: Runtime>(
Ok(SentHttpRequest { response: result.response, body: result.response_body })
}
+53 -22
View File
@@ -4,53 +4,84 @@ use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
origin: Option<ImportOrigin>,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
import_contents(window, &contents).await
let plan =
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
commit_import(window, plan)
}
pub(crate) async fn import_url<R: Runtime>(
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
}
pub(crate) async fn plan_import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<BatchUpsertResult> {
let contents = fetch_import_url(window, url).await?;
import_contents(window, &contents).await
destination: ImportDestination,
) -> Result<ImportPlan> {
let url = normalize_import_url(url)?;
let contents = fetch_import_url(window, &url).await?;
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
}
async fn import_contents<R: Runtime>(
async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>();
destination: ImportDestination,
origin: Option<ImportOrigin>,
) -> Result<ImportPlan> {
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let query_manager = window.db_manager();
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
Ok(import::import_data(ImportDataParams {
Ok(import::plan_import_data(PlanImportDataParams {
query_manager: &query_manager,
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
workspace_context,
destination,
contents,
origin,
})
.await?)
}
/// Canonicalize so re-importing the same file through a different spelling of its path still
/// matches the linked source.
pub(crate) fn file_origin(file_path: &str) -> ImportOrigin {
let path = std::path::Path::new(file_path);
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let label = canonical
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.to_string());
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
}
pub(crate) fn url_origin(url: &str) -> ImportOrigin {
ImportOrigin { origin: url.to_string(), label: url.to_string() }
}
pub(crate) fn commit_import<R: Runtime>(
window: &WebviewWindow<R>,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk.
///
@@ -79,7 +110,7 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
}
fn normalize_import_url(url: &str) -> Result<String> {
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
let url = url.trim();
if url.is_empty() {
return Err(Error::GenericError("Import URL must not be empty".to_string()));
+316 -285
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{import_data, import_url};
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -29,23 +29,22 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::send::ResponseBody;
use yaak_commands::responses::locate_response_body;
use yaak_commands::resolve::resolve_grpc_request;
use yaak_commands::responses::locate_response_body;
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
use yaak_grpc::{Code, ServiceDefinition};
use yaak_mac_window::AppHandleMacWindowExt;
use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType,
HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_plugins::events::{
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
use yaak_sse::sse::ServerSentEvent;
@@ -250,7 +249,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let req = render_grpc_request(
&resolved_request,
@@ -310,7 +309,7 @@ async fn cmd_grpc_go<R: Runtime>(
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_grpc_request(
&resolved_request,
@@ -331,18 +330,20 @@ async fn cmd_grpc_go<R: Runtime>(
let settings = app_handle.db().get_settings();
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
let conn = app_handle.db().upsert_grpc_connection(
&GrpcConnection {
workspace_id: request.workspace_id.clone(),
request_id: request.id.clone(),
status: -1,
elapsed: 0,
state: GrpcConnectionState::Initialized,
url: request.url.clone(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
let conn = app_handle.with_tx(|tx| {
tx.upsert_grpc_connection(
&GrpcConnection {
workspace_id: request.workspace_id.clone(),
request_id: request.id.clone(),
status: -1,
elapsed: 0,
state: GrpcConnectionState::Initialized,
url: request.url.clone(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)
})?;
let conn_id = conn.id.clone();
@@ -387,15 +388,17 @@ async fn cmd_grpc_go<R: Runtime>(
let connection = match connection {
Ok(c) => c,
Err(err) => {
app_handle.db().upsert_grpc_connection(
&GrpcConnection {
elapsed: start.elapsed().as_millis() as i32,
error: Some(err.to_string()),
state: GrpcConnectionState::Closed,
..conn.clone()
},
&UpdateSource::from_window_label(window.label()),
)?;
app_handle.with_tx(|tx| {
tx.upsert_grpc_connection(
&GrpcConnection {
elapsed: start.elapsed().as_millis() as i32,
error: Some(err.to_string()),
state: GrpcConnectionState::Closed,
..conn.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})?;
return Ok(conn_id);
}
};
@@ -496,15 +499,17 @@ async fn cmd_grpc_go<R: Runtime>(
.await?;
let msg = strip_json_comments(&msg);
app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: format!("Connecting to {}", req.url),
event_type: GrpcEventType::ConnectionStart,
metadata: metadata.clone(),
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)?;
app_handle.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: format!("Connecting to {}", req.url),
event_type: GrpcEventType::ConnectionStart,
metadata: metadata.clone(),
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})?;
async move {
// Create callback for streaming methods that handles both success and error
@@ -514,24 +519,28 @@ async fn cmd_grpc_go<R: Runtime>(
let window_label = window.label().to_string();
move |result: std::result::Result<String, String>| match result {
Ok(msg) => {
let _ = app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
);
let _ = app_handle.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: msg,
event_type: GrpcEventType::ClientMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
)
});
}
Err(error) => {
let _ = app_handle.db().upsert_grpc_event(
&GrpcEvent {
content: format!("Failed to send message: {}", error),
event_type: GrpcEventType::Error,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
);
let _ = app_handle.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: format!("Failed to send message: {}", error),
event_type: GrpcEventType::Error,
..base_event.clone()
},
&UpdateSource::from_window_label(&window_label),
)
});
}
}
};
@@ -584,36 +593,38 @@ async fn cmd_grpc_go<R: Runtime>(
if !method_desc.is_client_streaming() {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
event_type: GrpcEventType::ClientMessage,
content: msg,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
event_type: GrpcEventType::ClientMessage,
content: msg,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
match maybe_msg {
Some(Ok(msg)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
metadata: metadata_to_map(msg.metadata().clone()),
content: if msg.metadata().len() == 0 {
"Received response"
} else {
"Received response with metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
metadata: metadata_to_map(msg.metadata().clone()),
content: if msg.metadata().len() == 0 {
"Received response"
} else {
"Received response with metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
let response_message = msg.into_inner();
let content = match connection
@@ -623,83 +634,88 @@ async fn cmd_grpc_go<R: Runtime>(
Ok(content) => content,
Err(err) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Failed to read response".to_string(),
error: Some(err.to_string()),
status: Some(Code::Internal as i32),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: "Failed to read response".to_string(),
error: Some(err.to_string()),
status: Some(Code::Internal as i32),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
return;
}
};
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content,
event_type: GrpcEventType::ServerMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content,
event_type: GrpcEventType::ServerMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Connection complete".to_string(),
event_type: GrpcEventType::ConnectionEnd,
status: Some(Code::Ok as i32),
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: "Connection complete".to_string(),
event_type: GrpcEventType::ConnectionEnd,
status: Some(Code::Ok as i32),
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
app_handle
.db()
.upsert_grpc_event(
&(match e.status {
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Request failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
None => GrpcEvent {
error: Some(e.message),
.with_tx(|tx| {
tx.upsert_grpc_event(
&(match e.status {
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Request failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
}),
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
Some(Err(e)) => {
app_handle
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
}),
&UpdateSource::from_window_label(window.label()),
)
.unwrap();
}
Some(Err(e)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
None => {
@@ -710,64 +726,67 @@ async fn cmd_grpc_go<R: Runtime>(
let mut stream = match maybe_stream {
Some(Ok(stream)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
metadata: metadata_to_map(stream.metadata().clone()),
content: if stream.metadata().len() == 0 {
"Received response"
} else {
"Received response with metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
metadata: metadata_to_map(stream.metadata().clone()),
content: if stream.metadata().len() == 0 {
"Received response"
} else {
"Received response with metadata"
}
.to_string(),
event_type: GrpcEventType::Info,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
stream.into_inner()
}
Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {
warn!("GRPC stream error {e:?}");
app_handle
.db()
.upsert_grpc_event(
&(match e.status {
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Stream failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
}),
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&(match e.status {
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Stream failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
}),
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
return;
}
Some(Err(e)) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
return;
}
@@ -784,65 +803,69 @@ async fn cmd_grpc_go<R: Runtime>(
Ok(message) => message,
Err(err) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Failed to read response".to_string(),
error: Some(err.to_string()),
status: Some(Code::Internal as i32),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: "Failed to read response".to_string(),
error: Some(err.to_string()),
status: Some(Code::Internal as i32),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
break;
}
};
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: message,
event_type: GrpcEventType::ServerMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: message,
event_type: GrpcEventType::ServerMessage,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
}
Ok(None) => {
let trailers =
stream.trailers().await.unwrap_or_default().unwrap_or_default();
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Connection complete".to_string(),
status: Some(Code::Ok as i32),
metadata: metadata_to_map(trailers),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: "Connection complete".to_string(),
status: Some(Code::Ok as i32),
metadata: metadata_to_map(trailers),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
break;
}
Err(status) => {
app_handle
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Stream failed".to_string(),
error: Some(status.message().to_string()),
status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: "Stream failed".to_string(),
error: Some(status.message().to_string()),
status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
break;
}
@@ -875,21 +898,24 @@ async fn cmd_grpc_go<R: Runtime>(
}).unwrap();
},
_ = cancelled_rx.changed() => {
w.db().upsert_grpc_event(
&GrpcEvent {
content: "Cancelled".to_string(),
event_type: GrpcEventType::ConnectionEnd,
status: Some(Code::Cancelled as i32),
..base_msg.clone()
},
&UpdateSource::from_window_label(window.label()),
).unwrap();
w.with_tx(|tx| {
tx.upsert_grpc_event(
&GrpcEvent {
content: "Cancelled".to_string(),
event_type: GrpcEventType::ConnectionEnd,
status: Some(Code::Cancelled as i32),
..base_msg.clone()
},
&UpdateSource::from_window_label(window.label()),
)
})
.unwrap();
w.with_tx(|c| {
c.upsert_grpc_connection(
&GrpcConnection{
elapsed: start.elapsed().as_millis() as i32,
status: Code::Cancelled as i32,
state: GrpcConnectionState::Closed,
elapsed: start.elapsed().as_millis() as i32,
status: Code::Cancelled as i32,
state: GrpcConnectionState::Closed,
..c.get_grpc_connection( &conn_id).unwrap().clone()
},
&UpdateSource::from_window_label(window.label()),
@@ -962,7 +988,6 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
@@ -977,7 +1002,8 @@ async fn cmd_http_response_body<R: Runtime>(
.ok_or(GenericError("Failed to find response body".to_string()))?;
match filter {
Some(filter) if !filter.is_empty() => Ok(plugin_manager
Some(filter) if !filter.is_empty() => Ok(plugins_ext::plugin_manager(&window)
.await?
.filter_data(&window.plugin_context(), filter, &body, content_type)
.await?),
_ => Ok(FilterResponse { content: body, error: None }),
@@ -1014,30 +1040,25 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
) -> YaakResult<BatchUpsertResult> {
import_data(&window, file_path).await
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
) -> YaakResult<BatchUpsertResult> {
import_url(&window, url).await
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> {
commit_import(&window, plan)
}
/// Decodes base64 and writes the bytes to a file the user picked.
///
@@ -1072,15 +1093,17 @@ async fn cmd_send_http_request<R: Runtime>(
let request = app_handle.db().get_http_request(&request_id)?;
let blobs = app_handle.blob_manager();
let response = app_handle.db().upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: request.workspace_id.clone(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
&blobs,
)?;
let response = app_handle.with_tx(|tx| {
tx.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: request.workspace_id.clone(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
&blobs,
)
})?;
let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
app_handle.listen_any(format!("cancel_http_response_{}", response.id), move |_event| {
@@ -1118,22 +1141,23 @@ async fn cmd_send_http_request<R: Runtime>(
Ok(sent) => sent.response,
Err(e) => {
let resp = app_handle.db().get_http_response(&response.id)?;
app_handle.db().upsert_http_response(
&HttpResponse {
state: HttpResponseState::Closed,
error: Some(e.to_string()),
..resp
},
&UpdateSource::from_window_label(window.label()),
&blobs,
)?
app_handle.with_tx(|tx| {
tx.upsert_http_response(
&HttpResponse {
state: HttpResponseState::Closed,
error: Some(e.to_string()),
..resp
},
&UpdateSource::from_window_label(window.label()),
&blobs,
)
})?
}
};
Ok(r)
}
async fn cmd_new_child_window<R: Runtime>(
parent_window: WebviewWindow<R>,
url: &str,
@@ -1261,8 +1285,8 @@ pub fn run() {
.setup(|app| {
let lifecycle_host = yaak_lifecycle::Host::owner()
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
if let Err(e) =
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
if let Err(e) = app
.with_tx(|tx| yaak_lifecycle::on_launch(&lifecycle_host, tx, &app.blob_manager()))
{
error!("on_launch hook failed: {e:?}");
}
@@ -1441,7 +1465,10 @@ fn safe_uri(endpoint: &str) -> String {
fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
let plugin_manager: State<'_, PluginManager> = app_handle.state();
let plugin_manager = match plugins_ext::plugin_manager(&app_handle).await {
Ok(pm) => pm,
Err(_) => return, // The runtime failed to boot; there are no events
};
let (rx_id, mut rx) = plugin_manager.subscribe("app").await;
while let Some(event) = rx.recv().await {
@@ -1482,9 +1509,13 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
}
};
let plugin_manager: State<'_, PluginManager> = app_handle.state();
if let Err(e) = plugin_manager.reply(&event, &ev).await {
warn!("Failed to reply to plugin manager: {:?}", e)
match plugins_ext::plugin_manager(&app_handle).await {
Ok(pm) => {
if let Err(e) = pm.reply(&event, &ev).await {
warn!("Failed to reply to plugin manager: {:?}", e)
}
}
Err(e) => warn!("Failed to get plugin manager for reply: {e:?}"),
}
});
}
@@ -10,7 +10,7 @@ use tauri::plugin::TauriPlugin;
use tauri::{Emitter, Manager, Runtime, State};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::client_db::{ClientDb, WriteDb};
use yaak_models::error::Result;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{ModelPayload, UpdateSource};
@@ -95,7 +95,7 @@ pub trait QueryManagerExt<'a, R> {
fn db(&'a self) -> ClientDb<'a>;
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
where
F: FnOnce(&ClientDb) -> Result<T>;
F: FnOnce(&WriteDb) -> Result<T>;
}
impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
@@ -110,7 +110,7 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
where
F: FnOnce(&ClientDb) -> Result<T>,
F: FnOnce(&WriteDb) -> Result<T>,
{
let qm = self.state::<QueryManager>();
qm.inner().with_tx(func)
@@ -55,12 +55,15 @@ impl YaakNotifier {
seen.push(id.to_string());
debug!("Marked notification as seen {}", id);
let seen_json = serde_json::to_string(&seen)?;
window.db().set_key_value_raw(
KV_NAMESPACE,
KV_KEY,
seen_json.as_str(),
&UpdateSource::from_window_label(window.label()),
);
window.with_tx(|tx| {
tx.set_key_value_raw(
KV_NAMESPACE,
KV_KEY,
seen_json.as_str(),
&UpdateSource::from_window_label(window.label()),
);
Ok(())
})?;
Ok(())
}
@@ -32,7 +32,6 @@ use yaak_plugins::events::{
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
WorkspaceInfo,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_handle::PluginHandle;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -112,7 +111,7 @@ async fn handle_host_plugin_request<R: Runtime>(
}
let new_plugin = Plugin { updated_at: Utc::now().naive_utc(), ..plugin };
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
app_handle.with_tx(|tx| tx.upsert_plugin(&new_plugin, &UpdateSource::Plugin))?;
}
if !req.silent {
@@ -205,7 +204,7 @@ async fn handle_host_plugin_request<R: Runtime>(
req.grpc_request.folder_id.as_deref(),
environment_id.as_deref(),
)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new(
plugin_manager,
@@ -231,7 +230,7 @@ async fn handle_host_plugin_request<R: Runtime>(
req.http_request.folder_id.as_deref(),
environment_id.as_deref(),
)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new(
plugin_manager,
@@ -267,7 +266,7 @@ async fn handle_host_plugin_request<R: Runtime>(
folder_id.as_deref(),
environment_id.as_deref(),
)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let cb = PluginTemplateCallback::new(
plugin_manager,
@@ -295,15 +294,17 @@ async fn handle_host_plugin_request<R: Runtime>(
HttpResponse::default()
} else {
let blobs = window.blob_manager();
window.db().upsert_http_response(
&HttpResponse {
request_id: http_request.id.clone(),
workspace_id: http_request.workspace_id.clone(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
&blobs,
)?
window.with_tx(|tx| {
tx.upsert_http_response(
&HttpResponse {
request_id: http_request.id.clone(),
workspace_id: http_request.workspace_id.clone(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
&blobs,
)
})?
};
let http_response = send_http_request_with_context(
+92 -30
View File
@@ -16,8 +16,7 @@ use std::time::{Duration, Instant};
use tauri::path::BaseDirectory;
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent,
is_dev,
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, is_dev,
};
use tokio::sync::Mutex;
use ts_rs::TS;
@@ -28,6 +27,7 @@ use yaak_plugins::api::{
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
search_plugins,
};
use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager;
@@ -35,6 +35,38 @@ use yaak_plugins::plugin_meta::get_plugin_meta;
static EXITING: AtomicBool = AtomicBool::new(false);
// ============================================================================
// Plugin Manager Handle
// ============================================================================
/// The plugin runtime boots in the background so startup doesn't wait on it.
/// This handle is the only way to reach the manager: [`PluginManagerHandle::get`]
/// resolves once boot completes, so callers can never observe a
/// partially-initialized runtime.
#[derive(Clone)]
pub struct PluginManagerHandle {
rx: tokio::sync::watch::Receiver<Option<std::result::Result<PluginManager, String>>>,
}
impl PluginManagerHandle {
pub async fn get(&self) -> yaak_plugins::error::Result<PluginManager> {
let mut rx = self.rx.clone();
let result = rx
.wait_for(|v| v.is_some())
.await
.map_err(|_| PluginErr("Plugin runtime boot task died".to_string()))?;
result.clone().unwrap().map_err(PluginErr)
}
}
/// Wait for the plugin runtime to finish booting and return the manager.
pub async fn plugin_manager<R: Runtime>(
manager: &impl Manager<R>,
) -> yaak_plugins::error::Result<PluginManager> {
let handle = manager.state::<PluginManagerHandle>().inner().clone();
handle.get().await
}
// ============================================================================
// Plugin Updater
// ============================================================================
@@ -146,7 +178,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
name: &str,
version: Option<String>,
) -> Result<()> {
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let app_version = window.app_handle().package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
@@ -167,18 +199,22 @@ pub async fn cmd_plugins_install_from_directory<R: Runtime>(
window: WebviewWindow<R>,
directory: &str,
) -> Result<Plugin> {
let plugin = window.db().upsert_plugin(
&Plugin {
directory: directory.into(),
url: None,
enabled: true,
source: PluginSource::Filesystem,
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
// 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.with_tx(|tx| {
tx.upsert_plugin(
&Plugin {
directory: directory.into(),
url: None,
enabled: true,
source: PluginSource::Filesystem,
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)
})?;
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
Ok(plugin)
@@ -188,7 +224,7 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
plugin_id: &str,
window: WebviewWindow<R>,
) -> Result<Plugin> {
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
let plugin_context = window.plugin_context();
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
@@ -217,7 +253,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
return Ok(Vec::new());
}
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(plugin_manager(&window).await?);
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
let plugin_context = window.plugin_context();
@@ -300,20 +336,40 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
let query_manager =
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
// Create plugin manager asynchronously
// Boot the plugin runtime in the background so the window shows
// immediately. Everything that needs plugins resolves the handle,
// which waits for this task to finish.
let (tx, rx) = tokio::sync::watch::channel(None);
app_handle.manage(PluginManagerHandle { rx });
let app_handle_clone = app_handle.clone();
tauri::async_runtime::block_on(async move {
let manager = PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&query_manager,
&PluginContext::new_empty(),
dev_mode,
tauri::async_runtime::spawn(async move {
let result = tokio::time::timeout(
Duration::from_secs(60),
PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&query_manager,
&PluginContext::new_empty(),
dev_mode,
),
)
.await
.expect("Failed to start plugin runtime");
.unwrap_or_else(|_| {
Err(yaak_plugins::error::Error::PluginErr(
"Timed out starting the plugin runtime".to_string(),
))
});
let manager = match result {
Ok(manager) => manager,
Err(e) => {
error!("Failed to start plugin runtime: {e:?}");
let _ = tx.send(Some(Err(e.to_string())));
return;
}
};
// Surface unexpected runtime crashes to the user
let mut crash_rx = manager.runtime_crash_rx();
@@ -339,7 +395,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
});
app_handle_clone.manage(manager);
let _ = tx.send(Some(Ok(manager)));
});
let plugin_updater = PluginUpdater::new();
@@ -355,8 +411,14 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
api.prevent_exit();
tauri::async_runtime::block_on(async move {
info!("Exiting plugin runtime due to app exit");
let manager: State<PluginManager> = app.state();
manager.terminate().await;
// Bound the wait in case the exit comes while boot is still
// in flight
let get_manager = plugin_manager(app);
if let Ok(Ok(manager)) =
tokio::time::timeout(Duration::from_secs(5), get_manager).await
{
manager.terminate().await;
}
app.exit(0);
});
}
+1 -1
View File
@@ -4,5 +4,5 @@
//! `yaak-commands` when the template commands did. Callers in this crate do not
//! need to track which is which.
pub use yaak_models::render::{render_grpc_request, render_http_request};
pub use yaak_commands::render::{render_json_value, render_template};
pub use yaak_models::render::{render_grpc_request, render_http_request};
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -12,8 +12,8 @@ use tokio::sync::watch;
use yaak_rpc_schema::WatchResult;
use yaak_sync::error::Error::InvalidSyncDirectory;
use yaak_sync::sync::{
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
get_fs_candidates,
FsCandidate, SyncOp, apply_db_sync_ops, apply_fs_sync_ops, apply_sync_state_ops,
compute_sync_ops, get_db_candidates, get_fs_candidates,
};
use yaak_sync::watch::{WatchEvent, watch_directory};
@@ -49,11 +49,14 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
sync_dir: &Path,
workspace_id: &str,
) -> Result<()> {
let db = app_handle.db();
// Files first, so the write transaction never waits on the filesystem
let pending = apply_fs_sync_ops(workspace_id, sync_dir, sync_ops)?;
let blobs = app_handle.blob_manager();
let sync_state_ops = apply_sync_ops(&db, &blobs, workspace_id, sync_dir, sync_ops)?;
apply_sync_state_ops(&db, workspace_id, sync_dir, sync_state_ops)?;
Ok(())
app_handle.db_manager().with_tx(|tx| {
let sync_state_ops = apply_db_sync_ops(tx, &blobs, workspace_id, sync_dir, pending)?;
apply_sync_state_ops(tx, workspace_id, sync_dir, sync_state_ops)?;
Ok(())
})
}
pub(crate) async fn sync_watch<R, F>(
+3 -3
View File
@@ -14,7 +14,6 @@ use tokio::task::block_in_place;
use tokio::time::sleep;
use ts_rs::TS;
use yaak_models::util::generate_id;
use yaak_plugins::manager::PluginManager;
use url::Url;
use yaak_api::get_system_proxy_url;
@@ -98,8 +97,9 @@ impl YaakUpdater {
block_in_place(|| {
tauri::async_runtime::block_on(async move {
info!("Shutting down plugin manager before update");
let plugin_manager = w.state::<PluginManager>();
plugin_manager.terminate().await;
if let Ok(plugin_manager) = crate::plugins_ext::plugin_manager(&w).await {
plugin_manager.terminate().await;
}
});
});
})
@@ -1,6 +1,6 @@
use crate::PluginContextExt;
use crate::error::Result;
use crate::import::import_data;
use crate::import::{file_origin, import_data, url_origin};
use crate::models_ext::QueryManagerExt;
use log::{info, warn};
use std::collections::HashMap;
@@ -12,7 +12,6 @@ use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::generate_id;
use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install;
use yaak_plugins::manager::PluginManager;
pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>,
@@ -44,7 +43,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
return Ok(());
}
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(window).await?);
let query_manager = app_handle.db_manager();
let app_version = app_handle.package_info().version.to_string();
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
@@ -70,6 +69,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
}
"import-data" => {
let mut file_path = query_map.get("path").map(|s| s.to_owned());
let mut origin = None;
let name = query_map.get("name").map(|s| s.to_owned()).unwrap_or("data".to_string());
_ = window.set_focus();
@@ -99,6 +99,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
.to_string();
fs::write(&p, json)?;
file_path = Some(p);
origin = Some(url_origin(file_url));
}
let file_path = match file_path {
@@ -117,7 +118,8 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
}
};
let results = import_data(window, &file_path).await?;
let origin = origin.unwrap_or_else(|| file_origin(&file_path));
let results = import_data(window, &file_path, Some(origin)).await?;
window.emit(
"show_toast",
ShowToastRequest {
+137 -124
View File
@@ -13,6 +13,7 @@ use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message;
use url::Url;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::CookieStore;
use yaak_http::path_placeholders::apply_path_placeholders;
@@ -22,12 +23,10 @@ use yaak_models::models::{
};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_ws::{WebsocketManager, render_websocket_request};
pub async fn cmd_ws_send<R: Runtime>(
@@ -44,18 +43,20 @@ pub async fn cmd_ws_send<R: Runtime>(
{
Ok(connection) => Ok(connection),
Err(e) => {
app_handle.db().upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: connection.request_id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Error,
message: e.to_string().into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
app_handle.with_tx(|tx| {
tx.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: connection.request_id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Error,
message: e.to_string().into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)
})?;
Ok(connection)
}
@@ -77,7 +78,7 @@ async fn send_websocket_message<R: Runtime>(
)?;
let (resolved_request, _auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request(
&resolved_request,
@@ -97,18 +98,20 @@ async fn send_websocket_message<R: Runtime>(
let mut ws_manager = ws_manager.lock().await;
ws_manager.send(&connection.id, Message::Text(message.clone().into())).await?;
app_handle.db().upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: request.id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Text,
message: message.into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
app_handle.with_tx(|tx| {
tx.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: request.id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Text,
message: message.into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)
})?;
Ok(connection.clone())
}
@@ -119,14 +122,13 @@ pub async fn cmd_ws_close<R: Runtime>(
window: WebviewWindow<R>,
ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> {
let connection = {
let db = app_handle.db();
let connection = db.get_websocket_connection(connection_id)?;
db.upsert_websocket_connection(
let connection = app_handle.with_tx(|tx| {
let connection = tx.get_websocket_connection(connection_id)?;
tx.upsert_websocket_connection(
&WebsocketConnection { state: WebsocketConnectionState::Closing, ..connection },
&UpdateSource::from_window_label(window.label()),
)?
};
)
})?;
let mut ws_manager = ws_manager.lock().await;
if let Err(e) = ws_manager.close(&connection.id).await {
@@ -142,7 +144,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
cookie_jar_id: Option<&str>,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
_plugin_manager: State<'_, PluginManager>,
ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(request_id)?;
@@ -156,7 +157,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
let settings = app_handle.db().get_settings();
let (resolved_request, auth_context_id) =
resolve_websocket_request(&window.db(), &unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let plugin_manager = Arc::new(crate::plugins_ext::plugin_manager(&app_handle).await?);
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let request = render_websocket_request(
&resolved_request,
@@ -171,14 +172,16 @@ pub async fn cmd_ws_connect<R: Runtime>(
)
.await?;
let connection = app_handle.db().upsert_websocket_connection(
&WebsocketConnection {
workspace_id: request.workspace_id.clone(),
request_id: request_id.to_string(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
let connection = app_handle.with_tx(|tx| {
tx.upsert_websocket_connection(
&WebsocketConnection {
workspace_id: request.workspace_id.clone(),
request_id: request_id.to_string(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)
})?;
let (mut url, url_parameters) = apply_path_placeholders(&request.url, &request.url_parameters);
if !url.starts_with("ws://") && !url.starts_with("wss://") {
@@ -189,14 +192,16 @@ pub async fn cmd_ws_connect<R: Runtime>(
let mut url = match Url::parse(&url) {
Ok(url) => url,
Err(e) => {
return Ok(app_handle.db().upsert_websocket_connection(
&WebsocketConnection {
error: Some(format!("Failed to parse URL {}", e.to_string())),
state: WebsocketConnectionState::Closed,
..connection
},
&UpdateSource::from_window_label(window.label()),
)?);
return Ok(app_handle.with_tx(|tx| {
tx.upsert_websocket_connection(
&WebsocketConnection {
error: Some(format!("Failed to parse URL {}", e.to_string())),
state: WebsocketConnectionState::Closed,
..connection
},
&UpdateSource::from_window_label(window.label()),
)
})?);
}
};
@@ -323,28 +328,32 @@ pub async fn cmd_ws_connect<R: Runtime>(
{
Ok(r) => r,
Err(e) => {
return Ok(app_handle.db().upsert_websocket_connection(
&WebsocketConnection {
error: Some(e.to_string()),
state: WebsocketConnectionState::Closed,
..connection
},
&UpdateSource::from_window_label(window.label()),
)?);
return Ok(app_handle.with_tx(|tx| {
tx.upsert_websocket_connection(
&WebsocketConnection {
error: Some(e.to_string()),
state: WebsocketConnectionState::Closed,
..connection
},
&UpdateSource::from_window_label(window.label()),
)
})?);
}
};
app_handle.db().upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: request.id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Open,
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
app_handle.with_tx(|tx| {
tx.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: request.id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Open,
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)
})?;
let response_headers = response
.headers()
@@ -368,20 +377,22 @@ pub async fn cmd_ws_connect<R: Runtime>(
if !set_cookie_headers.is_empty() {
store.store_cookies_from_response(&convert_ws_url_to_http(&url), &set_cookie_headers);
cookie_jar.cookies = store.get_all_cookies();
app_handle.db().upsert_cookie_jar(cookie_jar, &UpdateSource::Background)?;
app_handle.with_tx(|tx| tx.upsert_cookie_jar(cookie_jar, &UpdateSource::Background))?;
}
}
let connection = app_handle.db().upsert_websocket_connection(
&WebsocketConnection {
state: WebsocketConnectionState::Connected,
headers: response_headers,
status: response.status().as_u16() as i32,
url: request.url.clone(),
..connection
},
&UpdateSource::from_window_label(window.label()),
)?;
let connection = app_handle.with_tx(|tx| {
tx.upsert_websocket_connection(
&WebsocketConnection {
state: WebsocketConnectionState::Connected,
headers: response_headers,
status: response.status().as_u16() as i32,
url: request.url.clone(),
..connection
},
&UpdateSource::from_window_label(window.label()),
)
})?;
{
let connection_id = connection.id.clone();
@@ -397,57 +408,60 @@ pub async fn cmd_ws_connect<R: Runtime>(
}
app_handle
.db()
.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection_id.clone(),
request_id: request_id.clone(),
workspace_id: workspace_id.clone(),
is_server: true,
message_type: match message {
Message::Text(_) => WebsocketEventType::Text,
Message::Binary(_) => WebsocketEventType::Binary,
Message::Ping(_) => WebsocketEventType::Ping,
Message::Pong(_) => WebsocketEventType::Pong,
Message::Close(_) => WebsocketEventType::Close,
// Raw frame will never happen during a read
Message::Frame(_) => WebsocketEventType::Frame,
.with_tx(|tx| {
tx.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection_id.clone(),
request_id: request_id.clone(),
workspace_id: workspace_id.clone(),
is_server: true,
message_type: match message {
Message::Text(_) => WebsocketEventType::Text,
Message::Binary(_) => WebsocketEventType::Binary,
Message::Ping(_) => WebsocketEventType::Ping,
Message::Pong(_) => WebsocketEventType::Pong,
Message::Close(_) => WebsocketEventType::Close,
// Raw frame will never happen during a read
Message::Frame(_) => WebsocketEventType::Frame,
},
message: message.into_data().into(),
..Default::default()
},
message: message.into_data().into(),
..Default::default()
},
&UpdateSource::from_window_label(&window_label),
)
&UpdateSource::from_window_label(&window_label),
)
})
.unwrap();
}
info!("Websocket connection closed");
if !has_written_close {
app_handle
.db()
.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection_id.clone(),
request_id: request_id.clone(),
workspace_id: workspace_id.clone(),
is_server: true,
message_type: WebsocketEventType::Close,
..Default::default()
},
&UpdateSource::from_window_label(&window_label),
)
.with_tx(|tx| {
tx.upsert_websocket_event(
&WebsocketEvent {
connection_id: connection_id.clone(),
request_id: request_id.clone(),
workspace_id: workspace_id.clone(),
is_server: true,
message_type: WebsocketEventType::Close,
..Default::default()
},
&UpdateSource::from_window_label(&window_label),
)
})
.unwrap();
}
app_handle
.db()
.upsert_websocket_connection(
&WebsocketConnection {
workspace_id: request.workspace_id.clone(),
request_id: request_id.to_string(),
state: WebsocketConnectionState::Closed,
..connection
},
&UpdateSource::from_window_label(&window_label),
)
.with_tx(|tx| {
tx.upsert_websocket_connection(
&WebsocketConnection {
workspace_id: request.workspace_id.clone(),
request_id: request_id.to_string(),
state: WebsocketConnectionState::Closed,
..connection
},
&UpdateSource::from_window_label(&window_label),
)
})
.unwrap();
});
}
@@ -455,7 +469,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
Ok(connection)
}
/// Convert WS URL to HTTP URL for cookie filtering
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
fn convert_ws_url_to_http(ws_url: &Url) -> Url {
+31 -12
View File
@@ -9,7 +9,7 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use ts_rs::TS;
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_common::platform::get_os_str;
use yaak_models::client_db::ClientDb;
use yaak_models::client_db::{ClientDb, WriteDb};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
@@ -17,6 +17,10 @@ use yaak_models::util::UpdateSource;
/// This is needed temporarily until all crates are refactored to not use Tauri.
trait QueryManagerExt<'a, R> {
fn db(&'a self) -> ClientDb<'a>;
fn with_tx<T>(
&'a self,
func: impl FnOnce(&WriteDb) -> yaak_models::error::Result<T>,
) -> yaak_models::error::Result<T>;
}
impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
@@ -24,6 +28,14 @@ impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
let qm = self.state::<QueryManager>();
qm.inner().connect()
}
fn with_tx<T>(
&'a self,
func: impl FnOnce(&WriteDb) -> yaak_models::error::Result<T>,
) -> yaak_models::error::Result<T> {
let qm = self.state::<QueryManager>();
qm.inner().with_tx(func)
}
}
const KV_NAMESPACE: &str = "license";
@@ -137,12 +149,17 @@ pub async fn activate_license<R: Runtime>(
}
let body: ActivateLicenseResponsePayload = response.json().await?;
window.app_handle().db().set_key_value_str(
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
body.activation_id.as_str(),
&UpdateSource::from_window_label(window.label()),
);
if let Err(e) = window.app_handle().with_tx(|tx| {
tx.set_key_value_str(
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
body.activation_id.as_str(),
&UpdateSource::from_window_label(window.label()),
);
Ok(())
}) {
warn!("Failed to store license activation: {e}");
}
if let Err(e) = window.emit("license-activated", true) {
warn!("Failed to emit check-license event: {}", e);
@@ -172,11 +189,13 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
return Err(ServerError);
}
app_handle.db().delete_key_value(
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
&UpdateSource::from_window_label(window.label()),
)?;
app_handle.with_tx(|tx| {
tx.delete_key_value(
KV_ACTIVATION_ID_KEY,
KV_NAMESPACE,
&UpdateSource::from_window_label(window.label()),
)
})?;
if let Err(e) = app_handle.emit("license-deactivated", true) {
warn!("Failed to emit deactivate-license event: {}", e);
+2 -1
View File
@@ -21,7 +21,8 @@
//! the second ask. Sharing the handle instead makes nested *reads* work the way
//! they do on the desktop; nested *write transactions* fail on both, only
//! differently (here SQLite refuses the inner `BEGIN`; natively the inner
//! connection blocks on `busy_timeout` and then fails).
//! call waits for the one writer connection, which the outer call holds, and
//! times out).
#[cfg(not(target_arch = "wasm32"))]
mod imp {
@@ -20,7 +20,9 @@ impl UpdateSource {
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum ModelChangeEvent {
Upsert { created: bool },
Upsert {
created: bool,
},
/// A delete for a workspace implies deletion of every model in that
/// workspace — children are bulk-deleted without their own change rows or
/// events, and consumers must prune the subtree themselves (the frontend
+13
View File
@@ -11,6 +11,7 @@ export type AnyModel =
| HttpRequest
| HttpResponse
| HttpResponseEvent
| ImportSource
| KeyValue
| Plugin
| Settings
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+11 -5
View File
@@ -3,10 +3,10 @@ import type { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } f
import type { CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose } from "./gen_events";
import type { BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, GitStatusSummary, GitWorktreeStatus, PullResult, PushResult } from "./gen_git";
import type { ServiceDefinition } from "./gen_grpc";
import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models";
import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models";
import type { PluginMetadata } from "./gen_search";
import type { SyncOp } from "./gen_sync";
import type { BatchUpsertResult } from "./gen_util";
import type { BatchUpsertResult, ImportDestination, ImportPlan } from "./gen_util";
import type { Tokens } from "./parser";
import type { ServerSentEvent } from "./sse";
@@ -29,6 +29,8 @@ export type CmdCallWorkspaceActionReq = { req: CallWorkspaceActionRequest, };
export type CmdCheckForUpdatesReq = Record<string, never>;
export type CmdCommitImportReq = { plan: ImportPlan, };
export type CmdCurlToRequestReq = { command: string, workspaceId: string, };
export type CmdDecryptTemplateReq = { template: string, };
@@ -141,9 +143,13 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, };
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
export type CmdImportDataReq = { filePath: string, };
export type CmdImportDataReq = { filePath: string, destination: ImportDestination, };
export type CmdImportUrlReq = { url: string, };
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
export type CmdImportUrlReq = { url: string, destination: ImportDestination, };
export type CmdListImportSourcesReq = { workspaceId: string, };
export type CmdMetadataReq = Record<string, never>;
@@ -248,6 +254,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, BatchUpsertResult], cmd_import_url: [CmdImportUrlReq, BatchUpsertResult], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array<GitCommit>], cmd_git_log_for_file: [CmdGitLogForFileReq, Array<GitCommit>], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, nuLine truncated
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogRLine truncated
export type WatchResult = { unlistenEvent: string, };
+105 -2
View File
@@ -1,4 +1,107 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
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"
| "ignored";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
/**
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/**
* The model types an import plan can contain.
*/
export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
+38 -9
View File
@@ -21,18 +21,18 @@ use yaak_git::{
use yaak_grpc::ServiceDefinition;
use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
WorkspaceMeta,
};
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
};
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_sse::sse::ServerSentEvent;
@@ -229,6 +229,7 @@ pub struct CmdGetHttpResponseEventsReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
@@ -236,6 +237,31 @@ pub struct CmdImportDataReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCommitImportReq {
pub plan: ImportPlan,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdListImportSourcesReq {
pub workspace_id: String,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportSourcesForOriginReq {
#[ts(optional)]
pub file_path: Option<String>,
#[ts(optional)]
pub url: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
@@ -909,8 +935,11 @@ macro_rules! with_commands {
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+5 -5
View File
@@ -13,8 +13,9 @@ pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
/// `dispatch` call frame, so it cannot borrow, and contexts are cheap clones
/// (handles and `Arc`s). Synchronous handlers wrap into this via `rpc_handler!`
/// with no visible change.
type HandlerFn<Ctx> =
Box<dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync>;
type HandlerFn<Ctx> = Box<
dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync,
>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
@@ -249,9 +250,8 @@ macro_rules! rpc_handler_async {
Box::new(|ctx, payload| {
Box::pin(async move {
let req = serde_json::from_value(payload).map_err($crate::RpcError::from)?;
let res = $f(ctx, req)
.await
.map_err(|e| $crate::RpcError { message: e.to_string() })?;
let res =
$f(ctx, req).await.map_err(|e| $crate::RpcError { message: e.to_string() })?;
serde_json::to_value(res).map_err($crate::RpcError::from)
})
})
+7 -2
View File
@@ -72,7 +72,7 @@ pub trait Host: Clone {
self.query_manager().connect()
}
fn blobs(&self) -> BlobContext {
fn blobs(&self) -> BlobContext<'_> {
self.blob_manager().connect()
}
}
@@ -121,7 +121,12 @@ pub trait PluginHost: Host {
/// a render — the variables come from the environment chain, which is an
/// ordinary database read — so handing back the callback keeps the rest of
/// rendering shared instead of pushing whole commands behind this trait.
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback;
/// Async so hosts that finish booting their plugin runtime in the
/// background can wait for it here.
fn template_callback(
&self,
purpose: RenderPurpose,
) -> impl Future<Output = crate::Result<impl TemplateCallback>>;
/// Every template function the installed plugins expose, for the
/// autocomplete menu.
+25 -17
View File
@@ -11,10 +11,11 @@ use yaak_models::queries::workspaces::default_headers;
use yaak_rpc_schema::*;
pub async fn models_upsert<H: Host>(host: H, req: ModelsUpsertReq) -> Result<String> {
let db = host.db();
let blobs = host.blob_manager();
let source = host.update_source();
Ok(yaak_models::models_ops::upsert_model(&db, blobs, req.model, &source)?)
Ok(host
.query_manager()
.with_tx(|tx| yaak_models::models_ops::upsert_model(tx, blobs, req.model, &source))?)
}
/// Deletes cascade — a workspace can hold thousands of requests — and run in a
@@ -75,12 +76,9 @@ pub async fn models_upsert_graphql_introspection<H: Host>(
req: ModelsUpsertGraphqlIntrospectionReq,
) -> Result<GraphQlIntrospection> {
let source = host.update_source();
Ok(host.db().upsert_graphql_introspection(
&req.workspace_id,
&req.request_id,
req.content,
&source,
)?)
Ok(host.query_manager().with_tx(|tx| {
tx.upsert_graphql_introspection(&req.workspace_id, &req.request_id, req.content, &source)
})?)
}
/// Everything the frontend's model store needs to boot, as one JSON string.
@@ -112,9 +110,16 @@ pub async fn models_workspace_models<H: PluginHost>(
// Add the workspace children
if let Some(wid) = req.workspace_id.as_deref() {
// Opening a workspace is where the rows it is assumed to have get created
host.query_manager().with_tx(|tx| {
tx.ensure_base_environment(wid)?;
tx.ensure_default_cookie_jar(wid)?;
tx.ensure_workspace_meta(wid)?;
Ok::<(), yaak_models::error::Error>(())
})?;
let db = host.db();
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_environments(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
@@ -132,23 +137,26 @@ pub async fn cmd_get_workspace_meta<H: Host>(
host: H,
req: CmdGetWorkspaceMetaReq,
) -> Result<WorkspaceMeta> {
let db = host.db();
let workspace = db.get_workspace(&req.workspace_id)?;
Ok(db.get_or_create_workspace_meta(&workspace.id)?)
let workspace = host.db().get_workspace(&req.workspace_id)?;
Ok(host.query_manager().with_tx(|tx| tx.ensure_workspace_meta(&workspace.id))?)
}
pub async fn cmd_delete_all_grpc_connections<H: Host>(
host: H,
req: CmdDeleteAllGrpcConnectionsReq,
) -> Result<()> {
Ok(host.db().delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())?)
Ok(host.query_manager().with_tx(|tx| {
tx.delete_all_grpc_connections_for_request(&req.request_id, &host.update_source())
})?)
}
pub async fn cmd_delete_all_http_responses<H: Host>(
host: H,
req: CmdDeleteAllHttpResponsesReq,
) -> Result<()> {
host.db().delete_all_http_responses_for_request(&req.request_id, &host.update_source())?;
host.query_manager().with_tx(|tx| {
tx.delete_all_http_responses_for_request(&req.request_id, &host.update_source())
})?;
Ok(())
}
@@ -156,9 +164,9 @@ pub async fn cmd_ws_delete_connections<H: Host>(
host: H,
req: CmdWsDeleteConnectionsReq,
) -> Result<()> {
Ok(host
.db()
.delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())?)
Ok(host.query_manager().with_tx(|tx| {
tx.delete_all_websocket_connections_for_request(&req.request_id, &host.update_source())
})?)
}
pub async fn cmd_delete_send_history<H: Host>(host: H, req: CmdDeleteSendHistoryReq) -> Result<()> {
+1 -1
View File
@@ -66,7 +66,7 @@ pub(crate) async fn render_form_values<H: PluginHost>(
let environment_chain =
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
let cb = host.template_callback(purpose);
let cb = host.template_callback(purpose).await?;
let rendered =
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
+2 -2
View File
@@ -21,7 +21,7 @@ pub async fn cmd_render_template<H: PluginHost>(
) -> Result<String> {
let environment_chain =
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)).await?;
let options = RenderOptions {
// A preview that throws would show the user an error where they expect
// to see the value so far, so callers rendering *into the UI* ask for
@@ -41,7 +41,7 @@ pub async fn cmd_template_tokens_to_string<H: PluginHost>(
host: H,
req: CmdTemplateTokensToStringReq,
) -> Result<String> {
let cb = host.template_callback(RenderPurpose::Preview);
let cb = host.template_callback(RenderPurpose::Preview).await?;
Ok(transform_args(req.tokens, &cb)?.to_string())
}
+73 -61
View File
@@ -118,6 +118,7 @@ impl Host for TestHost {
#[tokio::test(flavor = "multi_thread")]
async fn writes_carry_the_client_id() {
let host = TestHost::new();
host.drain_writes(); // the rows startup creates
let workspace = Workspace { name: "From a test".to_string(), ..Default::default() };
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
@@ -250,8 +251,11 @@ impl PluginHost for SingleThreadedHost {
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
}
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
NoTemplateFunctions
async fn template_callback(
&self,
_purpose: RenderPurpose,
) -> yaak_commands::Result<impl TemplateCallback> {
Ok(NoTemplateFunctions)
}
async fn template_function_summaries(
@@ -403,21 +407,23 @@ async fn a_single_threaded_host_can_implement_the_trait() {
// shared code; only the callback came from the host. Rendering a real
// variable is what proves the chain was resolved rather than skipped.
let environment = host
.db()
.upsert_environment(
&Environment {
workspace_id: id.clone(),
name: "Test env".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "greeting".to_string(),
value: "hello".to_string(),
id: None,
}],
..Default::default()
},
&host.update_source(),
)
.query_manager()
.with_tx(|tx| {
tx.upsert_environment(
&Environment {
workspace_id: id.clone(),
name: "Test env".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "greeting".to_string(),
value: "hello".to_string(),
id: None,
}],
..Default::default()
},
&host.update_source(),
)
})
.expect("seed environment");
let rendered = cmd_render_template(
@@ -456,30 +462,33 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
};
let workspace = host
.db()
.upsert_workspace(
&Workspace { name: "Auth".to_string(), ..Default::default() },
&host.update_source(),
)
.query_manager()
.with_tx(|tx| {
tx.upsert_workspace(
&Workspace { name: "Auth".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: "token".to_string(),
value: "s3cret".to_string(),
id: None,
}],
..Default::default()
},
&host.update_source(),
)
host.query_manager()
.with_tx(|tx| {
tx.upsert_environment(
&Environment {
workspace_id: workspace.id.clone(),
name: "Env".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "token".to_string(),
value: "s3cret".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 environment = host.db().list_environments(&workspace.id).expect("list").remove(0);
let mut values = HashMap::new();
values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string()));
@@ -518,30 +527,33 @@ async fn template_function_values_are_rendered_before_the_host_sees_them() {
};
let workspace = host
.db()
.upsert_workspace(
&Workspace { name: "Functions".to_string(), ..Default::default() },
&host.update_source(),
)
.query_manager()
.with_tx(|tx| {
tx.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(),
)
host.query_manager()
.with_tx(|tx| {
tx.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 environment = host.db().list_environments(&workspace.id).expect("list").remove(0);
let mut values = HashMap::new();
values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string()));
+9 -7
View File
@@ -83,7 +83,7 @@ impl EncryptionManager {
let workspace_meta = self.query_manager.with_tx::<WorkspaceMeta, Error>(|tx| {
let workspace = tx.get_workspace(workspace_id)?;
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
let workspace_meta = tx.ensure_workspace_meta(workspace_id)?;
tx.upsert_workspace(
&Workspace { encryption_key_challenge, ..workspace },
&UpdateSource::Background,
@@ -103,7 +103,7 @@ impl EncryptionManager {
pub fn ensure_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
let workspace_meta =
self.query_manager.connect().get_or_create_workspace_meta(workspace_id)?;
self.query_manager.with_tx(|tx| tx.ensure_workspace_meta(workspace_id))?;
// Already exists
if let Some(_) = workspace_meta.encryption_key {
@@ -120,7 +120,7 @@ impl EncryptionManager {
self.query_manager.with_tx::<(), Error>(|tx| {
let workspace = tx.get_workspace(workspace_id)?;
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
let workspace_meta = tx.ensure_workspace_meta(workspace_id)?;
// Clear encryption challenge on workspace
tx.upsert_workspace(
@@ -152,10 +152,12 @@ impl EncryptionManager {
}
};
let db = self.query_manager.connect();
let workspace_meta = db.get_or_create_workspace_meta(workspace_id)?;
let key = match workspace_meta.encryption_key {
let key = match self
.query_manager
.connect()
.get_workspace_meta(workspace_id)
.and_then(|m| m.encryption_key)
{
None => return Err(MissingWorkspaceKey),
Some(k) => k,
};
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MethodDefinition = { name: string, schema: string, clientStreaming: boolean, serverStreaming: boolean, };
export type ServiceDefinition = { name: string, methods: Array<MethodDefinition>, };
+1 -1
View File
@@ -210,7 +210,7 @@ fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEn
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
"google.protobuf.Duration" => JsonSchemaEntry::string(),
"google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
+32 -36
View File
@@ -7,7 +7,7 @@
use log::info;
use std::path::PathBuf;
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::client_db::WriteDb;
use yaak_models::error::Result;
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
@@ -44,7 +44,8 @@ impl Host {
}
/// Run once after the database is open, before the host answers anything.
pub fn on_launch(host: &Host, db: &ClientDb, blobs: &BlobManager) -> Result<()> {
/// Takes the write handle: a launch closes what the last session left open.
pub fn on_launch(host: &Host, db: &WriteDb, blobs: &BlobManager) -> Result<()> {
db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)?;
if host.role == Role::Owner {
@@ -77,53 +78,48 @@ mod tests {
#[test]
fn only_the_owner_closes_what_the_last_session_left_open() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let source = &UpdateSource::Background;
let workspace = db
.upsert_workspace(
&Workspace { name: "Hooks".to_string(), ..Default::default() },
source,
)
.unwrap();
let request = db
.upsert_http_request(
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)
.unwrap();
let pending = db
.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
state: HttpResponseState::Connected,
..Default::default()
},
source,
&blob_manager,
)
let pending = query_manager
.with_tx(|db| {
let workspace = db.upsert_workspace(
&Workspace { name: "Hooks".to_string(), ..Default::default() },
source,
)?;
let request = db.upsert_http_request(
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)?;
db.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
state: HttpResponseState::Connected,
..Default::default()
},
source,
&blob_manager,
)
})
.unwrap();
on_launch(&Host::guest(), &db, &blob_manager).unwrap();
let response = db.get_http_response(&pending.id).unwrap();
query_manager.with_tx(|db| on_launch(&Host::guest(), db, &blob_manager)).unwrap();
let response = query_manager.connect().get_http_response(&pending.id).unwrap();
assert!(matches!(response.state, HttpResponseState::Connected));
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
let response = db.get_http_response(&pending.id).unwrap();
query_manager.with_tx(|db| on_launch(&Host::owner(), db, &blob_manager)).unwrap();
let response = query_manager.connect().get_http_response(&pending.id).unwrap();
assert!(matches!(response.state, HttpResponseState::Closed));
}
#[test]
fn owner_without_a_filesystem_still_sweeps_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
{
let blob_ctx = blob_manager.connect();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
}
blob_manager
.with_tx(|b| b.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())))
.unwrap();
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
query_manager.with_tx(|db| on_launch(&Host::owner(), db, &blob_manager)).unwrap();
assert!(!blob_manager.connect().body_exists("rs_gone").unwrap());
}
+30
View File
@@ -12,6 +12,7 @@ export type AnyModel =
| HttpRequest
| HttpResponse
| HttpResponseEvent
| ImportSource
| KeyValue
| Plugin
| Settings
@@ -336,6 +337,35 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type ImportSourceResource = {
model: "import_source_resource";
createdAt: string;
updatedAt: string;
importSourceId: string;
sourceKey: string;
modelType: string;
/**
* `None` once the user has decided not to import this key
*/
modelId?: string;
/**
* Hash of the resource as last applied or decided from the source, if one was recorded
*/
contentHash?: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+105 -2
View File
@@ -1,4 +1,107 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
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"
| "ignored";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
/**
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/**
* The model types an import plan can contain.
*/
export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
+1
View File
@@ -12,6 +12,7 @@ export function newStoreData(): ModelStoreData {
http_request: {},
http_response: {},
http_response_event: {},
import_source: {},
key_value: {},
plugin: {},
settings: {},
@@ -0,0 +1,25 @@
CREATE TABLE import_sources
(
id TEXT NOT NULL PRIMARY KEY,
model TEXT DEFAULT 'import_source' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
workspace_id TEXT NOT NULL,
importer TEXT NOT NULL,
origin TEXT NOT NULL,
origin_label TEXT NOT NULL,
last_imported_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE import_source_resources
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT NOT NULL,
snapshot TEXT NOT NULL,
PRIMARY KEY (import_source_id, source_key)
);
@@ -0,0 +1,24 @@
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
-- so a resource the user chose not to import can be remembered.
CREATE TABLE import_source_resources_new
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
content_hash TEXT,
PRIMARY KEY (import_source_id, source_key)
);
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
source_key, model_type, model_id, content_hash)
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
FROM import_source_resources;
DROP TABLE import_source_resources;
ALTER TABLE import_source_resources_new
RENAME TO import_source_resources;
+165 -110
View File
@@ -1,9 +1,11 @@
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::util::generate_prefixed_id;
use include_dir::{Dir, include_dir};
use log::{debug, info};
use rusqlite::{OptionalExtension, params};
use yaak_database::{SqliteConn, SqlitePool};
use rusqlite::{OptionalExtension, Transaction, TransactionBehavior, params};
use std::ops::Deref;
use yaak_database::{ConnectionOrTx, SqlitePool};
static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations");
@@ -22,41 +24,64 @@ impl BodyChunk {
}
}
/// Manages the blob database connection pool.
// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the
/// Manages the blob database: a reader pool and a single writer, for the
/// same reason as [`crate::query_manager::QueryManager`].
// Pools are internally synchronized — don't wrap them in a Mutex. A Mutex held across the
// blocking `get()` serializes every blob access behind the slowest waiter, freezing the
// whole app whenever the pool is exhausted.
#[derive(Debug, Clone)]
pub struct BlobManager {
pool: SqlitePool,
readers: SqlitePool,
writer: SqlitePool,
}
impl BlobManager {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
/// `writer` must be a pool with a single connection.
pub fn new(readers: SqlitePool, writer: SqlitePool) -> Self {
Self { readers, writer }
}
pub fn connect(&self) -> BlobContext {
let conn = self.pool.get().expect("Failed to get blob DB connection from pool");
BlobContext { conn }
/// A read handle from the reader pool.
pub fn connect(&self) -> BlobContext<'_> {
let conn = self.readers.get().expect("Failed to get blob DB connection from pool");
BlobContext { conn: ConnectionOrTx::Connection(conn) }
}
/// Run `func` in a transaction on the writer connection.
pub fn with_tx<T, E>(
&self,
func: impl FnOnce(&BlobWriter) -> std::result::Result<T, E>,
) -> std::result::Result<T, E>
where
E: From<crate::error::Error>,
{
let conn = self.writer.get().map_err(crate::error::Error::SqlPoolError)?;
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
.map_err(crate::error::Error::SqlError)?;
let writer = BlobWriter { ctx: BlobContext { conn: ConnectionOrTx::Transaction(&tx) } };
match func(&writer) {
Ok(val) => {
tx.commit().map_err(|e| {
GenericError(format!("Failed to commit blob transaction {e:?}"))
})?;
Ok(val)
}
Err(e) => {
tx.rollback().map_err(|e| {
GenericError(format!("Failed to rollback blob transaction {e:?}"))
})?;
Err(e)
}
}
}
}
/// Context for blob database operations.
pub struct BlobContext {
conn: SqliteConn,
/// Read handle for the blob database.
pub struct BlobContext<'a> {
conn: ConnectionOrTx<'a>,
}
impl BlobContext {
/// Insert a single chunk.
pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {
self.conn.execute(
"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)",
params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],
)?;
Ok(())
}
impl<'a> BlobContext<'a> {
/// Get all chunks for a body, ordered by chunk_index.
pub fn get_chunks(&self, body_id: &str) -> Result<Vec<BodyChunk>> {
let mut stmt = self.conn.prepare(
@@ -87,25 +112,11 @@ impl BlobContext {
Ok(ids)
}
/// Delete all chunks for a body.
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
Ok(())
}
/// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all bodies for a response).
pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {
self.conn
.execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?;
Ok(())
}
}
/// Get total size of a body without loading data.
impl BlobContext {
/// Get total size of a body without loading data.
pub fn get_body_size(&self, body_id: &str) -> Result<usize> {
let size: i64 = self
.conn
.resolve()
.query_row(
"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1",
params![body_id],
@@ -119,6 +130,7 @@ impl BlobContext {
pub fn body_exists(&self, body_id: &str) -> Result<bool> {
let count: i64 = self
.conn
.resolve()
.query_row(
"SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1",
params![body_id],
@@ -129,6 +141,44 @@ impl BlobContext {
}
}
/// Write handle for the blob database. Derefs to [`BlobContext`] for reads.
pub struct BlobWriter<'a> {
ctx: BlobContext<'a>,
}
impl<'a> Deref for BlobWriter<'a> {
type Target = BlobContext<'a>;
fn deref(&self) -> &BlobContext<'a> {
&self.ctx
}
}
impl<'a> BlobWriter<'a> {
/// Insert a single chunk.
pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {
self.conn.execute(
"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)",
params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],
)?;
Ok(())
}
/// Delete all chunks for a body.
pub fn delete_chunks(&self, body_id: &str) -> Result<()> {
self.conn.execute("DELETE FROM body_chunks WHERE body_id = ?1", params![body_id])?;
Ok(())
}
/// Delete all chunks matching a body_id prefix (e.g., "rs_abc123.%" to delete all
/// bodies for a response).
pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {
self.conn
.execute("DELETE FROM body_chunks WHERE body_id LIKE ?1", params![body_id_prefix])?;
Ok(())
}
}
/// Run migrations for the blob database.
pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
info!("Running blob database migrations");
@@ -196,28 +246,39 @@ pub fn migrate_blob_db(pool: &SqlitePool) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
fn create_test_pool() -> SqlitePool {
fn create_test_manager() -> BlobManager {
let manager = r2d2_sqlite::SqliteConnectionManager::memory();
let pool = r2d2::Pool::builder().max_size(1).build(manager).unwrap();
migrate_blob_db(&pool).unwrap();
pool
BlobManager::new(pool.clone(), pool)
}
fn insert(manager: &BlobManager, chunks: &[BodyChunk]) {
manager
.with_tx(|b| {
for c in chunks {
b.insert_chunk(c)?;
}
Ok::<_, Error>(())
})
.unwrap();
}
#[test]
fn test_insert_and_get_chunks() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let manager = create_test_manager();
let body_id = "rs_test123.request";
let chunk1 = BodyChunk::new(body_id, 0, b"Hello, ".to_vec());
let chunk2 = BodyChunk::new(body_id, 1, b"World!".to_vec());
insert(
&manager,
&[
BodyChunk::new(body_id, 0, b"Hello, ".to_vec()),
BodyChunk::new(body_id, 1, b"World!".to_vec()),
],
);
ctx.insert_chunk(&chunk1).unwrap();
ctx.insert_chunk(&chunk2).unwrap();
let chunks = ctx.get_chunks(body_id).unwrap();
let chunks = manager.connect().get_chunks(body_id).unwrap();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].chunk_index, 0);
assert_eq!(chunks[0].data, b"Hello, ");
@@ -227,18 +288,19 @@ mod tests {
#[test]
fn test_get_chunks_ordered_by_index() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let manager = create_test_manager();
let body_id = "rs_test123.request";
// Insert out of order
ctx.insert_chunk(&BodyChunk::new(body_id, 2, b"C".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"A".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"B".to_vec())).unwrap();
insert(
&manager,
&[
BodyChunk::new(body_id, 2, b"C".to_vec()),
BodyChunk::new(body_id, 0, b"A".to_vec()),
BodyChunk::new(body_id, 1, b"B".to_vec()),
],
);
let chunks = ctx.get_chunks(body_id).unwrap();
let chunks = manager.connect().get_chunks(body_id).unwrap();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].data, b"A");
assert_eq!(chunks[1].data, b"B");
@@ -247,89 +309,85 @@ mod tests {
#[test]
fn test_delete_chunks() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let manager = create_test_manager();
let body_id = "rs_test123.request";
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"data".to_vec())).unwrap();
insert(&manager, &[BodyChunk::new(body_id, 0, b"data".to_vec())]);
assert!(manager.connect().body_exists(body_id).unwrap());
assert!(ctx.body_exists(body_id).unwrap());
ctx.delete_chunks(body_id).unwrap();
manager.with_tx(|b| b.delete_chunks(body_id)).unwrap();
let ctx = manager.connect();
assert!(!ctx.body_exists(body_id).unwrap());
assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0);
}
#[test]
fn test_delete_chunks_like() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let manager = create_test_manager();
// Insert chunks for same response but different body types
ctx.insert_chunk(&BodyChunk::new("rs_abc.request", 0, b"req".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new("rs_abc.response", 0, b"resp".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new("rs_other.request", 0, b"other".to_vec())).unwrap();
insert(
&manager,
&[
BodyChunk::new("rs_abc.request", 0, b"req".to_vec()),
BodyChunk::new("rs_abc.response", 0, b"resp".to_vec()),
BodyChunk::new("rs_other.request", 0, b"other".to_vec()),
],
);
// Delete all bodies for rs_abc
ctx.delete_chunks_like("rs_abc.%").unwrap();
manager.with_tx(|b| b.delete_chunks_like("rs_abc.%")).unwrap();
// rs_abc bodies should be gone
let ctx = manager.connect();
assert!(!ctx.body_exists("rs_abc.request").unwrap());
assert!(!ctx.body_exists("rs_abc.response").unwrap());
// rs_other should still exist
assert!(ctx.body_exists("rs_other.request").unwrap());
}
#[test]
fn test_get_body_size() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let manager = create_test_manager();
let body_id = "rs_test123.request";
ctx.insert_chunk(&BodyChunk::new(body_id, 0, b"Hello".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new(body_id, 1, b"World".to_vec())).unwrap();
insert(
&manager,
&[
BodyChunk::new(body_id, 0, b"Hello".to_vec()),
BodyChunk::new(body_id, 1, b"World".to_vec()),
],
);
let size = ctx.get_body_size(body_id).unwrap();
let size = manager.connect().get_body_size(body_id).unwrap();
assert_eq!(size, 10); // "Hello" + "World" = 10 bytes
}
#[test]
fn test_get_body_size_empty() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let size = ctx.get_body_size("nonexistent").unwrap();
let manager = create_test_manager();
let size = manager.connect().get_body_size("nonexistent").unwrap();
assert_eq!(size, 0);
}
#[test]
fn test_body_exists() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let manager = create_test_manager();
assert!(!manager.connect().body_exists("rs_test.request").unwrap());
assert!(!ctx.body_exists("rs_test.request").unwrap());
insert(&manager, &[BodyChunk::new("rs_test.request", 0, b"data".to_vec())]);
ctx.insert_chunk(&BodyChunk::new("rs_test.request", 0, b"data".to_vec())).unwrap();
assert!(ctx.body_exists("rs_test.request").unwrap());
assert!(manager.connect().body_exists("rs_test.request").unwrap());
}
#[test]
fn test_multiple_bodies_isolated() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let manager = create_test_manager();
insert(
&manager,
&[
BodyChunk::new("body1", 0, b"data1".to_vec()),
BodyChunk::new("body2", 0, b"data2".to_vec()),
],
);
let ctx = manager.connect();
ctx.insert_chunk(&BodyChunk::new("body1", 0, b"data1".to_vec())).unwrap();
ctx.insert_chunk(&BodyChunk::new("body2", 0, b"data2".to_vec())).unwrap();
let chunks1 = ctx.get_chunks("body1").unwrap();
let chunks2 = ctx.get_chunks("body2").unwrap();
@@ -341,16 +399,13 @@ mod tests {
#[test]
fn test_large_chunk() {
let pool = create_test_pool();
let manager = BlobManager::new(pool);
let ctx = manager.connect();
let manager = create_test_manager();
// 1MB chunk
let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
let body_id = "rs_large.request";
insert(&manager, &[BodyChunk::new(body_id, 0, large_data.clone())]);
ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap();
let ctx = manager.connect();
let chunks = ctx.get_chunks(body_id).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].data, large_data);
+45 -9
View File
@@ -3,18 +3,25 @@ use crate::models::{AnyModel, UpsertModelInfo};
use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};
use rusqlite::params;
use sea_query::{IntoColumnRef, IntoIden, SimpleExpr};
use std::cell::RefCell;
use std::fmt::Debug;
use std::ops::Deref;
use std::sync::mpsc;
use yaak_database::DbContext;
/// A read handle. Comes from the reader pool and can only query.
///
/// Anything that changes a row lives on [`WriteDb`], which is only ever handed
/// out inside a transaction on the single writer connection. That split is
/// what keeps the pool from filling with writers waiting on each other: there
/// is one writer, so there is never a second one to wait for.
pub struct ClientDb<'a> {
pub(crate) ctx: DbContext<'a>,
pub(crate) events_tx: mpsc::Sender<ModelPayload>,
}
impl<'a> ClientDb<'a> {
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
Self { ctx, events_tx }
pub fn new(ctx: DbContext<'a>) -> Self {
Self { ctx }
}
/// Access the underlying connection for custom queries.
@@ -22,8 +29,6 @@ impl<'a> ClientDb<'a> {
self.ctx.conn()
}
// --- Read delegates (thin wrappers over DbContext) ---
pub(crate) fn find_one<M>(
&self,
col: impl IntoColumnRef + IntoIden + Clone,
@@ -64,6 +69,39 @@ impl<'a> ClientDb<'a> {
{
Ok(self.ctx.find_many(col, value, limit)?)
}
}
/// A write handle: a [`ClientDb`] on the writer connection, inside a
/// transaction, that can also change rows. Derefs to [`ClientDb`] so every
/// query is available while writing, and reads inside the transaction see
/// its own uncommitted writes.
///
/// Model events are held back until the transaction commits; a rollback
/// discards them along with the rows.
pub struct WriteDb<'a> {
db: ClientDb<'a>,
events_tx: mpsc::Sender<ModelPayload>,
pending_events: RefCell<Vec<ModelPayload>>,
}
impl<'a> Deref for WriteDb<'a> {
type Target = ClientDb<'a>;
fn deref(&self) -> &ClientDb<'a> {
&self.db
}
}
impl<'a> WriteDb<'a> {
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
Self { db: ClientDb::new(ctx), events_tx, pending_events: RefCell::new(Vec::new()) }
}
/// The events for everything written so far, to send once the
/// transaction has committed.
pub(crate) fn into_events(self) -> Vec<ModelPayload> {
self.pending_events.into_inner()
}
/// Bulk-delete all rows matching a column value WITHOUT recording model
/// changes or emitting events. Only use for cascades whose deletion is
@@ -80,8 +118,6 @@ impl<'a> ClientDb<'a> {
Ok(self.ctx.delete_many::<M>(col, value)?)
}
// --- Write operations (with event recording) ---
pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + UpsertModelInfo + Clone,
@@ -95,7 +131,7 @@ impl<'a> ClientDb<'a> {
};
self.record_model_change(&payload)?;
let _ = self.events_tx.send(payload);
self.pending_events.borrow_mut().push(payload);
Ok(m)
}
@@ -113,7 +149,7 @@ impl<'a> ClientDb<'a> {
};
self.record_model_change(&payload)?;
let _ = self.events_tx.send(payload);
self.pending_events.borrow_mut().push(payload);
Ok(m.clone())
}
+50 -13
View File
@@ -10,8 +10,8 @@ use yaak_database::SqlitePool;
pub mod blob_manager;
pub mod client_db;
pub mod cookies;
mod connection_or_tx;
pub mod cookies;
pub mod error;
pub mod migrate;
pub mod models;
@@ -64,6 +64,17 @@ mod open {
.map_err(|e| Error::Database(e.to_string()))
}
/// `(readers, writer)` over one file: a pool of `max_size` readers and a
/// pool of exactly one writer.
pub fn file_pools(
path: impl Into<PathBuf>,
max_size: u32,
min_idle: u32,
) -> Result<(SqlitePool, SqlitePool)> {
let path: PathBuf = path.into();
Ok((file_pool(&path, max_size, min_idle)?, file_pool(&path, 1, 1)?))
}
pub fn memory_pool() -> Result<SqlitePool> {
let manager = SqliteConnectionManager::memory().with_init(|c| init_connection(c));
// In-memory DB doesn't support multiple connections
@@ -90,6 +101,16 @@ mod open {
Ok(SqlitePool::single(conn))
}
/// One connection is all a browser VFS allows, so it reads and writes.
pub fn file_pools(
path: impl Into<PathBuf>,
max_size: u32,
min_idle: u32,
) -> Result<(SqlitePool, SqlitePool)> {
let pool = file_pool(path, max_size, min_idle)?;
Ok((pool.clone(), pool))
}
pub fn memory_pool() -> Result<SqlitePool> {
let conn = Connection::open_in_memory()?;
init_connection(&conn)?;
@@ -108,27 +129,33 @@ pub fn init_standalone(
let db_path = db_path.as_ref();
let blob_path = blob_path.as_ref();
// Main database pool. Sized for concurrent in-flight queries, not concurrent app
// features — connections are held per-statement, so even heavy fan-out (e.g. many
// gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections
// hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit.
// Each database gets a reader pool and a one-connection writer pool; see
// `QueryManager` for why. Reader pools are sized for concurrent in-flight
// queries, not concurrent app features — connections are held per-statement,
// so even heavy fan-out (e.g. many gRPC streams) only needs a handful at once.
// Keep them modest: WAL connections hold ~3 file descriptors each, and macOS
// GUI apps get a 256 fd soft limit.
info!("Initializing app database {db_path:?}");
let pool = open::file_pool(db_path, 20, 2)?;
migrate_db(&pool)?;
let (readers, writer) = open::file_pools(db_path, 20, 2)?;
migrate_db(&writer)?;
info!("Initializing blobs database {blob_path:?}");
let blob_pool = open::file_pool(blob_path, 10, 1)?;
migrate_blob_db(&blob_pool)?;
let (blob_readers, blob_writer) = open::file_pools(blob_path, 10, 1)?;
migrate_blob_db(&blob_writer)?;
let (tx, rx) = mpsc::channel();
let query_manager = QueryManager::new(pool, tx);
let blob_manager = BlobManager::new(blob_pool);
let query_manager = QueryManager::new(readers, writer, tx);
let blob_manager = BlobManager::new(blob_readers, blob_writer);
bootstrap(&query_manager)?;
Ok((query_manager, blob_manager, rx))
}
/// Initialize the database managers with in-memory SQLite databases.
/// Useful for testing and CI environments.
///
/// An in-memory database is private to its connection, so the one connection
/// is both the reader pool and the writer.
pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {
let pool = open::memory_pool()?;
migrate_db(&pool)?;
@@ -137,8 +164,18 @@ pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<Mod
migrate_blob_db(&blob_pool)?;
let (tx, rx) = mpsc::channel();
let query_manager = QueryManager::new(pool, tx);
let blob_manager = BlobManager::new(blob_pool);
let query_manager = QueryManager::new(pool.clone(), pool, tx);
let blob_manager = BlobManager::new(blob_pool.clone(), blob_pool);
bootstrap(&query_manager)?;
Ok((query_manager, blob_manager, rx))
}
/// The rows every client assumes exist: settings and at least one workspace.
fn bootstrap(query_manager: &QueryManager) -> Result<()> {
query_manager.with_tx(|tx| {
tx.ensure_settings()?;
tx.ensure_default_workspace()?;
Ok(())
})
}
+123
View File
@@ -3022,6 +3022,127 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "import_sources")]
pub struct ImportSource {
#[ts(type = "\"import_source\"")]
pub model: String,
pub id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub workspace_id: String,
pub importer: String,
pub origin: String,
pub origin_label: String,
pub last_imported_at: NaiveDateTime,
}
impl UpsertModelInfo for ImportSource {
fn table_name() -> impl IntoTableRef + IntoIden {
ImportSourceIden::Table
}
fn id_column() -> impl IntoIden + Eq + Clone {
ImportSourceIden::Id
}
fn generate_id() -> String {
generate_prefixed_id("im")
}
fn order_by() -> (impl IntoColumnRef, Order) {
(ImportSourceIden::CreatedAt, Desc)
}
fn get_id(&self) -> String {
self.id.clone()
}
fn insert_values(
self,
source: &UpdateSource,
) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
use ImportSourceIden::*;
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
(WorkspaceId, self.workspace_id.into()),
(Importer, self.importer.into()),
(Origin, self.origin.into()),
(OriginLabel, self.origin_label.into()),
(LastImportedAt, self.last_imported_at.into()),
])
}
fn update_columns() -> Vec<impl IntoIden> {
vec![
ImportSourceIden::UpdatedAt,
ImportSourceIden::Importer,
ImportSourceIden::Origin,
ImportSourceIden::OriginLabel,
ImportSourceIden::LastImportedAt,
]
}
fn from_row(row: &Row) -> rusqlite::Result<Self>
where
Self: Sized,
{
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
workspace_id: row.get("workspace_id")?,
importer: row.get("importer")?,
origin: row.get("origin")?,
origin_label: row.get("origin_label")?,
last_imported_at: row.get("last_imported_at")?,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "import_source_resources")]
pub struct ImportSourceResource {
#[ts(type = "\"import_source_resource\"")]
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub import_source_id: String,
pub source_key: String,
pub model_type: String,
/// `None` once the user has decided not to import this key
#[ts(optional)]
pub model_id: Option<String>,
/// Hash of the resource as last applied or decided from the source, if one was recorded
#[ts(optional)]
pub content_hash: Option<String>,
}
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
type Error = rusqlite::Error;
fn try_from(r: &Row<'s>) -> std::result::Result<Self, Self::Error> {
Ok(Self {
model: r.get("model")?,
created_at: r.get("created_at")?,
updated_at: r.get("updated_at")?,
import_source_id: r.get("import_source_id")?,
source_key: r.get("source_key")?,
model_type: r.get("model_type")?,
model_id: r.get("model_id")?,
content_hash: r.get("content_hash")?,
})
}
}
/// Only used as a `from_row` fallback for an unparseable settings column. The
/// value a *new* model gets comes from that model's `Default` impl.
fn default_request_message_size_setting() -> InheritedIntSetting {
@@ -3093,6 +3214,7 @@ define_any_model! {
HttpRequest,
HttpResponse,
HttpResponseEvent,
ImportSource,
KeyValue,
Plugin,
Settings,
@@ -3125,6 +3247,7 @@ impl<'de> Deserialize<'de> for AnyModel {
Some(m) if m == "http_request" => HttpRequest(fv(value).unwrap()),
Some(m) if m == "http_response" => HttpResponse(fv(value).unwrap()),
Some(m) if m == "http_response_event" => HttpResponseEvent(fv(value).unwrap()),
Some(m) if m == "import_source" => ImportSource(fv(value).unwrap()),
Some(m) if m == "key_value" => KeyValue(fv(value).unwrap()),
Some(m) if m == "plugin" => Plugin(fv(value).unwrap()),
Some(m) if m == "settings" => Settings(fv(value).unwrap()),
+4 -4
View File
@@ -8,14 +8,14 @@
//! caller is a desktop window or an HTTP request.
use crate::blob_manager::BlobManager;
use crate::client_db::ClientDb;
use crate::client_db::WriteDb;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::models::AnyModel;
use crate::util::UpdateSource;
pub fn upsert_model(
db: &ClientDb,
db: &WriteDb,
blobs: &BlobManager,
model: AnyModel,
source: &UpdateSource,
@@ -41,7 +41,7 @@ pub fn upsert_model(
/// Deletes cascade, so callers run this inside a transaction.
pub fn delete_model(
tx: &ClientDb,
tx: &WriteDb,
blobs: &BlobManager,
model: AnyModel,
source: &UpdateSource,
@@ -69,7 +69,7 @@ pub fn delete_model(
/// The model is re-read from the database rather than taken from the caller, so
/// a duplicate never comes from a stale frontend snapshot.
pub fn duplicate_model(
tx: &ClientDb,
tx: &WriteDb,
model_type: &str,
model_id: &str,
source: &UpdateSource,
+2 -2
View File
@@ -1,10 +1,10 @@
use crate::client_db::ClientDb;
use crate::client_db::WriteDb;
use crate::error::Result;
use crate::models::{Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};
use crate::util::{BatchUpsertResult, UpdateSource};
use log::info;
impl<'a> ClientDb<'a> {
impl<'a> WriteDb<'a> {
pub fn batch_upsert(
&self,
workspaces: Vec<Workspace>,
+10 -6
View File
@@ -1,4 +1,4 @@
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{CookieJar, CookieJarIden};
use crate::util::UpdateSource;
@@ -9,18 +9,22 @@ impl<'a> ClientDb<'a> {
}
pub fn list_cookie_jars(&self, workspace_id: &str) -> Result<Vec<CookieJar>> {
let mut cookie_jars = self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)?;
self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)
}
}
if cookie_jars.is_empty() {
impl<'a> WriteDb<'a> {
/// A workspace with no cookie jar gets a default one.
pub fn ensure_default_cookie_jar(&self, workspace_id: &str) -> Result<()> {
if self.list_cookie_jars(workspace_id)?.is_empty() {
let jar = CookieJar {
name: "Default".to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
};
cookie_jars.push(self.upsert_cookie_jar(&jar, &UpdateSource::Background)?);
self.upsert_cookie_jar(&jar, &UpdateSource::Background)?;
}
Ok(cookie_jars)
Ok(())
}
pub fn delete_cookie_jar(
+84 -82
View File
@@ -1,5 +1,5 @@
use super::conflict_free_name;
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
use crate::error::Result;
use crate::models::{Environment, EnvironmentIden, EnvironmentVariable};
@@ -20,7 +20,7 @@ impl<'a> ClientDb<'a> {
}
pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {
let environments = self.list_environments_ensure_base(workspace_id)?;
let environments = self.list_environments(workspace_id)?;
let base_environments = environments
.into_iter()
.filter(|e| e.parent_model == "workspace")
@@ -30,38 +30,96 @@ impl<'a> ClientDb<'a> {
return Err(MultipleBaseEnvironments(workspace_id.to_string()));
}
Ok(base_environments.first().cloned().ok_or(
// Should never happen because one should be created above if it does not exist
MissingBaseEnvironment(workspace_id.to_string()),
)?)
Ok(base_environments
.first()
.cloned()
.ok_or(MissingBaseEnvironment(workspace_id.to_string()))?)
}
/// Lists environments and will create a base environment if one doesn't exist
pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {
let mut environments = self.list_environments_dangerous(workspace_id)?;
pub fn list_environments(&self, workspace_id: &str) -> Result<Vec<Environment>> {
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
}
let base_environment = environments.iter().find(|e| e.parent_model == "workspace");
/// Find other environments with the same parent folder
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
if environment.parent_model != "folder" {
return Vec::new();
}
if let None = base_environment {
let e = self.upsert_environment(
&Environment {
workspace_id: workspace_id.to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
&UpdateSource::Background,
self.list_environments(&environment.workspace_id)
.unwrap_or_default()
.into_iter()
.filter(|e| {
e.id != environment.id
&& e.parent_model == "folder"
&& e.parent_id == environment.parent_id
})
.collect()
}
pub fn resolve_environments(
&self,
workspace_id: &str,
folder_id: Option<&str>,
active_environment_id: Option<&str>,
) -> Result<Vec<Environment>> {
let mut environments = Vec::new();
if let Some(folder_id) = folder_id {
let folder = self.get_folder(folder_id)?;
// Add current folder's environment
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
environments.push(e);
};
// Recurse up
let ancestors = self.resolve_environments(
workspace_id,
folder.folder_id.as_deref(),
active_environment_id,
)?;
info!("Created base environment {} for {workspace_id}", e.id);
environments.push(e);
environments.extend(ancestors);
} else {
// Add active and base environments
if let Some(id) = active_environment_id {
if let Ok(e) = self.get_environment(&id) {
// Add active sub environment
environments.push(e);
};
};
// Add the base environment. A workspace that has never been
// opened has none yet; it simply contributes no variables.
match self.get_base_environment(workspace_id) {
Ok(e) => environments.push(e),
Err(MissingBaseEnvironment(_)) => {}
Err(e) => return Err(e),
}
}
Ok(environments)
}
}
/// List environments for a workspace. Prefer list_environments_ensure_base()
fn list_environments_dangerous(&self, workspace_id: &str) -> Result<Vec<Environment>> {
Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)
impl<'a> WriteDb<'a> {
/// The workspace's base environment, created if it does not exist.
pub fn ensure_base_environment(&self, workspace_id: &str) -> Result<Environment> {
match self.get_base_environment(workspace_id) {
Err(MissingBaseEnvironment(_)) => {}
other => return other,
}
let e = self.upsert_environment(
&Environment {
workspace_id: workspace_id.to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
&UpdateSource::Background,
)?;
info!("Created base environment {} for {workspace_id}", e.id);
Ok(e)
}
pub fn delete_environment(
@@ -72,7 +130,7 @@ impl<'a> ClientDb<'a> {
let deleted_environment = self.delete(environment, source)?;
// Recreate the base environment if we happened to delete it
self.list_environments_ensure_base(&environment.workspace_id)?;
self.ensure_base_environment(&environment.workspace_id)?;
Ok(deleted_environment)
}
@@ -90,7 +148,7 @@ impl<'a> ClientDb<'a> {
let mut environment = environment.clone();
environment.id = "".to_string();
let sibling_names = self
.list_environments_dangerous(&environment.workspace_id)?
.list_environments(&environment.workspace_id)?
.into_iter()
.map(|e| e.name)
.collect::<Vec<_>>();
@@ -98,23 +156,6 @@ impl<'a> ClientDb<'a> {
self.upsert_environment(&environment, source)
}
/// Find other environments with the same parent folder
fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {
if environment.parent_model != "folder" {
return Vec::new();
}
self.list_environments_dangerous(&environment.workspace_id)
.unwrap_or_default()
.into_iter()
.filter(|e| {
e.id != environment.id
&& e.parent_model == "folder"
&& e.parent_id == environment.parent_id
})
.collect()
}
pub fn upsert_environment(
&self,
environment: &Environment,
@@ -154,43 +195,4 @@ impl<'a> ClientDb<'a> {
source,
)
}
pub fn resolve_environments(
&self,
workspace_id: &str,
folder_id: Option<&str>,
active_environment_id: Option<&str>,
) -> Result<Vec<Environment>> {
let mut environments = Vec::new();
if let Some(folder_id) = folder_id {
let folder = self.get_folder(folder_id)?;
// Add current folder's environment
if let Some(e) = self.get_environment_by_folder_id(folder_id)? {
environments.push(e);
};
// Recurse up
let ancestors = self.resolve_environments(
workspace_id,
folder.folder_id.as_deref(),
active_environment_id,
)?;
environments.extend(ancestors);
} else {
// Add active and base environments
if let Some(id) = active_environment_id {
if let Ok(e) = self.get_environment(&id) {
// Add active sub environment
environments.push(e);
};
};
// Add the base environment
environments.push(self.get_base_environment(workspace_id)?);
}
Ok(environments)
}
}
+96 -94
View File
@@ -1,5 +1,5 @@
use super::{conflict_free_name, merge_headers};
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Result;
use crate::models::{
@@ -20,99 +20,6 @@ impl<'a> ClientDb<'a> {
self.find_many(FolderIden::WorkspaceId, workspace_id, None)
}
pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
match self.conn() {
ConnectionOrTx::Connection(_) => {}
ConnectionOrTx::Transaction(_) => {}
}
let fid = &folder.id;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.delete_http_request(&m, source)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
self.delete_grpc_request(&m, source)?;
}
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
self.delete_websocket_request(&m, source)?;
}
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
self.delete_environment(&e, source)?;
}
// Recurse down into child folders
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
self.delete_folder(&folder, source)?;
}
self.delete(folder, source)
}
pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {
let folder = self.get_folder(id)?;
self.delete_folder(&folder, source)
}
pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
self.upsert(folder, source)
}
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
let fid = &src_folder.id;
let mut folder = Folder {
id: "".into(),
sort_priority: src_folder.sort_priority + 0.001,
..src_folder.clone()
};
let sibling_names = self
.list_folders(&folder.workspace_id)?
.into_iter()
.filter(|f| f.folder_id == folder.folder_id)
.map(|f| f.name)
.collect::<Vec<_>>();
folder.name = conflict_free_name(&folder.name, &sibling_names);
let new_folder = self.upsert_folder(&folder, source)?;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.upsert_http_request(
&HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
self.upsert_websocket_request(
&WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
self.upsert_grpc_request(
&GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
self.upsert_environment(
&Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
// Recurse down
self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;
}
Ok(new_folder)
}
pub fn resolve_auth_for_folder(
&self,
folder: &Folder,
@@ -219,3 +126,98 @@ impl<'a> ClientDb<'a> {
})
}
}
impl<'a> WriteDb<'a> {
pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
match self.conn() {
ConnectionOrTx::Connection(_) => {}
ConnectionOrTx::Transaction(_) => {}
}
let fid = &folder.id;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.delete_http_request(&m, source)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
self.delete_grpc_request(&m, source)?;
}
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
self.delete_websocket_request(&m, source)?;
}
for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {
self.delete_environment(&e, source)?;
}
// Recurse down into child folders
for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
self.delete_folder(&folder, source)?;
}
self.delete(folder, source)
}
pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {
let folder = self.get_folder(id)?;
self.delete_folder(&folder, source)
}
pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {
self.upsert(folder, source)
}
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
let fid = &src_folder.id;
let mut folder = Folder {
id: "".into(),
sort_priority: src_folder.sort_priority + 0.001,
..src_folder.clone()
};
let sibling_names = self
.list_folders(&folder.workspace_id)?
.into_iter()
.filter(|f| f.folder_id == folder.folder_id)
.map(|f| f.name)
.collect::<Vec<_>>();
folder.name = conflict_free_name(&folder.name, &sibling_names);
let new_folder = self.upsert_folder(&folder, source)?;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.upsert_http_request(
&HttpRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {
self.upsert_websocket_request(
&WebsocketRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {
self.upsert_grpc_request(
&GrpcRequest { id: "".into(), folder_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {
self.upsert_environment(
&Environment { id: "".into(), parent_id: Some(new_folder.id.clone()), ..m },
source,
)?;
}
for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {
// Recurse down
self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;
}
Ok(new_folder)
}
}
@@ -1,4 +1,4 @@
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};
use crate::util::UpdateSource;
@@ -11,7 +11,9 @@ impl<'a> ClientDb<'a> {
pub fn get_graphql_introspection(&self, request_id: &str) -> Option<GraphQlIntrospection> {
self.find_optional(GraphQlIntrospectionIden::RequestId, request_id)
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_graphql_introspection(
&self,
workspace_id: &str,
@@ -1,4 +1,4 @@
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};
use crate::queries::MAX_HISTORY_ITEMS;
@@ -13,6 +13,20 @@ impl<'a> ClientDb<'a> {
self.find_one(GrpcConnectionIden::Id, id)
}
pub fn list_grpc_connections_for_request(
&self,
request_id: &str,
limit: Option<u64>,
) -> Result<Vec<GrpcConnection>> {
self.find_many(GrpcConnectionIden::RequestId, request_id, limit)
}
pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {
self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn delete_all_grpc_connections_for_request(
&self,
request_id: &str,
@@ -53,18 +67,6 @@ impl<'a> ClientDb<'a> {
self.delete_grpc_connection(&grpc_connection, source)
}
pub fn list_grpc_connections_for_request(
&self,
request_id: &str,
limit: Option<u64>,
) -> Result<Vec<GrpcConnection>> {
self.find_many(GrpcConnectionIden::RequestId, request_id, limit)
}
pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {
self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)
}
pub fn cancel_pending_grpc_connections(&self) -> Result<()> {
let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;
let (sql, params) = Query::update()
@@ -1,4 +1,4 @@
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{GrpcEvent, GrpcEventIden};
use crate::util::UpdateSource;
@@ -11,7 +11,9 @@ impl<'a> ClientDb<'a> {
pub fn list_grpc_events(&self, connection_id: &str) -> Result<Vec<GrpcEvent>> {
self.find_many(GrpcEventIden::ConnectionId, connection_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_grpc_event(
&self,
grpc_event: &GrpcEvent,
+47 -45
View File
@@ -1,5 +1,5 @@
use super::{conflict_free_name, merge_headers};
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{
AnyModel, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader,
@@ -32,50 +32,6 @@ impl<'a> ClientDb<'a> {
Ok(children)
}
pub fn delete_grpc_request(
&self,
m: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
self.delete(m, source)
}
pub fn delete_grpc_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<GrpcRequest> {
let request = self.get_grpc_request(id)?;
self.delete_grpc_request(&request, source)
}
pub fn duplicate_grpc_request(
&self,
grpc_request: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
let mut request = grpc_request.clone();
request.id = "".to_string();
request.sort_priority = request.sort_priority + 0.001;
let sibling_names = self
.list_grpc_requests(&request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
request.name = conflict_free_name(&request.name, &sibling_names);
self.upsert(&request, source)
}
pub fn upsert_grpc_request(
&self,
grpc_request: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
self.upsert(grpc_request, source)
}
pub fn resolve_auth_for_grpc_request(
&self,
grpc_request: &GrpcRequest,
@@ -146,3 +102,49 @@ impl<'a> ClientDb<'a> {
})
}
}
impl<'a> WriteDb<'a> {
pub fn delete_grpc_request(
&self,
m: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
self.delete(m, source)
}
pub fn delete_grpc_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<GrpcRequest> {
let request = self.get_grpc_request(id)?;
self.delete_grpc_request(&request, source)
}
pub fn duplicate_grpc_request(
&self,
grpc_request: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
let mut request = grpc_request.clone();
request.id = "".to_string();
request.sort_priority = request.sort_priority + 0.001;
let sibling_names = self
.list_grpc_requests(&request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
request.name = conflict_free_name(&request.name, &sibling_names);
self.upsert(&request, source)
}
pub fn upsert_grpc_request(
&self,
grpc_request: &GrpcRequest,
source: &UpdateSource,
) -> Result<GrpcRequest> {
self.upsert(grpc_request, source)
}
}
+111 -98
View File
@@ -1,5 +1,5 @@
use super::{conflict_free_name, merge_headers};
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{
AnyModel, Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
@@ -18,50 +18,6 @@ impl<'a> ClientDb<'a> {
self.find_many(HttpRequestIden::WorkspaceId, workspace_id, None)
}
pub fn delete_http_request(
&self,
m: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
self.delete(m, source)
}
pub fn delete_http_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<HttpRequest> {
let http_request = self.get_http_request(id)?;
self.delete_http_request(&http_request, source)
}
pub fn duplicate_http_request(
&self,
http_request: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
let mut http_request = http_request.clone();
http_request.id = "".to_string();
http_request.sort_priority = http_request.sort_priority + 0.001;
let sibling_names = self
.list_http_requests(&http_request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == http_request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
self.upsert(&http_request, source)
}
pub fn upsert_http_request(
&self,
http_request: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
self.upsert(http_request, source)
}
pub fn resolve_auth_for_http_request(
&self,
http_request: &HttpRequest,
@@ -179,6 +135,52 @@ impl<'a> ClientDb<'a> {
}
}
impl<'a> WriteDb<'a> {
pub fn delete_http_request(
&self,
m: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
self.delete(m, source)
}
pub fn delete_http_request_by_id(
&self,
id: &str,
source: &UpdateSource,
) -> Result<HttpRequest> {
let http_request = self.get_http_request(id)?;
self.delete_http_request(&http_request, source)
}
pub fn duplicate_http_request(
&self,
http_request: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
let mut http_request = http_request.clone();
http_request.id = "".to_string();
http_request.sort_priority = http_request.sort_priority + 0.001;
let sibling_names = self
.list_http_requests(&http_request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == http_request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
self.upsert(&http_request, source)
}
pub fn upsert_http_request(
&self,
http_request: &HttpRequest,
source: &UpdateSource,
) -> Result<HttpRequest> {
self.upsert(http_request, source)
}
}
#[cfg(test)]
mod tests {
use crate::init_in_memory;
@@ -225,72 +227,83 @@ mod tests {
#[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 source = &UpdateSource::Background;
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");
let (folder, request) = query_manager
.with_tx(|db| {
let workspace = db.upsert_workspace(
&Workspace {
name: "Test".to_string(),
setting_http_version: HttpVersion::Http2,
..Default::default()
},
source,
)?;
let folder = db.upsert_folder(
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)?;
let request = db.upsert_http_request(
&HttpRequest {
workspace_id: workspace.id.clone(),
folder_id: Some(folder.id.clone()),
..Default::default()
},
source,
)?;
Ok::<_, crate::error::Error>((folder, request))
})
.expect("Failed to seed");
// No overrides, so the workspace base value applies
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
let resolved = query_manager
.connect()
.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");
query_manager
.with_tx(|db| {
db.upsert_folder(
&Folder {
setting_http_version: InheritedHttpVersionSetting {
enabled: true,
value: HttpVersion::Http1,
},
..folder
},
source,
)
})
.expect("Failed to update folder");
let resolved = query_manager
.connect()
.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,
let request = query_manager
.with_tx(|db| {
db.upsert_http_request(
&HttpRequest {
setting_http_version: InheritedHttpVersionSetting {
enabled: true,
value: HttpVersion::Auto,
},
..request
},
..request
},
&UpdateSource::Background,
)
source,
)
})
.expect("Failed to update request");
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
let resolved = query_manager
.connect()
.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");
}
@@ -1,4 +1,4 @@
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{HttpResponseEvent, HttpResponseEventIden};
use crate::util::UpdateSource;
@@ -7,7 +7,9 @@ impl<'a> ClientDb<'a> {
pub fn list_http_response_events(&self, response_id: &str) -> Result<Vec<HttpResponseEvent>> {
self.find_many(HttpResponseEventIden::ResponseId, response_id, None)
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_http_response_event(
&self,
http_response_event: &HttpResponseEvent,
@@ -1,5 +1,5 @@
use crate::blob_manager::BlobManager;
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};
use crate::queries::MAX_HISTORY_ITEMS;
@@ -31,20 +31,6 @@ impl<'a> ClientDb<'a> {
self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit)
}
/// Returns the number of responses deleted.
pub fn delete_all_http_responses_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<usize> {
let responses = self.list_http_responses_for_request(request_id, None)?;
let count = responses.len();
for m in responses {
self.delete(&m, source)?;
}
Ok(count)
}
/// Delete blob-stored response bodies whose owning HTTP response row no
/// longer exists. Blob ids are keyed by the response that owns them —
/// "{response_id}" for a response body, "{response_id}.request" for the
@@ -55,19 +41,24 @@ impl<'a> ClientDb<'a> {
///
/// Returns the number of orphaned bodies deleted.
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
let mut deleted = 0;
let orphaned = blobs
.connect()
.list_body_ids()?
.into_iter()
.filter(|body_id| {
let response_id = body_id.split('.').next().unwrap_or_default();
self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_none()
})
.collect::<Vec<_>>();
let blob_ctx = blobs.connect();
for body_id in blob_ctx.list_body_ids()? {
let response_id = body_id.split('.').next().unwrap_or_default();
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
blobs.with_tx(|b| {
for body_id in &orphaned {
b.delete_chunks(body_id)?;
}
blob_ctx.delete_chunks(&body_id)?;
deleted += 1;
}
Ok::<_, crate::error::Error>(())
})?;
Ok(deleted)
Ok(orphaned.len())
}
/// Delete response body data (blob chunks and body files) whose owning HTTP
@@ -96,8 +87,7 @@ impl<'a> ClientDb<'a> {
let Some(response_id) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some()
{
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
}
if fs::remove_file(&path).is_ok() {
@@ -108,6 +98,22 @@ impl<'a> ClientDb<'a> {
Ok(deleted)
}
}
impl<'a> WriteDb<'a> {
/// Returns the number of responses deleted.
pub fn delete_all_http_responses_for_request(
&self,
request_id: &str,
source: &UpdateSource,
) -> Result<usize> {
let responses = self.list_http_responses_for_request(request_id, None)?;
let count = responses.len();
for m in responses {
self.delete(&m, source)?;
}
Ok(count)
}
/// Returns the number of responses deleted.
pub fn delete_all_http_responses_for_workspace(
@@ -138,9 +144,8 @@ impl<'a> ClientDb<'a> {
}
// Delete request body blobs (pattern: {response_id}.request)
let blob_ctx = blob_manager.connect();
let body_id = format!("{}.request", http_response.id);
if let Err(e) = blob_ctx.delete_chunks(&body_id) {
if let Err(e) = blob_manager.with_tx(|b| b.delete_chunks(&body_id)) {
error!("Failed to delete request body blobs: {}", e);
}
@@ -187,36 +192,50 @@ impl<'a> ClientDb<'a> {
#[cfg(test)]
mod tests {
use crate::blob_manager::{BlobManager, BodyChunk};
use crate::client_db::ClientDb;
use crate::error::Error;
use crate::init_in_memory;
use crate::models::{HttpRequest, HttpResponse, Workspace};
use crate::query_manager::QueryManager;
use crate::util::UpdateSource;
/// A workspace, a request, and one response that still exists.
fn seed_live_response(db: &ClientDb, blob_manager: &BlobManager) -> HttpResponse {
fn seed_live_response(
query_manager: &QueryManager,
blob_manager: &BlobManager,
) -> HttpResponse {
let source = &UpdateSource::Background;
let workspace = db
.upsert_workspace(
&Workspace { name: "GC Test".to_string(), ..Default::default() },
source,
)
.expect("Failed to upsert workspace");
let request = db
.upsert_http_request(
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)
.expect("Failed to upsert request");
db.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
..Default::default()
},
source,
blob_manager,
)
.expect("Failed to upsert response")
query_manager
.with_tx(|db| {
let workspace = db.upsert_workspace(
&Workspace { name: "GC Test".to_string(), ..Default::default() },
source,
)?;
let request = db.upsert_http_request(
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)?;
db.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
..Default::default()
},
source,
blob_manager,
)
})
.expect("Failed to seed response")
}
fn insert_bodies(blob_manager: &BlobManager, body_ids: &[&str]) {
blob_manager
.with_tx(|b| {
for id in body_ids {
b.insert_chunk(&BodyChunk::new(*id, 0, b"data".to_vec()))?;
}
Ok::<_, Error>(())
})
.expect("Failed to insert chunks");
}
/// What a browser host runs: no filesystem, so bodies exist only as blob
@@ -224,23 +243,21 @@ mod tests {
#[test]
fn deletes_orphaned_response_body_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let live = seed_live_response(&db, &blob_manager);
let live = seed_live_response(&query_manager, &blob_manager);
let live_request_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
// needs to take it
let blob_ctx = blob_manager.connect();
blob_ctx.insert_chunk(&BodyChunk::new(&live.id, 0, b"live".to_vec())).unwrap();
blob_ctx
.insert_chunk(&BodyChunk::new(&live_request_body_id, 0, b"live".to_vec()))
.unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
}
insert_bodies(
&blob_manager,
&[
&live.id,
&live_request_body_id,
"rs_gone",
"rs_gone.request",
],
);
let deleted = db
let deleted = query_manager
.connect()
.delete_orphaned_response_body_blobs(&blob_manager)
.expect("Failed to GC response body blobs");
assert_eq!(deleted, 2);
@@ -255,24 +272,18 @@ mod tests {
#[test]
fn deletes_orphaned_response_bodies() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let live = seed_live_response(&db, &blob_manager);
let live = seed_live_response(&query_manager, &blob_manager);
let live_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
// needs to take it
let blob_ctx = blob_manager.connect();
blob_ctx.insert_chunk(&BodyChunk::new(&live_body_id, 0, b"live".to_vec())).unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
}
insert_bodies(&blob_manager, &[&live_body_id, "rs_gone.request"]);
let dir = std::env::temp_dir().join(format!("yaak-blob-gc-test-{}", live.id));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(&live.id), b"live").unwrap();
std::fs::write(dir.join("rs_gone"), b"dead").unwrap();
let deleted = db
let deleted = query_manager
.connect()
.delete_orphaned_response_bodies(&blob_manager, &dir)
.expect("Failed to GC response bodies");
assert_eq!(deleted, 2);
@@ -0,0 +1,96 @@
use crate::client_db::{ClientDb, WriteDb};
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())
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_import_source_resource(
&self,
resource: &ImportSourceResource,
) -> Result<ImportSourceResource> {
let (sql, params) = Query::insert()
.into_table(ImportSourceResourceIden::Table)
.columns([
ImportSourceResourceIden::CreatedAt,
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ImportSourceId,
ImportSourceResourceIden::SourceKey,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::ContentHash,
])
.values_panic([
CurrentTimestamp.into(),
CurrentTimestamp.into(),
resource.import_source_id.as_str().into(),
resource.source_key.as_str().into(),
resource.model_type.as_str().into(),
resource.model_id.clone().into(),
resource.content_hash.clone().into(),
])
.on_conflict(
OnConflict::columns([
ImportSourceResourceIden::ImportSourceId,
ImportSourceResourceIden::SourceKey,
])
.update_columns([
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::ContentHash,
])
.to_owned(),
)
.returning_all()
.build_rusqlite(SqliteQueryBuilder);
let mut stmt = self.conn().prepare(sql.as_str())?;
let m = stmt.query_row(&*params.as_params(), |row| row.try_into())?;
Ok(m)
}
pub fn delete_import_source_resource(
&self,
import_source_id: &str,
source_key: &str,
) -> Result<()> {
let (sql, params) = Query::delete()
.from_table(ImportSourceResourceIden::Table)
.cond_where(
Cond::all()
.add(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
.add(Expr::col(ImportSourceResourceIden::SourceKey).eq(source_key)),
)
.build_rusqlite(SqliteQueryBuilder);
self.conn().execute(sql.as_str(), &*params.as_params())?;
Ok(())
}
pub fn delete_import_source_resources(&self, import_source_id: &str) -> Result<()> {
let (sql, params) = Query::delete()
.from_table(ImportSourceResourceIden::Table)
.cond_where(Expr::col(ImportSourceResourceIden::ImportSourceId).eq(import_source_id))
.build_rusqlite(SqliteQueryBuilder);
self.conn().execute(sql.as_str(), &*params.as_params())?;
Ok(())
}
}
@@ -0,0 +1,47 @@
use crate::client_db::{ClientDb, WriteDb};
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))
}
}
impl<'a> WriteDb<'a> {
pub fn upsert_import_source(
&self,
import_source: &ImportSource,
source: &UpdateSource,
) -> Result<ImportSource> {
self.upsert(import_source, source)
}
pub fn delete_import_source(
&self,
import_source: &ImportSource,
source: &UpdateSource,
) -> Result<ImportSource> {
self.delete_import_source_resources(&import_source.id)?;
self.delete(import_source, source)
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
use crate::client_db::ClientDb;
use crate::client_db::{ClientDb, WriteDb};
use crate::error::Result;
use crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};
use crate::util::UpdateSource;
@@ -89,7 +89,9 @@ impl<'a> ClientDb<'a> {
.build_rusqlite(SqliteQueryBuilder);
self.conn().resolve().query_row(sql.as_str(), &*params.as_params(), KeyValue::from_row).ok()
}
}
impl<'a> WriteDb<'a> {
pub fn set_key_value_dte(
&self,
namespace: &str,
+3 -1
View File
@@ -11,6 +11,8 @@ mod grpc_requests;
mod http_requests;
mod http_response_events;
mod http_responses;
mod import_source_resources;
mod import_sources;
mod key_values;
mod model_changes;
mod plugin_key_values;
@@ -22,8 +24,8 @@ mod websocket_events;
mod websocket_requests;
mod workspace_metas;
pub mod workspaces;
pub use model_changes::PersistedModelChange;
pub(crate) use duplicate_name::conflict_free_name;
pub use model_changes::PersistedModelChange;
const MAX_HISTORY_ITEMS: usize = 20;
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more