Compare commits

..
Author SHA1 Message Date
dependabot[bot] c6e09a5af3 Bump vite-plus from 0.2.9 to 0.3.0 in the npm-development group (#632)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:56 -07:00
dependabot[bot] b05071ef4b Bump cpy-cli from 5.0.0 to 7.0.0 (#633)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:40 -07:00
dependabot[bot] a44f31eeb2 Bump cpx2 from 8.0.2 to 9.0.0 (#634)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:26 -07:00
dependabot[bot] 1103d05915 Bump zip from 4.0.0 to 7.0.0 (#637)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-11 08:17:15 -07:00
Gregory SchierandClaude Fable 5.1 2f5639a26f chore: cargo fmt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 19:35:33 -07:00
Gregory SchierandClaude Opus 5 7f8b7bf567 feat(import): remember the user's import selection (#619)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:26:43 -07:00
Gregory Schier 096e83a7bd chore(deps): patch open security advisories across npm deps (#630) 2026-09-10 14:34:00 -07:00
gschier 89ce84cefe Deploying to main from @ mountain-loop/yaak@d5d2a64c32 🚀 2026-09-10 15:37:06 +00:00
Gregory Schier d5d2a64c32 fix(cli): focus the agent skill description on when to use it (#625) 2026-09-09 08:50:26 -07:00
76 changed files with 3724 additions and 3501 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
npm-production:
dependency-type: production
update-types:
- minor
- patch
npm-development:
dependency-type: development
update-types:
- minor
- patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
cargo:
patterns:
- "*"
update-types:
- minor
- patch
Generated
+52 -28
View File
@@ -960,15 +960,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2"
version = "0.6.1"
@@ -978,16 +969,6 @@ dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "cairo-rs"
version = "0.18.5"
@@ -2114,7 +2095,7 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c169adf067a787e1f1c58ed62906a557de85388bee4b54fb878b722ff606b113"
dependencies = [
"bzip2 0.6.1",
"bzip2",
"clap",
"fs-err",
"indicatif",
@@ -2914,11 +2895,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
"wasm-bindgen",
]
[[package]]
@@ -4194,6 +4173,15 @@ dependencies = [
"value-bag",
]
[[package]]
name = "lzma-rust2"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619"
dependencies = [
"sha2",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -6174,6 +6162,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -8906,7 +8900,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -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]]
+1 -1
View File
@@ -17,7 +17,7 @@
<br>
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/rauchg"><img src="https:&#x2F;&#x2F;github.com&#x2F;rauchg.png" width="80px" alt="User avatar: rauchg" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<a href="https://github.com/PurplProto"><img src="https:&#x2F;&#x2F;github.com&#x2F;PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
-101
View File
@@ -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>
+196 -103
View File
@@ -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");
}
}
-50
View File
@@ -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",
});
})(),
);
}
-2
View File
@@ -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");
+1 -1
View File
@@ -99,6 +99,6 @@
"vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.2.9"
"vite-plus": "^0.3.0"
}
}
+1 -1
View File
@@ -32,6 +32,6 @@
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
"vite-plus": "^0.2.9"
"vite-plus": "^0.3.0"
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ tokio = { workspace = true, features = [
] }
walkdir = "2"
webbrowser = "1"
zip = "4"
zip = "7"
yaak = { workspace = true }
yaak-api = { workspace = true }
yaak-core = { workspace = true }
+7 -14
View File
@@ -1,19 +1,12 @@
---
name: use-yaak
description: >
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
folders, environments and variables, author HTTP requests, configure
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
send them individually or a whole folder/workspace at once, chain one
request's response into the next, and import existing APIs from OpenAPI,
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
API requests for reuse, to set up API requests for manual testing, to add auth
to a saved request, to turn an OpenAPI or Postman collection into runnable
requests, or to run a saved request suite against staging versus production.
Prefer this over one-off `curl` commands whenever the requests should be
saved, reused, shared, or run as a set.
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
requests for reuse or manual testing, configure auth on saved requests, import
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
saved requests across environments. Prefer over one-off `curl` when requests
should be saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
---
@@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry.
Two consequences worth holding onto. Requests you create are permanent user data
in an app they use, not scratch files, so name them the way the user would and
clean up anything created just to test. And because the app is right there, the
CLI is usually the wrong place to *read* a response in detail; it is the right
CLI is usually the wrong place to _read_ a response in detail; it is the right
place to build, organize, and run requests.
## The CLI describes itself
@@ -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"));
+2 -5
View File
@@ -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");
+1 -1
View File
@@ -13,7 +13,6 @@ use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
@@ -24,6 +23,7 @@ use yaak_models::models::Environment;
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,
@@ -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");
}
+2 -5
View File
@@ -145,11 +145,8 @@ async fn cache_control(req: Request, next: Next) -> Response {
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
let value = if hashed_name && !is_html {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
let value =
if hashed_name && !is_html { "public, max-age=31536000, immutable" } else { "no-cache" };
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
res
}
-1
View File
@@ -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 })
}
+3 -26
View File
@@ -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,
+1 -1
View File
@@ -4,5 +4,5 @@
//! `yaak-commands` when the template commands did. Callers in this crate do not
//! need to track which is which.
pub use yaak_models::render::{render_grpc_request, render_http_request};
pub use yaak_commands::render::{render_json_value, render_template};
pub use yaak_models::render::{render_grpc_request, render_http_request};
+474 -137
View File
@@ -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?)
}
+1 -10
View File
@@ -13,6 +13,7 @@ use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message;
use url::Url;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::CookieStore;
use yaak_http::path_placeholders::apply_path_placeholders;
@@ -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
-51
View File
@@ -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";
File diff suppressed because one or more lines are too long.
+82 -23
View File
@@ -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";
+6 -32
View File
@@ -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,
+5 -5
View File
@@ -13,8 +13,9 @@ pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
/// `dispatch` call frame, so it cannot borrow, and contexts are cheap clones
/// (handles and `Arc`s). Synchronous handlers wrap into this via `rpc_handler!`
/// with no visible change.
type HandlerFn<Ctx> =
Box<dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync>;
type HandlerFn<Ctx> = Box<
dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync,
>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
@@ -249,9 +250,8 @@ macro_rules! rpc_handler_async {
Box::new(|ctx, payload| {
Box::pin(async move {
let req = serde_json::from_value(payload).map_err($crate::RpcError::from)?;
let res = $f(ctx, req)
.await
.map_err(|e| $crate::RpcError { message: e.to_string() })?;
let res =
$f(ctx, req).await.map_err(|e| $crate::RpcError { message: e.to_string() })?;
serde_json::to_value(res).map_err($crate::RpcError::from)
})
})
+2 -39
View File
@@ -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,
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MethodDefinition = { name: string, schema: string, clientStreaming: boolean, serverStreaming: boolean, };
export type ServiceDefinition = { name: string, methods: Array<MethodDefinition>, };
+8 -53
View File
@@ -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";
+82 -23
View File
@@ -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;
-14
View File
@@ -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)?;
-185
View File
@@ -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(),
);
}
}
+1 -3
View File
@@ -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<()> {
+7 -166
View File
@@ -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(),
)
+1 -2
View File
@@ -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)
}
+4 -5
View File
@@ -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)
+37
View File
@@ -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)]
-180
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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};
+1 -46
View File
@@ -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" => {
+1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+13 -7
View File
@@ -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
View File
@@ -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(),
+1476 -913
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -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"
+5 -26
View File
@@ -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"],
+3 -41
View File
@@ -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 });
}
/**
+1 -1
View File
@@ -33,6 +33,6 @@
"@types/node": "^24.0.13"
},
"devDependencies": {
"cpy-cli": "^5.0.0"
"cpy-cli": "^7.0.0"
}
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
"dev": "yaakcli dev"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"@xmldom/xmldom": "^0.9.12",
"xpath": "^0.0.34"
}
}