mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-12 14:07:24 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e09a5af3 | ||
|
|
b05071ef4b | ||
|
|
a44f31eeb2 | ||
|
|
1103d05915 | ||
|
|
2f5639a26f | ||
|
|
7f8b7bf567 | ||
|
|
096e83a7bd | ||
|
|
89ce84cefe | ||
|
|
d5d2a64c32 |
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
|
||||
Generated
+52
-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]]
|
||||
@@ -11228,6 +11222,7 @@ dependencies = [
|
||||
"md5 0.8.0",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
@@ -11375,7 +11370,7 @@ dependencies = [
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-templates",
|
||||
"zip",
|
||||
"zip 7.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12045,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",
|
||||
@@ -12062,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",
|
||||
@@ -12077,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 -->
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Request versioning (IntelliJ Local History style)
|
||||
|
||||
Working plan for `feat/request-versioning`. Tracks
|
||||
[save-request-data-for-response-history](https://yaak.app/feedback/posts/save-request-data-for-response-history).
|
||||
|
||||
Selecting an old response should be able to show, and restore, the request that produced it.
|
||||
|
||||
## Model
|
||||
|
||||
One table, `model_versions`, versions every request type:
|
||||
|
||||
| column | meaning |
|
||||
| --- | --- |
|
||||
| `id`, `model`, `created_at`, `updated_at` | usual model columns |
|
||||
| `workspace_id` | owning workspace |
|
||||
| `model_type` | `http_request` / `grpc_request` / `websocket_request` |
|
||||
| `model_id` | the request the version belongs to |
|
||||
| `content_hash` | sha256 of the canonical document |
|
||||
| `document` | JSON of the request's editable content |
|
||||
| `reason` | `send` / `switch` / `idle` / `restore` / `manual` |
|
||||
|
||||
`http_responses`, `grpc_connections` and `websocket_connections` each gain a nullable
|
||||
`version_id`.
|
||||
|
||||
A version's `document` is the model's JSON with bookkeeping keys removed — `model`, `id`,
|
||||
`createdAt`, `updatedAt`, `workspaceId`, `folderId`, `sortPriority`. One rule, applied the same
|
||||
way to all three request types; the hash is taken over exactly what the document holds, so moving
|
||||
a request between folders or re-sorting it never mints a version.
|
||||
|
||||
`(model_id, content_hash)` is unique, so dedup is the database's job rather than a code path that
|
||||
can be forgotten. Sending an unchanged request ten times leaves one version and ten responses
|
||||
pointing at it.
|
||||
|
||||
## Snapshot
|
||||
|
||||
One primitive, `ClientDb::snapshot_request(request, reason)`: build the document, hash it, return
|
||||
the existing row for that hash or insert a new one, then prune. Everything calls it.
|
||||
|
||||
- **Sends.** `resolve_send_inputs` (HTTP, every host — desktop, CLI, plugin-triggered) snapshots
|
||||
before the response row is created, and the resulting id rides down to the response.
|
||||
gRPC and WebSocket connect paths do the same at their own connection upserts.
|
||||
- **Edit-session boundaries the frontend can see**, all through one RPC: switching to another
|
||||
request, window blur, app close, and a 60s idle timer after the last edit.
|
||||
|
||||
Over-triggering is free, so the trigger code stays dumb.
|
||||
|
||||
## Restore
|
||||
|
||||
`restore_request_version(version_id)`:
|
||||
|
||||
1. Snapshot the live request (reason `restore`), so anything newer than its last version is kept.
|
||||
2. Merge the version's document over the live model, keeping bookkeeping fields.
|
||||
3. Upsert. The written content's hash already exists, so no new version row appears.
|
||||
|
||||
The frontend calls `wasUpdatedExternally` afterwards so open editors reload.
|
||||
|
||||
## Retention
|
||||
|
||||
An unreferenced version survives only while it is among the newest 50 for its request *and* newer
|
||||
than 30 days. A version referenced by a response lives as long as that response. Deleting a
|
||||
request deletes its versions. Versions are local history: not synced to the filesystem, not in
|
||||
Git, not exported.
|
||||
|
||||
## UI (v1, HTTP)
|
||||
|
||||
When the selected response's version differs from the live request, the response header grows a
|
||||
state-labelled dropdown ("Request Changed", following the GraphQL editor's pattern) with **View
|
||||
Diff** and **Restore**. The diff reuses the Git dialog's `DiffViewer` over YAML renderings of the
|
||||
two documents. No versions timeline panel in v1; gRPC and WebSocket are wired on the backend from
|
||||
day one and their UI can follow.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Migration + `ModelVersion` model + bindings
|
||||
- [x] Hashing / document extraction, with tests
|
||||
- [x] Queries: snapshot, prune, restore, cascade
|
||||
- [x] Send pipelines: HTTP, gRPC, WebSocket (plus the browser host's own)
|
||||
- [x] RPC commands + web/wasm host
|
||||
- [x] Frontend: snapshot triggers, dropdown, diff dialog, restore
|
||||
|
||||
## Deliberately not in v1
|
||||
|
||||
- **No versions timeline panel.** The only entry point is a response, which is
|
||||
what the feedback asked for. A "browse all versions of this request" view is a
|
||||
second feature on the same data and can land later without a schema change.
|
||||
- **No gRPC or WebSocket UI.** Both record versions from day one, so the history
|
||||
is accumulating; only the indicator is HTTP-only.
|
||||
- **No `manual` trigger.** The reason exists so that adding a "Save version now"
|
||||
action later is a UI change and not a migration.
|
||||
- **Folders, environments and workspaces are not versioned.** The response
|
||||
timeline already records what a send inherited from them.
|
||||
|
||||
## Notes for later
|
||||
|
||||
- The version's `document` is the model minus bookkeeping keys, so a restore
|
||||
merges over the live model and a field added after a version was captured
|
||||
keeps its live value rather than being blanked.
|
||||
- `content_hash` sorts object keys before hashing. Relying on
|
||||
`serde_json::Map` being a `BTreeMap` is not safe: `preserve_order` is on in
|
||||
some builds of this workspace and off in others, which is exactly the bug the
|
||||
`key_order_does_not_change_the_hash` test caught.
|
||||
@@ -30,7 +30,6 @@ import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestVersionDropdown } from "./RequestVersionDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
import { ResponseCookies } from "./ResponseCookies";
|
||||
import { ResponseHeaders } from "./ResponseHeaders";
|
||||
@@ -264,14 +263,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<HStack space={1} className="justify-self-end shrink-0">
|
||||
<RequestVersionDropdown response={activeResponse} />
|
||||
<div className="justify-self-end shrink-0">
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
@@ -260,14 +260,17 @@ function LoadedImportDataDialog({
|
||||
|
||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
||||
|
||||
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
|
||||
// updates toggle together, while removals only ever cascade beneath a removed folder.
|
||||
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
|
||||
const targets = new Set(
|
||||
collectItems(node)
|
||||
.filter((i) => togglesWith(node.data, i))
|
||||
.map((i) => i.modelId),
|
||||
);
|
||||
// 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)));
|
||||
};
|
||||
|
||||
@@ -277,25 +280,16 @@ function LoadedImportDataDialog({
|
||||
);
|
||||
};
|
||||
|
||||
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
|
||||
// new folder can't exist, and a removed folder takes its contents with it.
|
||||
// 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) {
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null || parent.model !== "folder") break;
|
||||
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
|
||||
if (item.action !== "delete") continue;
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.action === "delete" && parent.selected) {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
if (parent.action === "delete" && parent.selected && item.action === "delete") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
@@ -314,18 +308,26 @@ function LoadedImportDataDialog({
|
||||
return item.selected;
|
||||
}).length;
|
||||
|
||||
const destinationLabel = (() => {
|
||||
if (plan.destination.type === "new_workspace") return "New workspace";
|
||||
// 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 folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name;
|
||||
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<ImportPlanItem> = (() => {
|
||||
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
|
||||
const planned = plan.resources.workspaces[0];
|
||||
const planDestination = plan.destination;
|
||||
const existing =
|
||||
@@ -335,11 +337,9 @@ function LoadedImportDataDialog({
|
||||
return {
|
||||
key: existing?.id ?? planned?.id ?? "workspace",
|
||||
data: {
|
||||
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
|
||||
model: "workspace",
|
||||
modelId: existing?.id ?? planned?.id ?? "workspace",
|
||||
name: existing?.name ?? planned?.name ?? "New workspace",
|
||||
selected: true,
|
||||
kind: "destination",
|
||||
label: existing?.name ?? planned?.name ?? "New workspace",
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: itemTree,
|
||||
};
|
||||
@@ -349,40 +349,42 @@ function LoadedImportDataDialog({
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label="Destination" value={destinationLabel} />
|
||||
<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)}
|
||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
||||
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>
|
||||
|
||||
{plan.warnings.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Import details</div>
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
{plan.warnings.map((warning) => (
|
||||
<div
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
||||
>
|
||||
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack space={2} alignItems="center" className="mt-3">
|
||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
||||
<Button
|
||||
@@ -402,7 +404,7 @@ function LoadedImportDataDialog({
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Apply"}
|
||||
: "Done"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
@@ -545,31 +547,42 @@ function LoadedImportDataDialog({
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
item,
|
||||
row,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
item: ImportPlanItem;
|
||||
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 === "workspace" || item.model === "folder" || item.model === "environment" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
|
||||
}
|
||||
/>
|
||||
{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 flex items-center gap-1.5">
|
||||
<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={[
|
||||
@@ -577,29 +590,49 @@ function ImportTreeRow({
|
||||
{ value: "take_source", label: "Take source" },
|
||||
]}
|
||||
/>
|
||||
<IconTooltip content={actionHelp(item)} iconSize="sm" />
|
||||
</div>
|
||||
) : (
|
||||
actionLabel(item) && (
|
||||
<InlineCode
|
||||
label != null && (
|
||||
<ActionChip
|
||||
label={label}
|
||||
help={actionHelp(item)}
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
item.action === "create" && "text-success",
|
||||
item.action === "update" && "text-info",
|
||||
item.action === "delete" && "text-danger",
|
||||
item.action === "keep_local" && item.selected && "text-warning",
|
||||
item.action === "ignored" && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{actionLabel(item)}
|
||||
<IconTooltip content={actionHelp(item)} iconSize="xs" />
|
||||
</InlineCode>
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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":
|
||||
@@ -610,29 +643,68 @@ function actionLabel(item: ImportPlanItem): string | null {
|
||||
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 "Added since the last import";
|
||||
return "Not in this workspace yet";
|
||||
case "update":
|
||||
return "Changed since the last import";
|
||||
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 "Deleted since the last import";
|
||||
return "Gone from the source since the last import. Checking it deletes it here";
|
||||
case "keep_local":
|
||||
return "Local edits made since the last import. Importing will revert them if checked";
|
||||
return help("Changed here since the last import. Checking it reverts to the source");
|
||||
case "conflict":
|
||||
return "Changed both here and in the file since the last import";
|
||||
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 buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
|
||||
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[] = [];
|
||||
@@ -646,45 +718,66 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem
|
||||
}
|
||||
}
|
||||
|
||||
const foldersFirst = (list: ImportPlanItem[]) => [
|
||||
const byKind = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "environment"),
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "folder"),
|
||||
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
|
||||
];
|
||||
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
|
||||
key: item.modelId,
|
||||
data: item,
|
||||
data: { kind: "item", item },
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
return foldersFirst(roots).map((r) => toNode(r, new Set()));
|
||||
// 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 collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
|
||||
return [node.data, ...node.children.flatMap(collectItems)];
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
|
||||
* (deletions, reverting local edits) never ride along with a parent toggle.
|
||||
* 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 togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
|
||||
if (item.model === "workspace") return false;
|
||||
if (root.action === "delete") return item.action === "delete";
|
||||
if (item.action === "keep_local") {
|
||||
return root.modelId === item.modelId && item.model !== "folder";
|
||||
}
|
||||
return item.action === "create" || item.action === "update";
|
||||
function togglableItem(row: TreeRow): ImportPlanItem | null {
|
||||
if (row.kind !== "item") return null;
|
||||
const { item } = row;
|
||||
return item.action === "unchanged" || item.action === "conflict" ? null : item;
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(
|
||||
node: CheckboxTreeNode<ImportPlanItem>,
|
||||
): boolean | "indeterminate" | "hidden" {
|
||||
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
|
||||
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;
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import { stringify } from "yaml";
|
||||
import { useRequestVersion } from "../hooks/useRequestVersion";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { restoreRequestVersion } from "../lib/restoreRequestVersion";
|
||||
import { Button } from "./core/Button";
|
||||
import { DiffViewer } from "./core/Editor/DiffViewer";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
|
||||
interface Props {
|
||||
response: Pick<HttpResponse, "requestId" | "versionId">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers the request a response was sent from, when that is no longer the
|
||||
* request you have.
|
||||
*
|
||||
* Hidden while the two agree, which is the overwhelmingly common case and the
|
||||
* one where there is nothing to say. Responses recorded before versioning
|
||||
* existed have no version and stay quiet forever.
|
||||
*/
|
||||
export function RequestVersionDropdown({ response }: Props) {
|
||||
const { data: comparison } = useRequestVersion(response.versionId, response.requestId);
|
||||
if (comparison == null || !comparison.differs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "View Diff",
|
||||
leftSlot: <Icon icon="git_branch" />,
|
||||
onSelect: () => showRequestVersionDiff(comparison),
|
||||
},
|
||||
{
|
||||
label: "Restore This Version",
|
||||
leftSlot: <Icon icon="history" />,
|
||||
onSelect: () => restoreRequestVersion(comparison.version),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="notice"
|
||||
className="font-sans"
|
||||
title="This request has changed since this response was sent"
|
||||
forDropdown
|
||||
>
|
||||
Request Changed
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function showRequestVersionDiff(comparison: RequestVersionComparison) {
|
||||
showDialog({
|
||||
id: "request-version-diff",
|
||||
title: "Request Changes Since This Response",
|
||||
size: "full",
|
||||
noPadding: true,
|
||||
render: () => (
|
||||
<div className="h-full flex flex-col px-4 pb-4">
|
||||
<DiffViewer
|
||||
original={toYaml(comparison.version.document)}
|
||||
modified={toYaml(comparison.currentDocument)}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Matches how the Git dialog renders a model for diffing. */
|
||||
function toYaml(document: unknown): string {
|
||||
return stringify(document, { indent: 2, lineWidth: 0 });
|
||||
}
|
||||
@@ -21,6 +21,8 @@ interface Props<T> {
|
||||
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;
|
||||
@@ -29,7 +31,9 @@ interface Props<T> {
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
const { node, depth = 0 } = props;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => props.isCollapsedByDefault?.(node) ?? false,
|
||||
);
|
||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||
|
||||
const checked = props.checked(node);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { RequestVersionComparison } from "@yaakapp-internal/models";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { allRequestsAtom } from "./useAllRequests";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
/**
|
||||
* The request version a response was sent from, alongside the request as it
|
||||
* stands now.
|
||||
*
|
||||
* Refetches when the live request is written, which is what keeps the "has this
|
||||
* changed?" answer honest while someone edits. The comparison itself is the
|
||||
* backend's — the frontend never hashes anything.
|
||||
*/
|
||||
export function useRequestVersion(versionId: string | null | undefined, requestId: string | null) {
|
||||
const requests = useAtomValue(allRequestsAtom);
|
||||
const liveUpdatedAt = requests.find((r) => r.id === requestId)?.updatedAt;
|
||||
|
||||
return useQuery({
|
||||
placeholderData: (prev) => prev,
|
||||
queryKey: ["request_version", versionId, liveUpdatedAt],
|
||||
enabled: versionId != null,
|
||||
queryFn: () =>
|
||||
rpc<RequestVersionComparison>("models_request_version", { versionId: versionId! }),
|
||||
});
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { ModelVersionReason } from "@yaakapp-internal/models";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { EditSessionTracker } from "./editSessionTracker";
|
||||
|
||||
type Capture = [requestId: string, reason: ModelVersionReason];
|
||||
|
||||
function tracker(idleMs = 1000) {
|
||||
const captured: Capture[] = [];
|
||||
return {
|
||||
captured,
|
||||
tracker: new EditSessionTracker((id, reason) => captured.push([id, reason]), idleMs),
|
||||
};
|
||||
}
|
||||
|
||||
describe("EditSessionTracker", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
test("captures a request once it has been left alone", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteEdit("rq_1");
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(captured).toEqual([["rq_1", "idle"]]);
|
||||
});
|
||||
|
||||
test("a burst of edits is one capture, not one per keystroke", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
t.noteEdit("rq_1");
|
||||
vi.advanceTimersByTime(500);
|
||||
}
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(captured).toEqual([["rq_1", "idle"]]);
|
||||
});
|
||||
|
||||
test("captures the request being left, not the one being opened", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
expect(captured).toEqual([]);
|
||||
|
||||
t.noteActiveRequest("rq_2");
|
||||
expect(captured).toEqual([["rq_1", "switch"]]);
|
||||
});
|
||||
|
||||
test("re-selecting the same request is not a boundary", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
t.noteActiveRequest("rq_1");
|
||||
expect(captured).toEqual([]);
|
||||
});
|
||||
|
||||
test("blur and close capture the request still on screen", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteActiveRequest("rq_1");
|
||||
t.noteBoundary();
|
||||
t.noteBoundary();
|
||||
expect(captured).toEqual([
|
||||
["rq_1", "switch"],
|
||||
["rq_1", "switch"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("nothing is captured before a request is open", () => {
|
||||
const { tracker: t, captured } = tracker();
|
||||
t.noteBoundary();
|
||||
t.noteActiveRequest(null);
|
||||
expect(captured).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ModelVersionReason } from "@yaakapp-internal/models";
|
||||
|
||||
/**
|
||||
* How long a request has to sit untouched before its edits become a version.
|
||||
* Long enough that typing a URL is one version rather than forty, short enough
|
||||
* that walking away from a half-finished edit still records it.
|
||||
*/
|
||||
export const IDLE_MS = 60_000;
|
||||
|
||||
type Snapshot = (requestId: string, reason: ModelVersionReason) => void;
|
||||
|
||||
/**
|
||||
* When a request's editing session ends.
|
||||
*
|
||||
* The backend versions a request on every send, which covers "what produced
|
||||
* this response". This covers the rest: an edit someone made and then walked
|
||||
* away from, which no send would ever have captured.
|
||||
*
|
||||
* It deliberately knows nothing about *what* changed. Versions are
|
||||
* content-addressed, so a boundary that turns out to have nothing behind it
|
||||
* costs one query and creates nothing — which is what lets this stay a timer
|
||||
* and two assignments instead of a change-tracking system.
|
||||
*/
|
||||
export class EditSessionTracker {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private idleRequestId: string | null = null;
|
||||
private activeRequestId: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly snapshot: Snapshot,
|
||||
private readonly idleMs: number = IDLE_MS,
|
||||
) {}
|
||||
|
||||
/** A request was written. Restarts its idle countdown. */
|
||||
noteEdit(requestId: string) {
|
||||
if (this.timer != null) clearTimeout(this.timer);
|
||||
this.idleRequestId = requestId;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const requestId = this.idleRequestId;
|
||||
if (requestId != null) this.snapshot(requestId, "idle");
|
||||
}, this.idleMs);
|
||||
}
|
||||
|
||||
/** The user moved to a different request, so the one they left is finished. */
|
||||
noteActiveRequest(requestId: string | null) {
|
||||
if (requestId === this.activeRequestId) return;
|
||||
const left = this.activeRequestId;
|
||||
this.activeRequestId = requestId;
|
||||
if (left != null) this.snapshot(left, "switch");
|
||||
}
|
||||
|
||||
/** The window lost focus or is closing. */
|
||||
noteBoundary() {
|
||||
if (this.activeRequestId != null) this.snapshot(this.activeRequestId, "switch");
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { flushAllPendingPatches } from "@yaakapp-internal/models";
|
||||
import type { ModelPayload, ModelVersion, ModelVersionReason } from "@yaakapp-internal/models";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { EditSessionTracker } from "./editSessionTracker";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
const REQUEST_MODELS = ["http_request", "grpc_request", "websocket_request"];
|
||||
|
||||
/**
|
||||
* Ask the backend to capture a request's current content.
|
||||
*
|
||||
* Quiet by design: a version that fails to write is not worth a toast, because
|
||||
* every caller below is reacting to the user leaving rather than asking for
|
||||
* anything.
|
||||
*/
|
||||
export function snapshotRequestVersion(requestId: string, reason: ModelVersionReason) {
|
||||
// Edits reach the database on a debounce, so flush before asking for a
|
||||
// version of what is in it
|
||||
flushAllPendingPatches();
|
||||
rpc<ModelVersion>("models_snapshot_request", { requestId, reason }).catch((err: unknown) => {
|
||||
console.warn("Failed to snapshot request version", err);
|
||||
});
|
||||
}
|
||||
|
||||
export function initRequestVersionSnapshots() {
|
||||
const tracker = new EditSessionTracker(snapshotRequestVersion);
|
||||
|
||||
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||
for (const payload of payloads) {
|
||||
if (payload.change.type !== "upsert") continue;
|
||||
if (!REQUEST_MODELS.includes(payload.model.model)) continue;
|
||||
tracker.noteEdit(payload.model.id);
|
||||
}
|
||||
});
|
||||
|
||||
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
|
||||
jotaiStore.sub(activeRequestIdAtom, () => {
|
||||
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
|
||||
});
|
||||
|
||||
platform.window.onFocusChanged((focused) => {
|
||||
if (!focused) tracker.noteBoundary();
|
||||
});
|
||||
|
||||
// Closing is the last boundary there is. Nothing can be awaited here, but the
|
||||
// write is already on its way and the backend outlives the window.
|
||||
window.addEventListener("beforeunload", () => tracker.noteBoundary());
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||
import type { ModelVersion } from "@yaakapp-internal/models";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { fireAndForget } from "./fireAndForget";
|
||||
import { rpc } from "./rpc";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
/**
|
||||
* Put an old version's content back into the live request.
|
||||
*
|
||||
* The backend captures whatever the request currently holds before
|
||||
* overwriting it, so this is not a destructive action even when the last edit
|
||||
* was never versioned — but the request is still rewritten under the user's
|
||||
* cursor, so it is announced.
|
||||
*/
|
||||
export function restoreRequestVersion(version: ModelVersion) {
|
||||
fireAndForget(
|
||||
(async () => {
|
||||
// The backend restores from the database, so anything still sitting in a
|
||||
// debounce has to land first — otherwise it would overwrite the restore
|
||||
await flushAllModelWrites();
|
||||
const requestId = await rpc<string>("models_restore_request_version", {
|
||||
versionId: version.id,
|
||||
});
|
||||
// The write came from this window, so the store's echo suppression would
|
||||
// otherwise leave open editors showing what was there before
|
||||
wasUpdatedExternally(requestId);
|
||||
showToast({
|
||||
id: "request-version-restored",
|
||||
color: "success",
|
||||
message: "Restored the request that produced this response",
|
||||
});
|
||||
})(),
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { createRoot } from "react-dom/client";
|
||||
import { initGit } from "./init/git";
|
||||
import { initSync } from "./init/sync";
|
||||
import { initGlobalListeners } from "./lib/initGlobalListeners";
|
||||
import { initRequestVersionSnapshots } from "./lib/requestVersions";
|
||||
import { jotaiStore } from "./lib/jotai";
|
||||
import { router } from "./lib/router";
|
||||
|
||||
@@ -37,7 +36,6 @@ initGit();
|
||||
initSync();
|
||||
initModelStore(jotaiStore);
|
||||
initGlobalListeners();
|
||||
initRequestVersionSnapshots();
|
||||
await changeModelStoreWorkspace(null); // Load global models
|
||||
|
||||
console.log("Creating React root");
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -68,7 +68,9 @@ async fn import(
|
||||
);
|
||||
}
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||
Some(workspace_id) => {
|
||||
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }
|
||||
}
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let plan = import::plan_import_resources(
|
||||
@@ -113,6 +115,10 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
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"));
|
||||
|
||||
@@ -883,11 +883,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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
@@ -209,7 +210,10 @@ fn re_import_merges_into_linked_workspace() {
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
|
||||
@@ -257,3 +261,62 @@ fn re_import_merges_into_linked_workspace() {
|
||||
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");
|
||||
db.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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ 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,
|
||||
|
||||
@@ -177,4 +177,3 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
|
||||
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
||||
}
|
||||
|
||||
@@ -29,18 +29,17 @@ 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::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
@@ -331,12 +330,6 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
let settings = app_handle.db().get_settings();
|
||||
let client_cert = find_client_certificate(&request.url, &settings.client_certificates);
|
||||
|
||||
// Capture the stored request, not the rendered one: what a restore should
|
||||
// put back is what the user typed
|
||||
let version_id = app_handle
|
||||
.db()
|
||||
.snapshot_request_for_send(&AnyRequest::GrpcRequest(unrendered_request.clone()));
|
||||
|
||||
let conn = app_handle.db().upsert_grpc_connection(
|
||||
&GrpcConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
@@ -345,7 +338,6 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
elapsed: 0,
|
||||
state: GrpcConnectionState::Initialized,
|
||||
url: request.url.clone(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
@@ -1041,20 +1033,6 @@ async fn cmd_commit_import<R: Runtime>(
|
||||
commit_import(&window, plan)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||
///
|
||||
/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
|
||||
@@ -1149,7 +1127,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
parent_window: WebviewWindow<R>,
|
||||
url: &str,
|
||||
|
||||
@@ -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,10 +27,10 @@ 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;
|
||||
use yaak_plugins::error::Error::PluginErr;
|
||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||
|
||||
static EXITING: AtomicBool = AtomicBool::new(false);
|
||||
@@ -355,9 +354,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
|
||||
"Timed out starting the plugin runtime".to_string(),
|
||||
)));
|
||||
.unwrap_or_else(|_| {
|
||||
Err(yaak_plugins::error::Error::PluginErr(
|
||||
"Timed out starting the plugin runtime".to_string(),
|
||||
))
|
||||
});
|
||||
|
||||
let manager = match result {
|
||||
Ok(manager) => manager,
|
||||
|
||||
@@ -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};
|
||||
@@ -21,9 +21,9 @@ use crate::notifications::YaakNotifier;
|
||||
use crate::updates::YaakUpdater;
|
||||
use log::warn;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use tokio::sync::Mutex;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
@@ -32,36 +32,36 @@ use yaak_git::{
|
||||
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
|
||||
GitStatusSummary, GitWorktreeStatus, PullResult, PushResult,
|
||||
};
|
||||
use yaak_grpc::manager::GrpcHandle;
|
||||
use yaak_grpc::ServiceDefinition;
|
||||
use yaak_grpc::manager::GrpcHandle;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{
|
||||
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, ImportSource, ModelVersion, Plugin, RequestVersionComparison, Settings,
|
||||
WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{BatchUpsertResult, ImportPlan};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
|
||||
GetWorkspaceActionsResponse,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
|
||||
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_rpc::RpcRouter;
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_sync::sync::SyncOp;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_ws::WebsocketManager;
|
||||
|
||||
/// Per-call context: the window a command was invoked from.
|
||||
@@ -231,17 +231,15 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
async fn call_folder_action(&self, req: CallFolderActionRequest) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().await?.call_folder_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
let results =
|
||||
self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
@@ -393,11 +391,17 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
|
||||
Ok(crate::cmd_metadata(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
|
||||
async fn cmd_render_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdRenderTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -405,55 +409,114 @@ async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackRe
|
||||
Ok(crate::cmd_send_feedback(ctx.window.app_handle().clone(), req.feature, req.text).await?)
|
||||
}
|
||||
|
||||
async fn cmd_dismiss_notification<R: Runtime>(ctx: ClientCtx<R>, req: CmdDismissNotificationReq) -> Result<()> {
|
||||
Ok(crate::cmd_dismiss_notification(ctx.window.clone(), &req.notification_id, ctx.window.app_handle().state::<Mutex<YaakNotifier>>()).await?)
|
||||
async fn cmd_dismiss_notification<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDismissNotificationReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_dismiss_notification(
|
||||
ctx.window.clone(),
|
||||
&req.notification_id,
|
||||
ctx.window.app_handle().state::<Mutex<YaakNotifier>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_reflect<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcReflectReq) -> Result<Vec<ServiceDefinition>> {
|
||||
Ok(crate::cmd_grpc_reflect(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<Mutex<GrpcHandle>>()).await?)
|
||||
async fn cmd_grpc_reflect<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGrpcReflectReq,
|
||||
) -> Result<Vec<ServiceDefinition>> {
|
||||
Ok(crate::cmd_grpc_reflect(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.proto_files,
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.app_handle().state::<Mutex<GrpcHandle>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_go<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcGoReq) -> Result<String> {
|
||||
Ok(crate::cmd_grpc_go(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<GrpcHandle>>()).await?)
|
||||
Ok(crate::cmd_grpc_go(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.proto_files,
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<GrpcHandle>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_restart<R: Runtime>(ctx: ClientCtx<R>, _req: CmdRestartReq) -> Result<()> {
|
||||
Ok(crate::cmd_restart(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendEphemeralRequestReq) -> Result<EphemeralHttpResponse> {
|
||||
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
|
||||
async fn cmd_send_ephemeral_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSendEphemeralRequestReq,
|
||||
) -> Result<EphemeralHttpResponse> {
|
||||
Ok(crate::cmd_send_ephemeral_request(
|
||||
req.request,
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().clone(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_format_json<R: Runtime>(ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(yaak_commands::data::cmd_format_json(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphqlReq) -> Result<String> {
|
||||
async fn cmd_format_graphql<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdFormatGraphqlReq,
|
||||
) -> Result<String> {
|
||||
Ok(crate::cmd_format_graphql(&req.text).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
|
||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?)
|
||||
async fn cmd_http_response_body<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpResponseBodyReq,
|
||||
) -> Result<FilterResponse> {
|
||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
|
||||
async fn cmd_http_response_body_path<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpResponseBodyPathReq,
|
||||
) -> Result<Option<String>> {
|
||||
Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
|
||||
async fn cmd_http_request_body<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpRequestBodyReq,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
|
||||
async fn cmd_get_sse_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetSseEventsReq,
|
||||
) -> Result<Vec<ServerSentEvent>> {
|
||||
Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
|
||||
async fn cmd_get_http_response_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpResponseEventsReq,
|
||||
) -> Result<Vec<HttpResponseEvent>> {
|
||||
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
|
||||
async fn cmd_import_data<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdImportDataReq,
|
||||
) -> Result<ImportPlan> {
|
||||
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
|
||||
}
|
||||
|
||||
@@ -461,16 +524,25 @@ async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) ->
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
|
||||
}
|
||||
|
||||
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
|
||||
async fn cmd_commit_import<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCommitImportReq,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
|
||||
}
|
||||
|
||||
async fn cmd_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
|
||||
async fn cmd_list_import_sources<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdListImportSourcesReq,
|
||||
) -> Result<Vec<ImportSource>> {
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
|
||||
}
|
||||
|
||||
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
|
||||
async fn cmd_import_sources_for_origin<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdImportSourcesForOriginReq,
|
||||
) -> Result<Vec<ImportSource>> {
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
let origin = match (req.file_path, req.url) {
|
||||
(Some(file_path), _) => crate::import::file_origin(&file_path).origin,
|
||||
@@ -483,67 +555,115 @@ async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdIm
|
||||
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
async fn cmd_http_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdHttpRequestActionsReq,
|
||||
) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWebsocketRequestActionsReq,
|
||||
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallWebsocketRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
async fn cmd_workspace_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWorkspaceActionsReq,
|
||||
) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
|
||||
async fn cmd_call_workspace_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallWorkspaceActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
async fn cmd_folder_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdFolderActionsReq,
|
||||
) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
|
||||
async fn cmd_call_folder_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallFolderActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGrpcRequestActionsReq,
|
||||
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
async fn cmd_template_function_summaries<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateFunctionSummariesReq,
|
||||
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
async fn cmd_template_function_config<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpAuthenticationSummariesReq,
|
||||
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetHttpAuthenticationConfigReq,
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_http_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallHttpRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallGrpcRequestActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCallHttpAuthenticationActionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
|
||||
async fn cmd_curl_to_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCurlToRequestReq,
|
||||
) -> Result<HttpRequest> {
|
||||
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -551,83 +671,159 @@ async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -
|
||||
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBase64ToBinaryReq) -> Result<()> {
|
||||
Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data).await?)
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSaveBase64ToBinaryReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_save_response<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveResponseReq) -> Result<()> {
|
||||
Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
|
||||
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
|
||||
async fn cmd_send_http_request<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSendHttpRequestReq,
|
||||
) -> Result<HttpResponse> {
|
||||
Ok(crate::cmd_send_http_request(
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
req.request_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdReloadPluginsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
|
||||
async fn cmd_plugin_info<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginInfoReq,
|
||||
) -> Result<PluginMetadata> {
|
||||
Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> {
|
||||
async fn cmd_delete_all_grpc_connections<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteAllGrpcConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_send_history<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteSendHistoryReq) -> Result<()> {
|
||||
async fn cmd_delete_send_history<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteSendHistoryReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllHttpResponsesReq) -> Result<()> {
|
||||
async fn cmd_delete_all_http_responses<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDeleteAllHttpResponsesReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetWorkspaceMetaReq) -> Result<WorkspaceMeta> {
|
||||
async fn cmd_get_workspace_meta<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetWorkspaceMetaReq,
|
||||
) -> Result<WorkspaceMeta> {
|
||||
Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewChildWindowReq) -> Result<()> {
|
||||
Ok(crate::cmd_new_child_window(ctx.window.clone(), &req.url, &req.label, &req.title, req.inner_size).await?)
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdNewChildWindowReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_new_child_window(
|
||||
ctx.window.clone(),
|
||||
&req.url,
|
||||
&req.label,
|
||||
&req.title,
|
||||
req.inner_size,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_new_main_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewMainWindowReq) -> Result<()> {
|
||||
async fn cmd_new_main_window<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdNewMainWindowReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::cmd_new_main_window(ctx.window.app_handle().clone(), &req.url).await?)
|
||||
}
|
||||
|
||||
async fn cmd_check_for_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdCheckForUpdatesReq) -> Result<bool> {
|
||||
Ok(crate::cmd_check_for_updates(ctx.window.clone(), ctx.window.app_handle().state::<Mutex<YaakUpdater>>()).await?)
|
||||
async fn cmd_check_for_updates<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
_req: CmdCheckForUpdatesReq,
|
||||
) -> Result<bool> {
|
||||
Ok(crate::cmd_check_for_updates(
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<YaakUpdater>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_decrypt_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdDecryptTemplateReq) -> Result<String> {
|
||||
async fn cmd_decrypt_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDecryptTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTemplateReq) -> Result<String> {
|
||||
async fn cmd_secure_template<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSecureTemplateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
async fn cmd_get_themes<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdGetThemesReq,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
|
||||
async fn cmd_enable_encryption<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdEnableEncryptionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_reveal_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdRevealWorkspaceKeyReq) -> Result<String> {
|
||||
async fn cmd_reveal_workspace_key<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdRevealWorkspaceKeyReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_set_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdSetWorkspaceKeyReq) -> Result<()> {
|
||||
async fn cmd_set_workspace_key<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSetWorkspaceKeyReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_disable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdDisableEncryptionReq) -> Result<()> {
|
||||
async fn cmd_disable_encryption<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDisableEncryptionReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_default_headers<R: Runtime>(ctx: ClientCtx<R>, req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
|
||||
async fn cmd_default_headers<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdDefaultHeadersReq,
|
||||
) -> Result<Vec<HttpRequestHeader>> {
|
||||
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -649,39 +845,45 @@ async fn models_delete<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDeleteReq) -> R
|
||||
Ok(deleted?)
|
||||
}
|
||||
|
||||
async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq) -> Result<String> {
|
||||
async fn models_duplicate<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsDuplicateReq,
|
||||
) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_snapshot_request<R: Runtime>(ctx: ClientCtx<R>, req: ModelsSnapshotRequestReq) -> Result<ModelVersion> {
|
||||
Ok(yaak_commands::models::models_snapshot_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_request_version<R: Runtime>(ctx: ClientCtx<R>, req: ModelsRequestVersionReq) -> Result<RequestVersionComparison> {
|
||||
Ok(yaak_commands::models::models_request_version(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_restore_request_version<R: Runtime>(ctx: ClientCtx<R>, req: ModelsRestoreRequestVersionReq) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_restore_request_version(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
|
||||
async fn models_websocket_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_grpc_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
|
||||
async fn models_grpc_events<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGrpcEventsReq,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(yaak_commands::models::models_grpc_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetSettingsReq) -> Result<Settings> {
|
||||
async fn models_get_settings<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGetSettingsReq,
|
||||
) -> Result<Settings> {
|
||||
Ok(yaak_commands::models::models_get_settings(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_get_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetGraphqlIntrospectionReq) -> Result<Option<GraphQlIntrospection>> {
|
||||
async fn models_get_graphql_introspection<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsGetGraphqlIntrospectionReq,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertGraphqlIntrospectionReq) -> Result<GraphQlIntrospection> {
|
||||
async fn models_upsert_graphql_introspection<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsUpsertGraphqlIntrospectionReq,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?)
|
||||
}
|
||||
|
||||
@@ -692,7 +894,10 @@ async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req:
|
||||
/// freezes the app"). Escape sequences sidestep it. This is a quirk of the
|
||||
/// webview transport, not of the data, so it lives in the adapter rather than
|
||||
/// the shared handler.
|
||||
async fn models_workspace_models<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWorkspaceModelsReq) -> Result<String> {
|
||||
async fn models_workspace_models<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: ModelsWorkspaceModelsReq,
|
||||
) -> Result<String> {
|
||||
let json = yaak_commands::models::models_workspace_models(ctx, req).await?;
|
||||
Ok(escape_str_for_webview(&json))
|
||||
}
|
||||
@@ -718,7 +923,10 @@ fn escape_str_for_webview(input: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn cmd_git_checkout<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitCheckoutReq) -> Result<String> {
|
||||
async fn cmd_git_checkout<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitCheckoutReq,
|
||||
) -> Result<String> {
|
||||
Ok(crate::git_ext::cmd_git_checkout(&req.dir, &req.branch, req.force).await?)
|
||||
}
|
||||
|
||||
@@ -726,31 +934,52 @@ async fn cmd_git_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitBranchReq) ->
|
||||
Ok(crate::git_ext::cmd_git_branch(&req.dir, &req.branch, req.base.as_deref()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_delete_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitDeleteBranchReq) -> Result<BranchDeleteResult> {
|
||||
async fn cmd_git_delete_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitDeleteBranchReq,
|
||||
) -> Result<BranchDeleteResult> {
|
||||
Ok(crate::git_ext::cmd_git_delete_branch(&req.dir, &req.branch, req.force).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_delete_remote_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitDeleteRemoteBranchReq) -> Result<()> {
|
||||
async fn cmd_git_delete_remote_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitDeleteRemoteBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_delete_remote_branch(&req.dir, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_merge_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitMergeBranchReq) -> Result<()> {
|
||||
async fn cmd_git_merge_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitMergeBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_merge_branch(&req.dir, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_rename_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRenameBranchReq) -> Result<()> {
|
||||
async fn cmd_git_rename_branch<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRenameBranchReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_rename_branch(&req.dir, &req.old_name, &req.new_name).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_status<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitStatusReq) -> Result<GitStatusSummary> {
|
||||
async fn cmd_git_status<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitStatusReq,
|
||||
) -> Result<GitStatusSummary> {
|
||||
Ok(crate::git_ext::cmd_git_status(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_branch_info<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitBranchInfoReq) -> Result<GitBranchInfo> {
|
||||
async fn cmd_git_branch_info<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitBranchInfoReq,
|
||||
) -> Result<GitBranchInfo> {
|
||||
Ok(crate::git_ext::cmd_git_branch_info(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_worktree_status<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitWorktreeStatusReq) -> Result<GitWorktreeStatus> {
|
||||
async fn cmd_git_worktree_status<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitWorktreeStatusReq,
|
||||
) -> Result<GitWorktreeStatus> {
|
||||
Ok(crate::git_ext::cmd_git_worktree_status(&req.dir).await?)
|
||||
}
|
||||
|
||||
@@ -758,15 +987,25 @@ async fn cmd_git_log<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitLogReq) -> Resul
|
||||
Ok(crate::git_ext::cmd_git_log(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_log_for_file<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitLogForFileReq) -> Result<Vec<GitCommit>> {
|
||||
async fn cmd_git_log_for_file<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitLogForFileReq,
|
||||
) -> Result<Vec<GitCommit>> {
|
||||
Ok(crate::git_ext::cmd_git_log_for_file(&req.dir, req.rela_path).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_file_diff_for_commit<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitFileDiffForCommitReq) -> Result<GitFileDiff> {
|
||||
Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path).await?)
|
||||
async fn cmd_git_file_diff_for_commit<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitFileDiffForCommitReq,
|
||||
) -> Result<GitFileDiff> {
|
||||
Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_initialize<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitInitializeReq) -> Result<()> {
|
||||
async fn cmd_git_initialize<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitInitializeReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_initialize(&req.dir).await?)
|
||||
}
|
||||
|
||||
@@ -790,11 +1029,17 @@ async fn cmd_git_pull<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullReq) -> Res
|
||||
Ok(crate::git_ext::cmd_git_pull(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_pull_force_reset<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullForceResetReq) -> Result<PullResult> {
|
||||
async fn cmd_git_pull_force_reset<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitPullForceResetReq,
|
||||
) -> Result<PullResult> {
|
||||
Ok(crate::git_ext::cmd_git_pull_force_reset(&req.dir, &req.remote, &req.branch).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_pull_merge<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullMergeReq) -> Result<PullResult> {
|
||||
async fn cmd_git_pull_merge<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitPullMergeReq,
|
||||
) -> Result<PullResult> {
|
||||
Ok(crate::git_ext::cmd_git_pull_merge(&req.dir, &req.remote, &req.branch).await?)
|
||||
}
|
||||
|
||||
@@ -806,27 +1051,47 @@ async fn cmd_git_unstage<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitUnstageReq)
|
||||
Ok(crate::git_ext::cmd_git_unstage(&req.dir, req.rela_paths).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_reset_changes<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitResetChangesReq) -> Result<()> {
|
||||
async fn cmd_git_reset_changes<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitResetChangesReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_reset_changes(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_restore_files<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRestoreFilesReq) -> Result<()> {
|
||||
async fn cmd_git_restore_files<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRestoreFilesReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_files(&req.dir, req.rela_paths).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_restore_file_from_commit<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRestoreFileFromCommitReq) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path).await?)
|
||||
async fn cmd_git_restore_file_from_commit<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRestoreFileFromCommitReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_add_credential<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitAddCredentialReq) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password).await?)
|
||||
async fn cmd_git_add_credential<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitAddCredentialReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_remotes<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRemotesReq) -> Result<Vec<GitRemote>> {
|
||||
async fn cmd_git_remotes<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitRemotesReq,
|
||||
) -> Result<Vec<GitRemote>> {
|
||||
Ok(crate::git_ext::cmd_git_remotes(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_git_add_remote<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitAddRemoteReq) -> Result<GitRemote> {
|
||||
async fn cmd_git_add_remote<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdGitAddRemoteReq,
|
||||
) -> Result<GitRemote> {
|
||||
Ok(crate::git_ext::cmd_git_add_remote(&req.dir, &req.name, &req.url).await?)
|
||||
}
|
||||
|
||||
@@ -834,58 +1099,130 @@ async fn cmd_git_rm_remote<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRmRemoteRe
|
||||
Ok(crate::git_ext::cmd_git_rm_remote(&req.dir, &req.name).await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_calculate<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncCalculateReq) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate(ctx.window.app_handle().clone(), &req.workspace_id, &req.sync_dir).await?)
|
||||
async fn cmd_sync_calculate<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSyncCalculateReq,
|
||||
) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate(
|
||||
ctx.window.app_handle().clone(),
|
||||
&req.workspace_id,
|
||||
&req.sync_dir,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_calculate_fs<R: Runtime>(_ctx: ClientCtx<R>, req: CmdSyncCalculateFsReq) -> Result<Vec<SyncOp>> {
|
||||
async fn cmd_sync_calculate_fs<R: Runtime>(
|
||||
_ctx: ClientCtx<R>,
|
||||
req: CmdSyncCalculateFsReq,
|
||||
) -> Result<Vec<SyncOp>> {
|
||||
Ok(crate::sync_ext::cmd_sync_calculate_fs(&req.dir).await?)
|
||||
}
|
||||
|
||||
async fn cmd_sync_apply<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncApplyReq) -> Result<()> {
|
||||
Ok(crate::sync_ext::cmd_sync_apply(ctx.window.app_handle().clone(), req.sync_ops, &req.sync_dir, &req.workspace_id).await?)
|
||||
Ok(crate::sync_ext::cmd_sync_apply(
|
||||
ctx.window.app_handle().clone(),
|
||||
req.sync_ops,
|
||||
&req.sync_dir,
|
||||
&req.workspace_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_delete_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsDeleteConnectionsReq) -> Result<()> {
|
||||
async fn cmd_ws_delete_connections<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsDeleteConnectionsReq,
|
||||
) -> Result<()> {
|
||||
Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_send<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsSendReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_send(&req.connection_id, req.environment_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_send<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsSendReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_send(
|
||||
&req.connection_id,
|
||||
req.environment_id.as_deref(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_close<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsCloseReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_close(&req.connection_id, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_close<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsCloseReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_close(
|
||||
&req.connection_id,
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
|
||||
async fn cmd_ws_connect<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdWsConnectReq,
|
||||
) -> Result<WebsocketConnection> {
|
||||
Ok(crate::ws_ext::cmd_ws_connect(
|
||||
&req.request_id,
|
||||
req.environment_id.as_deref(),
|
||||
req.cookie_jar_id.as_deref(),
|
||||
ctx.window.app_handle().clone(),
|
||||
ctx.window.clone(),
|
||||
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
|
||||
async fn cmd_plugins_search<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsSearchReq,
|
||||
) -> Result<PluginSearchResponse> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_search(ctx.window.app_handle().clone(), &req.query).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_install<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsInstallReq) -> Result<()> {
|
||||
async fn cmd_plugins_install<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsInstallReq,
|
||||
) -> Result<()> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install(ctx.window.clone(), &req.name, req.version).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_install_from_directory<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsInstallFromDirectoryReq) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory).await?)
|
||||
async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsInstallFromDirectoryReq,
|
||||
) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_uninstall<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsUninstallReq) -> Result<Plugin> {
|
||||
async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginsUninstallReq,
|
||||
) -> Result<Plugin> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
|
||||
async fn cmd_plugin_init_errors<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdPluginInitErrorsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdatesReq) -> Result<PluginUpdatesResponse> {
|
||||
async fn cmd_plugins_updates<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
_req: CmdPluginsUpdatesReq,
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_updates(ctx.window.app_handle().clone()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
|
||||
async fn cmd_plugins_update_all<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
_req: CmdPluginsUpdateAllReq,
|
||||
) -> Result<Vec<PluginNameVersion>> {
|
||||
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -20,14 +21,12 @@ use yaak_models::models::{
|
||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||
WebsocketEventType,
|
||||
};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
use yaak_plugins::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>(
|
||||
@@ -170,17 +169,10 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Capture the stored request, not the rendered one: what a restore should
|
||||
// put back is what the user typed
|
||||
let version_id = app_handle
|
||||
.db()
|
||||
.snapshot_request_for_send(&AnyRequest::WebsocketRequest(unrendered_request.clone()));
|
||||
|
||||
let connection = app_handle.db().upsert_websocket_connection(
|
||||
&WebsocketConnection {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
request_id: request_id.to_string(),
|
||||
version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
@@ -461,7 +453,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -138,10 +138,6 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -246,10 +242,6 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -355,28 +347,6 @@ export type KeyValue = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ModelVersion = {
|
||||
model: "model_version";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
/**
|
||||
* The `model` field of the versioned model, eg. `http_request`.
|
||||
*/
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
contentHash: string;
|
||||
document: Record<string, any>;
|
||||
reason: ModelVersionReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a version was captured. Not a UI label — the frontend decides how to
|
||||
* phrase these — but it is what makes a history readable when debugging.
|
||||
*/
|
||||
export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual";
|
||||
|
||||
export type Plugin = {
|
||||
model: "plugin";
|
||||
id: string;
|
||||
@@ -404,23 +374,6 @@ export type ProxySetting =
|
||||
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
/**
|
||||
* One version, next to the request as it stands now.
|
||||
*
|
||||
* Both halves come from the same place so they are guaranteed comparable: the
|
||||
* frontend renders them side by side, and `differs` is the same content-hash
|
||||
* comparison the backend uses everywhere else rather than a second opinion
|
||||
* formed in TypeScript.
|
||||
*/
|
||||
export type RequestVersionComparison = {
|
||||
version: ModelVersion;
|
||||
/**
|
||||
* The live request's editable content, in the same shape as the version's document.
|
||||
*/
|
||||
currentDocument: Record<string, any>;
|
||||
differs: boolean;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
@@ -485,10 +438,6 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
+2
-8
File diff suppressed because one or more lines are too long.
+82
-23
@@ -1,7 +1,21 @@
|
||||
// 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";
|
||||
|
||||
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
* 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, };
|
||||
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 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 ImportPlan = {
|
||||
importer: string;
|
||||
destination: ImportDestination;
|
||||
resources: BatchUpsertResult;
|
||||
warnings: Array<ImportPlanWarning>;
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string };
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>;
|
||||
origin?: ImportOrigin;
|
||||
};
|
||||
|
||||
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
|
||||
export type 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>;
|
||||
};
|
||||
|
||||
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
*/
|
||||
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
|
||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
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";
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
@@ -21,19 +21,18 @@ use yaak_git::{
|
||||
use yaak_grpc::ServiceDefinition;
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
|
||||
HttpResponseEvent, ImportSource, ModelVersion, ModelVersionReason, Plugin,
|
||||
RequestVersionComparison, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
|
||||
HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
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;
|
||||
@@ -534,28 +533,6 @@ pub struct ModelsDuplicateReq {
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsSnapshotRequestReq {
|
||||
pub request_id: String,
|
||||
pub reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsRequestVersionReq {
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ModelsRestoreRequestVersionReq {
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
@@ -1003,9 +980,6 @@ macro_rules! with_commands {
|
||||
models_upsert(ModelsUpsertReq) -> String,
|
||||
models_delete(ModelsDeleteReq) -> String,
|
||||
models_duplicate(ModelsDuplicateReq) -> String,
|
||||
models_snapshot_request(ModelsSnapshotRequestReq) -> ModelVersion,
|
||||
models_request_version(ModelsRequestVersionReq) -> RequestVersionComparison,
|
||||
models_restore_request_version(ModelsRestoreRequestVersionReq) -> String,
|
||||
models_websocket_events(ModelsWebsocketEventsReq) -> Vec<WebsocketEvent>,
|
||||
models_grpc_events(ModelsGrpcEventsReq) -> Vec<GrpcEvent>,
|
||||
models_get_settings(ModelsGetSettingsReq) -> Settings,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
use crate::error::Result;
|
||||
use crate::host::{Host, PluginHost};
|
||||
use yaak_models::models::{
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, ModelVersion,
|
||||
RequestVersionComparison, Settings, WebsocketEvent, WorkspaceMeta,
|
||||
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent,
|
||||
WorkspaceMeta,
|
||||
};
|
||||
use yaak_models::versions::version_document;
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
@@ -46,42 +45,6 @@ pub async fn models_duplicate<H: Host>(host: H, req: ModelsDuplicateReq) -> Resu
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Capture the request's current content, from an edit-session boundary the
|
||||
/// frontend can see: switching away, losing focus, closing, or falling idle.
|
||||
///
|
||||
/// The frontend does not track whether anything actually changed — versions are
|
||||
/// content-addressed, so an unchanged request returns the version it already
|
||||
/// had and the trigger code stays a one-liner.
|
||||
pub async fn models_snapshot_request<H: Host>(
|
||||
host: H,
|
||||
req: ModelsSnapshotRequestReq,
|
||||
) -> Result<ModelVersion> {
|
||||
Ok(host.db().snapshot_request_by_id(&req.request_id, req.reason)?)
|
||||
}
|
||||
|
||||
/// A version and the live request side by side, for the diff and for deciding
|
||||
/// whether there is anything worth offering.
|
||||
pub async fn models_request_version<H: Host>(
|
||||
host: H,
|
||||
req: ModelsRequestVersionReq,
|
||||
) -> Result<RequestVersionComparison> {
|
||||
let db = host.db();
|
||||
let version = db.get_model_version(&req.version_id)?;
|
||||
let current_document = version_document(&db.get_any_request(&version.model_id)?.to_value()?)?;
|
||||
let differs = !db.request_matches_version(&version)?;
|
||||
Ok(RequestVersionComparison { version, current_document, differs })
|
||||
}
|
||||
|
||||
/// Returns the id of the request that was restored.
|
||||
pub async fn models_restore_request_version<H: Host>(
|
||||
host: H,
|
||||
req: ModelsRestoreRequestVersionReq,
|
||||
) -> Result<String> {
|
||||
let source = host.update_source();
|
||||
let restored = host.db().restore_request_version(&req.version_id, &source)?;
|
||||
Ok(restored.id().to_string())
|
||||
}
|
||||
|
||||
pub async fn models_websocket_events<H: Host>(
|
||||
host: H,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
|
||||
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>, };
|
||||
+8
-53
@@ -139,10 +139,6 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -247,10 +243,6 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -364,8 +356,14 @@ export type ImportSourceResource = {
|
||||
importSourceId: string;
|
||||
sourceKey: string;
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
snapshot: 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 };
|
||||
@@ -390,28 +388,6 @@ export type ModelPayload = {
|
||||
change: ModelChangeEvent;
|
||||
};
|
||||
|
||||
export type ModelVersion = {
|
||||
model: "model_version";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
/**
|
||||
* The `model` field of the versioned model, eg. `http_request`.
|
||||
*/
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
contentHash: string;
|
||||
document: Record<string, any>;
|
||||
reason: ModelVersionReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a version was captured. Not a UI label — the frontend decides how to
|
||||
* phrase these — but it is what makes a history readable when debugging.
|
||||
*/
|
||||
export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual";
|
||||
|
||||
export type ParentAuthentication = {
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
@@ -455,23 +431,6 @@ export type ProxySetting =
|
||||
|
||||
export type ProxySettingAuth = { user: string; password: string };
|
||||
|
||||
/**
|
||||
* One version, next to the request as it stands now.
|
||||
*
|
||||
* Both halves come from the same place so they are guaranteed comparable: the
|
||||
* frontend renders them side by side, and `differs` is the same content-hash
|
||||
* comparison the backend uses everywhere else rather than a second opinion
|
||||
* formed in TypeScript.
|
||||
*/
|
||||
export type RequestVersionComparison = {
|
||||
version: ModelVersion;
|
||||
/**
|
||||
* The live request's editable content, in the same shape as the version's document.
|
||||
*/
|
||||
currentDocument: Record<string, any>;
|
||||
differs: boolean;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
model: "settings";
|
||||
id: string;
|
||||
@@ -535,10 +494,6 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
Generated
+82
-23
@@ -1,7 +1,21 @@
|
||||
// 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";
|
||||
|
||||
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
* 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, };
|
||||
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 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 ImportPlan = {
|
||||
importer: string;
|
||||
destination: ImportDestination;
|
||||
resources: BatchUpsertResult;
|
||||
warnings: Array<ImportPlanWarning>;
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string };
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>;
|
||||
origin?: ImportOrigin;
|
||||
};
|
||||
|
||||
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
|
||||
export type 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>;
|
||||
};
|
||||
|
||||
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
*/
|
||||
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
|
||||
export type ImportPlanReason = "moved_into_ignored_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
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";
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
@@ -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,20 +0,0 @@
|
||||
CREATE TABLE model_versions
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
model TEXT DEFAULT 'model_version' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
document TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Content addressing, enforced by the database rather than by every caller.
|
||||
CREATE UNIQUE INDEX model_versions_content ON model_versions (model_id, content_hash);
|
||||
|
||||
ALTER TABLE http_responses ADD COLUMN version_id TEXT;
|
||||
ALTER TABLE grpc_connections ADD COLUMN version_id TEXT;
|
||||
ALTER TABLE websocket_connections ADD COLUMN version_id TEXT;
|
||||
@@ -118,20 +118,6 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(m.clone())
|
||||
}
|
||||
|
||||
/// Upsert a model WITHOUT recording a model change or emitting an event.
|
||||
///
|
||||
/// Only for rows that are nobody's business but this process's — model
|
||||
/// versions, whose whole point is that they are local history. Anything the
|
||||
/// frontend, sync or another window should learn about goes through
|
||||
/// [`Self::upsert`].
|
||||
pub(crate) fn upsert_untracked<M>(&self, model: &M) -> Result<M>
|
||||
where
|
||||
M: UpsertModelInfo + Clone,
|
||||
{
|
||||
let (m, _created) = self.ctx.upsert(model, &UpdateSource::Background.to_db())?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn record_model_change(&self, payload: &ModelPayload) -> Result<()> {
|
||||
let payload_json = serde_json::to_string(payload)?;
|
||||
let source_json = serde_json::to_string(&payload.update_source)?;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
//! What counts as a model's *content*, and how content becomes a hash.
|
||||
//!
|
||||
//! Several features need to answer "are these two models the same?" without
|
||||
//! being fooled by the fields that change every time a model is written at all:
|
||||
//! request versioning asks it to decide whether to capture a new version,
|
||||
//! import asks it to decide whether a re-import is a change or a conflict.
|
||||
//! They ask slightly different questions — see [`PLACEMENT_KEYS`] — so what is
|
||||
//! shared here is the mechanism and the reasoning, not one fixed answer.
|
||||
//!
|
||||
//! The implementation is lifted from the import merge work on
|
||||
//! `import-remember-selection` (#619), which got here first and got it right;
|
||||
//! that branch's private copy should become a call into this module when it
|
||||
//! lands.
|
||||
//!
|
||||
//! Not shared with directory sync, deliberately. Sync checksums the *bytes of a
|
||||
//! file* to notice that someone edited it on disk, so its hash has to reflect
|
||||
//! formatting and key order — exactly what this module throws away.
|
||||
|
||||
use crate::error::Result;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Fields that say which model this is and when it was last touched, rather
|
||||
/// than anything a user typed.
|
||||
///
|
||||
/// Every model carries them and every write rewrites at least `updatedAt`, so
|
||||
/// leaving them in would make every model differ from every copy of itself.
|
||||
/// `id` is not listed because it needs [`strip_ids`], which reaches nested rows
|
||||
/// too.
|
||||
pub const IDENTITY_KEYS: &[&str] = &["model", "workspaceId", "createdAt", "updatedAt"];
|
||||
|
||||
/// Fields that say where a model sits, rather than what it holds.
|
||||
///
|
||||
/// `sortPriority` is not content for anybody: importers number it from source
|
||||
/// order, so comparing it turns one insertion into an update of everything
|
||||
/// after it, and dragging a request up the sidebar is not an edit.
|
||||
///
|
||||
/// `folderId` is where the two callers actually part company, and it is a real
|
||||
/// disagreement rather than an oversight. Versioning drops it: moving a request
|
||||
/// into a folder is not an edit and must not mint a version. Import keeps it:
|
||||
/// equality there means "same content in the same place", so a source that
|
||||
/// moved a resource is showing you a change.
|
||||
pub const PLACEMENT_KEYS: &[&str] = &["folderId", "sortPriority"];
|
||||
|
||||
/// A model's JSON with the named top-level keys removed.
|
||||
pub fn without_keys(mut value: Value, keys: &[&str]) -> Value {
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
for key in keys {
|
||||
object.remove(*key);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// Drop every `id`, at every depth.
|
||||
///
|
||||
/// A header, parameter, or variable carries an `id` that identifies its row to
|
||||
/// the editor rather than anything about its content, and the editor fills
|
||||
/// those in the first time it touches a resource. Dropping every `id` keeps
|
||||
/// that from reading as a change — otherwise merely opening a request would
|
||||
/// look like an edit of all of its headers at once.
|
||||
pub fn strip_ids(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => Value::Object(
|
||||
object
|
||||
.into_iter()
|
||||
.filter(|(key, _)| key != "id")
|
||||
.map(|(key, value)| (key, strip_ids(value)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(items) => Value::Array(items.into_iter().map(strip_ids).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix on every hash this module writes.
|
||||
///
|
||||
/// A hash written by a version a build doesn't understand says nothing about
|
||||
/// the content, and the caller needs to be able to tell that apart from a hash
|
||||
/// that says "different". Bump it whenever the stripping or the canonical form
|
||||
/// changes.
|
||||
pub const CONTENT_HASH_VERSION: &str = "v1:";
|
||||
|
||||
/// A stable hash of a document's content.
|
||||
pub fn content_hash(document: &Value) -> Result<String> {
|
||||
let canonical = serde_json::to_string(&sorted_keys(document.clone()))?;
|
||||
Ok(format!("{CONTENT_HASH_VERSION}{:x}", Sha256::digest(canonical.as_bytes())))
|
||||
}
|
||||
|
||||
/// Whether a stored hash was written by an algorithm this build understands.
|
||||
pub fn hash_is_readable(hash: &str) -> bool {
|
||||
hash.starts_with(CONTENT_HASH_VERSION)
|
||||
}
|
||||
|
||||
/// Rebuild every object with its keys in sorted order.
|
||||
///
|
||||
/// Serializing straight from the input would not do: whether
|
||||
/// `serde_json::Map` preserves insertion order or sorts is a workspace-wide
|
||||
/// feature decision — `preserve_order` is on in some builds of this workspace
|
||||
/// and off in others — and a document read back from SQLite has whatever order
|
||||
/// it was written in. Sorting first makes the hash depend on the content and
|
||||
/// nothing else, in every build.
|
||||
fn sorted_keys(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let mut entries = object.into_iter().collect::<Vec<_>>();
|
||||
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
Value::Object(entries.into_iter().map(|(k, v)| (k, sorted_keys(v))).collect())
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.into_iter().map(sorted_keys).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// The hash has to survive a round trip through SQLite, which stores a
|
||||
/// document as text and hands back whatever order it was written in. It
|
||||
/// also has to survive `serde_json`'s `preserve_order` feature being on in
|
||||
/// one build of the workspace and off in another.
|
||||
#[test]
|
||||
fn key_order_does_not_change_the_hash() {
|
||||
let a: Value = serde_json::from_str(r#"{"url":"a","method":"GET"}"#).unwrap();
|
||||
let b: Value = serde_json::from_str(r#"{"method":"GET","url":"a"}"#).unwrap();
|
||||
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_order_does_not_change_the_hash_when_nested() {
|
||||
let a: Value =
|
||||
serde_json::from_str(r#"{"body":{"text":"x","type":"json"},"headers":[{"a":1,"b":2}]}"#)
|
||||
.unwrap();
|
||||
let b: Value =
|
||||
serde_json::from_str(r#"{"headers":[{"b":2,"a":1}],"body":{"type":"json","text":"x"}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
/// Sorting keys must not make different documents collide.
|
||||
#[test]
|
||||
fn array_order_still_changes_the_hash() {
|
||||
let a: Value = serde_json::from_str(r#"{"headers":[{"n":"a"},{"n":"b"}]}"#).unwrap();
|
||||
let b: Value = serde_json::from_str(r#"{"headers":[{"n":"b"},{"n":"a"}]}"#).unwrap();
|
||||
assert_ne!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_carry_a_readable_version() {
|
||||
let hash = content_hash(&json!({"url": "a"})).unwrap();
|
||||
assert!(hash_is_readable(&hash));
|
||||
assert!(!hash_is_readable("v99:deadbeef"));
|
||||
assert!(!hash_is_readable("deadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_keys_leaves_everything_else_alone() {
|
||||
let stripped = without_keys(json!({"model": "http_request", "url": "a"}), IDENTITY_KEYS);
|
||||
let object = stripped.as_object().unwrap();
|
||||
assert!(!object.contains_key("model"));
|
||||
assert_eq!(object.get("url").unwrap(), "a");
|
||||
}
|
||||
|
||||
/// The editor writes row ids into headers and parameters the first time it
|
||||
/// touches a request, so nested ids have to go or that reads as an edit.
|
||||
#[test]
|
||||
fn strip_ids_reaches_nested_rows() {
|
||||
let with_ids = json!({
|
||||
"id": "rq_1",
|
||||
"url": "a",
|
||||
"headers": [{"id": "h_1", "name": "Accept", "value": "*/*"}],
|
||||
});
|
||||
let without = json!({
|
||||
"url": "a",
|
||||
"headers": [{"name": "Accept", "value": "*/*"}],
|
||||
});
|
||||
assert_eq!(strip_ids(with_ids.clone()), strip_ids(without.clone()));
|
||||
assert_eq!(
|
||||
content_hash(&strip_ids(with_ids)).unwrap(),
|
||||
content_hash(&strip_ids(without)).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,8 @@ use yaak_database::SqlitePool;
|
||||
|
||||
pub mod blob_manager;
|
||||
pub mod client_db;
|
||||
pub mod content;
|
||||
pub mod cookies;
|
||||
mod connection_or_tx;
|
||||
pub mod cookies;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod models;
|
||||
@@ -22,7 +21,6 @@ pub mod queries;
|
||||
pub mod query_manager;
|
||||
pub mod render;
|
||||
pub mod util;
|
||||
pub mod versions;
|
||||
|
||||
/// Per-connection setup, applied by every pool on every connection it opens.
|
||||
fn init_connection(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
|
||||
@@ -1539,8 +1539,6 @@ pub struct WebsocketConnection {
|
||||
pub state: WebsocketConnectionState,
|
||||
pub status: i32,
|
||||
pub url: String,
|
||||
/// The request version this connection was opened from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for WebsocketConnection {
|
||||
@@ -1580,7 +1578,6 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
(State, serde_json::to_value(&self.state)?.as_str().into()),
|
||||
(Status, self.status.into()),
|
||||
(Url, self.url.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1593,7 +1590,6 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
WebsocketConnectionIden::State,
|
||||
WebsocketConnectionIden::Status,
|
||||
WebsocketConnectionIden::Url,
|
||||
WebsocketConnectionIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1616,7 +1612,6 @@ impl UpsertModelInfo for WebsocketConnection {
|
||||
error: row.get("error")?,
|
||||
state: serde_json::from_str(format!(r#""{state}""#).as_str()).unwrap(),
|
||||
status: row.get("status")?,
|
||||
version_id: row.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1970,8 +1965,6 @@ pub struct HttpResponse {
|
||||
pub state: HttpResponseState,
|
||||
pub url: String,
|
||||
pub version: Option<String>,
|
||||
/// The request version this response was sent from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for HttpResponse {
|
||||
@@ -2021,7 +2014,6 @@ impl UpsertModelInfo for HttpResponse {
|
||||
(Url, self.url.into()),
|
||||
(Version, self.version.into()),
|
||||
(RequestContentLength, self.request_content_length.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2044,7 +2036,6 @@ impl UpsertModelInfo for HttpResponse {
|
||||
HttpResponseIden::StatusReason,
|
||||
HttpResponseIden::Url,
|
||||
HttpResponseIden::Version,
|
||||
HttpResponseIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2080,7 +2071,6 @@ impl UpsertModelInfo for HttpResponse {
|
||||
r.get::<_, String>("request_headers").unwrap_or_default().as_str(),
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
version_id: r.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2526,8 +2516,6 @@ pub struct GrpcConnection {
|
||||
pub state: GrpcConnectionState,
|
||||
pub trailers: BTreeMap<String, String>,
|
||||
pub url: String,
|
||||
/// The request version this connection was opened from, when one was captured.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for GrpcConnection {
|
||||
@@ -2569,7 +2557,6 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
(Error, self.error.as_ref().map(|s| s.as_str()).into()),
|
||||
(Trailers, serde_json::to_string(&self.trailers)?.into()),
|
||||
(Url, self.url.into()),
|
||||
(VersionId, self.version_id.into()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2584,7 +2571,6 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
GrpcConnectionIden::Error,
|
||||
GrpcConnectionIden::Trailers,
|
||||
GrpcConnectionIden::Url,
|
||||
GrpcConnectionIden::VersionId,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2609,7 +2595,6 @@ impl UpsertModelInfo for GrpcConnection {
|
||||
url: row.get("url")?,
|
||||
error: row.get("error")?,
|
||||
trailers: serde_json::from_str(trailers.as_str()).unwrap_or_default(),
|
||||
version_id: row.get("version_id").unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3133,8 +3118,12 @@ pub struct ImportSourceResource {
|
||||
pub import_source_id: String,
|
||||
pub source_key: String,
|
||||
pub model_type: String,
|
||||
pub model_id: String,
|
||||
pub snapshot: 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 {
|
||||
@@ -3149,159 +3138,11 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
|
||||
source_key: r.get("source_key")?,
|
||||
model_type: r.get("model_type")?,
|
||||
model_id: r.get("model_id")?,
|
||||
snapshot: r.get("snapshot")?,
|
||||
content_hash: r.get("content_hash")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a version was captured. Not a UI label — the frontend decides how to
|
||||
/// phrase these — but it is what makes a history readable when debugging.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub enum ModelVersionReason {
|
||||
Send,
|
||||
Switch,
|
||||
Idle,
|
||||
Restore,
|
||||
/// Reserved: an explicit "save a version now" action, which has no UI yet.
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Default for ModelVersionReason {
|
||||
fn default() -> Self {
|
||||
Self::Manual
|
||||
}
|
||||
}
|
||||
|
||||
/// A point-in-time copy of one request's editable content.
|
||||
///
|
||||
/// Versions are content-addressed: `content_hash` covers exactly what
|
||||
/// `document` holds, and `(model_id, content_hash)` is unique, so capturing the
|
||||
/// same content twice returns the row that already exists. That is what lets
|
||||
/// every send snapshot unconditionally without growing the table.
|
||||
///
|
||||
/// Deliberately absent from [`AnyModel`]: versions are local history. They are
|
||||
/// not synced, not exported, and not mirrored into the frontend's model store —
|
||||
/// the frontend asks for the one version it needs to show.
|
||||
impl Default for ModelVersion {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "model_version".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
model_type: String::new(),
|
||||
model_id: String::new(),
|
||||
content_hash: String::new(),
|
||||
document: Value::Object(Default::default()),
|
||||
reason: ModelVersionReason::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "model_versions")]
|
||||
pub struct ModelVersion {
|
||||
#[ts(type = "\"model_version\"")]
|
||||
pub model: String,
|
||||
pub id: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub workspace_id: String,
|
||||
|
||||
/// The `model` field of the versioned model, eg. `http_request`.
|
||||
pub model_type: String,
|
||||
pub model_id: String,
|
||||
pub content_hash: String,
|
||||
#[ts(type = "Record<string, any>")]
|
||||
pub document: Value,
|
||||
pub reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
impl UpsertModelInfo for ModelVersion {
|
||||
fn table_name() -> impl IntoTableRef + IntoIden {
|
||||
ModelVersionIden::Table
|
||||
}
|
||||
|
||||
fn id_column() -> impl IntoIden + Eq + Clone {
|
||||
ModelVersionIden::Id
|
||||
}
|
||||
|
||||
fn generate_id() -> String {
|
||||
generate_prefixed_id("mv")
|
||||
}
|
||||
|
||||
fn order_by() -> (impl IntoColumnRef, Order) {
|
||||
(ModelVersionIden::CreatedAt, Desc)
|
||||
}
|
||||
|
||||
fn get_id(&self) -> String {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn insert_values(
|
||||
self,
|
||||
source: &UpdateSource,
|
||||
) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
|
||||
use ModelVersionIden::*;
|
||||
Ok(vec![
|
||||
(CreatedAt, upsert_date(source, self.created_at)),
|
||||
(UpdatedAt, upsert_date(source, self.updated_at)),
|
||||
(WorkspaceId, self.workspace_id.into()),
|
||||
(ModelType, self.model_type.into()),
|
||||
(ModelId, self.model_id.into()),
|
||||
(ContentHash, self.content_hash.into()),
|
||||
(Document, serde_json::to_string(&self.document)?.into()),
|
||||
(Reason, serde_json::to_value(self.reason)?.as_str().into()),
|
||||
])
|
||||
}
|
||||
|
||||
fn update_columns() -> Vec<impl IntoIden> {
|
||||
vec![ModelVersionIden::UpdatedAt]
|
||||
}
|
||||
|
||||
fn from_row(row: &Row) -> rusqlite::Result<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let document: String = row.get("document")?;
|
||||
let reason: String = row.get("reason")?;
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
model: row.get("model")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
workspace_id: row.get("workspace_id")?,
|
||||
model_type: row.get("model_type")?,
|
||||
model_id: row.get("model_id")?,
|
||||
content_hash: row.get("content_hash")?,
|
||||
document: serde_json::from_str(&document).unwrap_or_default(),
|
||||
reason: serde_json::from_str(format!(r#""{reason}""#).as_str()).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One version, next to the request as it stands now.
|
||||
///
|
||||
/// Both halves come from the same place so they are guaranteed comparable: the
|
||||
/// frontend renders them side by side, and `differs` is the same content-hash
|
||||
/// comparison the backend uses everywhere else rather than a second opinion
|
||||
/// formed in TypeScript.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct RequestVersionComparison {
|
||||
pub version: ModelVersion,
|
||||
/// The live request's editable content, in the same shape as the version's document.
|
||||
#[ts(type = "Record<string, any>")]
|
||||
pub current_document: Value,
|
||||
pub differs: bool,
|
||||
}
|
||||
|
||||
/// Only used as a `from_row` fallback for an unparseable settings column. The
|
||||
/// value a *new* model gets comes from that model's `Default` impl.
|
||||
fn default_request_message_size_setting() -> InheritedIntSetting {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
use serde_json::Value;
|
||||
|
||||
pub enum AnyRequest {
|
||||
HttpRequest(HttpRequest),
|
||||
@@ -9,36 +8,6 @@ pub enum AnyRequest {
|
||||
WebsocketRequest(WebsocketRequest),
|
||||
}
|
||||
|
||||
/// Run an expression against whichever request this is, bound as `$request`.
|
||||
macro_rules! with_request {
|
||||
($self:expr, |$request:ident| $body:expr) => {
|
||||
match $self {
|
||||
AnyRequest::HttpRequest($request) => $body,
|
||||
AnyRequest::GrpcRequest($request) => $body,
|
||||
AnyRequest::WebsocketRequest($request) => $body,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl AnyRequest {
|
||||
pub fn id(&self) -> &str {
|
||||
with_request!(self, |request| &request.id)
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
with_request!(self, |request| &request.workspace_id)
|
||||
}
|
||||
|
||||
/// The model name, eg. `http_request`.
|
||||
pub fn model_type(&self) -> &str {
|
||||
with_request!(self, |request| &request.model)
|
||||
}
|
||||
|
||||
pub fn to_value(&self) -> Result<Value> {
|
||||
Ok(with_request!(self, |request| serde_json::to_value(request)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
|
||||
if let Ok(http_request) = self.get_http_request(id) {
|
||||
|
||||
@@ -38,7 +38,6 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<GrpcRequest> {
|
||||
self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(m.id.as_str())?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<HttpRequest> {
|
||||
self.delete_all_http_responses_for_request(m.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(m.id.as_str())?;
|
||||
self.delete(m, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,7 @@ impl<'a> ClientDb<'a> {
|
||||
let Some(response_id) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some()
|
||||
{
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
|
||||
continue;
|
||||
}
|
||||
if fs::remove_file(&path).is_ok() {
|
||||
|
||||
@@ -34,7 +34,7 @@ impl<'a> ClientDb<'a> {
|
||||
ImportSourceResourceIden::SourceKey,
|
||||
ImportSourceResourceIden::ModelType,
|
||||
ImportSourceResourceIden::ModelId,
|
||||
ImportSourceResourceIden::Snapshot,
|
||||
ImportSourceResourceIden::ContentHash,
|
||||
])
|
||||
.values_panic([
|
||||
CurrentTimestamp.into(),
|
||||
@@ -42,8 +42,8 @@ impl<'a> ClientDb<'a> {
|
||||
resource.import_source_id.as_str().into(),
|
||||
resource.source_key.as_str().into(),
|
||||
resource.model_type.as_str().into(),
|
||||
resource.model_id.as_str().into(),
|
||||
resource.snapshot.as_str().into(),
|
||||
resource.model_id.clone().into(),
|
||||
resource.content_hash.clone().into(),
|
||||
])
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
@@ -54,7 +54,7 @@ impl<'a> ClientDb<'a> {
|
||||
ImportSourceResourceIden::UpdatedAt,
|
||||
ImportSourceResourceIden::ModelType,
|
||||
ImportSourceResourceIden::ModelId,
|
||||
ImportSourceResourceIden::Snapshot,
|
||||
ImportSourceResourceIden::ContentHash,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ mod import_source_resources;
|
||||
mod import_sources;
|
||||
mod key_values;
|
||||
mod model_changes;
|
||||
mod model_versions;
|
||||
mod plugin_key_values;
|
||||
mod plugins;
|
||||
mod settings;
|
||||
@@ -25,8 +24,8 @@ mod websocket_events;
|
||||
mod websocket_requests;
|
||||
mod workspace_metas;
|
||||
pub mod workspaces;
|
||||
pub use model_changes::PersistedModelChange;
|
||||
pub(crate) use duplicate_name::conflict_free_name;
|
||||
pub use model_changes::PersistedModelChange;
|
||||
|
||||
const MAX_HISTORY_ITEMS: usize = 20;
|
||||
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
GrpcRequest, HttpRequest, ModelVersion, ModelVersionIden, ModelVersionReason, UpsertModelInfo,
|
||||
WebsocketRequest,
|
||||
};
|
||||
use crate::queries::any_request::AnyRequest;
|
||||
use crate::util::UpdateSource;
|
||||
use crate::content::content_hash;
|
||||
use crate::versions::{apply_version_document, version_document};
|
||||
use log::warn;
|
||||
use sea_query::{Expr, ExprTrait, Query, SqliteQueryBuilder};
|
||||
use sea_query_rusqlite::RusqliteBinder;
|
||||
|
||||
/// Unreferenced versions older than this are dropped.
|
||||
const RETENTION_DAYS: i64 = 30;
|
||||
|
||||
/// How many unreferenced versions a request keeps, newest first.
|
||||
const RETENTION_COUNT: i64 = 50;
|
||||
|
||||
impl<'a> ClientDb<'a> {
|
||||
pub fn get_model_version(&self, id: &str) -> Result<ModelVersion> {
|
||||
self.find_one(ModelVersionIden::Id, id)
|
||||
}
|
||||
|
||||
/// Every version of one model, newest first.
|
||||
pub fn list_model_versions(&self, model_id: &str) -> Result<Vec<ModelVersion>> {
|
||||
self.find_many(ModelVersionIden::ModelId, model_id, None)
|
||||
}
|
||||
|
||||
/// Capture a request's current content, or return the version that already
|
||||
/// holds it.
|
||||
///
|
||||
/// The single entry point for creating versions. Callers do not check
|
||||
/// whether anything changed first — that is what content addressing is for,
|
||||
/// and it is why a send, a window blur and an idle timer can all call this
|
||||
/// on the same unedited request and leave one row behind.
|
||||
pub fn snapshot_request(
|
||||
&self,
|
||||
request: &AnyRequest,
|
||||
reason: ModelVersionReason,
|
||||
) -> Result<ModelVersion> {
|
||||
let document = version_document(&request.to_value()?)?;
|
||||
let content_hash = content_hash(&document)?;
|
||||
|
||||
if let Some(existing) = self.find_version_by_hash(request.id(), &content_hash) {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let version = self.upsert_untracked(&ModelVersion {
|
||||
workspace_id: request.workspace_id().to_string(),
|
||||
model_type: request.model_type().to_string(),
|
||||
model_id: request.id().to_string(),
|
||||
content_hash: content_hash.clone(),
|
||||
document,
|
||||
reason,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let version = match version {
|
||||
Ok(version) => version,
|
||||
// Two sends of the same request can both miss the lookup above and
|
||||
// race to insert. The unique index settles it, and the loser wants
|
||||
// exactly what the winner wrote.
|
||||
Err(err) => match self.find_version_by_hash(request.id(), &content_hash) {
|
||||
Some(existing) => return Ok(existing),
|
||||
None => return Err(err),
|
||||
},
|
||||
};
|
||||
|
||||
self.prune_model_versions(request.id())?;
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
pub fn snapshot_request_by_id(
|
||||
&self,
|
||||
request_id: &str,
|
||||
reason: ModelVersionReason,
|
||||
) -> Result<ModelVersion> {
|
||||
self.snapshot_request(&self.get_any_request(request_id)?, reason)
|
||||
}
|
||||
|
||||
/// What every send calls: capture the request, and don't make a fuss.
|
||||
///
|
||||
/// A send is not worth failing over history that couldn't be written, and
|
||||
/// a request with no id is ephemeral and has nothing to version. Either way
|
||||
/// the response just has no version to offer.
|
||||
pub fn snapshot_request_for_send(&self, request: &AnyRequest) -> Option<String> {
|
||||
if request.id().is_empty() {
|
||||
return None;
|
||||
}
|
||||
match self.snapshot_request(request, ModelVersionReason::Send) {
|
||||
Ok(version) => Some(version.id),
|
||||
Err(err) => {
|
||||
warn!("Failed to snapshot request before send: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a version's content back over the live request.
|
||||
///
|
||||
/// Anything the live request has picked up since its last version is
|
||||
/// captured first, so a restore is never the thing that loses an edit. The
|
||||
/// content being written already has a version — the one being restored —
|
||||
/// so this leaves no new row behind.
|
||||
pub fn restore_request_version(
|
||||
&self,
|
||||
version_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<AnyRequest> {
|
||||
let version = self.get_model_version(version_id)?;
|
||||
let live = self.get_any_request(&version.model_id)?;
|
||||
self.snapshot_request(&live, ModelVersionReason::Restore)?;
|
||||
|
||||
let restored = apply_version_document(&live.to_value()?, &version.document);
|
||||
Ok(match live {
|
||||
AnyRequest::HttpRequest(_) => AnyRequest::HttpRequest(
|
||||
self.upsert_http_request(&serde_json::from_value::<HttpRequest>(restored)?, source)?,
|
||||
),
|
||||
AnyRequest::GrpcRequest(_) => AnyRequest::GrpcRequest(
|
||||
self.upsert_grpc_request(&serde_json::from_value::<GrpcRequest>(restored)?, source)?,
|
||||
),
|
||||
AnyRequest::WebsocketRequest(_) => AnyRequest::WebsocketRequest(
|
||||
self.upsert_websocket_request(
|
||||
&serde_json::from_value::<WebsocketRequest>(restored)?,
|
||||
source,
|
||||
)?,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a request's content has moved on from a given version.
|
||||
pub fn request_matches_version(&self, version: &ModelVersion) -> Result<bool> {
|
||||
let live = self.get_any_request(&version.model_id)?;
|
||||
let hash = content_hash(&version_document(&live.to_value()?)?)?;
|
||||
Ok(hash == version.content_hash)
|
||||
}
|
||||
|
||||
pub fn delete_model_versions_for_model(&self, model_id: &str) -> Result<usize> {
|
||||
self.delete_many_untracked::<ModelVersion>(ModelVersionIden::ModelId, model_id)
|
||||
}
|
||||
|
||||
/// Drop the versions a request no longer needs.
|
||||
///
|
||||
/// A version referenced by a response outlives retention entirely — the
|
||||
/// point of the feature is that an old response can still show what sent
|
||||
/// it. Everything else is history the user has not asked to keep, and
|
||||
/// survives only while it is both recent and among the newest few.
|
||||
pub fn prune_model_versions(&self, model_id: &str) -> Result<usize> {
|
||||
let cutoff = format!("-{RETENTION_DAYS} days");
|
||||
let sql = r#"
|
||||
DELETE FROM model_versions
|
||||
WHERE model_id = ?1
|
||||
AND id NOT IN (
|
||||
SELECT version_id FROM http_responses WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
UNION
|
||||
SELECT version_id FROM grpc_connections WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
UNION
|
||||
SELECT version_id FROM websocket_connections WHERE request_id = ?1 AND version_id IS NOT NULL
|
||||
)
|
||||
AND (
|
||||
created_at < datetime('now', ?2)
|
||||
OR id NOT IN (
|
||||
SELECT id FROM model_versions WHERE model_id = ?1
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?3
|
||||
)
|
||||
)
|
||||
"#;
|
||||
Ok(self.conn().execute(sql, rusqlite::params![model_id, cutoff, RETENTION_COUNT])?)
|
||||
}
|
||||
|
||||
fn find_version_by_hash(&self, model_id: &str, content_hash: &str) -> Option<ModelVersion> {
|
||||
let (sql, params) = Query::select()
|
||||
.from(ModelVersionIden::Table)
|
||||
.column(sea_query::Asterisk)
|
||||
.cond_where(
|
||||
Expr::col(ModelVersionIden::ModelId)
|
||||
.eq(model_id)
|
||||
.and(Expr::col(ModelVersionIden::ContentHash).eq(content_hash)),
|
||||
)
|
||||
.build_rusqlite(SqliteQueryBuilder);
|
||||
let mut stmt = self.conn().prepare(sql.as_str()).ok()?;
|
||||
stmt.query_row(&*params.as_params(), ModelVersion::from_row).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpResponse, Workspace};
|
||||
|
||||
fn source() -> UpdateSource {
|
||||
UpdateSource::Background
|
||||
}
|
||||
|
||||
fn seed(db: &ClientDb) -> (Workspace, HttpRequest) {
|
||||
let workspace = db
|
||||
.upsert_workspace(&Workspace { name: "Versions".to_string(), ..Default::default() }, &source())
|
||||
.expect("Failed to upsert workspace");
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Original".to_string(),
|
||||
url: "https://example.com/one".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
(workspace, request)
|
||||
}
|
||||
|
||||
fn snapshot(db: &ClientDb, request_id: &str, reason: ModelVersionReason) -> ModelVersion {
|
||||
db.snapshot_request_by_id(request_id, reason).expect("Failed to snapshot")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshotting_unchanged_content_reuses_the_same_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let first = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
let second = snapshot(&db, &request.id, ModelVersionReason::Idle);
|
||||
let third = snapshot(&db, &request.id, ModelVersionReason::Switch);
|
||||
|
||||
assert_eq!(first.id, second.id);
|
||||
assert_eq!(first.id, third.id);
|
||||
// The first capture's reason is the one that sticks; a version is its content
|
||||
assert_eq!(second.reason, ModelVersionReason::Send);
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookkeeping_writes_do_not_mint_a_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let first = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
|
||||
let folder = db
|
||||
.upsert_folder(
|
||||
&crate::models::Folder {
|
||||
workspace_id: request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
folder_id: Some(folder.id),
|
||||
sort_priority: 42.0,
|
||||
..db.get_http_request(&request.id).unwrap()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(snapshot(&db, &request.id, ModelVersionReason::Idle).id, first.id);
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// The pair editor writes a generated `id` into every header row the first
|
||||
/// time it touches a request, and that write reaches the database like any
|
||||
/// other. Without nested id stripping, merely opening a request would mint
|
||||
/// a version whose diff is nothing but ids.
|
||||
#[test]
|
||||
fn row_ids_written_by_the_editor_do_not_mint_a_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let header = |id: Option<&str>| crate::models::HttpRequestHeader {
|
||||
name: "Accept".to_string(),
|
||||
value: "application/json".to_string(),
|
||||
id: id.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let request = db
|
||||
.upsert_http_request(&HttpRequest { headers: vec![header(None)], ..request }, &source())
|
||||
.unwrap();
|
||||
let first = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
|
||||
// Opening the request in the editor fills the row id in
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { headers: vec![header(Some("row_generated"))], ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(snapshot(&db, &request.id, ModelVersionReason::Idle).id, first.id);
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
|
||||
// A real edit to the same row still counts
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
headers: vec![crate::models::HttpRequestHeader {
|
||||
value: "text/plain".to_string(),
|
||||
..header(Some("row_generated"))
|
||||
}],
|
||||
..request.clone()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(snapshot(&db, &request.id, ModelVersionReason::Idle).id, first.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_content_mints_a_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
snapshot(&db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_writes_the_old_content_back_without_a_new_version() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let original = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
url: "https://example.com/two".to_string(),
|
||||
name: "Edited".to_string(),
|
||||
..request.clone()
|
||||
},
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
let edited = snapshot(&db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
db.restore_request_version(&original.id, &source()).expect("Failed to restore");
|
||||
|
||||
let live = db.get_http_request(&request.id).unwrap();
|
||||
assert_eq!(live.url, "https://example.com/one");
|
||||
assert_eq!(live.name, "Original");
|
||||
assert_eq!(live.id, request.id);
|
||||
|
||||
// The restored content already had a version, and the edit it replaced
|
||||
// still has its own, so nothing new appears
|
||||
let versions = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert!(versions.iter().any(|v| v.id == original.id));
|
||||
assert!(versions.iter().any(|v| v.id == edited.id));
|
||||
}
|
||||
|
||||
/// The case restore exists to be safe for: an edit that was never captured.
|
||||
#[test]
|
||||
fn restoring_captures_uncaptured_edits_first() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let original = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/unsaved".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
db.restore_request_version(&original.id, &source()).expect("Failed to restore");
|
||||
|
||||
let versions = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(versions.len(), 2);
|
||||
let rescued = versions.iter().find(|v| v.id != original.id).unwrap();
|
||||
assert_eq!(rescued.reason, ModelVersionReason::Restore);
|
||||
assert_eq!(rescued.document.get("url").unwrap(), "https://example.com/unsaved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_matches_version_tracks_the_live_content() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let version = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
assert!(db.request_matches_version(&version).unwrap());
|
||||
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!db.request_matches_version(&version).unwrap());
|
||||
}
|
||||
|
||||
/// Write `count` distinct versions by walking the request's URL forward.
|
||||
fn make_versions(db: &ClientDb, request: &HttpRequest, count: usize) -> Vec<ModelVersion> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: format!("https://example.com/{i}"), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
snapshot(db, &request.id, ModelVersionReason::Idle)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreferenced_versions_are_pruned_to_the_newest_fifty() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let versions = make_versions(&db, &request, RETENTION_COUNT as usize + 10);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(kept.len(), RETENTION_COUNT as usize);
|
||||
// The oldest went first
|
||||
assert!(!kept.iter().any(|v| v.id == versions[0].id));
|
||||
assert!(kept.iter().any(|v| v.id == versions.last().unwrap().id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_referenced_version_survives_retention() {
|
||||
let (query_manager, blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (workspace, request) = seed(&db);
|
||||
|
||||
let pinned = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
version_id: Some(pinned.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
&source(),
|
||||
&blobs,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
make_versions(&db, &request, RETENTION_COUNT as usize + 10);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert!(
|
||||
kept.iter().any(|v| v.id == pinned.id),
|
||||
"a version a response points at must outlive retention",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreferenced_versions_expire_after_thirty_days() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
let old = snapshot(&db, &request.id, ModelVersionReason::Send);
|
||||
db.conn()
|
||||
.execute(
|
||||
"UPDATE model_versions SET created_at = datetime('now', '-31 days') WHERE id = ?1",
|
||||
rusqlite::params![old.id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Any later capture prunes
|
||||
db.upsert_http_request(
|
||||
&HttpRequest { url: "https://example.com/two".to_string(), ..request.clone() },
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
let fresh = snapshot(&db, &request.id, ModelVersionReason::Idle);
|
||||
|
||||
let kept = db.list_model_versions(&request.id).unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].id, fresh.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_request_deletes_its_versions() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let (_workspace, request) = seed(&db);
|
||||
|
||||
make_versions(&db, &request, 3);
|
||||
assert!(!db.list_model_versions(&request.id).unwrap().is_empty());
|
||||
|
||||
db.delete_http_request_by_id(&request.id, &source()).unwrap();
|
||||
assert!(db.list_model_versions(&request.id).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ impl<'a> ClientDb<'a> {
|
||||
source: &UpdateSource,
|
||||
) -> Result<WebsocketRequest> {
|
||||
self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;
|
||||
self.delete_model_versions_for_model(websocket_request.id.as_str())?;
|
||||
self.delete(websocket_request, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ use crate::models::{
|
||||
GraphQlIntrospection, GraphQlIntrospectionIden, GrpcConnection, GrpcConnectionIden, GrpcEvent,
|
||||
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
|
||||
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
|
||||
ImportSourceIden, ModelVersion, ModelVersionIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
|
||||
WebsocketConnection,
|
||||
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
|
||||
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
|
||||
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
|
||||
WebsocketConnection, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden,
|
||||
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta,
|
||||
WorkspaceMetaIden,
|
||||
};
|
||||
use crate::util::UpdateSource;
|
||||
use log::warn;
|
||||
@@ -90,7 +90,6 @@ impl<'a> ClientDb<'a> {
|
||||
self.delete_import_source_resources(&import_source.id)?;
|
||||
}
|
||||
self.delete_many_untracked::<ImportSource>(ImportSourceIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<ModelVersion>(ModelVersionIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
|
||||
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
|
||||
self.delete(workspace, source)
|
||||
|
||||
@@ -109,6 +109,28 @@ pub enum ImportDestination {
|
||||
pub struct ImportPlanWarning {
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
#[serde(default)]
|
||||
pub level: ImportPlanWarningLevel,
|
||||
}
|
||||
|
||||
/// Whether a plan's note is something to know or something to think twice about.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportPlanWarningLevel {
|
||||
#[default]
|
||||
Info,
|
||||
Warning,
|
||||
}
|
||||
|
||||
impl ImportPlanWarning {
|
||||
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Info }
|
||||
}
|
||||
|
||||
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Warning }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an import's contents came from, used to link the committed workspace back to it.
|
||||
@@ -169,6 +191,16 @@ pub enum ImportPlanAction {
|
||||
Unchanged,
|
||||
KeepLocal,
|
||||
Conflict,
|
||||
/// Present in the source but previously turned down; selecting it imports it again
|
||||
Ignored,
|
||||
}
|
||||
|
||||
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportPlanReason {
|
||||
MovedIntoIgnoredFolder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
@@ -193,6 +225,11 @@ pub struct ImportPlanItem {
|
||||
pub selected: bool,
|
||||
#[ts(optional)]
|
||||
pub resolution: Option<ImportConflictResolution>,
|
||||
#[ts(optional)]
|
||||
pub reason: Option<ImportPlanReason>,
|
||||
/// Fields where the source and the local copy disagree, so the preview can say why
|
||||
#[serde(default)]
|
||||
pub changed_fields: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
//! Request versioning's answer to "what is this request's content?"
|
||||
//!
|
||||
//! The mechanism lives in [`crate::content`], shared with import. What is
|
||||
//! decided here is versioning's own policy: placement is not content, and a
|
||||
//! restore lays a document back over the model it came from.
|
||||
|
||||
use crate::content::{IDENTITY_KEYS, PLACEMENT_KEYS, strip_ids, without_keys};
|
||||
use crate::error::Result;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// The editable content of a model, as the object a version stores.
|
||||
///
|
||||
/// Dropping [`PLACEMENT_KEYS`] as well as [`IDENTITY_KEYS`] is what makes a
|
||||
/// version stable: moving a request into a folder, dragging it up the sidebar,
|
||||
/// or simply saving it again rewrite those and nothing else, and none of them
|
||||
/// should mint a version or show up in a diff. `strip_ids` does the same job
|
||||
/// for the row ids the editor writes into headers and parameters — without it,
|
||||
/// opening a request would mint a version whose diff is nothing but ids.
|
||||
///
|
||||
/// One rule covers HTTP, gRPC and WebSocket, because the three differ only in
|
||||
/// the content fields, which are all kept.
|
||||
pub fn version_document<T: Serialize>(model: &T) -> Result<Value> {
|
||||
let stripped = [IDENTITY_KEYS, PLACEMENT_KEYS].concat();
|
||||
Ok(without_keys(strip_ids(serde_json::to_value(model)?), &stripped))
|
||||
}
|
||||
|
||||
/// Lay a version's document back over a live model.
|
||||
///
|
||||
/// Keys the document carries win; keys it doesn't mention keep whatever the
|
||||
/// live model has. That covers both halves of a restore: bookkeeping (id,
|
||||
/// folder, sort order) survives because the document never held it, and a field
|
||||
/// added to the model after the version was captured survives because the
|
||||
/// version predates it.
|
||||
pub fn apply_version_document(live: &Value, document: &Value) -> Value {
|
||||
let mut merged = live.as_object().cloned().unwrap_or_else(Map::new);
|
||||
if let Some(document) = document.as_object() {
|
||||
for (key, value) in document {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(merged)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content::content_hash;
|
||||
use crate::models::{HttpRequest, HttpRequestHeader};
|
||||
use chrono::Utc;
|
||||
|
||||
fn request() -> HttpRequest {
|
||||
HttpRequest {
|
||||
id: "rq_1".to_string(),
|
||||
workspace_id: "wk_1".to_string(),
|
||||
folder_id: Some("fl_1".to_string()),
|
||||
name: "Get user".to_string(),
|
||||
url: "https://example.com/users/1".to_string(),
|
||||
method: "GET".to_string(),
|
||||
sort_priority: 1.0,
|
||||
headers: vec![HttpRequestHeader {
|
||||
name: "Accept".to_string(),
|
||||
value: "application/json".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_of(request: &HttpRequest) -> String {
|
||||
content_hash(&version_document(request).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_holds_content_and_drops_bookkeeping() {
|
||||
let document = version_document(&request()).unwrap();
|
||||
let object = document.as_object().unwrap();
|
||||
|
||||
for key in [IDENTITY_KEYS, PLACEMENT_KEYS].concat() {
|
||||
assert!(!object.contains_key(key), "document should not carry {key}");
|
||||
}
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/users/1");
|
||||
assert_eq!(object.get("name").unwrap(), "Get user");
|
||||
assert_eq!(object.get("method").unwrap(), "GET");
|
||||
assert!(object.contains_key("headers"));
|
||||
assert!(object.contains_key("body"));
|
||||
assert!(object.contains_key("authentication"));
|
||||
assert!(object.contains_key("description"));
|
||||
assert!(object.contains_key("settingFollowRedirects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookkeeping_never_changes_the_hash() {
|
||||
let base = hash_of(&request());
|
||||
|
||||
let moved = HttpRequest { folder_id: Some("fl_2".to_string()), ..request() };
|
||||
assert_eq!(hash_of(&moved), base, "folder");
|
||||
|
||||
let resorted = HttpRequest { sort_priority: 99.5, ..request() };
|
||||
assert_eq!(hash_of(&resorted), base, "sort priority");
|
||||
|
||||
let touched =
|
||||
HttpRequest { updated_at: Utc::now().naive_utc(), created_at: Utc::now().naive_utc(), ..request() };
|
||||
assert_eq!(hash_of(&touched), base, "timestamps");
|
||||
|
||||
let renamed_id = HttpRequest { id: "rq_2".to_string(), ..request() };
|
||||
assert_eq!(hash_of(&renamed_id), base, "id");
|
||||
|
||||
let moved_workspace = HttpRequest { workspace_id: "wk_2".to_string(), ..request() };
|
||||
assert_eq!(hash_of(&moved_workspace), base, "workspace");
|
||||
}
|
||||
|
||||
/// The other half of the split documented on [`PLACEMENT_KEYS`]. Import
|
||||
/// counts a move between folders as a change; versioning must not, or
|
||||
/// dragging a request around the sidebar would mint versions nobody asked
|
||||
/// for.
|
||||
#[test]
|
||||
fn placement_is_not_content_here_even_though_import_says_it_is() {
|
||||
let base = version_document(&request()).unwrap();
|
||||
let moved =
|
||||
version_document(&HttpRequest { folder_id: Some("fl_2".into()), ..request() }).unwrap();
|
||||
let resorted =
|
||||
version_document(&HttpRequest { sort_priority: 99.5, ..request() }).unwrap();
|
||||
|
||||
assert_eq!(moved, base);
|
||||
assert_eq!(resorted, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editable_content_changes_the_hash() {
|
||||
let base = hash_of(&request());
|
||||
|
||||
assert_ne!(hash_of(&HttpRequest { url: "https://example.com/users/2".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { method: "POST".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { name: "Get other user".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { description: "Notes".into(), ..request() }), base);
|
||||
assert_ne!(hash_of(&HttpRequest { headers: vec![], ..request() }), base);
|
||||
assert_ne!(
|
||||
hash_of(&HttpRequest { body_type: Some("application/json".into()), ..request() }),
|
||||
base
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#[test]
|
||||
fn applying_a_document_keeps_the_live_model_identity() {
|
||||
let live = serde_json::to_value(request()).unwrap();
|
||||
let document = version_document(&HttpRequest {
|
||||
url: "https://example.com/users/2".to_string(),
|
||||
..request()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let merged = apply_version_document(&live, &document);
|
||||
let object = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/users/2");
|
||||
assert_eq!(object.get("id").unwrap(), "rq_1");
|
||||
assert_eq!(object.get("folderId").unwrap(), "fl_1");
|
||||
assert_eq!(object.get("sortPriority").unwrap(), 1.0);
|
||||
assert_eq!(object.get("model").unwrap(), "http_request");
|
||||
}
|
||||
|
||||
/// A version captured before a field existed must not blank that field out.
|
||||
#[test]
|
||||
fn applying_an_older_document_leaves_unknown_fields_alone() {
|
||||
let live = serde_json::to_value(request()).unwrap();
|
||||
let document = serde_json::json!({ "url": "https://example.com/old" });
|
||||
|
||||
let merged = apply_version_document(&live, &document);
|
||||
let object = merged.as_object().unwrap();
|
||||
|
||||
assert_eq!(object.get("url").unwrap(), "https://example.com/old");
|
||||
assert_eq!(object.get("method").unwrap(), "GET");
|
||||
assert_eq!(object.get("name").unwrap(), "Get user");
|
||||
}
|
||||
}
|
||||
-12
@@ -138,10 +138,6 @@ export type GrpcConnection = {
|
||||
state: GrpcConnectionState;
|
||||
trailers: { [key in string]?: string };
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type GrpcConnectionState = "initialized" | "connected" | "closed";
|
||||
@@ -246,10 +242,6 @@ export type HttpResponse = {
|
||||
state: HttpResponseState;
|
||||
url: string;
|
||||
version: string | null;
|
||||
/**
|
||||
* The request version this response was sent from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type HttpResponseEvent = {
|
||||
@@ -438,10 +430,6 @@ export type WebsocketConnection = {
|
||||
state: WebsocketConnectionState;
|
||||
status: number;
|
||||
url: string;
|
||||
/**
|
||||
* The request version this connection was opened from, when one was captured.
|
||||
*/
|
||||
versionId: string | null;
|
||||
};
|
||||
|
||||
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::error::Result;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use crate::models::SyncModel;
|
||||
use chrono::Utc;
|
||||
use log::{info, warn};
|
||||
@@ -11,6 +10,7 @@ use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{SyncState, WorkspaceMeta};
|
||||
use yaak_models::util::{UpdateSource, get_workspace_export_resources};
|
||||
|
||||
@@ -32,13 +32,12 @@ use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::cookies::apply_cookie_changes;
|
||||
use yaak_models::models::{
|
||||
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
|
||||
HttpSendSettings, ModelVersionReason, RequestVersionComparison,
|
||||
HttpSendSettings,
|
||||
};
|
||||
use yaak_models::models_ops;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::render_http_request;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_models::versions::version_document;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback};
|
||||
|
||||
/// Names inside the VFS, not paths on any disk. Two files because the desktop
|
||||
@@ -219,19 +218,6 @@ struct UpsertIntrospectionReq {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SnapshotRequestReq {
|
||||
request_id: String,
|
||||
reason: ModelVersionReason,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VersionIdReq {
|
||||
version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResponseIdReq {
|
||||
@@ -333,37 +319,6 @@ fn dispatch(
|
||||
to_json(id)
|
||||
}
|
||||
|
||||
"models_snapshot_request" => {
|
||||
let req: SnapshotRequestReq = from_js(payload)?;
|
||||
to_json(
|
||||
host.queries
|
||||
.connect()
|
||||
.snapshot_request_by_id(&req.request_id, req.reason)
|
||||
.map_err(js_error)?,
|
||||
)
|
||||
}
|
||||
|
||||
"models_request_version" => {
|
||||
let req: VersionIdReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
let version = db.get_model_version(&req.version_id).map_err(js_error)?;
|
||||
let request = db.get_any_request(&version.model_id).map_err(js_error)?;
|
||||
let current_document =
|
||||
version_document(&request.to_value().map_err(js_error)?).map_err(js_error)?;
|
||||
let differs = !db.request_matches_version(&version).map_err(js_error)?;
|
||||
to_json(RequestVersionComparison { version, current_document, differs })
|
||||
}
|
||||
|
||||
"models_restore_request_version" => {
|
||||
let req: VersionIdReq = from_js(payload)?;
|
||||
let restored = host
|
||||
.queries
|
||||
.connect()
|
||||
.restore_request_version(&req.version_id, source)
|
||||
.map_err(js_error)?;
|
||||
to_json(restored.id().to_string())
|
||||
}
|
||||
|
||||
"models_get_settings" => to_json(host.queries.connect().get_settings()),
|
||||
|
||||
"models_get_graphql_introspection" => {
|
||||
|
||||
@@ -9,6 +9,7 @@ async-trait = "0.1"
|
||||
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
||||
log = { workspace = true }
|
||||
md5 = "0.8.0"
|
||||
sha2 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
+1065
-215
File diff suppressed because it is too large.
Load diff
@@ -668,9 +668,10 @@ mod tests {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let store = FakeBodyStore { body: b"hello".to_vec(), reads: RefCell::new(Vec::new()) };
|
||||
|
||||
let info_payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
|
||||
GetHttpResponseBodyInfoRequest { response_id: "rs_test".to_string() },
|
||||
);
|
||||
let info_payload =
|
||||
InternalEventPayload::GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest {
|
||||
response_id: "rs_test".to_string(),
|
||||
});
|
||||
let info = handle_shared_plugin_event(
|
||||
&query_manager,
|
||||
&store,
|
||||
@@ -716,9 +717,10 @@ mod tests {
|
||||
#[test]
|
||||
fn an_unreadable_response_body_becomes_an_error_reply() {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
|
||||
GetHttpResponseBodyInfoRequest { response_id: "rs_never_persisted".to_string() },
|
||||
);
|
||||
let payload =
|
||||
InternalEventPayload::GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest {
|
||||
response_id: "rs_never_persisted".to_string(),
|
||||
});
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
@@ -727,7 +729,11 @@ mod tests {
|
||||
|
||||
match result {
|
||||
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(resp))) => {
|
||||
assert!(resp.error.contains("rs_never_persisted"), "unhelpful error: {}", resp.error)
|
||||
assert!(
|
||||
resp.error.contains("rs_never_persisted"),
|
||||
"unhelpful error: {}",
|
||||
resp.error
|
||||
)
|
||||
}
|
||||
other => panic!("unexpected missing-response result: {other:?}"),
|
||||
}
|
||||
|
||||
+3
-104
@@ -24,10 +24,9 @@ use yaak_http::types::{
|
||||
use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::models::{
|
||||
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
|
||||
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState,
|
||||
ProxySetting, ProxySettingAuth, ResolvedHttpRequestSettings,
|
||||
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting,
|
||||
ProxySettingAuth, ResolvedHttpRequestSettings,
|
||||
};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::render_http_request;
|
||||
use yaak_models::util::{UpdateSource, generate_prefixed_id};
|
||||
@@ -284,9 +283,6 @@ pub struct HttpSendInputs {
|
||||
/// Cookies the send starts with. The store is shared, so reading it back after the send
|
||||
/// returns (or fails) yields the cookies the transaction collected.
|
||||
pub cookie_store: Option<CookieStore>,
|
||||
/// The version holding the request's content as it was when this send was resolved,
|
||||
/// which the response will point at. `None` for an ephemeral request with no id.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a send writes its response. Without it, the send keeps everything in memory: no
|
||||
@@ -438,13 +434,6 @@ pub fn resolve_send_inputs(
|
||||
client_certificates: settings.client_certificates,
|
||||
},
|
||||
cookie_store: cookies.map(CookieStore::from_cookies),
|
||||
// Captured here rather than deeper in the send because this is the last place that
|
||||
// still holds the *stored* request: further down it has been resolved against its
|
||||
// folder and workspace and then rendered, and neither of those is what a restore
|
||||
// should put back. Every host reaches sending through this function — the desktop,
|
||||
// the CLI, plugin-triggered sends — so every response gets a version without each
|
||||
// of them remembering to ask for one.
|
||||
version_id: db.snapshot_request_for_send(&AnyRequest::HttpRequest(request.clone())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -592,8 +581,7 @@ pub async fn send_http_request_by_id<T: TemplateCallback>(
|
||||
pub async fn send_http_request<T: TemplateCallback>(
|
||||
params: SendHttpRequestParams<'_, T>,
|
||||
) -> Result<SendHttpRequestResult> {
|
||||
let HttpSendInputs { request, environment_chain, runtime_config, cookie_store, version_id } =
|
||||
params.inputs;
|
||||
let HttpSendInputs { request, environment_chain, runtime_config, cookie_store } = params.inputs;
|
||||
let (request, auth_context_id) = request.into_parts();
|
||||
let storage = params.storage;
|
||||
let send_options = runtime_config.send_options();
|
||||
@@ -631,7 +619,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
let mut response = params.existing_response.unwrap_or_default();
|
||||
response.request_id = request.id.clone();
|
||||
response.workspace_id = request.workspace_id.clone();
|
||||
response.version_id = version_id;
|
||||
response.request_content_length = request_content_length;
|
||||
response.request_headers = sendable_request
|
||||
.headers
|
||||
@@ -1358,7 +1345,6 @@ mod tests {
|
||||
client_certificates: Vec::new(),
|
||||
},
|
||||
cookie_store: Some(CookieStore::new()),
|
||||
version_id: None,
|
||||
},
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: None,
|
||||
@@ -1428,7 +1414,6 @@ mod tests {
|
||||
client_certificates: Vec::new(),
|
||||
},
|
||||
cookie_store: Some(CookieStore::new()),
|
||||
version_id: None,
|
||||
},
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: None,
|
||||
@@ -1481,92 +1466,6 @@ mod tests {
|
||||
(query_manager, cookie_jar, temp_dir)
|
||||
}
|
||||
|
||||
/// The whole point of the feature, end to end: a stored send must leave a
|
||||
/// response that can name the request behind it, and repeated sends of an
|
||||
/// unchanged request must all name the same one.
|
||||
#[tokio::test]
|
||||
async fn a_stored_send_links_the_request_version_that_produced_it() {
|
||||
let (query_manager, blob_manager, temp_dir) = seed_send_storage();
|
||||
let request = query_manager
|
||||
.connect()
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: "wk_test".to_string(),
|
||||
url: "http://localhost/test".to_string(),
|
||||
name: "Original".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to seed request");
|
||||
|
||||
let first = stored_send(&query_manager, &blob_manager, temp_dir.path(), &request).await;
|
||||
let second = stored_send(&query_manager, &blob_manager, temp_dir.path(), &request).await;
|
||||
|
||||
let version_id = first.version_id.clone().expect("a stored send must record a version");
|
||||
assert_eq!(second.version_id, Some(version_id.clone()), "an unchanged request is one version");
|
||||
|
||||
let db = query_manager.connect();
|
||||
let version = db.get_model_version(&version_id).expect("Failed to load version");
|
||||
assert_eq!(version.model_id, request.id);
|
||||
assert_eq!(version.document.get("url").unwrap(), "http://localhost/test");
|
||||
assert_eq!(db.list_model_versions(&request.id).unwrap().len(), 1);
|
||||
|
||||
// Editing after the fact is what the response pane has to be able to notice
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_http_request(&HttpRequest { name: "Edited".to_string(), ..request.clone() }, &UpdateSource::Sync)
|
||||
.expect("Failed to edit request");
|
||||
assert!(!db.request_matches_version(&version).expect("Failed to compare"));
|
||||
}
|
||||
|
||||
async fn stored_send(
|
||||
query_manager: &QueryManager,
|
||||
blob_manager: &BlobManager,
|
||||
response_dir: &std::path::Path,
|
||||
request: &HttpRequest,
|
||||
) -> HttpResponse {
|
||||
let executor = StubExecutor { body: b"hello world" };
|
||||
let inputs = resolve_send_inputs(query_manager, request, None, None)
|
||||
.expect("Failed to resolve send inputs");
|
||||
send_http_request(SendHttpRequestParams {
|
||||
inputs,
|
||||
template_callback: &NoopTemplateCallback,
|
||||
storage: Some(ResponseStorage {
|
||||
query_manager,
|
||||
blob_manager,
|
||||
update_source: UpdateSource::Sync,
|
||||
response_dir,
|
||||
}),
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
cancelled_rx: None,
|
||||
existing_response: None,
|
||||
prepare_sendable_request: None,
|
||||
executor: &executor,
|
||||
})
|
||||
.await
|
||||
.expect("send should succeed")
|
||||
.response
|
||||
}
|
||||
|
||||
fn seed_send_storage() -> (QueryManager, BlobManager, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
&temp_dir.path().join("db.sqlite"),
|
||||
&temp_dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.expect("Failed to initialize DB");
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(
|
||||
&Workspace { id: "wk_test".to_string(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.expect("Failed to seed workspace");
|
||||
(query_manager, blob_manager, temp_dir)
|
||||
}
|
||||
|
||||
fn cookie(name: &str) -> Cookie {
|
||||
Cookie {
|
||||
name: name.to_string(),
|
||||
|
||||
Generated
+1476
-913
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -130,12 +130,14 @@
|
||||
"tar": "^7.5.22",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9",
|
||||
"vite-plus": "^0.3.0",
|
||||
"vitest": "^4.1.10",
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@vitest/mocker": "^4.1.11",
|
||||
"js-yaml": "^4.3.1",
|
||||
"underscore": "^1.13.8",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9"
|
||||
},
|
||||
"packageManager": "npm@11.11.1"
|
||||
|
||||
@@ -63,10 +63,6 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
db.rpc("models_get_graphql_introspection", payload),
|
||||
models_upsert_graphql_introspection: (payload, db) =>
|
||||
db.rpc("models_upsert_graphql_introspection", payload),
|
||||
models_snapshot_request: (payload, db) => db.rpc("models_snapshot_request", payload),
|
||||
models_request_version: (payload, db) => db.rpc("models_request_version", payload),
|
||||
models_restore_request_version: (payload, db) =>
|
||||
db.rpc("models_restore_request_version", payload),
|
||||
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
|
||||
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
|
||||
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
|
||||
@@ -80,12 +76,7 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
cmd_send_http_request: (payload, db) => {
|
||||
const requestId = str(payload, "requestId");
|
||||
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
|
||||
return sendHttpRequest(
|
||||
db,
|
||||
requestId,
|
||||
str(payload, "environmentId"),
|
||||
str(payload, "cookieJarId"),
|
||||
);
|
||||
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
|
||||
},
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
@@ -271,16 +262,10 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: [
|
||||
"WebSocket requests aren't available in the browser yet",
|
||||
"websocket",
|
||||
],
|
||||
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
|
||||
// Anything that needs files the page can't reach.
|
||||
cmd_import_data: [
|
||||
"Importing from a file needs a filesystem, which a browser tab has no",
|
||||
"localFiles",
|
||||
],
|
||||
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
|
||||
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
|
||||
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
|
||||
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
||||
@@ -313,14 +298,8 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_template_tokens_to_string: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
@@ -30,7 +30,6 @@ import type {
|
||||
HttpResponse,
|
||||
HttpResponseEventData,
|
||||
HttpSendSettings,
|
||||
ModelVersion,
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { Frame, SendRequest } from "@yaakapp-internal/web";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
@@ -72,13 +71,7 @@ export async function sendHttpRequest(
|
||||
// a failure to render or to reach the server lands in the response pane as
|
||||
// that response's error rather than as a toast that names no request.
|
||||
const workspaceId = await workspaceIdOfRequest(db, requestId);
|
||||
const versionId = await snapshotRequestVersion(db, requestId);
|
||||
const response = new ResponseWriter(db, {
|
||||
model: "http_response",
|
||||
requestId,
|
||||
workspaceId,
|
||||
versionId,
|
||||
});
|
||||
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
|
||||
await response.create();
|
||||
|
||||
const cancel = new AbortController();
|
||||
@@ -95,29 +88,6 @@ export async function sendHttpRequest(
|
||||
return response.current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture what is about to be sent, so the response can offer it back later.
|
||||
* The desktop does this inside its send pipeline; this host's pipeline is here,
|
||||
* so this is where it goes. Versions are content-addressed, so repeated sends
|
||||
* of an unchanged request all point at the same one.
|
||||
*/
|
||||
async function snapshotRequestVersion(
|
||||
db: WorkerConnection,
|
||||
requestId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const version = await db.rpc<ModelVersion>("models_snapshot_request", {
|
||||
requestId,
|
||||
reason: "send",
|
||||
});
|
||||
return version.id;
|
||||
} catch (err) {
|
||||
// History is not worth failing a send over
|
||||
console.warn("Failed to snapshot request version", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSend(
|
||||
db: WorkerConnection,
|
||||
response: ResponseWriter,
|
||||
@@ -345,16 +315,8 @@ class TimelineWriter {
|
||||
* yaak-models), so an edit made while the send was in flight survives rather
|
||||
* than being written over by the send's stale snapshot.
|
||||
*/
|
||||
async function persistCookies(
|
||||
db: WorkerConnection,
|
||||
jar: CookieJar,
|
||||
cookies: Cookie[],
|
||||
): Promise<void> {
|
||||
await db.rpc("web_persist_send_cookies", {
|
||||
cookieJarId: jar.id,
|
||||
before: jar.cookies,
|
||||
after: cookies,
|
||||
});
|
||||
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> {
|
||||
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,6 @@
|
||||
"@types/node": "^24.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cpy-cli": "^5.0.0"
|
||||
"cpy-cli": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"test": "vp test --run tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^10.1.0"
|
||||
"@faker-js/faker": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
|
||||
@@ -142,6 +142,7 @@ exports[`template-function-faker > exports all expected template functions 1`] =
|
||||
"faker.location.longitude",
|
||||
"faker.location.nearbyGPSCoordinate",
|
||||
"faker.location.ordinalDirection",
|
||||
"faker.location.postalAddress",
|
||||
"faker.location.secondaryAddress",
|
||||
"faker.location.state",
|
||||
"faker.location.street",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^2.0.10",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"hono": "^4.12.34",
|
||||
"hono": "^4.13.5",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"dev": "yaakcli dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@
|
||||
"@1password/sdk": "^0.4.0-beta.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cpx2": "^8.0.0"
|
||||
"cpx2": "^9.0.0"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": "yaakcli dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user