Compare commits

...
Author SHA1 Message Date
Gregory SchierandGitHub 1f8fc00e7c Merge branch 'main' into worktree-perf 2026-08-02 08:07:00 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
077bfa87b8 Bump body-parser from 2.2.2 to 2.3.0 (#512)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:31:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
733f2e5929 Bump @hono/node-server from 1.19.14 to 2.0.10 (#513)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:04 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0f994f89ac Bump hono from 4.12.25 to 4.12.27 (#514)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
24f76398f9 Bump postcss from 8.5.16 to 8.5.25 (#522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:59 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
14475915df Bump shell-quote from 1.8.4 to 1.10.0 (#517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a50e04f565 Bump seroval from 1.4.2 to 1.5.6 (#518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:54 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
32fbd66912 Bump fast-uri from 3.1.2 to 3.1.5 (#521)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:52 -07:00
SuandGitHub 3503a9da8e Fix inline rename during IME composition (#519) 2026-08-01 07:45:36 -07:00
Gregory Schier cef6abf5d0 Handle OAuth callback connections concurrently so browser preconnects can't block login 2026-07-29 07:54:59 -07:00
Gregory Schier a2a6cb17ca Improve typing and sidebar performance in large workspaces 2026-07-25 07:14:46 -07:00
Gregory Schier 3f098f95fe Increase import timeout from 5s to 60s 2026-07-24 07:36:44 -07:00
Gregory Schier 195f89337f Skip contribution policy check for bot PRs 2026-07-21 07:59:19 -07:00
Gregory Schier 57fffe5a41 Fix extracted text flashing on SSE stream updates 2026-07-21 07:59:19 -07:00
gschier 0c24d6562a Deploying to main from @ mountain-loop/yaak@b1ea3e3560 🚀 2026-07-17 15:59:44 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b1ea3e3560 Bump serde_with from 3.12.0 to 3.21.0 (#511)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 22:35:58 -07:00
Gregory SchierandGitHub d72b7d7d30 Fix URL autocomplete replacement (#510) 2026-07-15 08:33:14 -07:00
Gregory SchierandGitHub b40e2cdc1b Fix keyboard shortcut help layout (#509) 2026-07-14 11:19:56 -07:00
Gregory SchierandGitHub 19cc6ee6d4 Batch streamed request body history into full-size chunks (#508) 2026-07-14 09:13:50 -07:00
Gregory SchierandGitHub e59ecce886 Fix Insomnia text body imports (#507) 2026-07-14 09:13:47 -07:00
Gregory SchierandGitHub c30f5b767b Close responses before request body history finishes (#498) 2026-07-14 08:55:40 -07:00
Gregory SchierandGitHub 42b22d4c07 Fall back to JWT exp claim when OAuth token response has no expires_in (#506) 2026-07-14 08:24:07 -07:00
Gregory SchierandGitHub e05feba708 Duplicate models from the DB instead of frontend snapshots (#505) 2026-07-14 07:43:14 -07:00
Gregory Schier 6b9b3660de Remap build paths in wasm output and regenerate for wasm-bindgen 0.2.121 2026-07-13 13:31:31 -07:00
Gregory SchierandGitHub e20a184c70 Scope Git watching and status to the sync directory (#503) 2026-07-09 15:26:37 -07:00
Gregory SchierandGitHub 155260521e Detect and surface plugin runtime crashes (#502) 2026-07-09 09:05:34 -07:00
Gregory SchierandGitHub 2fee5ba413 Use generated release notes placeholder (#501) 2026-07-06 09:54:38 -07:00
65 changed files with 1748 additions and 532 deletions
@@ -630,6 +630,22 @@ async function checkPullRequest({
const pr = response.data;
const issueNumber = pr.number;
if (pr.user.type === "Bot") {
core.notice(
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `bot @${pr.user.login}`,
}),
skipped: true,
};
}
if (
minimumAutomaticPullNumber != null &&
pr.number < minimumAutomaticPullNumber
+1 -1
View File
@@ -185,7 +185,7 @@ jobs:
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseBody: "<!-- generated-by-yaak-releases -->"
releaseDraft: true
prerelease: true
projectPath: ./crates-tauri/yaak-app-client
Generated
+68 -11
View File
@@ -862,6 +862,15 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -1752,8 +1761,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
@@ -1763,6 +1782,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
@@ -1776,7 +1807,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"darling_core 0.20.11",
"quote",
"syn 2.0.101",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote",
"syn 2.0.101",
]
@@ -7316,6 +7358,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.1"
@@ -7384,7 +7438,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
dependencies = [
"darling",
"darling 0.20.11",
"heck 0.4.1",
"proc-macro2",
"quote",
@@ -7653,17 +7707,19 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.12.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"serde",
"serde_derive",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
@@ -7671,11 +7727,11 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.12.0"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.101",
@@ -11113,6 +11169,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.17",
"tokio",
"ts-rs",
+1 -1
View File
@@ -20,7 +20,7 @@
<!-- 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 -->
</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;<!-- sponsors-base -->
<!-- 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 -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)
@@ -1,4 +1,4 @@
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
import { type GrpcRequest, type HttpRequestHeader, patchModel, patchModelDebounced} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
@@ -75,11 +75,13 @@ export function GrpcRequestPane({
const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
const handleChangeMessage = useCallback(
// Not debounced: handleSend reads message from the store, so a pending
// debounced patch would send stale text
(message: string) => patchModel(activeRequest, { message }),
[activeRequest],
);
@@ -146,12 +148,12 @@ export function GrpcRequestPane({
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
[activeRequest],
);
const handleDescriptionChange = useCallback(
(description: string) => patchModel(activeRequest, { description }),
(description: string) => patchModelDebounced(activeRequest, { description }),
[activeRequest],
);
@@ -299,7 +301,7 @@ export function GrpcRequestPane({
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
+17 -30
View File
@@ -1,12 +1,11 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -16,7 +15,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
@@ -39,6 +37,7 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui";
import type { Pair } from "./core/PairEditor";
@@ -76,15 +75,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -273,28 +269,19 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
[activeRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest],
);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => ({
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
}),
() => getUrlCompletionConfig(autocompleteUrls),
[autocompleteUrls],
);
@@ -331,7 +318,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -377,7 +364,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -385,7 +372,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
@@ -437,7 +424,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModel(activeRequest, { body })}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
+6 -1
View File
@@ -112,6 +112,7 @@ function Sidebar({ className }: { className?: string }) {
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
@@ -724,7 +725,11 @@ function Sidebar({ className }: { className?: string }) {
);
}
export default Sidebar;
// Memoized so route navigations (which re-render the workspace layout) don't
// re-render the sidebar subtree. In large workspaces a sidebar re-render is
// very expensive: it re-renders DndContext, whose context churn re-renders
// every visible TreeItem regardless of their memo comparators.
export default memo(Sidebar);
function getGitContextMenuItems({
items,
@@ -1,5 +1,5 @@
import type { WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { flushAllModelWrites, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
import classNames from "classnames";
@@ -8,8 +8,7 @@ import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -18,7 +17,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
@@ -26,6 +24,7 @@ import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { Pair } from "./core/PairEditor";
@@ -54,15 +53,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -127,23 +123,15 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => ({
minMatch: 3,
options:
autocompleteUrls.length > 0
? autocompleteUrls
: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
}),
() => getUrlCompletionConfig(autocompleteUrls),
[autocompleteUrls],
);
const handleConnect = useCallback(async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
await connectWebsocket({
requestId: activeRequest.id,
environmentId: getActiveEnvironment()?.id ?? null,
@@ -153,6 +141,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const handleSend = useCallback(async () => {
if (connection == null) return;
await flushAllModelWrites(); // The backend reads the message from the DB
await sendWebsocket({
connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null,
@@ -165,7 +154,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, [connection]);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -249,7 +238,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -257,7 +246,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_MESSAGE}>
@@ -269,7 +258,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
heightMode={fullHeight ? "full" : "auto"}
defaultValue={activeRequest.message}
language={messageLanguage}
onChange={(message) => patchModel(activeRequest, { message })}
onChange={(message) => patchModelDebounced(activeRequest, { message })}
stateKey={`json.${activeRequest.id}`}
/>
</TabContent>
@@ -286,7 +275,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
@@ -294,7 +283,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey}
onChange={(description) => patchModel(activeRequest, { description })}
onChange={(description) => patchModelDebounced(activeRequest, { description })}
/>
</div>
</TabContent>
@@ -1,4 +1,5 @@
import { startCompletion } from "@codemirror/autocomplete";
import { debounce } from "@yaakapp-internal/lib";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
import { foldState, forceParsing } from "@codemirror/language";
import type { EditorStateConfig, Extension } from "@codemirror/state";
@@ -381,6 +382,7 @@ function EditorInner({
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy();
cm.current = null;
return;
@@ -639,7 +641,7 @@ function getExtensions({
onChange.current?.(update.state.doc.toString());
}
saveCachedEditorState(stateKey, update.state);
saveCachedEditorStateDebounced(stateKey, update.state);
}),
];
}
@@ -652,6 +654,27 @@ const placeholderElFromText = (text: string | undefined) => {
return el;
};
// Serializing the state (full doc + history) and md5-ing the doc is too
// expensive to do on every update (each keystroke and cursor move), so
// debounce it per state key and flush when the editor unmounts.
const SAVE_STATE_DEBOUNCE_MS = 500;
const stateSavers = new Map<string, ReturnType<typeof debounce>>();
function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
if (!stateKey) return;
let saver = stateSavers.get(stateKey);
if (saver == null) {
saver = debounce((s: EditorState) => saveCachedEditorState(stateKey, s), SAVE_STATE_DEBOUNCE_MS);
stateSavers.set(stateKey, saver);
}
saver(state);
}
function flushCachedEditorState(stateKey: string | null) {
if (!stateKey) return;
stateSavers.get(stateKey)?.flush();
}
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
@@ -1,10 +1,14 @@
import type { CompletionContext } from "@codemirror/autocomplete";
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { defaultBoost } from "./twig/completion";
export type GenericCompletion = GenericCompletionOption & {
apply?: Completion["apply"];
};
export interface GenericCompletionConfig {
minMatch?: number;
options: GenericCompletionOption[];
options: GenericCompletion[];
}
/**
@@ -0,0 +1,49 @@
import type { Completion } from "@codemirror/autocomplete";
import { EditorState, type TransactionSpec } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { applyUrlCompletion, getUrlCompletionConfig } from "./completion";
describe("applyUrlCompletion", () => {
test("consumes an existing protocol suffix and preserves the rest of the URL", () => {
expect(applyCompletion("http://rickandmortyapi.com/api/character", "http://", 4)).toBe(
"http://rickandmortyapi.com/api/character",
);
});
test("inserts a protocol when there is no existing suffix", () => {
expect(applyCompletion("htt", "http://", 3)).toBe("http://");
});
test("replaces the full URL when accepting a saved URL", () => {
expect(applyCompletion("htt://old.example/path", "https://new.example/api", 3)).toBe(
"https://new.example/api",
);
});
});
describe("getUrlCompletionConfig", () => {
test("always includes protocols alongside saved URL options", () => {
const config = getUrlCompletionConfig([{ label: "https://example.com" }]);
expect(config.options.map((option) => option.label)).toEqual([
"http://",
"https://",
"https://example.com",
]);
expect(config.options.every((option) => option.apply === applyUrlCompletion)).toBe(true);
});
});
function applyCompletion(document: string, label: string, cursor: number) {
let state = EditorState.create({ doc: document, selection: { anchor: cursor } });
const view = {
state,
dispatch: (spec: TransactionSpec) => {
state = state.update(spec).state;
},
} as unknown as EditorView;
applyUrlCompletion(view, { label } satisfies Completion, 0, cursor);
return state.doc.toString();
}
@@ -1,9 +1,51 @@
import { genericCompletion } from "../genericCompletion";
import { insertCompletionText, pickedCompletion, type Completion } from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import {
genericCompletion,
type GenericCompletion,
type GenericCompletionConfig,
} from "../genericCompletion";
export const completions = genericCompletion({
options: [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
minMatch: 1,
});
const protocolOptions: GenericCompletionOption[] = [
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
];
export function getUrlCompletionConfig(
options: GenericCompletionOption[],
minMatch = 3,
): GenericCompletionConfig {
const urlOptions = [
...protocolOptions,
...options.filter(
(option) => !protocolOptions.some((protocol) => protocol.label === option.label),
),
];
return {
minMatch,
options: urlOptions.map<GenericCompletion>((option) => ({
...option,
apply: applyUrlCompletion,
})),
};
}
export function applyUrlCompletion(
view: EditorView,
completion: Completion,
from: number,
to: number,
) {
const isProtocol = /^https?:\/\/$/.test(completion.label);
const replaceTo = isProtocol
? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
: view.state.doc.length;
view.dispatch({
...insertCompletionText(view.state, completion.label, from, replaceTo),
annotations: pickedCompletion.of(completion),
});
}
export const completions = genericCompletion(getUrlCompletionConfig([], 1));
@@ -0,0 +1,25 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { HotkeyList } from "./HotkeyList";
vi.mock("./Hotkey", () => ({
Hotkey: ({ action }: { action: HotkeyAction }) =>
action === "sidebar.selected.move" ? null : <span>{action}</span>,
}));
vi.mock("./HotkeyLabel", () => ({
HotkeyLabel: ({ action }: { action: HotkeyAction }) => <span>{action}</span>,
}));
describe("HotkeyList", () => {
test("keeps a grid cell for actions without a shortcut", () => {
const markup = renderToStaticMarkup(
<HotkeyList hotkeys={["sidebar.selected.move", "request.send"]} />,
);
expect(markup).toContain(
'<span>sidebar.selected.move</span><div class="ml-4"></div><span>request.send</span>',
);
});
});
@@ -18,7 +18,10 @@ export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
{hotkeys.map((hotkey) => (
<Fragment key={hotkey}>
<HotkeyLabel className="truncate" action={hotkey} />
<Hotkey className="ml-4" action={hotkey} />
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
<div className="ml-4">
<Hotkey action={hotkey} />
</div>
</Fragment>
))}
{bottomSlot}
@@ -230,11 +230,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
/>
{externalEntries.find((e) => e.status !== "current") && (
<>
<Separator className="mt-3 mb-1">External file changes</Separator>
<Separator className="mt-3 mb-1">Other files</Separator>
{externalEntries.map((entry) => (
<ExternalTreeNode
key={entry.relaPath + entry.status}
entry={entry}
relaDir={status.data?.relaDir ?? ""}
onCheck={checkEntry}
/>
))}
@@ -395,15 +396,22 @@ function TreeNodeChildren({
function ExternalTreeNode({
entry,
relaDir,
onCheck,
}: {
entry: GitStatusEntry;
relaDir: string;
onCheck: (entry: GitStatusEntry) => void;
}) {
if (entry.status === "current") {
return null;
}
// Show paths relative to the sync directory when inside it
const displayPath = entry.relaPath.startsWith(`${relaDir}/`)
? entry.relaPath.slice(relaDir.length + 1)
: entry.relaPath;
return (
<Checkbox
fullWidth
@@ -413,7 +421,7 @@ function ExternalTreeNode({
title={
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" />
<div className="truncate">{entry.relaPath}</div>
<div className="truncate">{displayPath}</div>
<InlineCode
className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center",
@@ -219,7 +219,7 @@ function ActualEventStreamViewer({ response }: Props) {
<SseSummaryFooter
style={style}
error={summary.error ? String(summary.error) : null}
isLoading={summary.isLoading}
isLoading={summary.isLoading && summary.data == null}
onRenderMarkdownChange={renderMarkdownSetting.set}
renderMarkdown={renderMarkdown}
resultKeyPath={summarySettings.resultKeyPath ?? ""}
+24
View File
@@ -4,6 +4,7 @@ import {
websocketRequestsAtom,
} from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
export const allRequestsAtom = atom((get) => [
...get(httpRequestsAtom),
@@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [
export function useAllRequests() {
return useAtomValue(allRequestsAtom);
}
const stringArrayEqual = (a: string[], b: string[]) =>
a.length === b.length && a.every((v, i) => v === b[i]);
// Identity-stable derivations so subscribers don't recompute or re-render when
// unrelated request fields change (eg. every debounced edit of a request)
export const allRequestIdsAtom = selectAtom(
allRequestsAtom,
(requests) => requests.map((r) => r.id),
stringArrayEqual,
);
export const allRequestUrlsAtom = selectAtom(
allRequestsAtom,
(requests) => {
const urls = new Set<string>();
for (const r of requests) {
if (r.url) urls.add(r.url);
}
return Array.from(urls);
},
stringArrayEqual,
);
+9 -2
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { emit } from "@tauri-apps/api/event";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
@@ -22,8 +23,14 @@ export function useGrpc(
const go = useMutation<void, string>({
mutationKey: ["grpc_go", conn?.id],
mutationFn: () =>
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }),
mutationFn: async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
return invokeCmd<void>("cmd_grpc_go", {
requestId,
environmentId: environment?.id,
protoFiles,
});
},
});
const send = useMutation({
+8 -10
View File
@@ -6,21 +6,19 @@ import { useMemo } from "react";
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
const folders = useAtomValue(foldersAtom);
return useMemo(() => getParentFolders(folders, m), [folders, m]);
// Key on folderId, not the model itself, so edits to the model (eg. every URL
// keystroke replacing the active request) don't produce a new array identity
const folderId = m?.folderId ?? null;
return useMemo(() => getParentFolders(folders, folderId), [folders, folderId]);
}
function getParentFolders(
folders: Folder[],
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
): Folder[] {
if (currentModel == null) return [];
function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
if (folderId == null) return [];
const parentFolder = currentModel.folderId
? folders.find((f) => f.id === currentModel.folderId)
: null;
const parentFolder = folders.find((f) => f.id === folderId);
if (parentFolder == null) {
return [];
}
return [parentFolder, ...getParentFolders(folders, parentFolder)];
return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
}
@@ -6,6 +6,7 @@ import { getResponseBodySseSummary } from "../lib/responseBody";
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
return useQuery<SseSummary>({
enabled: resultKeyPath != null,
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [
"response-body-sse-summary",
response.id,
+89 -21
View File
@@ -16,6 +16,7 @@ use tokio::net::{TcpListener, TcpStream};
const OAUTH_CLIENT_ID: &str = "a1fe44800c2d7e803cad1b4bf07a291c";
const KEYRING_USER: &str = "yaak";
const AUTH_TIMEOUT: Duration = Duration::from_secs(300);
const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_REQUEST_BYTES: usize = 16 * 1024;
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -209,35 +210,71 @@ async fn receive_oauth_code(
expected_state: &str,
app_base_url: &str,
) -> CommandResult<String> {
// Browsers speculatively open extra connections that may never carry a
// request. Handle each connection concurrently so an idle socket can't
// block the one carrying the real callback.
let (tx, mut rx) = tokio::sync::mpsc::channel::<CommandResult<String>>(1);
loop {
let (mut stream, _) = listener
.accept()
.await
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
match parse_callback_request(&mut stream).await {
Ok((state, code)) => {
if state != expected_state {
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
continue;
}
let success_redirect = format!("{app_base_url}/login/oauth/success");
write_redirect(&mut stream, &success_redirect)
.await
.map_err(|e| format!("Failed responding to OAuth callback: {e}"))?;
return Ok(code);
tokio::select! {
accepted = listener.accept() => {
let (stream, _) = accepted
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
tokio::spawn(handle_callback_connection(
stream,
expected_state.to_string(),
app_base_url.to_string(),
tx.clone(),
));
}
Err(error) => {
let _ = write_bad_request(&mut stream, &error).await;
if error.starts_with("OAuth provider returned error:") {
return Err(error);
result = rx.recv() => {
if let Some(result) = result {
return result;
}
}
}
}
}
async fn handle_callback_connection(
mut stream: TcpStream,
expected_state: String,
app_base_url: String,
tx: tokio::sync::mpsc::Sender<CommandResult<String>>,
) {
let parsed = match tokio::time::timeout(
CALLBACK_READ_TIMEOUT,
parse_callback_request(&mut stream),
)
.await
{
Ok(parsed) => parsed,
Err(_) => return, // Idle speculative connection; drop it
};
match parsed {
Ok((state, code)) => {
if state != expected_state {
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
return;
}
let success_redirect = format!("{app_base_url}/login/oauth/success");
let result = match write_redirect(&mut stream, &success_redirect).await {
Ok(()) => Ok(code),
Err(e) => Err(format!("Failed responding to OAuth callback: {e}")),
};
let _ = tx.send(result).await;
}
Err(error) => {
let _ = write_bad_request(&mut stream, &error).await;
if error.starts_with("OAuth provider returned error:") {
let _ = tx.send(Err(error)).await;
}
}
}
}
async fn parse_callback_request(stream: &mut TcpStream) -> CommandResult<(String, String)> {
let target = read_http_target(stream).await?;
if !target.starts_with("/oauth/callback") {
@@ -488,6 +525,37 @@ mod tests {
assert!(err.contains("User denied"));
}
#[tokio::test]
async fn receive_oauth_code_ignores_idle_speculative_connections() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(async move {
receive_oauth_code(listener, "expected-state", "http://localhost:9444").await
});
// Browsers preconnect sockets that never carry a request; these must
// not block the connection carrying the real callback.
let _idle1 = TcpStream::connect(addr).await.expect("connect idle 1");
let _idle2 = TcpStream::connect(addr).await.expect("connect idle 2");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let mut client = TcpStream::connect(addr).await.expect("connect");
client
.write_all(
b"GET /oauth/callback?code=abc123&state=expected-state HTTP/1.1\r\nHost: localhost\r\n\r\n",
)
.await
.expect("write");
let code = tokio::time::timeout(std::time::Duration::from_secs(2), server)
.await
.expect("idle connections must not block the real callback")
.expect("join")
.expect("should return code");
assert_eq!(code, "abc123");
}
#[tokio::test]
async fn receive_oauth_code_fails_fast_on_provider_error() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
@@ -32,18 +32,37 @@ pub(crate) async fn watch_git_worktree_status<R: Runtime>(
let repo_dir = dir.to_path_buf();
let workdir = paths.workdir;
let gitdir = paths.gitdir;
let commondir = paths.commondir;
let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = notify::recommended_watcher(tx)
.map_err(|e| Error::GenericError(format!("Failed to watch Git repository: {e}")))?;
// Watch only the directory Yaak syncs to, not the whole worktree — the
// containing repo may be huge and busy (e.g. a sync dir inside a monorepo),
// and watching it all burns CPU re-checking status for unrelated changes
watcher
.watch(&workdir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git worktree: {e}")))?;
if gitdir != workdir {
.watch(&repo_dir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git sync directory: {e}")))?;
// Watch the git metadata that affects branch/status info: the top-level
// gitdir files (HEAD, index) and refs. Not the whole gitdir, since
// .git/objects churns constantly during fetches and gc. Refs and
// packed-refs live in the common dir, which only differs from the gitdir
// for linked worktrees
watcher
.watch(&gitdir, notify::RecursiveMode::NonRecursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
if commondir != gitdir {
watcher
.watch(&gitdir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git metadata: {e}")))?;
.watch(&commondir, notify::RecursiveMode::NonRecursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git common dir: {e}")))?;
}
let refs_dir = commondir.join("refs");
if refs_dir.exists() {
watcher
.watch(&refs_dir, notify::RecursiveMode::Recursive)
.map_err(|e| Error::GenericError(format!("Failed to watch Git refs: {e}")))?;
}
let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);
@@ -70,6 +89,7 @@ pub(crate) async fn watch_git_worktree_status<R: Runtime>(
&repo_dir,
&workdir,
&gitdir,
&commondir,
&channel,
).await;
}
@@ -98,9 +118,10 @@ async fn handle_git_watch_event(
repo_dir: &Path,
workdir: &Path,
gitdir: &Path,
commondir: &Path,
channel: &Channel<GitWorktreeStatus>,
) {
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir) {
if !is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir) {
return;
}
@@ -111,7 +132,7 @@ async fn handle_git_watch_event(
loop {
select! {
Some(event_res) = async_rx.recv() => {
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir);
let _ = is_relevant_git_watch_event(event_res, repo_dir, workdir, gitdir, commondir);
}
_ = &mut settle_window => {
break;
@@ -127,6 +148,7 @@ fn is_relevant_git_watch_event(
repo_dir: &Path,
workdir: &Path,
gitdir: &Path,
commondir: &Path,
) -> bool {
let event = match event_res {
Ok(event) => event,
@@ -137,7 +159,7 @@ fn is_relevant_git_watch_event(
};
for path in event.paths {
if path.strip_prefix(gitdir).is_ok() {
if path.strip_prefix(gitdir).is_ok() || path.strip_prefix(commondir).is_ok() {
return true;
}
+19 -8
View File
@@ -194,20 +194,31 @@ pub(crate) fn models_delete<R: Runtime>(
#[tauri::command]
pub(crate) fn models_duplicate<R: Runtime>(
window: WebviewWindow<R>,
model: AnyModel,
model_type: String,
model_id: String,
) -> Result<String> {
use yaak_models::error::Error::GenericError;
// Use transaction for duplications because it might recurse
window.with_tx(|tx| {
let source = &UpdateSource::from_window_label(window.label());
let id = match model {
AnyModel::Environment(m) => tx.duplicate_environment(&m, source)?.id,
AnyModel::Folder(m) => tx.duplicate_folder(&m, source)?.id,
AnyModel::GrpcRequest(m) => tx.duplicate_grpc_request(&m, source)?.id,
AnyModel::HttpRequest(m) => tx.duplicate_http_request(&m, source)?.id,
AnyModel::WebsocketRequest(m) => tx.duplicate_websocket_request(&m, source)?.id,
a => return Err(GenericError(format!("Cannot duplicate AnyModel {a:?})"))),
// Fetch the model fresh from the DB so the duplicate doesn't come from
// a stale frontend snapshot
let id = match model_type.as_str() {
"environment" => {
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
}
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
"grpc_request" => {
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
}
"http_request" => {
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
}
"websocket_request" => {
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
}
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
};
Ok(id)
@@ -28,7 +28,7 @@ use yaak_plugins::api::{
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
search_plugins,
};
use yaak_plugins::events::PluginContext;
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::plugin_meta::get_plugin_meta;
@@ -315,6 +315,30 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.await
.expect("Failed to start plugin runtime");
// Surface unexpected runtime crashes to the user
let mut crash_rx = manager.runtime_crash_rx();
let app_handle_crash = app_handle_clone.clone();
tauri::async_runtime::spawn(async move {
if crash_rx.wait_for(|status| status.is_some()).await.is_ok() {
let status = crash_rx.borrow().clone().unwrap_or_default();
// The crash may happen during startup, before any window or
// frontend listener exists — wait so the toast isn't lost
while app_handle_crash.webview_windows().is_empty() {
tokio::time::sleep(Duration::from_millis(500)).await;
}
tokio::time::sleep(Duration::from_secs(3)).await;
let _ = app_handle_crash.emit(
"show_toast",
ShowToastRequest {
message: format!("Plugin runtime crashed ({status})"),
color: Some(Color::Danger),
icon: None,
timeout: None,
},
);
}
});
app_handle_clone.manage(manager);
});
+4
View File
@@ -18,3 +18,7 @@ url = "2"
yaak-common = { workspace = true }
yaak-models = { workspace = true }
yaak-sync = { workspace = true }
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt"] }
+6 -1
View File
@@ -19,7 +19,12 @@ export type GitStatus = "untracked" | "conflict" | "current" | "modified" | "rem
export type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };
export type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
export type GitStatusSummary = { path: string,
/**
* The status directory relative to the repo root ("" when it IS the root).
* Useful for displaying entry paths relative to the sync directory
*/
relaDir: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };
export type GitWorktreeStatus = { entries: Array<GitWorktreeStatusEntry>, };
+87 -2
View File
@@ -1,11 +1,39 @@
use crate::binary::new_binary_command;
use crate::error::Error::GenericError;
use crate::repository::open_repo;
use crate::status::repo_relative_dir;
use log::info;
use std::path::Path;
/// Commit the staged changes within `dir` (their current worktree content,
/// matching what the commit dialog displays). Scoping the commit to the sync
/// directory means files staged outside of it (e.g. elsewhere in a containing
/// monorepo) are never swept into a Yaak commit — they stay staged for the
/// user's own next commit.
pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
let out =
new_binary_command(dir).await?.args(["commit", "--message", message]).output().await?;
// Run git from the repo root: command-line pathspecs resolve relative to
// the working directory, and staged paths are repo-root-relative
let (workdir, rela_dir) = {
let repo = open_repo(dir)?;
let workdir = repo
.workdir()
.ok_or_else(|| GenericError("Repository has no worktree".to_string()))?
.to_path_buf();
(workdir, repo_relative_dir(&repo, dir))
};
let staged = staged_files(&workdir, rela_dir.as_deref()).await?;
if staged.is_empty() {
return Err(GenericError("No staged changes to commit".to_string()));
}
let mut cmd = new_binary_command(&workdir).await?;
// --literal-pathspecs: the staged paths are exact files, never patterns
cmd.arg("--literal-pathspecs");
cmd.args(["commit", "--message", message, "--"]);
cmd.args(staged);
let out = cmd.output().await?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
@@ -19,3 +47,60 @@ pub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {
Ok(())
}
/// Repo-relative paths of staged changes, limited to `rela_dir` when given.
/// Must be run from the repo root so the pathspec resolves correctly. Uses
/// -z for NUL separation so paths with special characters come through
/// unquoted, --no-renames so staged renames list both sides, and
/// --literal-pathspecs so a scope dir with glob characters isn't a pattern
async fn staged_files(workdir: &Path, rela_dir: Option<&str>) -> crate::error::Result<Vec<String>> {
let mut cmd = new_binary_command(workdir).await?;
cmd.arg("--literal-pathspecs");
cmd.args(["diff", "--cached", "--name-only", "--no-renames", "-z"]);
if let Some(rela_dir) = rela_dir {
cmd.args(["--", rela_dir]);
}
let out = cmd.output().await?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(GenericError(format!("Failed to list staged files: {}", stderr)));
}
Ok(String::from_utf8_lossy(&out.stdout)
.split('\0')
.filter(|p| !p.is_empty())
.map(String::from)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_staged_files_scoped() {
let tmp = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(tmp.path()).unwrap();
// Scope dir name doubles as a glob pattern; sync1 is its glob decoy
let sync_dir = tmp.path().join("sync[1]");
std::fs::create_dir(&sync_dir).unwrap();
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
std::fs::create_dir(tmp.path().join("sync1")).unwrap();
std::fs::write(tmp.path().join("sync1").join("decoy.txt"), "decoy").unwrap();
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("sync[1]/yaak.req_1.yaml")).unwrap();
index.add_path(Path::new("sync1/decoy.txt")).unwrap();
index.add_path(Path::new("outside.txt")).unwrap();
index.write().unwrap();
let scoped = staged_files(tmp.path(), Some("sync[1]")).await.unwrap();
assert_eq!(scoped, vec!["sync[1]/yaak.req_1.yaml"]);
let all = staged_files(tmp.path(), None).await.unwrap();
assert_eq!(all.len(), 3);
}
}
+5 -2
View File
@@ -21,8 +21,11 @@ pub enum PullResult {
fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
let repo = open_repo(dir)?;
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false).include_untracked(false);
// Scoped to the sync directory: uncommitted changes elsewhere in a
// containing monorepo shouldn't block pulling. Git itself still refuses
// a merge that would clobber uncommitted files outside the sync dir
let mut opts = crate::status::scoped_status_options(&repo, dir);
opts.include_untracked(false);
let statuses = repo.statuses(Some(&mut opts))?;
Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT))
}
+8 -1
View File
@@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
pub struct GitRepositoryPaths {
pub workdir: PathBuf,
pub gitdir: PathBuf,
pub commondir: PathBuf,
}
pub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {
@@ -22,7 +23,13 @@ pub fn git_repository_paths(dir: &Path) -> Result<GitRepositoryPaths> {
.workdir()
.ok_or_else(|| Error::GenericError("Git repository does not have a worktree".into()))?
.to_path_buf();
Ok(GitRepositoryPaths { workdir, gitdir: repo.path().to_path_buf() })
Ok(GitRepositoryPaths {
workdir,
gitdir: repo.path().to_path_buf(),
// Same as gitdir except for linked worktrees, where shared refs and
// packed-refs live in the main repository's git directory
commondir: repo.commondir().to_path_buf(),
})
}
pub fn git_path_is_ignored(dir: &Path, rela_path: &Path) -> Result<bool> {
+109 -10
View File
@@ -12,6 +12,9 @@ use yaak_sync::models::SyncModel;
#[ts(export, export_to = "gen_git.ts")]
pub struct GitStatusSummary {
pub path: String,
/// The status directory relative to the repo root ("" when it IS the root).
/// Useful for displaying entry paths relative to the sync directory
pub rela_dir: String,
pub head_ref: Option<String>,
pub head_ref_shorthand: Option<String>,
pub entries: Vec<GitStatusEntry>,
@@ -79,11 +82,8 @@ pub enum GitStatus {
pub fn git_worktree_status(dir: &Path) -> crate::error::Result<GitWorktreeStatus> {
let repo = open_repo(dir)?;
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false)
.include_untracked(true)
.recurse_untracked_dirs(true)
.include_unmodified(false);
let mut opts = scoped_status_options(&repo, dir);
opts.include_unmodified(false);
let mut entries = Vec::new();
for entry in repo.statuses(Some(&mut opts))?.into_iter() {
@@ -115,11 +115,8 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
let branch_info = git_branch_info_for_repo(&repo, dir)?;
let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok());
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false)
.include_untracked(true) // Include untracked
.recurse_untracked_dirs(true) // Show all untracked
.include_unmodified(true); // Include unchanged
let mut opts = scoped_status_options(&repo, dir);
opts.include_unmodified(true); // Include unchanged
// TODO: Support renames
@@ -160,6 +157,7 @@ pub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {
Ok(GitStatusSummary {
entries,
rela_dir: repo_relative_dir(&repo, dir).unwrap_or_default(),
path: branch_info.path,
head_ref: branch_info.head_ref,
head_ref_shorthand: branch_info.head_ref_shorthand,
@@ -266,6 +264,40 @@ fn git_status_from_raw(status: git2::Status) -> Option<(GitStatus, bool)> {
Some((status, staged))
}
/// Construct StatusOptions for a walk scoped to `dir`. Yaak only cares about
/// the sync directory, and a full walk is expensive when the containing repo
/// is large (e.g. a sync dir inside a monorepo); scoping is a no-op when
/// `dir` is the repo root. Always build status walks through this so a new
/// call site can't forget the scoping.
pub(crate) fn scoped_status_options(repo: &git2::Repository, dir: &Path) -> git2::StatusOptions {
let mut opts = git2::StatusOptions::new();
opts.include_ignored(false).include_untracked(true).recurse_untracked_dirs(true);
if let Some(rela) = repo_relative_dir(repo, dir) {
opts.pathspec(rela);
// Match the path literally (exact or directory prefix) instead of as
// a glob — directory names can contain pattern characters like [ or *
opts.disable_pathspec_match(true);
}
opts
}
/// The path of `dir` relative to the repo root as a forward-slash string
/// (Git pathspecs use forward slashes even on Windows), or None when `dir`
/// is the root itself (or outside the repo). Both sides are canonicalized so
/// symlinked paths compare consistently.
pub(crate) fn repo_relative_dir(repo: &git2::Repository, dir: &Path) -> Option<String> {
let workdir = repo.workdir()?;
let workdir = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf());
let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let rela = canonical_dir.strip_prefix(&workdir).ok()?;
if rela.as_os_str().is_empty() {
return None;
}
let parts: Vec<String> =
rela.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
Some(parts.join("/"))
}
fn model_id_from_rela_path(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?;
if ext != "yaml" && ext != "yml" && ext != "json" {
@@ -274,3 +306,70 @@ fn model_id_from_rela_path(path: &Path) -> Option<String> {
path.file_stem()?.to_str()?.strip_prefix("yaak.").map(String::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_worktree_status_scoped_to_subdir() {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
let sync_dir = tmp.path().join("sync");
std::fs::create_dir(&sync_dir).unwrap();
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
// Status on a subdirectory only reports that subdirectory
let status = git_worktree_status(&sync_dir).unwrap();
let paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
assert_eq!(paths, vec!["sync/yaak.req_1.yaml"]);
assert_eq!(status.entries[0].model_id.as_deref(), Some("req_1"));
// Status on the repo root reports everything
let status = git_worktree_status(tmp.path()).unwrap();
assert_eq!(status.entries.len(), 2);
}
#[test]
fn test_worktree_status_scoped_literal_dir_name() {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
// A directory name that is also a valid glob pattern ([1] matches "1")
let sync_dir = tmp.path().join("sync[1]");
std::fs::create_dir(&sync_dir).unwrap();
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
std::fs::create_dir(tmp.path().join("sync1")).unwrap();
std::fs::write(tmp.path().join("sync1").join("decoy.txt"), "glob match").unwrap();
let status = git_worktree_status(&sync_dir).unwrap();
let paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
assert_eq!(paths, vec!["sync[1]/yaak.req_1.yaml"]);
}
#[test]
fn test_status_scoped_to_subdir() {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
let sync_dir = tmp.path().join("sync");
std::fs::create_dir(&sync_dir).unwrap();
std::fs::write(sync_dir.join("yaak.req_1.yaml"), "inside").unwrap();
std::fs::write(sync_dir.join("README.md"), "external, but in sync dir").unwrap();
std::fs::write(tmp.path().join("outside.txt"), "outside").unwrap();
// The commit dialog's status only reports the sync directory
let status = git_status(&sync_dir).unwrap();
assert_eq!(status.rela_dir, "sync");
let mut paths: Vec<&str> = status.entries.iter().map(|e| e.rela_path.as_str()).collect();
paths.sort();
assert_eq!(paths, vec!["sync/README.md", "sync/yaak.req_1.yaml"]);
// Status on the repo root reports everything
let status = git_status(tmp.path()).unwrap();
assert_eq!(status.rela_dir, "");
assert_eq!(status.entries.len(), 3);
}
}
+71 -34
View File
@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { resolvedModelName } from "@yaakapp/yaak-client/lib/resolvedModelName";
import { debounce } from "@yaakapp-internal/lib";
import { AnyModel, ModelPayload } from "../bindings/gen_models";
import { modelStoreDataAtom } from "./atoms";
import { ExtractModel, JotaiStore, ModelStoreData } from "./types";
@@ -13,6 +13,9 @@ const pendingModelWrites = new Set<Promise<unknown>>();
export function initModelStore(store: JotaiStore) {
_store = store;
// Don't lose debounced patches if the window closes while one is pending
window.addEventListener("beforeunload", flushAllPendingPatches);
getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => {
if (shouldIgnoreModel(payload)) return;
@@ -54,6 +57,7 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
}
export async function flushAllModelWrites(): Promise<void> {
flushAllPendingPatches();
const results = await Promise.allSettled(pendingModelWrites);
const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") {
@@ -61,6 +65,61 @@ export async function flushAllModelWrites(): Promise<void> {
}
}
const PATCH_DEBOUNCE_MS = 400;
interface PendingPatch {
model: AnyModel["model"];
id: string;
patch: Record<string, unknown>;
write: ReturnType<typeof debounce>;
}
const pendingPatches = new Map<string, PendingPatch>();
/**
* Like patchModel, but coalesces rapid patches to the same model (eg. one per
* keystroke) into a single write. Later fields overwrite earlier ones, so it's
* only safe for whole-value fields like url, body, or headers. Pending patches
* flush after a short delay, and flushAllModelWrites() (called before sends and
* duplicates) flushes them immediately.
*/
export function patchModelDebounced<
M extends AnyModel["model"],
T extends ExtractModel<AnyModel, M>,
>(base: Pick<T, "id" | "model">, patch: Partial<T>): void {
const key = `${base.model}.${base.id}`;
let pending = pendingPatches.get(key);
if (pending == null) {
pending = {
model: base.model,
id: base.id,
patch: {},
write: debounce(() => writePendingPatch(key), PATCH_DEBOUNCE_MS),
};
pendingPatches.set(key, pending);
}
pending.patch = { ...pending.patch, ...patch };
pending.write();
}
function writePendingPatch(key: string) {
const pending = pendingPatches.get(key);
if (pending == null) return;
pendingPatches.delete(key);
try {
void patchModelById(pending.model, pending.id, pending.patch);
} catch (err) {
// Model may have been deleted while the patch was pending
console.warn("Failed to flush pending patch", key, err);
}
}
export function flushAllPendingPatches() {
for (const pending of Array.from(pendingPatches.values())) {
pending.write.flush();
}
}
let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | null) {
@@ -156,44 +215,22 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
await trackModelWrite(invoke<string>("models_delete", { model }));
}
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
model: T | null,
) {
export async function duplicateModel<
M extends AnyModel["model"],
T extends ExtractModel<AnyModel, M>,
>(model: T | null): Promise<string> {
if (model == null) {
throw new Error("Failed to duplicate null model");
}
// If the model has an explicit (non-empty) name, try to duplicate it with a name that doesn't conflict.
// When the name is empty, keep it empty so the display falls back to the URL.
let name = "name" in model ? model.name : undefined;
if (name) {
const existingModels = listModels(model.model);
for (let i = 0; i < 100; i++) {
const hasConflict = existingModels.some((m) => {
if ("folderId" in m && "folderId" in model && model.folderId !== m.folderId) {
return false;
} else if (resolvedModelName(m) !== name) {
return false;
}
return true;
});
if (!hasConflict) {
break;
}
// Flush pending writes first, since the backend duplicates from the DB (the passed-in
// model may be a stale snapshot, eg. from the memoized sidebar tree). Conflict-free
// naming ("Foo Copy 2") is also handled by the backend.
await flushAllModelWrites();
// Name conflict. Try another one
const m: RegExpMatchArray | null = name.match(/ Copy( (?<n>\d+))?$/);
if (m != null && m.groups?.n == null) {
name = name.substring(0, m.index) + " Copy 2";
} else if (m != null && m.groups?.n != null) {
name = name.substring(0, m.index) + ` Copy ${parseInt(m.groups.n) + 1}`;
} else {
name = `${name} Copy`;
}
}
}
return trackModelWrite(invoke<string>("models_duplicate", { model: { ...model, name } }));
return trackModelWrite(
invoke<string>("models_duplicate", { modelType: model.model, modelId: model.id }),
);
}
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
@@ -0,0 +1,56 @@
/// Compute a name for a duplicated model that doesn't conflict with any sibling
/// name, following the " Copy N" convention. Empty names are kept empty so the
/// display falls back to the URL.
pub(crate) fn conflict_free_name(name: &str, sibling_names: &[String]) -> String {
if name.is_empty() {
return String::new();
}
let mut name = name.to_string();
for _ in 0..100 {
if !sibling_names.contains(&name) {
break;
}
name = next_copy_name(&name);
}
name
}
fn next_copy_name(name: &str) -> String {
if let Some(base) = name.strip_suffix(" Copy") {
return format!("{base} Copy 2");
}
if let Some(idx) = name.rfind(" Copy ") {
let n = &name[idx + " Copy ".len()..];
if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) {
if let Ok(n) = n.parse::<u64>() {
return format!("{} Copy {}", &name[..idx], n + 1);
}
}
}
format!("{name} Copy")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_next_copy_name() {
assert_eq!(next_copy_name("Foo"), "Foo Copy");
assert_eq!(next_copy_name("Foo Copy"), "Foo Copy 2");
assert_eq!(next_copy_name("Foo Copy 2"), "Foo Copy 3");
assert_eq!(next_copy_name("Foo Copy 99"), "Foo Copy 100");
assert_eq!(next_copy_name("Copy"), "Copy Copy");
}
#[test]
fn test_conflict_free_name() {
let siblings = vec!["Foo".to_string(), "Foo Copy".to_string(), "".to_string()];
assert_eq!(conflict_free_name("Foo", &siblings), "Foo Copy 2");
assert_eq!(conflict_free_name("Bar", &siblings), "Bar");
assert_eq!(conflict_free_name("", &siblings), "");
}
}
@@ -1,3 +1,4 @@
use super::conflict_free_name;
use crate::client_db::ClientDb;
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
use crate::error::Result;
@@ -88,6 +89,12 @@ impl<'a> ClientDb<'a> {
) -> Result<Environment> {
let mut environment = environment.clone();
environment.id = "".to_string();
let sibling_names = self
.list_environments_dangerous(&environment.workspace_id)?
.into_iter()
.map(|e| e.name)
.collect::<Vec<_>>();
environment.name = conflict_free_name(&environment.name, &sibling_names);
self.upsert_environment(&environment, source)
}
+14 -8
View File
@@ -1,3 +1,4 @@
use super::conflict_free_name;
use crate::client_db::ClientDb;
use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Result;
@@ -62,14 +63,19 @@ impl<'a> ClientDb<'a> {
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
let fid = &src_folder.id;
let new_folder = self.upsert_folder(
&Folder {
id: "".into(),
sort_priority: src_folder.sort_priority + 0.001,
..src_folder.clone()
},
source,
)?;
let mut folder = Folder {
id: "".into(),
sort_priority: src_folder.sort_priority + 0.001,
..src_folder.clone()
};
let sibling_names = self
.list_folders(&folder.workspace_id)?
.into_iter()
.filter(|f| f.folder_id == folder.folder_id)
.map(|f| f.name)
.collect::<Vec<_>>();
folder.name = conflict_free_name(&folder.name, &sibling_names);
let new_folder = self.upsert_folder(&folder, source)?;
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
self.upsert_http_request(
@@ -1,4 +1,4 @@
use super::dedupe_headers;
use super::{conflict_free_name, dedupe_headers};
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
@@ -58,6 +58,13 @@ impl<'a> ClientDb<'a> {
let mut request = grpc_request.clone();
request.id = "".to_string();
request.sort_priority = request.sort_priority + 0.001;
let sibling_names = self
.list_grpc_requests(&request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
request.name = conflict_free_name(&request.name, &sibling_names);
self.upsert(&request, source)
}
@@ -1,4 +1,4 @@
use super::dedupe_headers;
use super::{conflict_free_name, dedupe_headers};
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
@@ -44,6 +44,13 @@ impl<'a> ClientDb<'a> {
let mut http_request = http_request.clone();
http_request.id = "".to_string();
http_request.sort_priority = http_request.sort_priority + 0.001;
let sibling_names = self
.list_http_requests(&http_request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == http_request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
self.upsert(&http_request, source)
}
+2
View File
@@ -1,6 +1,7 @@
pub mod any_request;
mod batch;
mod cookie_jars;
mod duplicate_name;
mod environments;
mod folders;
mod graphql_introspections;
@@ -22,6 +23,7 @@ mod websocket_requests;
mod workspace_metas;
pub mod workspaces;
pub use model_changes::PersistedModelChange;
pub(crate) use duplicate_name::conflict_free_name;
const MAX_HISTORY_ITEMS: usize = 20;
@@ -1,4 +1,4 @@
use super::dedupe_headers;
use super::{conflict_free_name, dedupe_headers};
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
@@ -60,6 +60,13 @@ impl<'a> ClientDb<'a> {
let mut websocket_request = websocket_request.clone();
websocket_request.id = "".to_string();
websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
let sibling_names = self
.list_websocket_requests(&websocket_request.workspace_id)?
.into_iter()
.filter(|m| m.folder_id == websocket_request.folder_id)
.map(|m| m.name)
.collect::<Vec<_>>();
websocket_request.name = conflict_free_name(&websocket_request.name, &sibling_names);
self.upsert(&websocket_request, source)
}
+38 -10
View File
@@ -52,6 +52,9 @@ pub struct PluginManager {
dev_mode: bool,
/// Errors from plugin initialization, retrievable once via `take_init_errors`.
init_errors: Arc<Mutex<Vec<(String, String)>>>,
/// Set to the exit status if the runtime dies unexpectedly, observable
/// via `runtime_crash_rx`.
runtime_crash_rx: tokio::sync::watch::Receiver<Option<String>>,
}
/// Callback for plugin initialization events (e.g., toast notifications)
@@ -83,6 +86,12 @@ impl PluginManager {
let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128);
let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false);
// Set to the exit status if the runtime dies unexpectedly. The app is
// still usable without the plugin runtime (just missing features), so
// this unblocks startup and is surfaced to the user rather than
// taking the app down.
let (unexpected_exit_tx, unexpected_exit_rx) =
tokio::sync::watch::channel::<Option<String>>(None);
let ws_service =
PluginRuntimeServerWebsocket::new(events_tx, client_disconnect_tx, client_connect_tx);
@@ -96,6 +105,7 @@ impl PluginManager {
installed_plugin_dir,
dev_mode,
init_errors: Default::default(),
runtime_crash_rx: unexpected_exit_rx.clone(),
};
// Forward events to subscribers
@@ -132,16 +142,22 @@ impl PluginManager {
let listener = TcpListener::bind(listen_addr).await.expect("Failed to bind TCP listener");
let addr = listener.local_addr().expect("Failed to get local address");
// 1. Wait for Node.js runtime to connect
// 1. Wait for the Node.js runtime to connect, or for it to die trying
let mut init_dead_rx = unexpected_exit_rx.clone();
let init_plugins_task = tokio::spawn(async move {
match client_connect_rx.changed().await {
Ok(_) => {
info!("Plugin runtime client connected!");
// Note: initialize_all_plugins is now called separately by the app
// after setting up the plugin list
}
Err(e) => {
warn!("Failed to receive from client connection rx {e:?}");
tokio::select! {
result = client_connect_rx.changed() => match result {
Ok(_) => {
info!("Plugin runtime client connected!");
// Note: initialize_all_plugins is now called separately by the app
// after setting up the plugin list
}
Err(e) => {
warn!("Failed to receive from client connection rx {e:?}");
}
},
_ = init_dead_rx.wait_for(|status| status.is_some()) => {
warn!("Plugin runtime exited before connecting; continuing without plugins");
}
}
});
@@ -159,11 +175,17 @@ impl PluginManager {
addr,
&kill_server_rx,
killed_tx,
unexpected_exit_tx,
)
.await?;
info!("Waiting for plugins to initialize");
init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?;
if unexpected_exit_rx.borrow().is_some() {
warn!("Skipping plugin initialization because the runtime is not running");
return Ok(plugin_manager);
}
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
let db = query_manager.connect();
for dir in &bundled_dirs {
@@ -201,6 +223,12 @@ impl PluginManager {
std::mem::take(&mut *self.init_errors.lock().await)
}
/// A receiver that is set to the exit status if the plugin runtime dies
/// unexpectedly.
pub fn runtime_crash_rx(&self) -> tokio::sync::watch::Receiver<Option<String>> {
self.runtime_crash_rx.clone()
}
/// Get the vendored plugin directory path (resolves dev mode path if applicable)
pub fn get_plugins_dir(&self) -> PathBuf {
if self.dev_mode {
@@ -1070,7 +1098,7 @@ impl PluginManager {
&InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(),
}),
Duration::from_secs(5),
Duration::from_secs(60),
)
.await?;
+25 -10
View File
@@ -1,5 +1,5 @@
use crate::error::Result;
use log::{info, warn};
use log::{error, info, warn};
use std::net::SocketAddr;
use std::path::Path;
use std::process::Stdio;
@@ -15,12 +15,16 @@ use yaak_common::command::new_xplatform_command;
/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
/// * `addr` - Socket address for the plugin runtime to connect to
/// * `kill_rx` - Channel to signal shutdown
/// * `killed_tx` - Notified once the runtime is killed after a shutdown signal
/// * `unexpected_exit_tx` - Set to the exit status if the runtime exits
/// without being asked to
pub async fn start_nodejs_plugin_runtime(
node_bin_path: &Path,
plugin_runtime_main: &Path,
addr: SocketAddr,
kill_rx: &Receiver<bool>,
killed_tx: oneshot::Sender<()>,
unexpected_exit_tx: tokio::sync::watch::Sender<Option<String>>,
) -> Result<()> {
// HACK: Remove UNC prefix for Windows paths to pass to sidecar
let plugin_runtime_main_str =
@@ -65,18 +69,29 @@ pub async fn start_nodejs_plugin_runtime(
});
}
// Handle kill signal
// Wait for either an explicit kill signal or the child exiting on its own.
// An unexpected exit is reported to the caller, which decides whether to
// abort startup or surface a user-facing error.
let mut kill_rx = kill_rx.clone();
tokio::spawn(async move {
if kill_rx.wait_for(|b| *b == true).await.is_err() {
warn!("Kill channel closed before explicit shutdown; terminating plugin runtime");
tokio::select! {
status = child.wait() => {
let status = status.map(|s| s.to_string()).unwrap_or_else(|e| e.to_string());
error!("Plugin runtime exited unexpectedly ({status})");
let _ = unexpected_exit_tx.send(Some(status));
}
closed = async { kill_rx.wait_for(|b| *b == true).await.is_err() } => {
if closed {
warn!("Kill channel closed before explicit shutdown; terminating plugin runtime");
}
info!("Killing plugin runtime");
if let Err(e) = child.kill().await {
warn!("Failed to kill plugin runtime: {e}");
}
info!("Killed plugin runtime");
let _ = killed_tx.send(());
}
}
info!("Killing plugin runtime");
if let Err(e) = child.kill().await {
warn!("Failed to kill plugin runtime: {e}");
}
info!("Killed plugin runtime");
let _ = killed_tx.send(());
});
Ok(())
+13 -1
View File
@@ -1,5 +1,6 @@
const { execSync } = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
if (process.env.SKIP_WASM_BUILD === "1") {
@@ -7,7 +8,18 @@ if (process.env.SKIP_WASM_BUILD === "1") {
return;
}
execSync("wasm-pack build --target bundler", { stdio: "inherit" });
// Remap machine-specific paths that rustc embeds into the binary (panic
// location strings), so builds are reproducible across machines
const sysroot = execSync("rustc --print sysroot").toString().trim();
const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
execSync("wasm-pack build --target bundler", {
stdio: "inherit",
env: {
...process.env,
RUSTFLAGS: `--remap-path-prefix=${cargoHome}=/cargo --remap-path-prefix=${sysroot}=/rustc`,
},
});
// Rewrite the generated entry to use Vite's ?init import style instead of
// the ES Module Integration style that wasm-pack generates, which Vite/rolldown
+4 -1
View File
@@ -1,5 +1,8 @@
/* tslint:disable */
/* eslint-disable */
export function unescape_template(template: string): any;
export function escape_template(template: string): any;
export function parse_template(template: string): any;
export function unescape_template(template: string): any;
+118 -128
View File
@@ -1,9 +1,82 @@
let wasm;
export function __wbg_set_wasm(val) {
wasm = val;
/**
* @param {string} template
* @returns {any}
*/
export function escape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.escape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* @param {string} template
* @returns {any}
*/
export function parse_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* @param {string} template
* @returns {any}
*/
export function unescape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.unescape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
export function __wbg___wbindgen_debug_string_edece8177ad01481(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}
export function __wbg___wbindgen_throw_9c31b086c2b26051(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
}
export function __wbg_new_02d162bc6cf02f60() {
const ret = new Object();
return ret;
}
export function __wbg_new_310879b66b6e95e1() {
const ret = new Array();
return ret;
}
export function __wbg_set_6be42768c690e380(arg0, arg1, arg2) {
arg0[arg1] = arg2;
}
export function __wbg_set_78ea6a19f4818587(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
}
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
}
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
}
function debugString(val) {
// primitive types
const type = typeof val;
@@ -69,10 +142,19 @@ function debugString(val) {
return className;
}
let WASM_VECTOR_LEN = 0;
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
@@ -80,25 +162,7 @@ function getUint8ArrayMemory0() {
return cachedUint8ArrayMemory0;
}
const lTextEncoder = typeof TextEncoder === 'undefined' ? (0, module.require)('util').TextEncoder : TextEncoder;
let cachedTextEncoder = new lTextEncoder('utf-8');
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
? function (arg, view) {
return cachedTextEncoder.encodeInto(arg, view);
}
: function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
});
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
@@ -119,14 +183,13 @@ function passStringToWasm0(arg, malloc, realloc) {
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
@@ -136,116 +199,43 @@ function passStringToWasm0(arg, malloc, realloc) {
return ptr;
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
const lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;
let cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_export_2.get(idx);
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
/**
* @param {string} template
* @returns {any}
*/
export function unescape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.unescape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return takeFromExternrefTable0(ret[0]);
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
/**
* @param {string} template
* @returns {any}
*/
export function escape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.escape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
/**
* @param {string} template
* @returns {any}
*/
export function parse_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
let WASM_VECTOR_LEN = 0;
let wasm;
export function __wbg_set_wasm(val) {
wasm = val;
}
export function __wbg_new_405e22f390576ce2() {
const ret = new Object();
return ret;
};
export function __wbg_new_78feb108b6472713() {
const ret = new Array();
return ret;
};
export function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
};
export function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) {
arg0[arg1] = arg2;
};
export function __wbindgen_debug_string(arg0, arg1) {
const ret = debugString(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
export function __wbindgen_init_externref_table() {
const table = wasm.__wbindgen_export_2;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
;
};
export function __wbindgen_string_new(arg0, arg1) {
const ret = getStringFromWasm0(arg0, arg1);
return ret;
};
export function __wbindgen_throw(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};
Binary file not shown.
+1 -1
View File
@@ -6,6 +6,6 @@ export const parse_template: (a: number, b: number) => [number, number, number];
export const unescape_template: (a: number, b: number) => [number, number, number];
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_export_2: WebAssembly.Table;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void;
+71 -19
View File
@@ -511,7 +511,10 @@ pub async fn send_http_request<T: TemplateCallback>(
.map_err(SendHttpRequestError::PrepareSendableRequest)?;
}
let request_content_length = sendable_body_length(sendable_request.body.as_ref());
let request_content_length = match sendable_request.body.as_ref() {
Some(SendableBody::Bytes(_)) => sendable_body_length(sendable_request.body.as_ref()),
Some(SendableBody::Stream { .. }) | None => None,
};
let mut response = params.existing_response.unwrap_or_default();
response.request_id = params.request.id.clone();
response.workspace_id = params.request.workspace_id.clone();
@@ -811,16 +814,6 @@ pub async fn send_http_request<T: TemplateCallback>(
})?;
drop(body_stream);
if let Some(task) = request_body_capture_task.take() {
match task.await {
Ok(Ok(total)) => {
response.request_content_length = Some(usize_to_i32(total));
}
Ok(Err(err)) => request_body_capture_error = Some(err),
Err(err) => request_body_capture_error = Some(err.to_string()),
}
}
if let Some(err) = request_body_capture_error.take() {
response.error = Some(append_error_message(
response.error.take(),
@@ -828,10 +821,6 @@ pub async fn send_http_request<T: TemplateCallback>(
));
}
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
if let Some(err) = body_read_error {
if persist_response {
let _ = persist_response_error(
@@ -849,6 +838,16 @@ pub async fn send_http_request<T: TemplateCallback>(
cookie_jar.as_mut(),
cookie_behavior.store.as_ref(),
)?;
if let Some(task) = request_body_capture_task.take() {
match task.await {
Ok(Ok(_)) => {}
Ok(Err(err)) => warn!("Failed to store request body after response error: {err}"),
Err(err) => warn!("Failed to join request body capture task: {err}"),
}
}
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
return Err(err);
}
@@ -875,6 +874,49 @@ pub async fn send_http_request<T: TemplateCallback>(
persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?;
// Request-body history can be much larger than the response. It should not keep the
// response in a loading state after the network/response-body work has completed.
if let Some(task) = request_body_capture_task.take() {
let mut update_response = false;
match task.await {
Ok(Ok(total)) => {
let total = Some(usize_to_i32(total));
if response.request_content_length != total {
response.request_content_length = total;
update_response = true;
}
}
Ok(Err(err)) => {
response.error = Some(append_error_message(
response.error.take(),
format!("Request succeeded but failed to store request body: {err}"),
));
update_response = true;
}
Err(err) => {
response.error = Some(append_error_message(
response.error.take(),
format!("Request succeeded but failed to store request body: {err}"),
));
update_response = true;
}
}
if update_response && persist_response {
response = params
.query_manager
.connect()
.upsert_http_response(&response, &params.update_source, params.blob_manager)
.map_err(SendHttpRequestError::PersistResponse)?;
}
}
// Timeline events are useful history, but they should not keep the response in a loading state
// after the network/response-body work has completed.
if let Err(join_err) = event_handle.await {
warn!("Failed to join response event task: {}", join_err);
}
Ok(SendHttpRequestResult { rendered_request, response, response_body })
}
@@ -907,14 +949,24 @@ async fn persist_request_body_stream(
) -> std::result::Result<usize, String> {
let mut chunk_index: i32 = 0;
let mut total_bytes = 0usize;
// Stream reads arrive in small (eg. 8-16 KiB) pieces, so accumulate them into
// full-size chunks to avoid thousands of tiny inserts for large bodies
let mut buf: Vec<u8> = Vec::with_capacity(REQUEST_BODY_CHUNK_SIZE);
while let Some(data) = rx.recv().await {
total_bytes += data.len();
if data.is_empty() {
continue;
buf.extend_from_slice(&data);
while buf.len() >= REQUEST_BODY_CHUNK_SIZE {
let data = buf.drain(..REQUEST_BODY_CHUNK_SIZE).collect();
let chunk = BodyChunk::new(&body_id, chunk_index, data);
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
chunk_index += 1;
}
let chunk = BodyChunk::new(&body_id, chunk_index, data);
}
if !buf.is_empty() {
let chunk = BodyChunk::new(&body_id, chunk_index, buf);
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
chunk_index += 1;
}
Ok(total_bytes)
+79 -37
View File
@@ -83,12 +83,12 @@
"@tauri-apps/cli": "npm:@tauri-apps/cli-cef@3.0.0-alpha.6",
"@types/babel__core": "^7.20.5",
"@vitejs/plugin-react": "^6.0.1",
"@yaakapp/cli": "latest",
"@yaakapp/cli": "*",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.5.16",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.2",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
@@ -6331,20 +6331,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -6354,6 +6354,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -8386,9 +8399,9 @@
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -9249,9 +9262,9 @@
}
},
"node_modules/hono": {
"version": "4.12.25",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"version": "4.12.27",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz",
"integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -12107,9 +12120,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@@ -13483,9 +13496,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -13503,7 +13516,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -14616,9 +14629,9 @@
}
},
"node_modules/seroval": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz",
"integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==",
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz",
"integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==",
"license": "MIT",
"engines": {
"node": ">=10"
@@ -14739,9 +14752,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15916,17 +15929,34 @@
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/mime-db": {
@@ -17266,9 +17296,9 @@
"version": "0.2.1",
"dependencies": {
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13",
"@hono/node-server": "^2.0.10",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.25",
"hono": "^4.12.27",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -17276,6 +17306,18 @@
"typescript": "^5.9.3"
}
},
"plugins-external/mcp-server/node_modules/@hono/node-server": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.10.tgz",
"integrity": "sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"hono": "^4"
}
},
"plugins/action-copy-curl": {
"name": "@yaak/action-copy-curl",
"version": "0.1.0"
+1 -1
View File
@@ -121,7 +121,7 @@
"dotenv-cli": "^11.0.0",
"nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.5.16",
"postcss": "^8.5.25",
"tailwindcss": "^4.3.2",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
+22 -3
View File
@@ -1,13 +1,32 @@
// oxlint-disable-next-line no-explicit-any
export function debounce(fn: (...args: any[]) => void, delay = 500) {
let timer: ReturnType<typeof setTimeout>;
let timer: ReturnType<typeof setTimeout> | null = null;
// oxlint-disable-next-line no-explicit-any
let lastArgs: any[] | null = null;
// oxlint-disable-next-line no-explicit-any
const result = (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
lastArgs = args;
if (timer != null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
}, delay);
};
result.cancel = () => {
if (timer != null) clearTimeout(timer);
timer = null;
lastArgs = null;
};
// Invoke a pending call immediately instead of waiting out the delay
result.flush = () => {
if (timer == null) return;
clearTimeout(timer);
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
};
return result;
}
+53 -14
View File
@@ -1,4 +1,5 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import type { Virtualizer } from "@tanstack/react-virtual";
import {
DndContext,
MeasuringStrategy,
@@ -23,7 +24,7 @@ import {
} from "react";
import { useKey, useKeyPressEvent } from "react-use";
import { computeSideForDragMove } from "../../lib/dnd";
import { useStore } from "jotai";
import { useAtomValue, useStore } from "jotai";
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
import { type CollapsedAtom, CollapsedAtomContext } from "./context";
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common";
@@ -87,7 +88,27 @@ function TreeInner<T extends { id: string }>(
) {
const store = useStore();
const treeRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLElement, Element> | null>(null);
const getScrollElement = useCallback(() => treeRef.current, []);
const handleVirtualizerReady = useCallback((v: Virtualizer<HTMLElement, Element>) => {
virtualizerRef.current = v;
}, []);
const selectableItems = useSelectableItems(root);
// Only render nodes that are actually visible (not filtered out, and not
// inside a collapsed folder). Mounting every node regardless of visibility
// makes large workspaces unusable: thousands of hidden TreeItems each run
// their dnd/context hooks on every tree commit just to return null.
const collapsedMap = useAtomValue(collapsedAtom);
const visibleItems = useMemo(() => {
return selectableItems.filter((i) => {
if (i.node.hidden) return false;
for (let p = i.node.parent; p != null; p = p.parent) {
if (collapsedMap[p.item.id]) return false;
}
return true;
});
}, [selectableItems, collapsedMap]);
const [showContextMenu, setShowContextMenu] = useState<{
items: unknown[];
x: number;
@@ -125,16 +146,28 @@ function TreeInner<T extends { id: string }>(
}, []);
const tryFocus = useCallback(() => {
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
'.tree-item button[tabindex="0"]',
);
if ($el == null) {
const find = () =>
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]');
const $el = find();
if ($el != null) {
// preventScroll so scrolling stays single-sourced (focus() implicitly
// scrolls, which fights the virtualizer's scrollToIndex)
$el.focus({ preventScroll: true });
$el.scrollIntoView({ block: "nearest" });
return true;
}
// The focused row may be virtualized out of range. Scroll it into range,
// then focus it once it has mounted.
const lastFocusedId = store.get(focusIdsFamily(treeId)).lastId;
const index = visibleItems.findIndex((i) => i.node.item.id === lastFocusedId);
if (index < 0) {
return false;
}
$el.focus();
$el.scrollIntoView({ block: "nearest" });
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
requestAnimationFrame(() => find()?.focus({ preventScroll: true }));
return true;
}, []);
}, [store, treeId, visibleItems]);
const ensureTabbableItem = useCallback(() => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
@@ -448,8 +481,8 @@ function TreeInner<T extends { id: string }>(
store.set(hoveredParentFamily(treeId), {
parentId: root.item.id,
parentDepth: root.depth,
index: selectableItems.length,
childIndex: selectableItems.length,
index: visibleItems.length,
childIndex: visibleItems.length,
});
return;
}
@@ -477,8 +510,8 @@ function TreeInner<T extends { id: string }>(
const item = node.item;
let hoveredParent = node.parent;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const dragIndex = visibleItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = visibleItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === "before" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1);
@@ -509,7 +542,7 @@ function TreeInner<T extends { id: string }>(
});
}
},
[root.depth, root.item.id, selectableItems, treeId],
[root.depth, root.item.id, selectableItems, treeId, visibleItems],
);
const handleDragStart = useCallback(
@@ -680,12 +713,18 @@ function TreeInner<T extends { id: string }>(
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none",
"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
// Virtualized rows are wrapped in .tree-row divs, so the sibling
// relationships above need wrapper-aware equivalents
"[&_.tree-row:has(.tree-item.selected)+.tree-row_.tree-item.selected]:rounded-t-none",
"[&_.tree-row:has(.tree-item.selected):has(+.tree-row_.tree-item.selected)_.tree-item.selected]:rounded-b-none",
)}
>
<TreeItemList
addTreeItemRef={handleAddTreeItemRef}
nodes={selectableItems}
nodes={visibleItems}
treeId={treeId}
getScrollElement={getScrollElement}
onVirtualizerReady={handleVirtualizerReady}
{...treeItemListProps}
/>
</div>
@@ -20,6 +20,7 @@ import {
} from "./context";
import type { TreeNode } from "./common";
import { getNodeKey } from "./common";
import { isImeCompositionEvent } from "./keyboard";
import type { TreeProps } from "./Tree";
import { TreeIndentGuide } from "./TreeIndentGuide";
@@ -170,6 +171,8 @@ function TreeItem_<T extends { id: string }>({
const handleEditKeyDown = useCallback(
async (e: ReactKeyboardEvent<HTMLInputElement>) => {
e.stopPropagation(); // Don't trigger other tree keys (like arrows)
if (isImeCompositionEvent(e.nativeEvent)) return;
switch (e.key) {
case "Enter":
if (editing) {
@@ -1,5 +1,7 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { CSSProperties } from "react";
import { Fragment } from "react";
import { Fragment, useLayoutEffect, useRef, useState } from "react";
import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree";
import { TreeDropMarker } from "./TreeDropMarker";
@@ -22,9 +24,22 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
className?: string;
forceDepth?: number;
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
/**
* Enable virtualization by providing the scroll container. Rows are then
* windowed with @tanstack/react-virtual and only visible rows mount.
*/
getScrollElement?: () => HTMLElement | null;
onVirtualizerReady?: (v: Virtualizer<HTMLElement, Element>) => void;
};
export function TreeItemList<T extends { id: string }>({
export function TreeItemList<T extends { id: string }>(props: TreeItemListProps<T>) {
if (props.getScrollElement != null) {
return <VirtualTreeItemList {...props} getScrollElement={props.getScrollElement} />;
}
return <StaticTreeItemList {...props} />;
}
function StaticTreeItemList<T extends { id: string }>({
className,
getItemKey,
nodes,
@@ -32,6 +47,8 @@ export function TreeItemList<T extends { id: string }>({
treeId,
forceDepth,
addTreeItemRef,
getScrollElement: _getScrollElement,
onVirtualizerReady: _onVirtualizerReady,
...props
}: TreeItemListProps<T>) {
return (
@@ -53,3 +70,89 @@ export function TreeItemList<T extends { id: string }>({
</ul>
);
}
// Rows are --height-sm (2rem). Derive the pixel estimate from the actual root
// font size so scroll math stays accurate under interface scaling.
function estimateRowHeightPx() {
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return 2 * rem;
}
function VirtualTreeItemList<T extends { id: string }>({
className,
getItemKey,
nodes,
style,
treeId,
forceDepth,
addTreeItemRef,
getScrollElement,
onVirtualizerReady,
...props
}: TreeItemListProps<T> & { getScrollElement: () => HTMLElement | null }) {
const listRef = useRef<HTMLUListElement>(null);
// Offset of the list within the scroll container (eg. container padding),
// so windowing and scrollToIndex targets aren't shifted by it
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const list = listRef.current;
const scroller = getScrollElement();
if (list == null || scroller == null) return;
const offset =
list.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
setScrollMargin(offset);
}, [getScrollElement]);
const virtualizer = useVirtualizer({
count: nodes.length,
getScrollElement,
estimateSize: estimateRowHeightPx,
overscan: 10,
scrollMargin,
});
useLayoutEffect(() => {
onVirtualizerReady?.(virtualizer);
}, [virtualizer, onVirtualizerReady]);
return (
<ul
ref={listRef}
style={{ ...style, height: `${virtualizer.getTotalSize()}px`, position: "relative" }}
className={className}
>
<TreeDropMarker node={null} treeId={treeId} index={0} />
{virtualizer.getVirtualItems().map((virtualItem) => {
const child = nodes[virtualItem.index];
if (child == null) return null;
return (
<div
// Key by item so window shifts don't remount rows unnecessarily
key={getItemKey(child.node.item)}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="tree-row"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
}}
>
<TreeItem
treeId={treeId}
setRef={addTreeItemRef}
node={child.node}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
{...props}
/>
<TreeDropMarker node={child.node} treeId={treeId} index={virtualItem.index + 1} />
</div>
);
})}
</ul>
);
}
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vite-plus/test";
import { isImeCompositionEvent } from "./keyboard";
describe("isImeCompositionEvent", () => {
test("detects an active standards-based composition", () => {
expect(isImeCompositionEvent({ isComposing: true, keyCode: 13 })).toBe(true);
});
test("detects the Safari/WebKit key code fallback", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 229 })).toBe(true);
});
test("does not classify an ordinary Enter keydown as composition", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 13 })).toBe(false);
});
test("does not classify an ordinary Escape keydown as composition", () => {
expect(isImeCompositionEvent({ isComposing: false, keyCode: 27 })).toBe(false);
});
});
@@ -0,0 +1,7 @@
export type ImeKeyboardEvent = Pick<KeyboardEvent, "isComposing" | "keyCode">;
export function isImeCompositionEvent(event: ImeKeyboardEvent): boolean {
// Safari can clear `isComposing` on the keydown that finishes composition.
// `229` is retained as the compatibility signal that an IME is processing it.
return event.isComposing || event.keyCode === 229;
}
+2 -2
View File
@@ -15,9 +15,9 @@
},
"dependencies": {
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13",
"@hono/node-server": "^2.0.10",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.25",
"hono": "^4.12.27",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -13,6 +13,7 @@ export async function getOrRefreshAccessToken(
credentialsInBody,
clientId,
clientSecret,
tokenName,
forceRefresh,
}: {
scope: string | null;
@@ -20,6 +21,7 @@ export async function getOrRefreshAccessToken(
credentialsInBody: boolean;
clientId: string;
clientSecret: string;
tokenName?: "access_token" | "id_token";
forceRefresh?: boolean;
},
): Promise<AccessToken | null> {
@@ -28,7 +30,7 @@ export async function getOrRefreshAccessToken(
return null;
}
const isExpired = isTokenExpired(token);
const isExpired = isTokenExpired(token, tokenName);
// Return the current access token if it's still valid
if (!isExpired && !forceRefresh) {
@@ -111,5 +113,5 @@ export async function getOrRefreshAccessToken(
refresh_token: response.refresh_token ?? token.response.refresh_token,
};
return storeToken(ctx, tokenArgs, newResponse);
return storeToken(ctx, tokenArgs, newResponse, tokenName);
}
@@ -67,6 +67,7 @@ export async function getAuthorizationCode(
clientId,
clientSecret,
credentialsInBody,
tokenName,
});
if (token != null) {
return token;
+3 -3
View File
@@ -37,7 +37,7 @@ export async function getImplicit(
authorizationUrl: authorizationUrlRaw,
};
const token = await getToken(ctx, tokenArgs);
if (token != null && !isTokenExpired(token)) {
if (token != null && !isTokenExpired(token, tokenName)) {
return token;
}
@@ -137,7 +137,7 @@ async function getTokenViaEmbeddedBrowser(
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try {
resolve(storeToken(ctx, tokenArgs, response));
resolve(storeToken(ctx, tokenArgs, response, tokenName));
} catch (err) {
reject(err);
}
@@ -195,5 +195,5 @@ async function extractImplicitToken(
response.id_token = idToken;
}
return storeToken(ctx, tokenArgs, response);
return storeToken(ctx, tokenArgs, response, tokenName);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import type { Context } from "@yaakapp/api";
import { jwtExpiresAt } from "./util";
export async function storeToken(
ctx: Context,
@@ -11,7 +12,10 @@ export async function storeToken(
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`);
}
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null;
// Prefer expires_in from the response, falling back to the JWT's own exp claim
const expiresAt = response.expires_in
? Date.now() + response.expires_in * 1000
: jwtExpiresAt(response[tokenName]);
const token: AccessToken = {
response,
expiresAt,
+29 -2
View File
@@ -1,7 +1,34 @@
import jwt from "jsonwebtoken";
import type { AccessToken } from "./store";
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
export function isTokenExpired(
token: AccessToken,
tokenName: "access_token" | "id_token" = "access_token",
) {
// Fall back to the JWT's own exp claim for tokens stored without an expiry
// (eg. from a token response that had no expires_in). Decode the same token
// that gets sent as the credential.
const expiresAt = token.expiresAt ?? jwtExpiresAt(token.response[tokenName]);
return expiresAt != null && Date.now() > expiresAt;
}
/**
* Get the expiry timestamp (ms) from a JWT's `exp` claim, or null if the token
* is not a JWT or has no `exp`.
*/
export function jwtExpiresAt(token: string | undefined): number | null {
if (!token) return null;
try {
const payload = jwt.decode(token);
if (payload != null && typeof payload === "object" && typeof payload.exp === "number") {
return payload.exp * 1000;
}
} catch {
// Opaque (non-JWT) token
}
return null;
}
export function extractCode(urlStr: string, redirectUri: string | null): string | null {
+54 -1
View File
@@ -1,5 +1,6 @@
import jwt from "jsonwebtoken";
import { describe, expect, test } from "vite-plus/test";
import { extractCode } from "../src/util";
import { extractCode, isTokenExpired, jwtExpiresAt } from "../src/util";
describe("extractCode", () => {
test("extracts code from query when same origin + path", () => {
@@ -107,3 +108,55 @@ describe("extractCode", () => {
expect(extractCode(url, redirect)).toBe("abc");
});
});
describe("isTokenExpired", () => {
const jwtWithExp = (expSecondsFromNow: number) =>
jwt.sign({ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }, "test-secret");
test("uses stored expiresAt when present", () => {
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() - 1000 })).toBe(
true,
);
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() + 10000 })).toBe(
false,
);
});
test("falls back to JWT exp claim when expiresAt is null", () => {
expect(
isTokenExpired({ response: { access_token: jwtWithExp(-60) }, expiresAt: null }),
).toBe(true);
expect(
isTokenExpired({ response: { access_token: jwtWithExp(60) }, expiresAt: null }),
).toBe(false);
});
test("treats opaque tokens without expiresAt as non-expiring", () => {
expect(isTokenExpired({ response: { access_token: "opaque-token" }, expiresAt: null })).toBe(
false,
);
});
test("checks the token that is used as the credential", () => {
// Expired id_token credential is not masked by an opaque access_token
const token = {
response: { access_token: "opaque-token", id_token: jwtWithExp(-60) },
expiresAt: null,
};
expect(isTokenExpired(token, "id_token")).toBe(true);
expect(isTokenExpired(token, "access_token")).toBe(false);
});
});
describe("jwtExpiresAt", () => {
test("extracts exp claim in milliseconds", () => {
const exp = Math.floor(Date.now() / 1000) + 300;
expect(jwtExpiresAt(jwt.sign({ exp }, "test-secret"))).toBe(exp * 1000);
});
test("returns null for non-JWT or missing tokens", () => {
expect(jwtExpiresAt("not-a-jwt")).toBeNull();
expect(jwtExpiresAt(undefined)).toBeNull();
expect(jwtExpiresAt(jwt.sign({ foo: "bar" }, "test-secret"))).toBeNull();
});
});
+87
View File
@@ -1,3 +1,5 @@
/* oxlint-disable no-explicit-any */
export function isJSObject(obj: unknown) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
@@ -13,6 +15,91 @@ export function convertId(id: string): string {
return `GENERATE_ID::${id}`;
}
export function importHttpBodyAndHeaders(obj: any) {
const { headers } = importHeaders(obj);
const { body, bodyType } = importHttpBody(obj.body);
const mimeType = typeof obj.body?.mimeType === "string" ? obj.body.mimeType.trim() : "";
if (
bodyType != null &&
mimeType !== "" &&
!headers.some((header: { name: string }) => header.name.toLowerCase() === "content-type")
) {
headers.push({ enabled: true, name: "Content-Type", value: mimeType });
}
return { body, bodyType, headers };
}
export function importHeaders(obj: any) {
const headers = (obj.headers ?? [])
.map((header: any) => ({
enabled: !header.disabled,
name: header.name ?? "",
value: header.value ?? "",
}))
.filter(({ name, value }: any) => name !== "" || value !== "");
return { headers } as const;
}
function importHttpBody(rawBody: any) {
const mimeType = typeof rawBody?.mimeType === "string" ? rawBody.mimeType.trim() : "";
const normalizedMimeType = mimeType.split(";", 1)[0]?.toLowerCase() ?? "";
if (normalizedMimeType === "application/octet-stream") {
return { bodyType: "binary", body: { filePath: rawBody.fileName ?? "" } };
}
if (normalizedMimeType === "application/x-www-form-urlencoded") {
return {
bodyType: "application/x-www-form-urlencoded",
body: {
form: (rawBody.params ?? []).map((parameter: any) => ({
enabled: !parameter.disabled,
name: parameter.name ?? "",
value: parameter.value ?? "",
})),
},
};
}
if (normalizedMimeType === "multipart/form-data") {
return {
bodyType: "multipart/form-data",
body: {
form: (rawBody.params ?? []).map((parameter: any) => ({
enabled: !parameter.disabled,
name: parameter.name ?? "",
value: parameter.value ?? "",
file: parameter.fileName ?? null,
})),
},
};
}
if (normalizedMimeType === "application/graphql") {
return { bodyType: "graphql", body: { text: rawBody.text ?? "" } };
}
if (normalizedMimeType === "application/json" || normalizedMimeType.endsWith("+json")) {
return { bodyType: "application/json", body: { text: rawBody.text ?? "" } };
}
if (
normalizedMimeType === "text/xml" ||
normalizedMimeType === "application/xml" ||
normalizedMimeType.endsWith("+xml")
) {
return { bodyType: "text/xml", body: { text: rawBody.text ?? "" } };
}
if (typeof rawBody?.text === "string") {
return { bodyType: "other", body: { text: rawBody.text } };
}
return { bodyType: null, body: {} };
}
export function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
+2 -42
View File
@@ -1,6 +1,6 @@
/* oxlint-disable no-explicit-any */
import type { PartialImportResources } from "@yaakapp/api";
import { convertId, convertTemplateSyntax, isJSObject } from "./common";
import { convertId, convertTemplateSyntax, importHttpBodyAndHeaders, isJSObject } from "./common";
export function convertInsomniaV4(parsed: any) {
if (!Array.isArray(parsed.resources)) return null;
@@ -64,38 +64,6 @@ export function convertInsomniaV4(parsed: any) {
}
function importHttpRequest(r: any, workspaceId: string): PartialImportResources["httpRequests"][0] {
let bodyType: string | null = null;
let body = {};
if (r.body.mimeType === "application/octet-stream") {
bodyType = "binary";
body = { filePath: r.body.fileName ?? "" };
} else if (r.body?.mimeType === "application/x-www-form-urlencoded") {
bodyType = "application/x-www-form-urlencoded";
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? "",
})),
};
} else if (r.body?.mimeType === "multipart/form-data") {
bodyType = "multipart/form-data";
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? "",
file: p.fileName ?? null,
})),
};
} else if (r.body?.mimeType === "application/graphql") {
bodyType = "graphql";
body = { text: r.body.text ?? "" };
} else if (r.body?.mimeType === "application/json") {
bodyType = "application/json";
body = { text: r.body.text ?? "" };
}
let authenticationType: string | null = null;
let authentication = {};
if (r.authentication.type === "bearer") {
@@ -127,18 +95,10 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
name: p.name ?? "",
value: p.value ?? "",
})),
body,
bodyType,
...importHttpBodyAndHeaders(r),
authentication,
authenticationType,
method: r.method,
headers: (r.headers ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? "",
}))
.filter(({ name, value }: any) => name !== "" || value !== ""),
};
}
+8 -47
View File
@@ -1,6 +1,12 @@
/* oxlint-disable no-explicit-any */
import type { PartialImportResources } from "@yaakapp/api";
import { convertId, convertTemplateSyntax, isJSObject } from "./common";
import {
convertId,
convertTemplateSyntax,
importHeaders,
importHttpBodyAndHeaders,
isJSObject,
} from "./common";
export function convertInsomniaV5(parsed: any) {
// Assert parsed is object
@@ -82,38 +88,6 @@ function importHttpRequest(
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
let bodyType: string | null = null;
let body = {};
if (r.body?.mimeType === "application/octet-stream") {
bodyType = "binary";
body = { filePath: r.body.fileName ?? "" };
} else if (r.body?.mimeType === "application/x-www-form-urlencoded") {
bodyType = "application/x-www-form-urlencoded";
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? "",
})),
};
} else if (r.body?.mimeType === "multipart/form-data") {
bodyType = "multipart/form-data";
body = {
form: (r.body.params ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? "",
file: p.fileName ?? null,
})),
};
} else if (r.body?.mimeType === "application/graphql") {
bodyType = "graphql";
body = { text: r.body.text ?? "" };
} else if (r.body?.mimeType === "application/json") {
bodyType = "application/json";
body = { text: r.body.text ?? "" };
}
return {
id: convertId(id),
workspaceId: convertId(workspaceId),
@@ -130,10 +104,8 @@ function importHttpRequest(
name: p.name ?? "",
value: p.value ?? "",
})),
body,
bodyType,
...importHttpBodyAndHeaders(r),
method: r.method,
...importHeaders(r),
...importAuthentication(r),
};
}
@@ -203,17 +175,6 @@ function importWebsocketRequest(
};
}
function importHeaders(obj: any) {
const headers = (obj.headers ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? "",
}))
.filter(({ name, value }: any) => name !== "" || value !== "");
return { headers } as const;
}
function importAuthentication(obj: any) {
let authenticationType: string | null = null;
let authentication = {};
@@ -0,0 +1,91 @@
import { describe, expect, test } from "vite-plus/test";
import { importHttpBodyAndHeaders } from "../src/common";
describe("importHttpBodyAndHeaders", () => {
test("imports XML text using the native XML body type", () => {
const result = importHttpBodyAndHeaders({
body: {
mimeType: "application/soap+xml; charset=utf-8",
text: "<soap:Envelope />",
},
});
expect(result).toEqual({
bodyType: "text/xml",
body: { text: "<soap:Envelope />" },
headers: [
{
enabled: true,
name: "Content-Type",
value: "application/soap+xml; charset=utf-8",
},
],
});
});
test("imports vendor JSON using the native JSON body type", () => {
const result = importHttpBodyAndHeaders({
body: {
mimeType: "application/problem+json",
text: '{"message":"Nope"}',
},
});
expect(result.bodyType).toBe("application/json");
expect(result.body).toEqual({ text: '{"message":"Nope"}' });
});
test("imports unknown text using the other body type", () => {
const result = importHttpBodyAndHeaders({
body: {
mimeType: "application/yaml",
text: "message: hello",
},
});
expect(result).toEqual({
bodyType: "other",
body: { text: "message: hello" },
headers: [
{
enabled: true,
name: "Content-Type",
value: "application/yaml",
},
],
});
});
test("preserves an explicit content type instead of adding a duplicate", () => {
const result = importHttpBodyAndHeaders({
body: {
mimeType: "application/yaml",
text: "message: hello",
},
headers: [
{
name: "content-type",
value: "application/x-yaml",
},
],
});
expect(result.headers).toEqual([
{
enabled: true,
name: "content-type",
value: "application/x-yaml",
},
]);
});
test("imports text without inventing a content type", () => {
const result = importHttpBodyAndHeaders({ body: { text: "hello" } });
expect(result).toEqual({
bodyType: "other",
body: { text: "hello" },
headers: [],
});
});
});