mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 20:23:40 +01:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e71b4b96 | ||
|
|
7c660f4528 | ||
|
|
0337ef35c9 | ||
|
|
602bd1bb45 | ||
|
|
57ab349901 | ||
|
|
7a6d275fdb | ||
|
|
7d0926c961 | ||
|
|
522b898620 | ||
|
|
cb7ed9264d | ||
|
|
90cb578e26 | ||
|
|
ff7eebf3cd | ||
|
|
eff0c06cb0 | ||
|
|
c6e09a5af3 | ||
|
|
b05071ef4b | ||
|
|
a44f31eeb2 | ||
|
|
1103d05915 | ||
|
|
2f5639a26f | ||
|
|
7f8b7bf567 | ||
|
|
096e83a7bd | ||
|
|
89ce84cefe | ||
|
|
d5d2a64c32 | ||
|
|
d2d4b80a09 | ||
|
|
d461c982ec | ||
|
|
8eebee460e | ||
|
|
81a2a5d955 | ||
|
|
e63a87718c | ||
|
|
222ea9cb83 | ||
|
|
360c098b09 | ||
|
|
fce1039bac | ||
|
|
f18f93d613 | ||
|
|
661a384bed |
No files matched your search
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
**/bindings/**
|
||||
**/routeTree.gen.ts
|
||||
crates/yaak-templates/pkg/**
|
||||
Generated
+54
-28
@@ -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]]
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/rauchg"><img src="https://github.com/rauchg.png" width="80px" alt="User avatar: rauchg" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||
@@ -25,6 +25,10 @@
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
|
||||
|
||||
## Features
|
||||
|
||||
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
|
||||
@@ -68,3 +72,9 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
## License
|
||||
|
||||
The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
|
||||
|
||||
The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
||||
import type {
|
||||
WorkspaceSettingsHighlight,
|
||||
WorkspaceSettingsTab,
|
||||
} from "../components/WorkspaceSettingsDialog";
|
||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
||||
export function openWorkspaceSettings(
|
||||
tab?: WorkspaceSettingsTab,
|
||||
highlight?: WorkspaceSettingsHighlight,
|
||||
) {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
WorkspaceSettingsDialog.show(workspaceId, tab);
|
||||
WorkspaceSettingsDialog.show(workspaceId, tab, highlight);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import type { Pair } from "./core/PairEditor";
|
||||
import { PairEditor } from "./core/PairEditor";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { SecretVariableHint } from "./hints/SecretVariableHint";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
@@ -46,6 +47,8 @@ interface Props<T> {
|
||||
stateKey: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
/** Offer to move literal secrets typed into password fields into a variable */
|
||||
suggestVariables?: boolean;
|
||||
}
|
||||
|
||||
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
@@ -57,6 +60,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
stateKey,
|
||||
className,
|
||||
disabled,
|
||||
suggestVariables,
|
||||
}: Props<T>) {
|
||||
const setDataAttr = useCallback(
|
||||
(name: string, value: JsonPrimitive) => {
|
||||
@@ -73,6 +77,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
stateKey={stateKey}
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
suggestVariables={suggestVariables}
|
||||
data={data}
|
||||
className={classNames(className, "pb-4")} // Pad the bottom to look nice
|
||||
/>
|
||||
@@ -103,6 +108,7 @@ type FormInputsProps<T> = Pick<
|
||||
> & {
|
||||
setDataAttr: (name: string, value: JsonPrimitive) => void;
|
||||
disabled?: boolean;
|
||||
suggestVariables?: boolean;
|
||||
};
|
||||
|
||||
function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
@@ -113,6 +119,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
setDataAttr,
|
||||
data,
|
||||
disabled,
|
||||
suggestVariables,
|
||||
}: FormInputsProps<T>) {
|
||||
return (
|
||||
<>
|
||||
@@ -147,6 +154,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
arg={input}
|
||||
autocompleteFunctions={autocompleteFunctions || false}
|
||||
autocompleteVariables={autocompleteVariables || false}
|
||||
suggestVariables={suggestVariables || false}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
||||
@@ -287,6 +295,7 @@ function TextArg({
|
||||
value,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
suggestVariables,
|
||||
stateKey,
|
||||
}: {
|
||||
arg: FormInputText;
|
||||
@@ -294,6 +303,7 @@ function TextArg({
|
||||
onChange: (v: string) => void;
|
||||
autocompleteFunctions: boolean;
|
||||
autocompleteVariables: boolean;
|
||||
suggestVariables: boolean;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const props: InputProps = {
|
||||
@@ -316,10 +326,19 @@ function TextArg({
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
};
|
||||
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
|
||||
return <Input {...props} />;
|
||||
}
|
||||
return <PlainInput {...props} />;
|
||||
const input =
|
||||
autocompleteVariables || autocompleteFunctions || arg.completionOptions ? (
|
||||
<Input {...props} />
|
||||
) : (
|
||||
<PlainInput {...props} />
|
||||
);
|
||||
if (!suggestVariables || !arg.password) return input;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{input}
|
||||
<SecretVariableHint name={arg.name} value={value} onReplace={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorArg({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
withEncryptionEnabled,
|
||||
} from "../lib/setupOrConfigureEncryption";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { FeatureHint } from "./core/FeatureHint";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
|
||||
import { ensurePairId } from "./core/PairEditor.util";
|
||||
@@ -152,6 +153,17 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
This sharable environment contains plain-text secrets
|
||||
</DismissibleBanner>
|
||||
)}
|
||||
{!environment.public && !isEncryptionEnabled && hasSecretLookingVariable(environment) && (
|
||||
<FeatureHint
|
||||
id="encrypt-secrets"
|
||||
className="mr-3"
|
||||
docsUrl="https://yaak.app/docs/collaboration/secrets-encryption"
|
||||
action={{ label: "Enable Encryption", onClick: setupOrConfigureEncryption }}
|
||||
>
|
||||
Some of these look like secrets. Encrypt them so the workspace is safe to sync to a
|
||||
folder or share with a team.
|
||||
</FeatureHint>
|
||||
)}
|
||||
</div>
|
||||
<PairOrBulkEditor
|
||||
setRef={setRef}
|
||||
@@ -173,3 +185,9 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECRET_NAME = /token|secret|password|passwd|api[_-]?key|private[_-]?key|credential/i;
|
||||
|
||||
function hasSecretLookingVariable(environment: Environment) {
|
||||
return environment.variables.some((v) => v.value.trim() !== "" && SECRET_NAME.test(v.name));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -178,6 +178,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
disabled={model.authentication.disabled === true}
|
||||
autocompleteVariables
|
||||
autocompleteFunctions
|
||||
suggestVariables
|
||||
stateKey={`auth.${model.id}.${model.authenticationType}`}
|
||||
inputs={authConfig.data?.args ?? []}
|
||||
data={model.authentication}
|
||||
|
||||
@@ -52,6 +52,7 @@ import { JsonBodyEditor } from "./JsonBodyEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { SharedOriginHint } from "./hints/SharedOriginHint";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
|
||||
@@ -165,6 +166,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
: 0;
|
||||
}
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -260,6 +263,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -267,6 +271,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
authTab,
|
||||
contentType,
|
||||
handleContentTypeChange,
|
||||
hasDescription,
|
||||
headersTab,
|
||||
numParams,
|
||||
numSettingsOverrides,
|
||||
@@ -340,24 +345,27 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
<UrlBar
|
||||
stateKey={`url.${activeRequest.id}`}
|
||||
key={forceUpdateKey + urlKey}
|
||||
url={activeRequest.url}
|
||||
placeholder="https://example.com"
|
||||
onPasteOverwrite={handlePaste}
|
||||
autocomplete={autocomplete}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
onUrlChange={handleUrlChange}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={updateKey}
|
||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
||||
/>
|
||||
<div>
|
||||
<UrlBar
|
||||
stateKey={`url.${activeRequest.id}`}
|
||||
key={forceUpdateKey + urlKey}
|
||||
url={activeRequest.url}
|
||||
placeholder="https://example.com"
|
||||
onPasteOverwrite={handlePaste}
|
||||
autocomplete={autocomplete}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
onUrlChange={handleUrlChange}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={updateKey}
|
||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
||||
/>
|
||||
<SharedOriginHint />
|
||||
</div>
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { Tooltip } from "./core/Tooltip";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { ChainingHint } from "./hints/ChainingHint";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
@@ -280,6 +281,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
{activeResponse.error}
|
||||
</Banner>
|
||||
)}
|
||||
{activeResponse && <ChainingHint response={activeResponse} mimeType={mimeType} />}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export function SyncToFilesystemSetting({
|
||||
)}
|
||||
|
||||
<SettingRowDirectory
|
||||
highlightKey="sync_dir"
|
||||
title="Local directory sync"
|
||||
description="Sync data to a folder for backup and Git integration."
|
||||
filePath={value.filePath}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,8 +25,12 @@ interface Props {
|
||||
workspaceId: string;
|
||||
hide: () => void;
|
||||
tab?: WorkspaceSettingsTab;
|
||||
highlight?: WorkspaceSettingsHighlight;
|
||||
}
|
||||
|
||||
/** Settings rows that can be pointed out when the dialog opens */
|
||||
export type WorkspaceSettingsHighlight = "sync_dir";
|
||||
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_DNS = "dns";
|
||||
const TAB_HEADERS = "headers";
|
||||
@@ -42,7 +46,7 @@ export type WorkspaceSettingsTab =
|
||||
|
||||
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
||||
|
||||
export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
export function WorkspaceSettingsDialog({ workspaceId, hide, tab, highlight }: Props) {
|
||||
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
|
||||
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
|
||||
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
|
||||
@@ -104,7 +108,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
|
||||
<SettingsList className="space-y-8 pb-3">
|
||||
<SettingsList className="space-y-8 pb-3" highlight={highlight}>
|
||||
<SettingsSection title={null}>
|
||||
<SyncToFilesystemSetting
|
||||
layout="settings"
|
||||
@@ -178,14 +182,23 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
|
||||
WorkspaceSettingsDialog.show = (
|
||||
workspaceId: string,
|
||||
tab?: WorkspaceSettingsTab,
|
||||
highlight?: WorkspaceSettingsHighlight,
|
||||
) => {
|
||||
showDialog({
|
||||
id: "workspace-settings",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-5rem)] max-h-200!",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
||||
<WorkspaceSettingsDialog
|
||||
workspaceId={workspaceId}
|
||||
hide={hide}
|
||||
tab={tab}
|
||||
highlight={highlight}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -2,11 +2,12 @@ import type { Color } from "@yaakapp-internal/plugins";
|
||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
||||
import { Banner } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { MouseEvent } from "react";
|
||||
import type { MouseEvent, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
type DismissibleBannerSize = "sm" | "xs";
|
||||
|
||||
@@ -18,17 +19,21 @@ export function DismissibleBanner({
|
||||
onDismiss,
|
||||
onShow,
|
||||
actions,
|
||||
variant = "banner",
|
||||
...props
|
||||
}: BannerProps & {
|
||||
id: string;
|
||||
size?: DismissibleBannerSize;
|
||||
onDismiss?: () => void | Promise<void>;
|
||||
onShow?: () => void | Promise<void>;
|
||||
/** A tip dismisses with an X in the corner instead of a text button */
|
||||
variant?: "banner" | "tip";
|
||||
actions?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
color?: Color;
|
||||
variant?: ButtonProps["variant"];
|
||||
rightSlot?: ReactNode;
|
||||
}[];
|
||||
}) {
|
||||
const {
|
||||
@@ -56,54 +61,69 @@ export function DismissibleBanner({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
const dismiss = (event: MouseEvent) => {
|
||||
stopParentClick(event);
|
||||
setDismissed(true).catch(console.error);
|
||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
||||
};
|
||||
const isTip = variant === "tip";
|
||||
const hasActionRow = !isTip || (actions?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Banner
|
||||
className={classNames(
|
||||
className,
|
||||
"relative",
|
||||
size === "xs" && "!px-2 !py-2 text-xs",
|
||||
)}
|
||||
className={classNames(className, "relative", size === "xs" && "!px-2 !py-2 text-xs")}
|
||||
{...props}
|
||||
>
|
||||
<div className="@container">
|
||||
<div className={classNames("@container", isTip && "relative")}>
|
||||
{isTip && (
|
||||
<IconButton
|
||||
icon="x"
|
||||
size="2xs"
|
||||
iconSize="sm"
|
||||
title="Dismiss message"
|
||||
className="absolute! -top-0.5 -right-1 @[34rem]:top-1/2 @[34rem]:-translate-y-1/2 opacity-50 hover:opacity-100"
|
||||
onClick={dismiss}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
|
||||
isTip && "@[34rem]:pr-5",
|
||||
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
||||
<Button
|
||||
variant="border"
|
||||
color={props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
setDismissed(true).catch(console.error);
|
||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
||||
}}
|
||||
title="Dismiss message"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
{actions?.map((a) => (
|
||||
<Button
|
||||
key={a.label}
|
||||
variant={a.variant ?? "border"}
|
||||
color={a.color ?? props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
a.onClick();
|
||||
}}
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{isTip ? <div className="pr-5 @[34rem]:pr-0">{children}</div> : children}
|
||||
{hasActionRow && (
|
||||
<div className="flex flex-col gap-1.5 @[16rem]:flex-row @[16rem]:flex-wrap @[16rem]:justify-end">
|
||||
{!isTip && (
|
||||
<Button
|
||||
variant="border"
|
||||
color={props.color}
|
||||
size={actionSize}
|
||||
onClick={dismiss}
|
||||
title="Dismiss message"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
{actions?.map((a) => (
|
||||
<Button
|
||||
key={a.label}
|
||||
rightSlot={a.rightSlot}
|
||||
variant={a.variant ?? "border"}
|
||||
color={a.color ?? props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
a.onClick();
|
||||
}}
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { setKeyValue } from "../../lib/keyValueStore";
|
||||
import { DismissibleBanner } from "./DismissibleBanner";
|
||||
|
||||
interface Props {
|
||||
/** Stable id; the dismissal is remembered under it. */
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
docsUrl?: string;
|
||||
action?: { label: string; onClick: () => void | Promise<void> };
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-time nudge toward a feature the user hasn't found yet. Dismissed once, gone for good.
|
||||
*/
|
||||
export function FeatureHint({ id, children, docsUrl, action, onDismiss, className }: Props) {
|
||||
const actions = [
|
||||
...(action ? [{ ...action, variant: "solid" as const, color: "info" as const }] : []),
|
||||
...(action != null && docsUrl != null
|
||||
? [
|
||||
{
|
||||
label: "Docs",
|
||||
rightSlot: <Icon icon="external_link" size="xs" className="opacity-60" />,
|
||||
onClick: () => platform.openUrl(docsUrl).catch(console.error),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return (
|
||||
<DismissibleBanner
|
||||
id={`feature-hint:${id}`}
|
||||
variant="tip"
|
||||
color="info"
|
||||
size="xs"
|
||||
onDismiss={onDismiss}
|
||||
className={className}
|
||||
actions={actions}
|
||||
>
|
||||
<div className="flex items-start gap-2 text-xs">
|
||||
<Icon icon="sparkles" size="xs" className="hidden @[16rem]:block mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
{children}
|
||||
{action == null && docsUrl != null && (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex items-center gap-0.5 underline hover:text-info cursor-pointer! [&_*]:cursor-pointer!"
|
||||
onClick={() => platform.openUrl(docsUrl).catch(console.error)}
|
||||
>
|
||||
docs
|
||||
<Icon
|
||||
icon="external_link"
|
||||
size="xs"
|
||||
className="opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
);
|
||||
}
|
||||
|
||||
export function dismissFeatureHint(id: string) {
|
||||
return setKeyValue({
|
||||
namespace: "global",
|
||||
key: ["dismiss-banner", `feature-hint:${id}`],
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
@@ -19,11 +20,31 @@ type SettingRowBaseProps = {
|
||||
controlClassName?: string;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Matched against the surrounding `SettingsList` highlight to point this row out when it opens */
|
||||
highlightKey?: string;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={classNames("w-full", className)}>{children}</div>;
|
||||
const SettingsHighlightContext = createContext<string | null>(null);
|
||||
|
||||
const FOCUSABLE =
|
||||
"button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
|
||||
|
||||
export function SettingsList({
|
||||
children,
|
||||
className,
|
||||
highlight = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** The `highlightKey` of a row to scroll to, focus, and briefly highlight */
|
||||
highlight?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<SettingsHighlightContext.Provider value={highlight}>
|
||||
<div className={classNames("w-full", className)}>{children}</div>
|
||||
</SettingsHighlightContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
@@ -58,17 +79,37 @@ export function SettingRow({
|
||||
controlClassName,
|
||||
description,
|
||||
disabled,
|
||||
highlightKey,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
} & SettingRowBaseProps) {
|
||||
const highlight = useContext(SettingsHighlightContext);
|
||||
const isHighlighted = highlightKey != null && highlightKey === highlight;
|
||||
const [flashing, setFlashing] = useState(isHighlighted);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const controlRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHighlighted) return;
|
||||
rowRef.current?.scrollIntoView({ block: "nearest" });
|
||||
controlRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
||||
}, [isHighlighted]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
aria-disabled={disabled || undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget) setFlashing(false);
|
||||
}}
|
||||
className={classNames(
|
||||
className,
|
||||
"@container border-b border-border-subtle py-4",
|
||||
disabled && "opacity-disabled",
|
||||
// Drawn on a layer so the tint gets even padding and stops short of the row's border
|
||||
flashing &&
|
||||
"relative isolate before:absolute before:-inset-x-3 before:inset-y-1 before:-z-10 before:rounded-lg before:animate-settingHighlight",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -84,6 +125,7 @@ export function SettingRow({
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={controlRef}
|
||||
className={classNames(
|
||||
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
|
||||
controlClassName,
|
||||
|
||||
@@ -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,10 +1,8 @@
|
||||
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
|
||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||
@@ -18,6 +16,7 @@ import { showErrorToast, showToast } from "../../lib/toast";
|
||||
import type { DropdownItem } from "../core/Dropdown";
|
||||
import { Dropdown } from "../core/Dropdown";
|
||||
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { SyncWorkspaceHint } from "../hints/SyncWorkspaceHint";
|
||||
import { useGitCallbacks } from "./callbacks";
|
||||
import { GitCommitDialog } from "./GitCommitDialog";
|
||||
import { GitRemotesDialog } from "./GitRemotesDialog";
|
||||
@@ -31,7 +30,7 @@ export function GitDropdown() {
|
||||
if (workspaceMeta == null) return null;
|
||||
|
||||
if (workspaceMeta.settingSyncDir == null) {
|
||||
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
|
||||
return <SyncWorkspaceHint workspaceId={workspaceMeta.workspaceId} />;
|
||||
}
|
||||
|
||||
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
|
||||
@@ -567,64 +566,6 @@ const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonEle
|
||||
},
|
||||
);
|
||||
|
||||
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
|
||||
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
|
||||
key: "setup_sync",
|
||||
fallback: {},
|
||||
});
|
||||
|
||||
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const banner = (
|
||||
<Banner color="info">
|
||||
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
|
||||
Git collaboration.
|
||||
</Banner>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
fullWidth
|
||||
items={[
|
||||
{
|
||||
type: "content",
|
||||
label: banner,
|
||||
},
|
||||
{
|
||||
color: "success",
|
||||
label: "Open Workspace Settings",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => openWorkspaceSettings("settings"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Hide This Message",
|
||||
leftSlot: <Icon icon="eye_closed" />,
|
||||
async onSelect() {
|
||||
const confirmed = await showConfirm({
|
||||
id: "hide-sync-menu-prompt",
|
||||
title: "Hide Setup Message",
|
||||
description: "You can configure filesystem sync or Git it in the workspace settings",
|
||||
});
|
||||
if (confirmed) {
|
||||
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<GitMenuButton>
|
||||
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
|
||||
<Icon icon="wrench" />
|
||||
<div className="truncate">Setup FS Sync or Git</div>
|
||||
</div>
|
||||
</GitMenuButton>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupGitDropdown({
|
||||
workspaceId,
|
||||
initRepo,
|
||||
|
||||
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
|
||||
showDialog({
|
||||
id: "git-diverged",
|
||||
title: "Branches Diverged",
|
||||
hideX: true,
|
||||
size: "sm",
|
||||
disableBackdropClose: true,
|
||||
onClose: () => resolve("cancel"),
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
DivergedDialog({
|
||||
remote,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { httpRequestsAtom } from "@yaakapp-internal/models";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { FeatureHint } from "../core/FeatureHint";
|
||||
|
||||
const MIN_REQUESTS = 2;
|
||||
const DOCS_URL = "https://yaak.app/docs/templating/request-chaining-scripting";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
/** A successful JSON response, in a workspace with somewhere to send its values. */
|
||||
export function ChainingHint({ response, mimeType }: Props) {
|
||||
const requestCount = useAtomValue(httpRequestsAtom).length;
|
||||
const status = response.status ?? 0;
|
||||
const isJson = mimeType != null && /json/i.test(mimeType);
|
||||
if (response.state !== "closed" || status < 200 || status >= 300) return null;
|
||||
if (!isJson || requestCount < MIN_REQUESTS) return null;
|
||||
|
||||
return (
|
||||
<FeatureHint id="chaining" className="mx-3 mt-1 shrink-0" docsUrl={DOCS_URL}>
|
||||
Need a value from here in another request? Press <InlineCode>Ctrl+Space</InlineCode> in any
|
||||
field and pick <InlineCode>response()</InlineCode> to pull it out with a JSONPath
|
||||
</FeatureHint>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEnvironmentsBreakdown } from "../../hooks/useEnvironmentsBreakdown";
|
||||
import {
|
||||
addVariableToBaseEnvironment,
|
||||
isTemplate,
|
||||
variableTemplate,
|
||||
} from "../../lib/addVariableToBaseEnvironment";
|
||||
import { dismissFeatureHint, FeatureHint } from "../core/FeatureHint";
|
||||
|
||||
const HINT_ID = "secret-variable";
|
||||
const DOCS_URL = "https://yaak.app/docs/templating/environments-and-variables";
|
||||
|
||||
interface Props {
|
||||
/** Becomes the variable name, so it should describe the value (eg. "password", "token") */
|
||||
name: string;
|
||||
value: string;
|
||||
onReplace: (template: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A literal secret typed straight into an auth field. Offer to move it to a variable so the
|
||||
* request references it instead of holding it.
|
||||
*/
|
||||
export function SecretVariableHint({ name, value, onReplace }: Props) {
|
||||
const { baseEnvironment } = useEnvironmentsBreakdown();
|
||||
if (value.trim() === "" || isTemplate(value) || baseEnvironment == null) return null;
|
||||
|
||||
return (
|
||||
<FeatureHint
|
||||
id={HINT_ID}
|
||||
docsUrl={DOCS_URL}
|
||||
action={{
|
||||
label: "Move to Variable",
|
||||
onClick: async () => {
|
||||
const finalName = await addVariableToBaseEnvironment(baseEnvironment, name, value);
|
||||
onReplace(variableTemplate(finalName));
|
||||
await dismissFeatureHint(HINT_ID);
|
||||
},
|
||||
}}
|
||||
>
|
||||
Typing this in by hand? Store it in a variable to reuse it across requests and swap it per
|
||||
environment.
|
||||
</FeatureHint>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { httpRequestsAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useEnvironmentsBreakdown } from "../../hooks/useEnvironmentsBreakdown";
|
||||
import {
|
||||
addVariableToBaseEnvironment,
|
||||
isTemplate,
|
||||
variableTemplate,
|
||||
} from "../../lib/addVariableToBaseEnvironment";
|
||||
import { FeatureHint } from "../core/FeatureHint";
|
||||
|
||||
const MIN_SHARED_REQUESTS = 3;
|
||||
const DOCS_URL = "https://yaak.app/docs/templating/environments-and-variables";
|
||||
|
||||
/**
|
||||
* Once a few requests spell out the same literal origin, offer to hoist it into a variable and
|
||||
* rewrite them all at once.
|
||||
*/
|
||||
export function SharedOriginHint() {
|
||||
const requests = useAtomValue(httpRequestsAtom);
|
||||
const { baseEnvironment } = useEnvironmentsBreakdown();
|
||||
|
||||
const shared = useMemo(() => {
|
||||
const byOrigin = new Map<string, typeof requests>();
|
||||
for (const r of requests) {
|
||||
if (isTemplate(r.url)) continue;
|
||||
const origin = literalOrigin(r.url);
|
||||
if (origin == null) continue;
|
||||
byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), r]);
|
||||
}
|
||||
let best: { origin: string; requests: typeof requests } | null = null;
|
||||
for (const [origin, group] of byOrigin) {
|
||||
if (group.length >= MIN_SHARED_REQUESTS && group.length > (best?.requests.length ?? 0)) {
|
||||
best = { origin, requests: group };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}, [requests]);
|
||||
|
||||
if (shared == null || baseEnvironment == null) return null;
|
||||
|
||||
const { origin } = shared;
|
||||
const count = shared.requests.length;
|
||||
|
||||
return (
|
||||
<FeatureHint
|
||||
id={`shared-origin:${origin}`}
|
||||
className="mt-1"
|
||||
docsUrl={DOCS_URL}
|
||||
action={{
|
||||
label: "Use a Variable",
|
||||
onClick: async () => {
|
||||
const name = await addVariableToBaseEnvironment(baseEnvironment, "base_url", origin);
|
||||
const template = variableTemplate(name);
|
||||
await Promise.all(
|
||||
shared.requests.map((r) =>
|
||||
patchModel(r, { url: template + r.url.slice(origin.length) }),
|
||||
),
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{count} requests start with <InlineCode>{origin}</InlineCode>. Put it in a variable and switch
|
||||
hosts by switching environments.
|
||||
</FeatureHint>
|
||||
);
|
||||
}
|
||||
|
||||
function literalOrigin(url: string): string | null {
|
||||
const m = /^(https?:\/\/[^/?#\s]+)/i.exec(url.trim());
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { showToast } from "../../lib/toast";
|
||||
import { FeatureHint } from "../core/FeatureHint";
|
||||
|
||||
const DOCS_URL = "https://yaak.app/docs/collaboration/local-directory-git-sync";
|
||||
|
||||
/** What the sidebar's Git slot shows while the workspace only lives in the app's database. */
|
||||
export function SyncWorkspaceHint({ workspaceId }: { workspaceId: string }) {
|
||||
// Hidden from the menu this replaced, so honor it
|
||||
const { value: legacyHidden } = useKeyValue<Record<string, boolean>>({
|
||||
key: "setup_sync",
|
||||
fallback: {},
|
||||
});
|
||||
|
||||
if (legacyHidden == null || legacyHidden[workspaceId]) return null;
|
||||
|
||||
return (
|
||||
<FeatureHint
|
||||
id={`sync-workspace:${workspaceId}`}
|
||||
className="mx-2 mb-2!"
|
||||
docsUrl={DOCS_URL}
|
||||
action={{ label: "Configure", onClick: () => openWorkspaceSettings("settings", "sync_dir") }}
|
||||
onDismiss={showSyncLocationToast}
|
||||
>
|
||||
Sync this workspace to a folder of plain files to back it up or track it with Git.
|
||||
</FeatureHint>
|
||||
);
|
||||
}
|
||||
|
||||
function showSyncLocationToast() {
|
||||
showToast({
|
||||
id: "sync-hint-dismissed",
|
||||
color: "info",
|
||||
timeout: 8000,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Sync whenever you're ready</h2>
|
||||
<p className="text-text-subtle text-sm">Directory sync and Git are in Workspace Settings</p>
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Environment } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { generateId } from "./generateId";
|
||||
|
||||
/** Adds a variable, suffixing the name if it's taken, and returns the name that was used. */
|
||||
export async function addVariableToBaseEnvironment(
|
||||
environment: Environment,
|
||||
name: string,
|
||||
value: string,
|
||||
): Promise<string> {
|
||||
const taken = new Set(environment.variables.map((v) => v.name));
|
||||
let finalName = name;
|
||||
for (let i = 2; taken.has(finalName); i++) {
|
||||
finalName = `${name}_${i}`;
|
||||
}
|
||||
await patchModel(environment, {
|
||||
variables: [
|
||||
...environment.variables,
|
||||
{ id: generateId(), name: finalName, value, enabled: true },
|
||||
],
|
||||
});
|
||||
return finalName;
|
||||
}
|
||||
|
||||
export function variableTemplate(name: string) {
|
||||
return `\${[ ${name} ]}`;
|
||||
}
|
||||
|
||||
export function isTemplate(value: string) {
|
||||
return value.includes("${[");
|
||||
}
|
||||
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
|
||||
showDialog({
|
||||
id,
|
||||
title,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ export async function showConfirm({
|
||||
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
||||
showDialog({
|
||||
...extraProps,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import {
|
||||
type BatchUpsertResult,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportSource,
|
||||
workspacesAtom,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
||||
// fetches) every time the dialog container re-renders.
|
||||
const planFile = (filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
|
||||
const planUrl = (url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination });
|
||||
const listSources = (workspaceId: string) =>
|
||||
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
||||
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
||||
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
|
||||
|
||||
export const importData = createFastMutation({
|
||||
mutationKey: ["import_data"],
|
||||
onError: (err: string) => {
|
||||
@@ -21,29 +41,41 @@ export const importData = createFastMutation({
|
||||
},
|
||||
mutationFn: async () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "sm",
|
||||
size: "lg",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||
try {
|
||||
await finishImport(await runImport());
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
const cancel = () => {
|
||||
hide();
|
||||
resolve();
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
hide();
|
||||
reject(err);
|
||||
};
|
||||
const commit = async (plan: ImportPlan) => {
|
||||
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
|
||||
hide();
|
||||
await finishImport(imported);
|
||||
resolve();
|
||||
};
|
||||
return (
|
||||
<ImportDataDialog
|
||||
importFile={(filePath) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||
}
|
||||
importUrl={(url) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||
}
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={planFile}
|
||||
planUrl={planUrl}
|
||||
listSources={listSources}
|
||||
findSourcesForOrigin={findSourcesForOrigin}
|
||||
commit={commit}
|
||||
cancel={cancel}
|
||||
onError={fail}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import type { JsonSchema } from "./jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "./jsonSchemaExample";
|
||||
|
||||
describe("buildExampleFromSchema", () => {
|
||||
test("fills scalar fields with placeholders", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number", format: "int32" },
|
||||
active: { type: "boolean" },
|
||||
data: { type: "string", format: "byte" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
name: "",
|
||||
age: 0,
|
||||
active: false,
|
||||
data: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("encodes 64-bit integers as strings", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "int64" },
|
||||
count: { type: "string", format: "uint64" },
|
||||
offset: { type: "string", format: "sfixed64" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
|
||||
});
|
||||
|
||||
test("fills date-time with a parseable timestamp", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { createdAt: { type: "string", format: "date-time" } },
|
||||
};
|
||||
|
||||
const example = buildExampleFromSchema(schema) as { createdAt: string };
|
||||
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
|
||||
});
|
||||
|
||||
test("fills a duration with a value that parses", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { timeout: { type: "string", format: "duration" } },
|
||||
};
|
||||
|
||||
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
|
||||
});
|
||||
|
||||
test("expands nested messages through $defs", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { user: { $ref: "#/$defs/example.User" } },
|
||||
$defs: {
|
||||
"example.User": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: { $ref: "#/$defs/example.Address" },
|
||||
},
|
||||
},
|
||||
"example.Address": {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
user: { name: "", address: { city: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("gives repeated fields a single placeholder item", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
|
||||
unknown: { type: "array" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
tags: [""],
|
||||
users: [{ name: "" }],
|
||||
unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the first value of an enum", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
|
||||
empty: { type: "string", enum: [] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
|
||||
});
|
||||
|
||||
test("gives maps a single placeholder entry", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
labels: { type: "object", additionalProperties: { type: "string" } },
|
||||
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
labels: { key: "" },
|
||||
users: { key: { name: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops at the root self-reference", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
children: { type: "array", items: { $ref: "#" } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
|
||||
});
|
||||
|
||||
test("stops at a cycle between messages", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
$defs: {
|
||||
"example.Node": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
parent: { $ref: "#/$defs/example.Node" },
|
||||
leaf: { $ref: "#/$defs/example.Leaf" },
|
||||
},
|
||||
},
|
||||
"example.Leaf": {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
node: { name: "", parent: {}, leaf: { node: {} } },
|
||||
});
|
||||
});
|
||||
|
||||
test("expands the same message twice when it is not on the same path", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { $ref: "#/$defs/example.User" },
|
||||
to: { $ref: "#/$defs/example.User" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
|
||||
});
|
||||
|
||||
test("fills every branch of a flattened oneof", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
text: { type: "string" },
|
||||
image: { $ref: "#/$defs/example.Image" },
|
||||
},
|
||||
$defs: {
|
||||
"example.Image": { type: "object", properties: { url: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
id: "",
|
||||
text: "",
|
||||
image: { url: "" },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops expanding once the node budget runs out", () => {
|
||||
// Every level references the next one twice, so an unbounded walk would build 2^depth
|
||||
// nodes without ever repeating a ref on the same path.
|
||||
const depth = 16;
|
||||
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
|
||||
for (let i = 0; i < depth; i++) {
|
||||
$defs[`d${i}`] = {
|
||||
type: "object",
|
||||
properties: {
|
||||
a: { $ref: `#/$defs/d${i + 1}` },
|
||||
b: { $ref: `#/$defs/d${i + 1}` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const example = buildExampleFromSchema({
|
||||
type: "object",
|
||||
properties: { root: { $ref: "#/$defs/d0" } },
|
||||
$defs,
|
||||
});
|
||||
|
||||
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
|
||||
expect(countNodes(example)).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
test("handles messages without a known type", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
empty: {},
|
||||
struct: { type: "object" },
|
||||
missing: { $ref: "#/$defs/example.Nope" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
|
||||
});
|
||||
});
|
||||
|
||||
function countNodes(value: unknown): number {
|
||||
if (Array.isArray(value)) {
|
||||
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
|
||||
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
type?: string;
|
||||
format?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
additionalProperties?: JsonSchema;
|
||||
enum?: unknown[];
|
||||
$defs?: Record<string, JsonSchema>;
|
||||
$ref?: string;
|
||||
};
|
||||
|
||||
const DEFS_PREFIX = "#/$defs/";
|
||||
const ROOT_REF = "#";
|
||||
|
||||
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
|
||||
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
|
||||
|
||||
// Refs on sibling branches each expand their own subtree, so a schema that references the
|
||||
// same messages repeatedly can produce exponentially many nodes without ever cycling.
|
||||
const MAX_NODES = 5000;
|
||||
|
||||
type Budget = { remaining: number };
|
||||
|
||||
/** Build a sample message with placeholder values for every field in the schema */
|
||||
export function buildExampleFromSchema(schema: JsonSchema): unknown {
|
||||
// The root is already being built, so a `#` ref anywhere below it is a cycle
|
||||
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
|
||||
}
|
||||
|
||||
function buildValue(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.remaining -= 1;
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
if (refPath.has(schema.$ref)) {
|
||||
return {};
|
||||
}
|
||||
const resolved = resolveRef(schema.$ref, root);
|
||||
if (resolved == null) {
|
||||
return {};
|
||||
}
|
||||
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return schema.enum[0] ?? "";
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "object":
|
||||
return buildObject(schema, root, refPath, budget);
|
||||
case "array":
|
||||
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
|
||||
case "string":
|
||||
return buildString(schema.format);
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildObject(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema.properties != null && typeof schema.properties === "object") {
|
||||
const example: Record<string, unknown> = {};
|
||||
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
||||
example[name] = buildValue(propertySchema, root, refPath, budget);
|
||||
}
|
||||
return example;
|
||||
}
|
||||
|
||||
// Maps have no properties, only a value schema
|
||||
if (schema.additionalProperties != null) {
|
||||
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildString(format: string | undefined): string {
|
||||
if (format === "date-time") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
|
||||
if (format === "duration") {
|
||||
return "0s";
|
||||
}
|
||||
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
|
||||
return "0";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
|
||||
if (ref === ROOT_REF) {
|
||||
return root;
|
||||
}
|
||||
if (!ref.startsWith(DEFS_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
|
||||
}
|
||||
@@ -25,13 +25,8 @@ export async function showPromptForm({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
hideX: true,
|
||||
size: size ?? "sm",
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
onClose: () => {
|
||||
// Click backdrop, close, or escape
|
||||
resolve(null);
|
||||
},
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
Prompt({
|
||||
onCancel: () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -4,53 +4,84 @@ use crate::models_ext::QueryManagerExt;
|
||||
use std::fs::read_to_string;
|
||||
use std::io::ErrorKind;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak::import::{self, ImportDataParams};
|
||||
use yaak::import::{self, PlanImportDataParams};
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
|
||||
|
||||
pub(crate) async fn import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
origin: Option<ImportOrigin>,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
import_contents(window, &contents).await
|
||||
let plan =
|
||||
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
|
||||
commit_import(window, plan)
|
||||
}
|
||||
|
||||
pub(crate) async fn import_url<R: Runtime>(
|
||||
pub(crate) async fn plan_import_data<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
file_path: &str,
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let contents = read_import_file(file_path)?;
|
||||
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
|
||||
}
|
||||
|
||||
pub(crate) async fn plan_import_url<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
url: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let contents = fetch_import_url(window, url).await?;
|
||||
import_contents(window, &contents).await
|
||||
destination: ImportDestination,
|
||||
) -> Result<ImportPlan> {
|
||||
let url = normalize_import_url(url)?;
|
||||
let contents = fetch_import_url(window, &url).await?;
|
||||
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
|
||||
}
|
||||
|
||||
async fn import_contents<R: Runtime>(
|
||||
async fn plan_import_contents<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
contents: &str,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let plugin_manager = window.state::<PluginManager>();
|
||||
destination: ImportDestination,
|
||||
origin: Option<ImportOrigin>,
|
||||
) -> Result<ImportPlan> {
|
||||
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
|
||||
let query_manager = window.db_manager();
|
||||
let plugin_context = window.plugin_context();
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id: window.workspace_id(),
|
||||
environment_id: window.environment_id(),
|
||||
cookie_jar_id: window.cookie_jar_id(),
|
||||
request_id: None,
|
||||
};
|
||||
|
||||
Ok(import::import_data(ImportDataParams {
|
||||
Ok(import::plan_import_data(PlanImportDataParams {
|
||||
query_manager: &query_manager,
|
||||
plugin_manager: &plugin_manager,
|
||||
plugin_context: &plugin_context,
|
||||
workspace_context,
|
||||
destination,
|
||||
contents,
|
||||
origin,
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Canonicalize so re-importing the same file through a different spelling of its path still
|
||||
/// matches the linked source.
|
||||
pub(crate) fn file_origin(file_path: &str) -> ImportOrigin {
|
||||
let path = std::path::Path::new(file_path);
|
||||
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let label = canonical
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| file_path.to_string());
|
||||
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
|
||||
}
|
||||
|
||||
pub(crate) fn url_origin(url: &str) -> ImportOrigin {
|
||||
ImportOrigin { origin: url.to_string(), label: url.to_string() }
|
||||
}
|
||||
|
||||
pub(crate) fn commit_import<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
plan: ImportPlan,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
|
||||
}
|
||||
|
||||
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
|
||||
/// pipeline as a file on disk.
|
||||
///
|
||||
@@ -79,7 +110,7 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
|
||||
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
|
||||
}
|
||||
|
||||
fn normalize_import_url(url: &str) -> Result<String> {
|
||||
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return Err(Error::GenericError("Import URL must not be empty".to_string()));
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map};
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::import::{import_data, import_url};
|
||||
use crate::import::{commit_import, plan_import_data, plan_import_url};
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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) -> (),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -66,7 +66,7 @@ pub(crate) async fn render_form_values<H: PluginHost>(
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose);
|
||||
let cb = host.template_callback(purpose).await?;
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn cmd_render_template<H: PluginHost>(
|
||||
) -> Result<String> {
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
|
||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
|
||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview)).await?;
|
||||
let options = RenderOptions {
|
||||
// A preview that throws would show the user an error where they expect
|
||||
// to see the value so far, so callers rendering *into the UI* ask for
|
||||
@@ -41,7 +41,7 @@ pub async fn cmd_template_tokens_to_string<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
let cb = host.template_callback(RenderPurpose::Preview);
|
||||
let cb = host.template_callback(RenderPurpose::Preview).await?;
|
||||
Ok(transform_args(req.tokens, &cb)?.to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Generated
+5
@@ -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>, };
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Generated
+105
-2
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user